Spaces:
Running
feat: KI-039 + KI-040 — single Clear-chat (keep profile) + named profiles
Browse filesKI-039 — Single Clear-chat button. The previous two-button setup
("Clear chat" / "Start fresh") confused users. Now one button labeled
"Clear chat" wipes the visible conversation but KEEPS the server-side
profile, so the bot doesn't lose context — next turn picks up with the
existing profile loaded. The user-stated rationale: "the LLM still knows
what conversations, what questions have already been answered, and it
can speak in a natural manner."
KI-040 — Named profiles for cross-session identity.
Backend changes:
• backend/profile_store.py (NEW) — JSON-keyed by normalised name.
save_profile / load_profile / list_profiles + is_valid_name.
Files at data/profiles/<slug>.json. Display name preserved inside
each file. Atomic write via .tmp + replace.
• backend/needs_finder.py — Profile dataclass gains `name: Optional[str]`.
GRAPH gets a new slot 0 ("name") that asks "First — what should I
call you?" before age. Free-text, validated 1-50 chars 50%+ alpha.
• backend/orchestrator.py — name-slot special handling:
(a) tolerates conversational prefixes ("I'm Rohit", "My name is",
"call me", "this is") and strips them before storing,
(b) auto-capitalises a sensible display form when the user typed
all-lowercase,
(c) on successful name capture, calls profile_store.load_profile.
If it returns a stored profile with material content, MERGES
every populated field into session.profile, marks every slot
as already-asked, flips free_form_session=True, and short-
circuits with a "Welcome back, <name>!" reply (brain_tag
`needs_finder::welcome_back`) — skipping the full 9-question
walk for returning visitors,
(d) on any other slot capture, persists the updated profile back
to the JSON store so the next visit sees the latest state.
Architecture note (per the user's "embed or JSON?" question): JSON is
the canonical store for name lookup (O(1) deterministic); Chroma's
profile chunk (existing upsert_profile_chunk in profile_rag.py) stays
the retrieval-time context layer (re-embedded on profile change, not
per query). Both layers stay in sync.
Frontend change:
• Two-button Clear chat / Start fresh consolidated to one "Clear chat"
button. Calls handleClearChat(false) — drop_profile stays false.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/needs_finder.py +9 -0
- backend/orchestrator.py +90 -1
- backend/profile_store.py +150 -0
- frontend/src/app/page.tsx +9 -7
|
@@ -30,6 +30,7 @@ from typing import Any, Optional
|
|
| 30 |
@dataclass
|
| 31 |
class Profile:
|
| 32 |
"""User profile accumulated during fact-find."""
|
|
|
|
| 33 |
age: Optional[int] = None
|
| 34 |
dependents: Optional[str] = None # "self", "self+spouse", "self+spouse+kids", "self+parents", etc.
|
| 35 |
income_band: Optional[str] = None # "under_5L", "5L-10L", "10L-25L", "25L+"
|
|
@@ -66,6 +67,14 @@ def _always(p: Profile) -> bool:
|
|
| 66 |
|
| 67 |
|
| 68 |
GRAPH: list[Question] = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
Question(
|
| 70 |
id="age",
|
| 71 |
prompt_en="First, your age? (Premium + eligibility + how long you can renew all hinge on this.)",
|
|
|
|
| 30 |
@dataclass
|
| 31 |
class Profile:
|
| 32 |
"""User profile accumulated during fact-find."""
|
| 33 |
+
name: Optional[str] = None # KI-040 — humanise + key for cross-session lookup
|
| 34 |
age: Optional[int] = None
|
| 35 |
dependents: Optional[str] = None # "self", "self+spouse", "self+spouse+kids", "self+parents", etc.
|
| 36 |
income_band: Optional[str] = None # "under_5L", "5L-10L", "10L-25L", "25L+"
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
GRAPH: list[Question] = [
|
| 70 |
+
Question(
|
| 71 |
+
id="name",
|
| 72 |
+
prompt_en="First — what should I call you? I'll save your profile so the next time you visit, you won't have to answer all this again.",
|
| 73 |
+
prompt_hi="सबसे पहले — आपको क्या कहूँ? आपकी profile save हो जाएगी, तो अगली बार दोबारा सवाल नहीं पूछूँगा।",
|
| 74 |
+
field="name",
|
| 75 |
+
is_core=True,
|
| 76 |
+
# Free-text — no parser; the orchestrator validates 1-50 chars
|
| 77 |
+
),
|
| 78 |
Question(
|
| 79 |
id="age",
|
| 80 |
prompt_en="First, your age? (Premium + eligibility + how long you can renew all hinge on this.)",
|
|
@@ -320,10 +320,73 @@ async def handle_turn(
|
|
| 320 |
# the slot-filler was actually working — see readback summaries in
|
| 321 |
# `needs_finder::fact_find_complete` turns).
|
| 322 |
fact_find_profile_updates: dict = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
if session.awaiting_question_id:
|
| 324 |
from backend.fact_find_normalizer import is_valid_answer, normalize_answer
|
| 325 |
qid = session.awaiting_question_id
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
ambiguous_or_failed = True
|
| 328 |
else:
|
| 329 |
try:
|
|
@@ -344,6 +407,14 @@ async def handle_turn(
|
|
| 344 |
# Reset re-ask counter on success
|
| 345 |
if hasattr(session, "_reask_counts"):
|
| 346 |
session._reask_counts.pop(qid, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
else:
|
| 348 |
ambiguous_or_failed = True
|
| 349 |
|
|
@@ -359,6 +430,24 @@ async def handle_turn(
|
|
| 359 |
session.set_awaiting(None)
|
| 360 |
ambiguous_or_failed = False # no longer a reask situation
|
| 361 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
# If the answer didn't normalize, pick the SAME question again (re-ask
|
| 363 |
# with a gentle clarifier) instead of moving on with garbage — UNLESS
|
| 364 |
# the cap above just kicked in, in which case we move on.
|
|
|
|
| 320 |
# the slot-filler was actually working — see readback summaries in
|
| 321 |
# `needs_finder::fact_find_complete` turns).
|
| 322 |
fact_find_profile_updates: dict = {}
|
| 323 |
+
# KI-040 — named-profile return-visit detection. Set when we resolve
|
| 324 |
+
# a name and find a stored profile for that name; used at the bottom
|
| 325 |
+
# of this branch to emit a "Welcome back" message instead of the
|
| 326 |
+
# next slot's question.
|
| 327 |
+
returning_visitor_greeting: Optional[str] = None
|
| 328 |
if session.awaiting_question_id:
|
| 329 |
from backend.fact_find_normalizer import is_valid_answer, normalize_answer
|
| 330 |
qid = session.awaiting_question_id
|
| 331 |
+
# KI-040 — special handling for the name slot: free-text, no enum.
|
| 332 |
+
if qid == "name":
|
| 333 |
+
from backend.profile_store import is_valid_name, load_profile, save_profile
|
| 334 |
+
raw_name = user_text.strip().strip(".,!?")
|
| 335 |
+
# Tolerate "I'm Rohit" / "My name is Rohit" / "call me Rohit"
|
| 336 |
+
for prefix in ("i'm ", "i am ", "my name is ", "name is ", "call me ", "this is "):
|
| 337 |
+
if raw_name.lower().startswith(prefix):
|
| 338 |
+
raw_name = raw_name[len(prefix):].strip()
|
| 339 |
+
break
|
| 340 |
+
# Capitalize a sensible display form
|
| 341 |
+
if raw_name and not any(c.isupper() for c in raw_name):
|
| 342 |
+
raw_name = " ".join(w.capitalize() for w in raw_name.split())
|
| 343 |
+
if is_valid_name(raw_name):
|
| 344 |
+
session.update_profile_field("name", raw_name)
|
| 345 |
+
fact_find_profile_updates["name"] = raw_name
|
| 346 |
+
if qid not in session.profile.asked:
|
| 347 |
+
session.profile.asked.append(qid)
|
| 348 |
+
session.set_awaiting(None)
|
| 349 |
+
if hasattr(session, "_reask_counts"):
|
| 350 |
+
session._reask_counts.pop(qid, None)
|
| 351 |
+
# Look up an existing profile for this name. If found,
|
| 352 |
+
# MERGE its fields into the current empty session so the
|
| 353 |
+
# user doesn't repeat the 9-question walk.
|
| 354 |
+
stored = load_profile(raw_name)
|
| 355 |
+
if stored is not None and any(
|
| 356 |
+
getattr(stored, f, None) not in (None, [], "")
|
| 357 |
+
for f in ("age", "dependents", "income_band", "primary_goal")
|
| 358 |
+
):
|
| 359 |
+
for field_name in (
|
| 360 |
+
"age", "dependents", "income_band", "existing_cover_inr",
|
| 361 |
+
"primary_goal", "location_tier", "parents_to_insure",
|
| 362 |
+
"parents_age_max", "parents_has_ped", "health_conditions",
|
| 363 |
+
"budget_band",
|
| 364 |
+
):
|
| 365 |
+
val = getattr(stored, field_name, None)
|
| 366 |
+
if val not in (None, [], ""):
|
| 367 |
+
session.update_profile_field(field_name, val)
|
| 368 |
+
fact_find_profile_updates[field_name] = val
|
| 369 |
+
# Mark all stored slots as already-asked so next_question
|
| 370 |
+
# skips them.
|
| 371 |
+
for slot_id in ("age", "dependents", "income_band",
|
| 372 |
+
"existing_cover", "primary_goal", "location",
|
| 373 |
+
"parents_age", "health_conditions", "budget"):
|
| 374 |
+
if slot_id not in session.profile.asked:
|
| 375 |
+
session.profile.asked.append(slot_id)
|
| 376 |
+
session.free_form_session = True
|
| 377 |
+
session._flush()
|
| 378 |
+
returning_visitor_greeting = (
|
| 379 |
+
f"Welcome back, {raw_name}! I've loaded your profile from last time. "
|
| 380 |
+
"Want me to suggest some policies that fit your situation, or do "
|
| 381 |
+
"you have a specific question in mind?"
|
| 382 |
+
)
|
| 383 |
+
else:
|
| 384 |
+
# New visitor — record an initial profile so subsequent
|
| 385 |
+
# turns persist; orchestrator will continue to next slot.
|
| 386 |
+
save_profile(raw_name, session.profile, session_id=session_id)
|
| 387 |
+
else:
|
| 388 |
+
ambiguous_or_failed = True
|
| 389 |
+
elif not is_valid_answer(user_text):
|
| 390 |
ambiguous_or_failed = True
|
| 391 |
else:
|
| 392 |
try:
|
|
|
|
| 407 |
# Reset re-ask counter on success
|
| 408 |
if hasattr(session, "_reask_counts"):
|
| 409 |
session._reask_counts.pop(qid, None)
|
| 410 |
+
# KI-040 — persist the updated profile to JSON store
|
| 411 |
+
# so next-visit lookup-by-name has the latest data.
|
| 412 |
+
if session.profile.name:
|
| 413 |
+
try:
|
| 414 |
+
from backend.profile_store import save_profile
|
| 415 |
+
save_profile(session.profile.name, session.profile, session_id=session_id)
|
| 416 |
+
except Exception:
|
| 417 |
+
pass
|
| 418 |
else:
|
| 419 |
ambiguous_or_failed = True
|
| 420 |
|
|
|
|
| 430 |
session.set_awaiting(None)
|
| 431 |
ambiguous_or_failed = False # no longer a reask situation
|
| 432 |
|
| 433 |
+
# KI-040 — returning-visitor short-circuit. If we recognised the user's
|
| 434 |
+
# name and loaded their stored profile, skip directly to the greeting
|
| 435 |
+
# without picking another fact-find question.
|
| 436 |
+
if returning_visitor_greeting:
|
| 437 |
+
return TurnResult(
|
| 438 |
+
reply_text=returning_visitor_greeting,
|
| 439 |
+
citations=[],
|
| 440 |
+
retrieved_chunk_ids=[],
|
| 441 |
+
brain_used="needs_finder::welcome_back",
|
| 442 |
+
intent="fact_find",
|
| 443 |
+
language=language,
|
| 444 |
+
latency_ms=int((time.time() - t0) * 1000),
|
| 445 |
+
raw_reply=returning_visitor_greeting,
|
| 446 |
+
faithfulness_passed=True,
|
| 447 |
+
blocked=False,
|
| 448 |
+
profile_updates=fact_find_profile_updates,
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
# If the answer didn't normalize, pick the SAME question again (re-ask
|
| 452 |
# with a gentle clarifier) instead of moving on with garbage — UNLESS
|
| 453 |
# the cap above just kicked in, in which case we move on.
|
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistent name-keyed profile store.
|
| 2 |
+
|
| 3 |
+
KI-040 (2026-05-14). Lets returning visitors say their name and have the
|
| 4 |
+
bot recognise them + auto-load their stored profile, so they don't have
|
| 5 |
+
to walk the 9-slot fact-find again.
|
| 6 |
+
|
| 7 |
+
Architectural answer to the "embed or JSON?" question:
|
| 8 |
+
|
| 9 |
+
• JSON (this module) — the canonical store, keyed by normalised name.
|
| 10 |
+
O(1) lookup, deterministic, human-readable, manually editable.
|
| 11 |
+
• Chroma vector chunk (existing backend/profile_rag.py) — re-embedded
|
| 12 |
+
when the profile changes, so retrieval-time the brain sees the
|
| 13 |
+
user's profile alongside policy chunks for the "what's best for me?"
|
| 14 |
+
style questions. Embedding cost = once per update, not per query.
|
| 15 |
+
|
| 16 |
+
Both layers stay in sync: when `save_profile()` is called here, the
|
| 17 |
+
orchestrator also fires `profile_rag.upsert_profile_chunk()` so the
|
| 18 |
+
Chroma side reflects the new state.
|
| 19 |
+
|
| 20 |
+
Files live under `data/profiles/<normalised-name>.json`. Names are
|
| 21 |
+
normalised to lowercase + alpha-only for the filename so "Rohit" and
|
| 22 |
+
"rohit." both resolve to the same profile. The original (capitalised)
|
| 23 |
+
display name is preserved inside the JSON.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import json
|
| 29 |
+
import logging
|
| 30 |
+
import re
|
| 31 |
+
import time
|
| 32 |
+
from dataclasses import asdict
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from typing import Optional
|
| 35 |
+
|
| 36 |
+
from backend.config import settings
|
| 37 |
+
from backend.needs_finder import Profile
|
| 38 |
+
|
| 39 |
+
_PROFILES_DIR = settings.CORPUS_DIR.parent.parent / "data" / "profiles"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _normalise_name(name: str) -> str:
|
| 43 |
+
"""Lowercase + strip to alphanumerics. 'Rohit Sharma' → 'rohit-sharma'."""
|
| 44 |
+
if not name:
|
| 45 |
+
return ""
|
| 46 |
+
cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", name.strip().lower()).strip("-")
|
| 47 |
+
return cleaned[:60] # cap filename length
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _path_for(name: str) -> Optional[Path]:
|
| 51 |
+
slug = _normalise_name(name)
|
| 52 |
+
if not slug:
|
| 53 |
+
return None
|
| 54 |
+
return _PROFILES_DIR / f"{slug}.json"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def is_valid_name(text: str) -> bool:
|
| 58 |
+
"""Heuristic name validation. Rejects empty, too long, mostly-non-alpha."""
|
| 59 |
+
if not text:
|
| 60 |
+
return False
|
| 61 |
+
s = text.strip()
|
| 62 |
+
if not (1 <= len(s) <= 50):
|
| 63 |
+
return False
|
| 64 |
+
# At least 60% alphabetic
|
| 65 |
+
alpha = sum(1 for c in s if c.isalpha())
|
| 66 |
+
return alpha / max(1, len(s)) >= 0.5
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_profile(name: str) -> Optional[Profile]:
|
| 70 |
+
"""Return the stored Profile for `name`, or None if no record exists.
|
| 71 |
+
|
| 72 |
+
Future-proofs against Profile schema drift — any persisted fields that no
|
| 73 |
+
longer exist on the Profile dataclass are silently dropped.
|
| 74 |
+
"""
|
| 75 |
+
p = _path_for(name)
|
| 76 |
+
if not p or not p.exists():
|
| 77 |
+
return None
|
| 78 |
+
try:
|
| 79 |
+
raw = json.loads(p.read_text())
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logging.warning("profile_store load failed name=%s: %s", name, e)
|
| 82 |
+
return None
|
| 83 |
+
prof_dict = raw.get("profile") or {}
|
| 84 |
+
valid_fields = set(Profile.__dataclass_fields__.keys())
|
| 85 |
+
prof_dict = {k: v for k, v in prof_dict.items() if k in valid_fields}
|
| 86 |
+
try:
|
| 87 |
+
return Profile(**prof_dict)
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logging.warning("profile_store reconstruct failed name=%s: %s", name, e)
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def save_profile(name: str, profile: Profile, *, session_id: Optional[str] = None) -> bool:
|
| 94 |
+
"""Persist `profile` keyed by `name`. Returns True on success."""
|
| 95 |
+
p = _path_for(name)
|
| 96 |
+
if not p:
|
| 97 |
+
return False
|
| 98 |
+
try:
|
| 99 |
+
_PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
existing: dict = {}
|
| 101 |
+
if p.exists():
|
| 102 |
+
try:
|
| 103 |
+
existing = json.loads(p.read_text())
|
| 104 |
+
except Exception:
|
| 105 |
+
existing = {}
|
| 106 |
+
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 107 |
+
sessions = list(existing.get("sessions") or [])
|
| 108 |
+
if session_id and session_id not in sessions:
|
| 109 |
+
sessions.append(session_id)
|
| 110 |
+
sessions = sessions[-20:] # keep last 20 only
|
| 111 |
+
payload = {
|
| 112 |
+
"name_display": (profile.name or name).strip(),
|
| 113 |
+
"name_slug": _normalise_name(name),
|
| 114 |
+
"profile": asdict(profile),
|
| 115 |
+
"first_seen": existing.get("first_seen") or now_iso,
|
| 116 |
+
"last_seen": now_iso,
|
| 117 |
+
"sessions": sessions,
|
| 118 |
+
}
|
| 119 |
+
tmp = p.with_suffix(".json.tmp")
|
| 120 |
+
tmp.write_text(json.dumps(payload, indent=2, default=str))
|
| 121 |
+
tmp.replace(p)
|
| 122 |
+
return True
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logging.warning("profile_store save failed name=%s: %s", name, e)
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def list_profiles() -> list[dict]:
|
| 129 |
+
"""Return summary of all stored profiles — used by the admin Profile +
|
| 130 |
+
Visitor Log view. One entry per file."""
|
| 131 |
+
if not _PROFILES_DIR.exists():
|
| 132 |
+
return []
|
| 133 |
+
out: list[dict] = []
|
| 134 |
+
for p in sorted(_PROFILES_DIR.glob("*.json")):
|
| 135 |
+
try:
|
| 136 |
+
raw = json.loads(p.read_text())
|
| 137 |
+
out.append({
|
| 138 |
+
"name_display": raw.get("name_display"),
|
| 139 |
+
"name_slug": raw.get("name_slug"),
|
| 140 |
+
"first_seen": raw.get("first_seen"),
|
| 141 |
+
"last_seen": raw.get("last_seen"),
|
| 142 |
+
"session_count": len(raw.get("sessions") or []),
|
| 143 |
+
"profile_complete_fields": sum(
|
| 144 |
+
1 for v in (raw.get("profile") or {}).values()
|
| 145 |
+
if v not in (None, "", [], 0)
|
| 146 |
+
),
|
| 147 |
+
})
|
| 148 |
+
except Exception:
|
| 149 |
+
continue
|
| 150 |
+
return out
|
|
@@ -691,16 +691,18 @@ export default function Page() {
|
|
| 691 |
<EmptyState onSuggest={(q) => send(q)} coverage={coverage} t={t} />
|
| 692 |
) : (
|
| 693 |
<>
|
| 694 |
-
{/* KI-020 / KI-039 — single Clear-chat control. One
|
| 695 |
-
the visible chat
|
| 696 |
-
|
| 697 |
-
|
|
|
|
|
|
|
| 698 |
<div className="flex items-center justify-end gap-2 mb-2 text-[11px]">
|
| 699 |
<button
|
| 700 |
-
onClick={() => handleClearChat(
|
| 701 |
disabled={busy}
|
| 702 |
-
className="px-2 py-1 rounded-md border border-[var(--border)] text-[var(--muted-foreground)] hover:text-
|
| 703 |
-
title="
|
| 704 |
>
|
| 705 |
Clear chat
|
| 706 |
</button>
|
|
|
|
| 691 |
<EmptyState onSuggest={(q) => send(q)} coverage={coverage} t={t} />
|
| 692 |
) : (
|
| 693 |
<>
|
| 694 |
+
{/* KI-020 / KI-039 / KI-040 — single Clear-chat control. One
|
| 695 |
+
click wipes the visible chat history but KEEPS the server-side
|
| 696 |
+
profile so the bot doesn't have to re-fact-find the user. The
|
| 697 |
+
bot will pick up with the existing profile on the next turn.
|
| 698 |
+
(A named-profile feature for fully-different identities is on
|
| 699 |
+
the roadmap — see backend/profile_store.py.) */}
|
| 700 |
<div className="flex items-center justify-end gap-2 mb-2 text-[11px]">
|
| 701 |
<button
|
| 702 |
+
onClick={() => handleClearChat(false)}
|
| 703 |
disabled={busy}
|
| 704 |
+
className="px-2 py-1 rounded-md border border-[var(--border)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:border-[var(--primary)] disabled:opacity-40 transition"
|
| 705 |
+
title="Clear the visible chat. Your profile is preserved so the bot picks up where it left off."
|
| 706 |
>
|
| 707 |
Clear chat
|
| 708 |
</button>
|