Vaibhav Kathait commited on
Commit ·
7f73287
1
Parent(s): 4cfabc8
Add en/it/fr/es/de language support; default to our own agent LLM instead of Anam's
Browse files- agent/test_blood_rag.py +39 -0
- anam/.env.example +8 -4
- anam/config.py +43 -7
- anam/index.html +69 -59
- anam/rag.py +33 -6
- anam/routers/sessions.py +11 -1
- anam/schemas.py +5 -3
- anam/static/css/style.css +14 -0
- anam/static/js/api.js +2 -2
- anam/static/js/app.js +23 -3
- anam/static/js/i18n.js +320 -0
- anam/static/js/kb.js +2 -1
agent/test_blood_rag.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quick test: upload the sample blood test → search → LLM question.
|
| 3 |
+
Run from the agent/ directory: python test_blood_rag.py
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import blood_rag
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
SAMPLE = Path(__file__).parent / "data" / "blood_test_sample.txt"
|
| 11 |
+
|
| 12 |
+
QUERIES = [
|
| 13 |
+
"what is the patient's iron level and ferritin?",
|
| 14 |
+
"which vitamins are deficient?",
|
| 15 |
+
"what are the cholesterol findings?",
|
| 16 |
+
"is there any inflammation?",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def main():
|
| 21 |
+
# 1. Index the sample report
|
| 22 |
+
print("\n=== Indexing sample blood test report ===")
|
| 23 |
+
text = SAMPLE.read_text()
|
| 24 |
+
meta = await blood_rag.add_document("blood_test_sample.txt", text)
|
| 25 |
+
print(f"Indexed: {meta}")
|
| 26 |
+
|
| 27 |
+
# 2. Search each query and print results
|
| 28 |
+
for q in QUERIES:
|
| 29 |
+
print(f"\n--- Query: '{q}' ---")
|
| 30 |
+
result = await blood_rag.search(q, top_k=2)
|
| 31 |
+
print(result[:600])
|
| 32 |
+
print("…")
|
| 33 |
+
|
| 34 |
+
print("\n=== RAG test complete ===")
|
| 35 |
+
print(f"Indexed documents: {blood_rag.list_documents()}")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
asyncio.run(main())
|
anam/.env.example
CHANGED
|
@@ -14,12 +14,16 @@ ANAM_AVATAR_ID=NA
|
|
| 14 |
ANAM_VOICE_ID=NA
|
| 15 |
|
| 16 |
# ── LLM ────────────────────────────────────────────────────────────────────────
|
| 17 |
-
# LLM_PROVIDER=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
# LLM_PROVIDER=CustomLLM → use a custom LLM registered in your Anam Lab
|
| 19 |
# (requires ANAM_LLM_ID set to the ID from Anam Lab)
|
| 20 |
-
# LLM_PROVIDER=ClientSide → disable Anam's brain; use .talk() commands from frontend only
|
| 21 |
#
|
| 22 |
-
# Available built-in LLM IDs (
|
| 23 |
# a7cf662c-2ace-4de1-a21e-ef0fbf144bb7 → GPT OSS 120B (recommended for new projects)
|
| 24 |
# ANAM_LLAMA_v3_3_70B_V1 → Llama 3.3 70B (fast, great conversationalist)
|
| 25 |
# b4f89001-9638-4879-a9c3-02cc9f9f2004 → GPT 4.1 (powerful, slower)
|
|
@@ -29,7 +33,7 @@ ANAM_VOICE_ID=NA
|
|
| 29 |
# 2. Add your LLM endpoint, API key, and model spec
|
| 30 |
# 3. Anam will validate it and give you a custom LLM ID
|
| 31 |
# 4. Set LLM_PROVIDER=CustomLLM and ANAM_LLM_ID=<that-id>
|
| 32 |
-
LLM_PROVIDER=
|
| 33 |
# ANAM_LLM_ID=your-custom-llm-id-from-anam-lab
|
| 34 |
|
| 35 |
# ── TTS ────────────────────────────────────────────────────────────────────────
|
|
|
|
| 14 |
ANAM_VOICE_ID=NA
|
| 15 |
|
| 16 |
# ── LLM ────────────────────────────────────────────────────────────────────────
|
| 17 |
+
# LLM_PROVIDER=ClientSide → (default) disable Anam's brain; every turn is routed
|
| 18 |
+
# to OUR OWN agent server (agent/main.py) instead of
|
| 19 |
+
# an Anam-hosted model. This is what we want normally —
|
| 20 |
+
# don't change it unless you specifically want Anam's
|
| 21 |
+
# managed LLM instead of ours.
|
| 22 |
+
# LLM_PROVIDER=NA → use Anam's default built-in LLM (GPT OSS 120B)
|
| 23 |
# LLM_PROVIDER=CustomLLM → use a custom LLM registered in your Anam Lab
|
| 24 |
# (requires ANAM_LLM_ID set to the ID from Anam Lab)
|
|
|
|
| 25 |
#
|
| 26 |
+
# Available built-in LLM IDs (only relevant for LLM_PROVIDER=NA/CustomLLM):
|
| 27 |
# a7cf662c-2ace-4de1-a21e-ef0fbf144bb7 → GPT OSS 120B (recommended for new projects)
|
| 28 |
# ANAM_LLAMA_v3_3_70B_V1 → Llama 3.3 70B (fast, great conversationalist)
|
| 29 |
# b4f89001-9638-4879-a9c3-02cc9f9f2004 → GPT 4.1 (powerful, slower)
|
|
|
|
| 33 |
# 2. Add your LLM endpoint, API key, and model spec
|
| 34 |
# 3. Anam will validate it and give you a custom LLM ID
|
| 35 |
# 4. Set LLM_PROVIDER=CustomLLM and ANAM_LLM_ID=<that-id>
|
| 36 |
+
LLM_PROVIDER=ClientSide
|
| 37 |
# ANAM_LLM_ID=your-custom-llm-id-from-anam-lab
|
| 38 |
|
| 39 |
# ── TTS ────────────────────────────────────────────────────────────────────────
|
anam/config.py
CHANGED
|
@@ -46,21 +46,52 @@ DEFAULT_VOICE_ID = DEFAULT_VOICE_ID or _FALLBACK_VOICE_ID
|
|
| 46 |
# ISO 639-1 code override for Anam's STT pipeline (what the avatar understands
|
| 47 |
# when listening). Set ANAM_LANGUAGE_CODE=NA to use Anam's default multilingual
|
| 48 |
# auto-detection instead.
|
| 49 |
-
_language_env = os.getenv("ANAM_LANGUAGE_CODE", "
|
| 50 |
DEFAULT_LANGUAGE_CODE = None if _language_env.upper() == "NA" else _language_env
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
# ── LLM ────────────────────────────────────────────────────────────────────────
|
| 53 |
# LLM_PROVIDER=NA → use Anam's default built-in LLM
|
| 54 |
# LLM_PROVIDER=CustomLLM → use a custom LLM registered in Anam Lab
|
| 55 |
# (set ANAM_LLM_ID to the ID returned by Anam Lab)
|
| 56 |
-
# LLM_PROVIDER=ClientSide → disable Anam's brain entirely;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
#
|
| 58 |
-
# Available built-in Anam LLM IDs:
|
| 59 |
# a7cf662c-2ace-4de1-a21e-ef0fbf144bb7 → GPT OSS 120B (recommended)
|
| 60 |
# ANAM_LLAMA_v3_3_70B_V1 → Llama 3.3 70B (fast, no tools/RAG)
|
| 61 |
# b4f89001-9638-4879-a9c3-02cc9f9f2004 → GPT 4.1 (capable, slower)
|
| 62 |
# CUSTOMER_CLIENT_V1 → Client-side LLM (use .talk() only)
|
| 63 |
-
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "
|
| 64 |
_llm_id_env = os.getenv("ANAM_LLM_ID", "NA")
|
| 65 |
|
| 66 |
if LLM_PROVIDER.upper() == "CLIENTSIDE":
|
|
@@ -87,13 +118,18 @@ TTS_PROVIDER = os.getenv("TTS_PROVIDER", "NA")
|
|
| 87 |
ENABLE_AUDIO_PASSTHROUGH: bool = TTS_PROVIDER.upper() != "NA"
|
| 88 |
|
| 89 |
# ── RAG system prompt ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
| 90 |
_rag_prompt_env = os.getenv("ANAM_RAG_SYSTEM_PROMPT", "NA")
|
| 91 |
-
|
| 92 |
"You are a helpful, friendly AI assistant with access to a knowledge base of uploaded documents. "
|
| 93 |
"When the user asks about something that may be in the documents, use the search_knowledge_base tool to look it up and answer based on what you find. "
|
| 94 |
"For questions not covered in the documents, answer freely from your general knowledge. Never refuse to answer. "
|
| 95 |
"VERY IMPORTANT: You are a speaking avatar. Always respond in plain natural spoken language only. "
|
| 96 |
"Never use bullet points, numbered lists, asterisks, pipe characters, hyphens as list markers, markdown headers, or any other formatting symbols. "
|
| 97 |
-
"Never write symbols that would sound strange when spoken aloud. Use only clear conversational sentences.
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
) if _rag_prompt_env.upper() == "NA" else _rag_prompt_env
|
|
|
|
| 46 |
# ISO 639-1 code override for Anam's STT pipeline (what the avatar understands
|
| 47 |
# when listening). Set ANAM_LANGUAGE_CODE=NA to use Anam's default multilingual
|
| 48 |
# auto-detection instead.
|
| 49 |
+
_language_env = os.getenv("ANAM_LANGUAGE_CODE", "en")
|
| 50 |
DEFAULT_LANGUAGE_CODE = None if _language_env.upper() == "NA" else _language_env
|
| 51 |
|
| 52 |
+
# The five languages the frontend's language switcher and this backend's
|
| 53 |
+
# system-prompt instructions are wired for. Adding a language means adding it
|
| 54 |
+
# here, to LANGUAGE_NAMES below, and to the frontend's i18n translations.
|
| 55 |
+
SUPPORTED_LANGUAGES = {"en", "it", "fr", "es", "de"}
|
| 56 |
+
|
| 57 |
+
LANGUAGE_NAMES = {
|
| 58 |
+
"en": "English",
|
| 59 |
+
"it": "Italian (italiano)",
|
| 60 |
+
"fr": "French (français)",
|
| 61 |
+
"es": "Spanish (español)",
|
| 62 |
+
"de": "German (Deutsch)",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def language_instruction(language_code: str | None) -> str:
|
| 67 |
+
"""Build the '(always) respond in <language>' clause for a system prompt.
|
| 68 |
+
|
| 69 |
+
Returns an empty string for None/unrecognised codes so callers can always
|
| 70 |
+
do `base_prompt + language_instruction(code)` without a conditional.
|
| 71 |
+
"""
|
| 72 |
+
name = LANGUAGE_NAMES.get((language_code or "").lower())
|
| 73 |
+
if not name:
|
| 74 |
+
return ""
|
| 75 |
+
return f" Always respond in {name}, regardless of the language the question was asked in."
|
| 76 |
+
|
| 77 |
# ── LLM ────────────────────────────────────────────────────────────────────────
|
| 78 |
# LLM_PROVIDER=NA → use Anam's default built-in LLM
|
| 79 |
# LLM_PROVIDER=CustomLLM → use a custom LLM registered in Anam Lab
|
| 80 |
# (set ANAM_LLM_ID to the ID returned by Anam Lab)
|
| 81 |
+
# LLM_PROVIDER=ClientSide → disable Anam's brain entirely; route every turn
|
| 82 |
+
# through OUR OWN agent server (agent/main.py,
|
| 83 |
+
# see AGENT_URL) instead of an Anam-hosted model.
|
| 84 |
+
#
|
| 85 |
+
# Default is ClientSide: we want our own agent's LLM (model/key/system-prompt we
|
| 86 |
+
# control) driving conversations, not Anam's managed models, unless a deployment
|
| 87 |
+
# explicitly opts back into Anam's built-in LLM via LLM_PROVIDER=NA/CustomLLM.
|
| 88 |
#
|
| 89 |
+
# Available built-in Anam LLM IDs (only used when LLM_PROVIDER=NA or CustomLLM):
|
| 90 |
# a7cf662c-2ace-4de1-a21e-ef0fbf144bb7 → GPT OSS 120B (recommended)
|
| 91 |
# ANAM_LLAMA_v3_3_70B_V1 → Llama 3.3 70B (fast, no tools/RAG)
|
| 92 |
# b4f89001-9638-4879-a9c3-02cc9f9f2004 → GPT 4.1 (capable, slower)
|
| 93 |
# CUSTOMER_CLIENT_V1 → Client-side LLM (use .talk() only)
|
| 94 |
+
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "ClientSide")
|
| 95 |
_llm_id_env = os.getenv("ANAM_LLM_ID", "NA")
|
| 96 |
|
| 97 |
if LLM_PROVIDER.upper() == "CLIENTSIDE":
|
|
|
|
| 118 |
ENABLE_AUDIO_PASSTHROUGH: bool = TTS_PROVIDER.upper() != "NA"
|
| 119 |
|
| 120 |
# ── RAG system prompt ──────────────────────────────────────────────────────────
|
| 121 |
+
# Language-neutral base — the "always respond in X" clause is appended from
|
| 122 |
+
# DEFAULT_LANGUAGE_CODE (or overridden per-request, see routers/sessions.py and
|
| 123 |
+
# rag.py's update_rag_persona) rather than hardcoded to one language.
|
| 124 |
_rag_prompt_env = os.getenv("ANAM_RAG_SYSTEM_PROMPT", "NA")
|
| 125 |
+
_RAG_SYSTEM_PROMPT_BASE = (
|
| 126 |
"You are a helpful, friendly AI assistant with access to a knowledge base of uploaded documents. "
|
| 127 |
"When the user asks about something that may be in the documents, use the search_knowledge_base tool to look it up and answer based on what you find. "
|
| 128 |
"For questions not covered in the documents, answer freely from your general knowledge. Never refuse to answer. "
|
| 129 |
"VERY IMPORTANT: You are a speaking avatar. Always respond in plain natural spoken language only. "
|
| 130 |
"Never use bullet points, numbered lists, asterisks, pipe characters, hyphens as list markers, markdown headers, or any other formatting symbols. "
|
| 131 |
+
"Never write symbols that would sound strange when spoken aloud. Use only clear conversational sentences."
|
| 132 |
+
)
|
| 133 |
+
RAG_SYSTEM_PROMPT = (
|
| 134 |
+
_RAG_SYSTEM_PROMPT_BASE + language_instruction(DEFAULT_LANGUAGE_CODE)
|
| 135 |
) if _rag_prompt_env.upper() == "NA" else _rag_prompt_env
|
anam/index.html
CHANGED
|
@@ -10,8 +10,20 @@
|
|
| 10 |
<body>
|
| 11 |
|
| 12 |
<header>
|
| 13 |
-
<
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
</header>
|
| 16 |
|
| 17 |
<!-- Video -->
|
|
@@ -21,8 +33,8 @@
|
|
| 21 |
<circle cx="12" cy="8" r="4"/>
|
| 22 |
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
| 23 |
</svg>
|
| 24 |
-
<p>Avatar will appear here</p>
|
| 25 |
-
<p id="placeholder-hint" style="font-size:11px;margin-top:2px;">Click
|
| 26 |
<div id="placeholder-spinner"></div>
|
| 27 |
<p id="placeholder-status"></p>
|
| 28 |
</div>
|
|
@@ -39,56 +51,54 @@
|
|
| 39 |
<!-- Status -->
|
| 40 |
<div id="status-bar">
|
| 41 |
<div id="status-dot"></div>
|
| 42 |
-
<span id="status-text">Not connected</span>
|
| 43 |
<span id="call-timer"></span>
|
| 44 |
</div>
|
| 45 |
|
| 46 |
<!-- Plan toggle (Free = 2 min cap) -->
|
| 47 |
<div id="plan-section">
|
| 48 |
<div id="plan-row">
|
| 49 |
-
<span class="plan-label" id="plan-free-label">Free · 2 min limit</span>
|
| 50 |
<label class="switch">
|
| 51 |
<input type="checkbox" id="plan-switch" checked />
|
| 52 |
<span class="knob"></span>
|
| 53 |
</label>
|
| 54 |
-
<span class="plan-label active" id="plan-pro-label">Pro · unlimited</span>
|
| 55 |
</div>
|
| 56 |
<div id="plan-banner">
|
| 57 |
-
⏱ <
|
| 58 |
-
<strong>2 minutes</strong> together, show a live countdown, and the session will
|
| 59 |
-
disconnect automatically when time runs out.
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
|
| 63 |
<!-- Controls -->
|
| 64 |
<div class="controls">
|
| 65 |
-
<select id="persona-select"><option value="">— Default ephemeral —</option></select>
|
| 66 |
-
<button id="connect-btn">Connect</button>
|
| 67 |
-
<button id="disconnect-btn" disabled>Disconnect</button>
|
| 68 |
</div>
|
| 69 |
|
| 70 |
<div id="preset-row">
|
| 71 |
-
<button id="advanced-toggle">⚙ Advanced (system prompt)</button>
|
| 72 |
-
<button id="clinical-preset-btn">🩺 Clinical Assistant preset</button>
|
| 73 |
</div>
|
| 74 |
|
| 75 |
<div id="advanced-panel">
|
| 76 |
-
<label for="system-prompt-input">System prompt (used when no persona selected and RAG is off)</label>
|
| 77 |
-
<textarea id="system-prompt-input" rows="3">You are a helpful, friendly AI assistant. Keep responses concise and conversational. Always respond in plain spoken language — never use bullet points, asterisks, pipe characters, markdown headers, or any formatting symbols.
|
| 78 |
|
| 79 |
<!-- ── LLM selector ───────────────────────────────────────────────────── -->
|
| 80 |
<div style="margin-top:10px;display:flex;flex-direction:column;gap:4px">
|
| 81 |
<label for="llm-select" style="font-size:11px;color:#666;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">LLM</label>
|
| 82 |
<select id="llm-select" style="padding:8px 10px;border-radius:6px;border:1px solid #2a2a2a;background:#1a1a1a;color:#e0e0e0;font-size:13px">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
<optgroup label="Anam built-in">
|
| 84 |
-
<option value="a7cf662c-2ace-4de1-a21e-ef0fbf144bb7">GPT OSS 120B
|
| 85 |
<option value="b4f89001-9638-4879-a9c3-02cc9f9f2004">GPT 4.1 — most capable</option>
|
| 86 |
<option value="ANAM_LLAMA_v3_3_70B_V1">Llama 3.3 70B — fastest (no tools/RAG)</option>
|
| 87 |
</optgroup>
|
| 88 |
-
<optgroup label="Custom">
|
| 89 |
-
<option value="__agent__">Agent LLM (localhost:8005) — tools & notepad</option>
|
| 90 |
-
<option value="__custom__">Custom Anam LLM ID…</option>
|
| 91 |
-
</optgroup>
|
| 92 |
</select>
|
| 93 |
</div>
|
| 94 |
|
|
@@ -128,8 +138,8 @@
|
|
| 128 |
|
| 129 |
<!-- Mode tabs (shown after connect) -->
|
| 130 |
<div class="mode-tabs" id="mode-tabs" style="display:none">
|
| 131 |
-
<button class="mode-tab active" data-mode="voice">🎙 Voice</button>
|
| 132 |
-
<button class="mode-tab" data-mode="text"
|
| 133 |
</div>
|
| 134 |
|
| 135 |
<!-- Input area -->
|
|
@@ -144,36 +154,36 @@
|
|
| 144 |
</button>
|
| 145 |
<button id="interrupt-btn" disabled>⏹ Interrupt</button>
|
| 146 |
</div>
|
| 147 |
-
<span id="voice-hint">Microphone is active — speak to the avatar</span>
|
| 148 |
</div>
|
| 149 |
<div id="text-mode-row">
|
| 150 |
-
<input id="message-input" type="text" placeholder="Type a message to the avatar…" disabled />
|
| 151 |
-
<button id="send-btn" disabled>Send</button>
|
| 152 |
</div>
|
| 153 |
</div>
|
| 154 |
|
| 155 |
<!-- Knowledge Base -->
|
| 156 |
<div id="kb-section">
|
| 157 |
<div class="kb-header">
|
| 158 |
-
<h3>📚 Knowledge Base</h3>
|
| 159 |
<div class="kb-header-right">
|
| 160 |
<!-- RAG mode tabs -->
|
| 161 |
<div class="rag-mode-tabs" id="rag-mode-tabs">
|
| 162 |
-
<button class="rag-mode-tab active" data-mode="anam">☁ Anam Cloud</button>
|
| 163 |
-
<button class="rag-mode-tab" data-mode="local">💻 Local</button>
|
| 164 |
</div>
|
| 165 |
</div>
|
| 166 |
</div>
|
| 167 |
|
| 168 |
<!-- ── Anam Cloud RAG panel ─────────────────────────────────────────────── -->
|
| 169 |
<div id="anam-rag-panel" style="display:flex;flex-direction:column;gap:14px">
|
| 170 |
-
<div class="rag-mode-note">Documents indexed in Anam's cloud — avatar searches per turn</div>
|
| 171 |
|
| 172 |
<div class="kb-header" style="margin-top:4px">
|
| 173 |
<div></div>
|
| 174 |
<div class="kb-header-right">
|
| 175 |
<span id="kb-setup-badge"></span>
|
| 176 |
-
<button class="kb-reset-btn" id="kb-reset-btn" title="Clear RAG state (does not delete from Anam)">Reset</button>
|
| 177 |
</div>
|
| 178 |
</div>
|
| 179 |
|
|
@@ -183,35 +193,35 @@
|
|
| 183 |
<polyline points="17 8 12 3 7 8"/>
|
| 184 |
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 185 |
</svg>
|
| 186 |
-
<p>Drop documents here</p>
|
| 187 |
-
<span>PDF · TXT · MD · DOCX · CSV — max 50 MB</span>
|
| 188 |
-
<button id="browse-btn">Browse files</button>
|
| 189 |
</div>
|
| 190 |
<input type="file" id="file-input" multiple accept=".pdf,.txt,.md,.docx,.csv" hidden />
|
| 191 |
|
| 192 |
<div id="upload-status">
|
| 193 |
<div class="spinner"></div>
|
| 194 |
-
<span id="upload-status-text">Uploading…</span>
|
| 195 |
</div>
|
| 196 |
|
| 197 |
<div id="doc-list"></div>
|
| 198 |
|
| 199 |
<label id="use-rag-row" for="use-rag-checkbox">
|
| 200 |
<input type="checkbox" id="use-rag-checkbox" checked />
|
| 201 |
-
<span id="use-rag-label">Use knowledge base — avatar will
|
| 202 |
<span id="rag-doc-count"></span>
|
| 203 |
</label>
|
| 204 |
|
| 205 |
<div id="rag-prompt-section">
|
| 206 |
-
<button id="rag-prompt-toggle">✏️ Edit RAG assistant behaviour (system prompt)</button>
|
| 207 |
<div id="rag-prompt-body" style="display:none;flex-direction:column;gap:8px">
|
| 208 |
-
<label for="rag-system-prompt">
|
| 209 |
System prompt — controls how the avatar uses the knowledge base and answers general questions
|
| 210 |
</label>
|
| 211 |
<textarea id="rag-system-prompt" rows="6"></textarea>
|
| 212 |
<div class="rag-prompt-actions">
|
| 213 |
-
<button id="apply-rag-prompt-btn">Apply to persona</button>
|
| 214 |
-
<span id="rag-prompt-hint">Changes apply to the next session you start.</span>
|
| 215 |
</div>
|
| 216 |
</div>
|
| 217 |
</div>
|
|
@@ -219,8 +229,8 @@
|
|
| 219 |
|
| 220 |
<!-- ── Local RAG panel ──────────────────────────────────────────────────── -->
|
| 221 |
<div id="local-rag-panel" style="display:none;flex-direction:column;gap:14px">
|
| 222 |
-
<div class="rag-mode-note">Documents chunked & embedded locally — only the most relevant passages are retrieved per question.
|
| 223 |
-
<br><span style="color:#555">Ideal for clinical guidelines, protocols & reference PDFs. Embeddings persist across restarts.</span>
|
| 224 |
</div>
|
| 225 |
|
| 226 |
<div id="local-drop-zone">
|
|
@@ -229,9 +239,9 @@
|
|
| 229 |
<polyline points="17 8 12 3 7 8"/>
|
| 230 |
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 231 |
</svg>
|
| 232 |
-
<p>Drop reference documents here</p>
|
| 233 |
-
<span>PDF · DOCX · TXT · MD</span>
|
| 234 |
-
<button id="local-browse-btn">Browse files</button>
|
| 235 |
</div>
|
| 236 |
<input type="file" id="local-file-input" multiple accept=".pdf,.docx,.txt,.md" hidden />
|
| 237 |
|
|
@@ -244,7 +254,7 @@
|
|
| 244 |
|
| 245 |
<label id="use-local-rag-row" for="use-local-rag-checkbox" style="display:none">
|
| 246 |
<input type="checkbox" id="use-local-rag-checkbox" checked />
|
| 247 |
-
<span>Inject documents into avatar's context when connecting</span>
|
| 248 |
<span id="local-doc-count"></span>
|
| 249 |
</label>
|
| 250 |
</div>
|
|
@@ -253,10 +263,10 @@
|
|
| 253 |
<!-- Blood Test Report -->
|
| 254 |
<div id="clinical-section">
|
| 255 |
<div class="kb-header">
|
| 256 |
-
<h3>🩺 Blood Test Report</h3>
|
| 257 |
<div class="kb-header-right">
|
| 258 |
<span id="report-status-badge"></span>
|
| 259 |
-
<button class="kb-reset-btn" id="report-reset-btn" title="Clear uploaded report">Reset</button>
|
| 260 |
</div>
|
| 261 |
</div>
|
| 262 |
|
|
@@ -266,15 +276,15 @@
|
|
| 266 |
<polyline points="17 8 12 3 7 8"/>
|
| 267 |
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 268 |
</svg>
|
| 269 |
-
<p>Drop your blood test report here</p>
|
| 270 |
-
<span>Text-based PDF — max 50 MB</span>
|
| 271 |
-
<button id="report-browse-btn">Browse files</button>
|
| 272 |
</div>
|
| 273 |
<input type="file" id="report-file-input" accept=".pdf" hidden />
|
| 274 |
|
| 275 |
<div id="report-upload-status">
|
| 276 |
<div class="spinner"></div>
|
| 277 |
-
<span id="report-upload-status-text">Analysing report…</span>
|
| 278 |
</div>
|
| 279 |
|
| 280 |
<div id="report-summary"></div>
|
|
@@ -283,26 +293,26 @@
|
|
| 283 |
<!-- Usage & cost telemetry -->
|
| 284 |
<div id="telemetry-panel">
|
| 285 |
<div class="tm-header">
|
| 286 |
-
<h3>📊 Session Cost</h3>
|
| 287 |
-
<button class="kb-reset-btn" id="telemetry-reset-btn">Reset</button>
|
| 288 |
</div>
|
| 289 |
|
| 290 |
<div id="telemetry-stats">
|
| 291 |
<div class="tm-stat">
|
| 292 |
<span class="tm-stat-value" id="tm-total-cost">$0.00</span>
|
| 293 |
-
<span class="tm-stat-label">Estimated cost</span>
|
| 294 |
</div>
|
| 295 |
<div class="tm-stat">
|
| 296 |
<span class="tm-stat-value" id="tm-total-minutes">0</span>
|
| 297 |
-
<span class="tm-stat-label">Minutes billed</span>
|
| 298 |
</div>
|
| 299 |
<div class="tm-stat">
|
| 300 |
<span class="tm-stat-value" id="tm-session-count">0</span>
|
| 301 |
-
<span class="tm-stat-label">Sessions</span>
|
| 302 |
</div>
|
| 303 |
<div class="tm-stat live" id="tm-live-stat" style="display:none">
|
| 304 |
<span class="tm-stat-value" id="tm-live-cost">$0.00</span>
|
| 305 |
-
<span class="tm-stat-label">Live session</span>
|
| 306 |
</div>
|
| 307 |
</div>
|
| 308 |
|
|
|
|
| 10 |
<body>
|
| 11 |
|
| 12 |
<header>
|
| 13 |
+
<div class="header-row">
|
| 14 |
+
<h1>Anam AI Demo</h1>
|
| 15 |
+
<div id="lang-switcher">
|
| 16 |
+
<label for="lang-select" data-i18n="lang.label">Language</label>
|
| 17 |
+
<select id="lang-select">
|
| 18 |
+
<option value="en">English</option>
|
| 19 |
+
<option value="it">Italiano</option>
|
| 20 |
+
<option value="fr">Français</option>
|
| 21 |
+
<option value="es">Español</option>
|
| 22 |
+
<option value="de">Deutsch</option>
|
| 23 |
+
</select>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
<p data-i18n="header.subtitle">Real-time conversation with an Anam AI persona — with document Q&A</p>
|
| 27 |
</header>
|
| 28 |
|
| 29 |
<!-- Video -->
|
|
|
|
| 33 |
<circle cx="12" cy="8" r="4"/>
|
| 34 |
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
| 35 |
</svg>
|
| 36 |
+
<p data-i18n="placeholder.text">Avatar will appear here</p>
|
| 37 |
+
<p id="placeholder-hint" style="font-size:11px;margin-top:2px;" data-i18n="placeholder.hint">Click Connect to start</p>
|
| 38 |
<div id="placeholder-spinner"></div>
|
| 39 |
<p id="placeholder-status"></p>
|
| 40 |
</div>
|
|
|
|
| 51 |
<!-- Status -->
|
| 52 |
<div id="status-bar">
|
| 53 |
<div id="status-dot"></div>
|
| 54 |
+
<span id="status-text" data-i18n="status.notConnected">Not connected</span>
|
| 55 |
<span id="call-timer"></span>
|
| 56 |
</div>
|
| 57 |
|
| 58 |
<!-- Plan toggle (Free = 2 min cap) -->
|
| 59 |
<div id="plan-section">
|
| 60 |
<div id="plan-row">
|
| 61 |
+
<span class="plan-label" id="plan-free-label" data-i18n="plan.free">Free · 2 min limit</span>
|
| 62 |
<label class="switch">
|
| 63 |
<input type="checkbox" id="plan-switch" checked />
|
| 64 |
<span class="knob"></span>
|
| 65 |
</label>
|
| 66 |
+
<span class="plan-label active" id="plan-pro-label" data-i18n="plan.pro">Pro · unlimited</span>
|
| 67 |
</div>
|
| 68 |
<div id="plan-banner">
|
| 69 |
+
⏱ <span data-i18n="plan.banner">Free plan: the avatar will tell you up front that you have 2 minutes together, show a live countdown, and the session will disconnect automatically when time runs out.</span>
|
|
|
|
|
|
|
| 70 |
</div>
|
| 71 |
</div>
|
| 72 |
|
| 73 |
<!-- Controls -->
|
| 74 |
<div class="controls">
|
| 75 |
+
<select id="persona-select"><option value="" data-i18n="persona.default">— Default ephemeral —</option></select>
|
| 76 |
+
<button id="connect-btn" data-i18n="controls.connect">Connect</button>
|
| 77 |
+
<button id="disconnect-btn" disabled data-i18n="controls.disconnect">Disconnect</button>
|
| 78 |
</div>
|
| 79 |
|
| 80 |
<div id="preset-row">
|
| 81 |
+
<button id="advanced-toggle" data-i18n="advanced.toggle">⚙ Advanced (system prompt)</button>
|
| 82 |
+
<button id="clinical-preset-btn" data-i18n="advanced.clinicalPreset">🩺 Clinical Assistant preset</button>
|
| 83 |
</div>
|
| 84 |
|
| 85 |
<div id="advanced-panel">
|
| 86 |
+
<label for="system-prompt-input" data-i18n="advanced.systemPromptLabel">System prompt (used when no persona selected and RAG is off)</label>
|
| 87 |
+
<textarea id="system-prompt-input" rows="3">You are a helpful, friendly AI assistant. Keep responses concise and conversational. Always respond in plain spoken language — never use bullet points, asterisks, pipe characters, markdown headers, or any formatting symbols.</textarea>
|
| 88 |
|
| 89 |
<!-- ── LLM selector ───────────────────────────────────────────────────── -->
|
| 90 |
<div style="margin-top:10px;display:flex;flex-direction:column;gap:4px">
|
| 91 |
<label for="llm-select" style="font-size:11px;color:#666;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">LLM</label>
|
| 92 |
<select id="llm-select" style="padding:8px 10px;border-radius:6px;border:1px solid #2a2a2a;background:#1a1a1a;color:#e0e0e0;font-size:13px">
|
| 93 |
+
<optgroup label="Ours (default)">
|
| 94 |
+
<option value="__agent__" selected>Our Agent LLM — tools & notepad</option>
|
| 95 |
+
<option value="__custom__">Custom Anam LLM ID…</option>
|
| 96 |
+
</optgroup>
|
| 97 |
<optgroup label="Anam built-in">
|
| 98 |
+
<option value="a7cf662c-2ace-4de1-a21e-ef0fbf144bb7">GPT OSS 120B</option>
|
| 99 |
<option value="b4f89001-9638-4879-a9c3-02cc9f9f2004">GPT 4.1 — most capable</option>
|
| 100 |
<option value="ANAM_LLAMA_v3_3_70B_V1">Llama 3.3 70B — fastest (no tools/RAG)</option>
|
| 101 |
</optgroup>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
</select>
|
| 103 |
</div>
|
| 104 |
|
|
|
|
| 138 |
|
| 139 |
<!-- Mode tabs (shown after connect) -->
|
| 140 |
<div class="mode-tabs" id="mode-tabs" style="display:none">
|
| 141 |
+
<button class="mode-tab active" data-mode="voice" data-i18n="mode.voice">🎙 Voice</button>
|
| 142 |
+
<button class="mode-tab" data-mode="text" data-i18n="mode.text">⌨️ Text</button>
|
| 143 |
</div>
|
| 144 |
|
| 145 |
<!-- Input area -->
|
|
|
|
| 154 |
</button>
|
| 155 |
<button id="interrupt-btn" disabled>⏹ Interrupt</button>
|
| 156 |
</div>
|
| 157 |
+
<span id="voice-hint" data-i18n="voice.hint">Microphone is active — speak to the avatar</span>
|
| 158 |
</div>
|
| 159 |
<div id="text-mode-row">
|
| 160 |
+
<input id="message-input" type="text" placeholder="Type a message to the avatar…" disabled data-i18n-placeholder="text.placeholder" />
|
| 161 |
+
<button id="send-btn" disabled data-i18n="text.send">Send</button>
|
| 162 |
</div>
|
| 163 |
</div>
|
| 164 |
|
| 165 |
<!-- Knowledge Base -->
|
| 166 |
<div id="kb-section">
|
| 167 |
<div class="kb-header">
|
| 168 |
+
<h3 data-i18n="kb.title">📚 Knowledge Base</h3>
|
| 169 |
<div class="kb-header-right">
|
| 170 |
<!-- RAG mode tabs -->
|
| 171 |
<div class="rag-mode-tabs" id="rag-mode-tabs">
|
| 172 |
+
<button class="rag-mode-tab active" data-mode="anam" data-i18n="kb.cloudTab">☁ Anam Cloud</button>
|
| 173 |
+
<button class="rag-mode-tab" data-mode="local" data-i18n="kb.localTab">💻 Local</button>
|
| 174 |
</div>
|
| 175 |
</div>
|
| 176 |
</div>
|
| 177 |
|
| 178 |
<!-- ── Anam Cloud RAG panel ─────────────────────────────────────────────── -->
|
| 179 |
<div id="anam-rag-panel" style="display:flex;flex-direction:column;gap:14px">
|
| 180 |
+
<div class="rag-mode-note" data-i18n="kb.cloudNote">Documents indexed in Anam's cloud — avatar searches per turn</div>
|
| 181 |
|
| 182 |
<div class="kb-header" style="margin-top:4px">
|
| 183 |
<div></div>
|
| 184 |
<div class="kb-header-right">
|
| 185 |
<span id="kb-setup-badge"></span>
|
| 186 |
+
<button class="kb-reset-btn" id="kb-reset-btn" title="Clear RAG state (does not delete from Anam)" data-i18n="kb.reset">Reset</button>
|
| 187 |
</div>
|
| 188 |
</div>
|
| 189 |
|
|
|
|
| 193 |
<polyline points="17 8 12 3 7 8"/>
|
| 194 |
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 195 |
</svg>
|
| 196 |
+
<p data-i18n="kb.dropHint">Drop documents here</p>
|
| 197 |
+
<span data-i18n="kb.dropSubtext">PDF · TXT · MD · DOCX · CSV — max 50 MB</span>
|
| 198 |
+
<button id="browse-btn" data-i18n="kb.browse">Browse files</button>
|
| 199 |
</div>
|
| 200 |
<input type="file" id="file-input" multiple accept=".pdf,.txt,.md,.docx,.csv" hidden />
|
| 201 |
|
| 202 |
<div id="upload-status">
|
| 203 |
<div class="spinner"></div>
|
| 204 |
+
<span id="upload-status-text" data-i18n="kb.uploading">Uploading…</span>
|
| 205 |
</div>
|
| 206 |
|
| 207 |
<div id="doc-list"></div>
|
| 208 |
|
| 209 |
<label id="use-rag-row" for="use-rag-checkbox">
|
| 210 |
<input type="checkbox" id="use-rag-checkbox" checked />
|
| 211 |
+
<span id="use-rag-label" data-i18n="kb.useRagLabel">Use knowledge base — avatar will search your docs when answering</span>
|
| 212 |
<span id="rag-doc-count"></span>
|
| 213 |
</label>
|
| 214 |
|
| 215 |
<div id="rag-prompt-section">
|
| 216 |
+
<button id="rag-prompt-toggle" data-i18n="kb.editPrompt">✏️ Edit RAG assistant behaviour (system prompt)</button>
|
| 217 |
<div id="rag-prompt-body" style="display:none;flex-direction:column;gap:8px">
|
| 218 |
+
<label for="rag-system-prompt" data-i18n="kb.promptLabel">
|
| 219 |
System prompt — controls how the avatar uses the knowledge base and answers general questions
|
| 220 |
</label>
|
| 221 |
<textarea id="rag-system-prompt" rows="6"></textarea>
|
| 222 |
<div class="rag-prompt-actions">
|
| 223 |
+
<button id="apply-rag-prompt-btn" data-i18n="kb.applyPrompt">Apply to persona</button>
|
| 224 |
+
<span id="rag-prompt-hint" data-i18n="kb.promptHint">Changes apply to the next session you start.</span>
|
| 225 |
</div>
|
| 226 |
</div>
|
| 227 |
</div>
|
|
|
|
| 229 |
|
| 230 |
<!-- ── Local RAG panel ──────────────────────────────────────────────────── -->
|
| 231 |
<div id="local-rag-panel" style="display:none;flex-direction:column;gap:14px">
|
| 232 |
+
<div class="rag-mode-note"><span data-i18n="kb.localNote">Documents chunked & embedded locally — only the most relevant passages are retrieved per question.</span>
|
| 233 |
+
<br><span style="color:#555" data-i18n="kb.localNoteSub">Ideal for clinical guidelines, protocols & reference PDFs. Embeddings persist across restarts.</span>
|
| 234 |
</div>
|
| 235 |
|
| 236 |
<div id="local-drop-zone">
|
|
|
|
| 239 |
<polyline points="17 8 12 3 7 8"/>
|
| 240 |
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 241 |
</svg>
|
| 242 |
+
<p data-i18n="kb.localDropHint">Drop reference documents here</p>
|
| 243 |
+
<span data-i18n="kb.localDropSubtext">PDF · DOCX · TXT · MD</span>
|
| 244 |
+
<button id="local-browse-btn" data-i18n="kb.localBrowse">Browse files</button>
|
| 245 |
</div>
|
| 246 |
<input type="file" id="local-file-input" multiple accept=".pdf,.docx,.txt,.md" hidden />
|
| 247 |
|
|
|
|
| 254 |
|
| 255 |
<label id="use-local-rag-row" for="use-local-rag-checkbox" style="display:none">
|
| 256 |
<input type="checkbox" id="use-local-rag-checkbox" checked />
|
| 257 |
+
<span data-i18n="kb.localInjectLabel">Inject documents into avatar's context when connecting</span>
|
| 258 |
<span id="local-doc-count"></span>
|
| 259 |
</label>
|
| 260 |
</div>
|
|
|
|
| 263 |
<!-- Blood Test Report -->
|
| 264 |
<div id="clinical-section">
|
| 265 |
<div class="kb-header">
|
| 266 |
+
<h3 data-i18n="clinical.title">🩺 Blood Test Report</h3>
|
| 267 |
<div class="kb-header-right">
|
| 268 |
<span id="report-status-badge"></span>
|
| 269 |
+
<button class="kb-reset-btn" id="report-reset-btn" title="Clear uploaded report" data-i18n="kb.reset">Reset</button>
|
| 270 |
</div>
|
| 271 |
</div>
|
| 272 |
|
|
|
|
| 276 |
<polyline points="17 8 12 3 7 8"/>
|
| 277 |
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 278 |
</svg>
|
| 279 |
+
<p data-i18n="clinical.dropHint">Drop your blood test report here</p>
|
| 280 |
+
<span data-i18n="clinical.dropSubtext">Text-based PDF — max 50 MB</span>
|
| 281 |
+
<button id="report-browse-btn" data-i18n="clinical.browse">Browse files</button>
|
| 282 |
</div>
|
| 283 |
<input type="file" id="report-file-input" accept=".pdf" hidden />
|
| 284 |
|
| 285 |
<div id="report-upload-status">
|
| 286 |
<div class="spinner"></div>
|
| 287 |
+
<span id="report-upload-status-text" data-i18n="clinical.analysing">Analysing report…</span>
|
| 288 |
</div>
|
| 289 |
|
| 290 |
<div id="report-summary"></div>
|
|
|
|
| 293 |
<!-- Usage & cost telemetry -->
|
| 294 |
<div id="telemetry-panel">
|
| 295 |
<div class="tm-header">
|
| 296 |
+
<h3 data-i18n="telemetry.title">📊 Session Cost</h3>
|
| 297 |
+
<button class="kb-reset-btn" id="telemetry-reset-btn" data-i18n="kb.reset">Reset</button>
|
| 298 |
</div>
|
| 299 |
|
| 300 |
<div id="telemetry-stats">
|
| 301 |
<div class="tm-stat">
|
| 302 |
<span class="tm-stat-value" id="tm-total-cost">$0.00</span>
|
| 303 |
+
<span class="tm-stat-label" data-i18n="telemetry.cost">Estimated cost</span>
|
| 304 |
</div>
|
| 305 |
<div class="tm-stat">
|
| 306 |
<span class="tm-stat-value" id="tm-total-minutes">0</span>
|
| 307 |
+
<span class="tm-stat-label" data-i18n="telemetry.minutes">Minutes billed</span>
|
| 308 |
</div>
|
| 309 |
<div class="tm-stat">
|
| 310 |
<span class="tm-stat-value" id="tm-session-count">0</span>
|
| 311 |
+
<span class="tm-stat-label" data-i18n="telemetry.sessions">Sessions</span>
|
| 312 |
</div>
|
| 313 |
<div class="tm-stat live" id="tm-live-stat" style="display:none">
|
| 314 |
<span class="tm-stat-value" id="tm-live-cost">$0.00</span>
|
| 315 |
+
<span class="tm-stat-label" data-i18n="telemetry.live">Live session</span>
|
| 316 |
</div>
|
| 317 |
</div>
|
| 318 |
|
anam/rag.py
CHANGED
|
@@ -1,12 +1,22 @@
|
|
| 1 |
import json
|
| 2 |
from pathlib import Path
|
|
|
|
| 3 |
|
| 4 |
import httpx
|
| 5 |
from fastapi import APIRouter, HTTPException, UploadFile, File
|
| 6 |
from fastapi.responses import JSONResponse
|
| 7 |
from pydantic import BaseModel
|
| 8 |
|
| 9 |
-
from config import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from helpers import auth_headers, check
|
| 11 |
|
| 12 |
# ── Persistent state ──────────────────────────────────────────────────────────
|
|
@@ -149,18 +159,32 @@ async def rag_delete_document(doc_id: str):
|
|
| 149 |
|
| 150 |
class UpdatePersonaRequest(BaseModel):
|
| 151 |
system_prompt: str
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
|
| 154 |
@router.put("/persona")
|
| 155 |
async def update_rag_persona(req: UpdatePersonaRequest):
|
| 156 |
-
"""Update the RAG persona's system prompt
|
|
|
|
|
|
|
|
|
|
| 157 |
if not rag_state.get("persona_id"):
|
| 158 |
raise HTTPException(status_code=404, detail="RAG persona not set up yet — upload a document first.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
async with httpx.AsyncClient(timeout=30) as c:
|
| 160 |
r = await c.put(
|
| 161 |
f"{BASE}/personas/{rag_state['persona_id']}",
|
| 162 |
headers=auth_headers(),
|
| 163 |
-
json=
|
| 164 |
)
|
| 165 |
check(r)
|
| 166 |
return JSONResponse({"ok": True})
|
|
@@ -168,14 +192,17 @@ async def update_rag_persona(req: UpdatePersonaRequest):
|
|
| 168 |
|
| 169 |
@router.get("/persona")
|
| 170 |
async def get_rag_persona():
|
| 171 |
-
"""Return the current RAG persona's system prompt."""
|
| 172 |
if not rag_state.get("persona_id"):
|
| 173 |
-
return JSONResponse({"system_prompt": RAG_SYSTEM_PROMPT})
|
| 174 |
async with httpx.AsyncClient(timeout=30) as c:
|
| 175 |
r = await c.get(f"{BASE}/personas/{rag_state['persona_id']}", headers=auth_headers())
|
| 176 |
check(r)
|
| 177 |
data = r.json()
|
| 178 |
-
return JSONResponse({
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
@router.post("/reset")
|
|
|
|
| 1 |
import json
|
| 2 |
from pathlib import Path
|
| 3 |
+
from typing import Optional
|
| 4 |
|
| 5 |
import httpx
|
| 6 |
from fastapi import APIRouter, HTTPException, UploadFile, File
|
| 7 |
from fastapi.responses import JSONResponse
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
+
from config import (
|
| 11 |
+
BASE,
|
| 12 |
+
ANAM_API_KEY,
|
| 13 |
+
DEFAULT_AVATAR_ID,
|
| 14 |
+
DEFAULT_VOICE_ID,
|
| 15 |
+
DEFAULT_LLM_ID,
|
| 16 |
+
DEFAULT_LANGUAGE_CODE,
|
| 17 |
+
RAG_SYSTEM_PROMPT,
|
| 18 |
+
SUPPORTED_LANGUAGES,
|
| 19 |
+
)
|
| 20 |
from helpers import auth_headers, check
|
| 21 |
|
| 22 |
# ── Persistent state ──────────────────────────────────────────────────────────
|
|
|
|
| 159 |
|
| 160 |
class UpdatePersonaRequest(BaseModel):
|
| 161 |
system_prompt: str
|
| 162 |
+
# Optional — one of config.SUPPORTED_LANGUAGES. Omit to leave the
|
| 163 |
+
# persona's current language untouched.
|
| 164 |
+
language_code: Optional[str] = None
|
| 165 |
|
| 166 |
|
| 167 |
@router.put("/persona")
|
| 168 |
async def update_rag_persona(req: UpdatePersonaRequest):
|
| 169 |
+
"""Update the RAG persona's system prompt (and optionally language) on the fly.
|
| 170 |
+
|
| 171 |
+
No need to reconnect — Anam applies persona edits to the next turn.
|
| 172 |
+
"""
|
| 173 |
if not rag_state.get("persona_id"):
|
| 174 |
raise HTTPException(status_code=404, detail="RAG persona not set up yet — upload a document first.")
|
| 175 |
+
if req.language_code and req.language_code not in SUPPORTED_LANGUAGES:
|
| 176 |
+
raise HTTPException(
|
| 177 |
+
status_code=400,
|
| 178 |
+
detail=f"Unsupported language_code '{req.language_code}'. Supported: {sorted(SUPPORTED_LANGUAGES)}",
|
| 179 |
+
)
|
| 180 |
+
payload: dict = {"systemPrompt": req.system_prompt}
|
| 181 |
+
if req.language_code:
|
| 182 |
+
payload["languageCode"] = req.language_code
|
| 183 |
async with httpx.AsyncClient(timeout=30) as c:
|
| 184 |
r = await c.put(
|
| 185 |
f"{BASE}/personas/{rag_state['persona_id']}",
|
| 186 |
headers=auth_headers(),
|
| 187 |
+
json=payload,
|
| 188 |
)
|
| 189 |
check(r)
|
| 190 |
return JSONResponse({"ok": True})
|
|
|
|
| 192 |
|
| 193 |
@router.get("/persona")
|
| 194 |
async def get_rag_persona():
|
| 195 |
+
"""Return the current RAG persona's system prompt and language."""
|
| 196 |
if not rag_state.get("persona_id"):
|
| 197 |
+
return JSONResponse({"system_prompt": RAG_SYSTEM_PROMPT, "language_code": DEFAULT_LANGUAGE_CODE})
|
| 198 |
async with httpx.AsyncClient(timeout=30) as c:
|
| 199 |
r = await c.get(f"{BASE}/personas/{rag_state['persona_id']}", headers=auth_headers())
|
| 200 |
check(r)
|
| 201 |
data = r.json()
|
| 202 |
+
return JSONResponse({
|
| 203 |
+
"system_prompt": data.get("systemPrompt", RAG_SYSTEM_PROMPT),
|
| 204 |
+
"language_code": data.get("languageCode", DEFAULT_LANGUAGE_CODE),
|
| 205 |
+
})
|
| 206 |
|
| 207 |
|
| 208 |
@router.post("/reset")
|
anam/routers/sessions.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import uuid
|
| 2 |
|
| 3 |
import httpx
|
| 4 |
-
from fastapi import APIRouter
|
| 5 |
from fastapi.responses import JSONResponse
|
| 6 |
from pydantic import BaseModel
|
| 7 |
|
|
@@ -13,7 +13,9 @@ from config import (
|
|
| 13 |
DEFAULT_LLM_ID,
|
| 14 |
DEFAULT_VOICE_ID,
|
| 15 |
ENABLE_AUDIO_PASSTHROUGH,
|
|
|
|
| 16 |
TTS_PROVIDER,
|
|
|
|
| 17 |
)
|
| 18 |
from helpers import auth_headers, check
|
| 19 |
from rag import rag_state
|
|
@@ -31,6 +33,14 @@ async def create_session_token(req: SessionTokenRequest):
|
|
| 31 |
system_prompt = req.system_prompt
|
| 32 |
|
| 33 |
language_code = req.language_code or DEFAULT_LANGUAGE_CODE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
if req.use_custom_llm:
|
| 36 |
persona_config = {
|
|
|
|
| 1 |
import uuid
|
| 2 |
|
| 3 |
import httpx
|
| 4 |
+
from fastapi import APIRouter, HTTPException
|
| 5 |
from fastapi.responses import JSONResponse
|
| 6 |
from pydantic import BaseModel
|
| 7 |
|
|
|
|
| 13 |
DEFAULT_LLM_ID,
|
| 14 |
DEFAULT_VOICE_ID,
|
| 15 |
ENABLE_AUDIO_PASSTHROUGH,
|
| 16 |
+
SUPPORTED_LANGUAGES,
|
| 17 |
TTS_PROVIDER,
|
| 18 |
+
language_instruction,
|
| 19 |
)
|
| 20 |
from helpers import auth_headers, check
|
| 21 |
from rag import rag_state
|
|
|
|
| 33 |
system_prompt = req.system_prompt
|
| 34 |
|
| 35 |
language_code = req.language_code or DEFAULT_LANGUAGE_CODE
|
| 36 |
+
if language_code and language_code not in SUPPORTED_LANGUAGES:
|
| 37 |
+
raise HTTPException(
|
| 38 |
+
status_code=400,
|
| 39 |
+
detail=f"Unsupported language_code '{language_code}'. Supported: {sorted(SUPPORTED_LANGUAGES)}",
|
| 40 |
+
)
|
| 41 |
+
# Append the "respond in X" clause to whatever prompt the caller supplied
|
| 42 |
+
# (default, clinical preset, etc.) rather than baking one language into it.
|
| 43 |
+
system_prompt = system_prompt + language_instruction(language_code)
|
| 44 |
|
| 45 |
if req.use_custom_llm:
|
| 46 |
persona_config = {
|
anam/schemas.py
CHANGED
|
@@ -9,13 +9,15 @@ class SessionTokenRequest(BaseModel):
|
|
| 9 |
system_prompt: str = (
|
| 10 |
"You are a helpful, friendly AI assistant. Keep responses concise and conversational. "
|
| 11 |
"Always respond in plain spoken language. Never use bullet points, asterisks, pipe characters, "
|
| 12 |
-
"markdown headers, or any formatting symbols — speak in natural sentences only.
|
| 13 |
-
"Always respond in Italian (italiano), regardless of the language the question was asked in."
|
| 14 |
)
|
| 15 |
avatar_id: Optional[str] = None
|
| 16 |
voice_id: Optional[str] = None
|
| 17 |
llm_id: Optional[str] = None
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
| 19 |
use_rag: bool = False
|
| 20 |
local_rag_doc_ids: list[str] = [] # inject these local docs into system prompt
|
| 21 |
use_custom_llm: bool = False # set llmId=CUSTOMER_CLIENT_V1 to bypass Anam's brain
|
|
|
|
| 9 |
system_prompt: str = (
|
| 10 |
"You are a helpful, friendly AI assistant. Keep responses concise and conversational. "
|
| 11 |
"Always respond in plain spoken language. Never use bullet points, asterisks, pipe characters, "
|
| 12 |
+
"markdown headers, or any formatting symbols — speak in natural sentences only."
|
|
|
|
| 13 |
)
|
| 14 |
avatar_id: Optional[str] = None
|
| 15 |
voice_id: Optional[str] = None
|
| 16 |
llm_id: Optional[str] = None
|
| 17 |
+
# ISO 639-1 code, one of config.SUPPORTED_LANGUAGES (en/it/fr/es/de) — falls back
|
| 18 |
+
# to ANAM_LANGUAGE_CODE. Drives both Anam's STT and the "respond in X" instruction
|
| 19 |
+
# appended to system_prompt server-side (see routers/sessions.py).
|
| 20 |
+
language_code: Optional[str] = None
|
| 21 |
use_rag: bool = False
|
| 22 |
local_rag_doc_ids: list[str] = [] # inject these local docs into system prompt
|
| 23 |
use_custom_llm: bool = False # set llmId=CUSTOMER_CLIENT_V1 to bypass Anam's brain
|
anam/static/css/style.css
CHANGED
|
@@ -11,6 +11,20 @@ header { text-align: center; }
|
|
| 11 |
header h1 { font-size: 26px; font-weight: 700; }
|
| 12 |
header p { font-size: 13px; color: #666; margin-top: 4px; }
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
/* ── Video ── */
|
| 15 |
#video-container {
|
| 16 |
width: 640px; max-width: 100%; aspect-ratio: 16/9;
|
|
|
|
| 11 |
header h1 { font-size: 26px; font-weight: 700; }
|
| 12 |
header p { font-size: 13px; color: #666; margin-top: 4px; }
|
| 13 |
|
| 14 |
+
.header-row {
|
| 15 |
+
display: flex; align-items: center; justify-content: center;
|
| 16 |
+
gap: 16px; position: relative;
|
| 17 |
+
}
|
| 18 |
+
#lang-switcher {
|
| 19 |
+
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
|
| 20 |
+
display: flex; align-items: center; gap: 6px;
|
| 21 |
+
}
|
| 22 |
+
#lang-switcher label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
|
| 23 |
+
#lang-switcher select {
|
| 24 |
+
padding: 6px 8px; border-radius: 6px; border: 1px solid #2a2a2a;
|
| 25 |
+
background: #1a1a1a; color: #e0e0e0; font-size: 12px;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
/* ── Video ── */
|
| 29 |
#video-container {
|
| 30 |
width: 640px; max-width: 100%; aspect-ratio: 16/9;
|
anam/static/js/api.js
CHANGED
|
@@ -23,11 +23,11 @@ export const getRagStatus = () => _request("/api/rag/status");
|
|
| 23 |
export const getRagDocuments = () => _request("/api/rag/documents");
|
| 24 |
export const deleteRagDocument = (id) => _request(`/api/rag/documents/${id}`, { method: "DELETE" });
|
| 25 |
export const getRagPrompt = () => _request("/api/rag/persona");
|
| 26 |
-
export const updateRagPrompt = (prompt) =>
|
| 27 |
_request("/api/rag/persona", {
|
| 28 |
method: "PUT",
|
| 29 |
headers: { "Content-Type": "application/json" },
|
| 30 |
-
body: JSON.stringify({ system_prompt: prompt }),
|
| 31 |
});
|
| 32 |
export const resetRag = () => _post("/api/rag/reset", {});
|
| 33 |
|
|
|
|
| 23 |
export const getRagDocuments = () => _request("/api/rag/documents");
|
| 24 |
export const deleteRagDocument = (id) => _request(`/api/rag/documents/${id}`, { method: "DELETE" });
|
| 25 |
export const getRagPrompt = () => _request("/api/rag/persona");
|
| 26 |
+
export const updateRagPrompt = (prompt, languageCode) =>
|
| 27 |
_request("/api/rag/persona", {
|
| 28 |
method: "PUT",
|
| 29 |
headers: { "Content-Type": "application/json" },
|
| 30 |
+
body: JSON.stringify({ system_prompt: prompt, language_code: languageCode || null }),
|
| 31 |
});
|
| 32 |
export const resetRag = () => _post("/api/rag/reset", {});
|
| 33 |
|
anam/static/js/app.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as kb from "./kb.js";
|
|
| 3 |
import * as localRag from "./local_rag.js";
|
| 4 |
import * as clinical from "./clinical.js";
|
| 5 |
import * as telemetry from "./telemetry.js";
|
|
|
|
| 6 |
|
| 7 |
const CLINICAL_SYSTEM_PROMPT =
|
| 8 |
"You are a clinical assistant avatar for the OLMA longevity platform. " +
|
|
@@ -71,6 +72,7 @@ const tmStats = $("telemetry-stats");
|
|
| 71 |
const tmSessions = $("telemetry-sessions");
|
| 72 |
const tmRateNote = $("telemetry-rate-note");
|
| 73 |
const tmResetBtn = $("telemetry-reset-btn");
|
|
|
|
| 74 |
|
| 75 |
// ── Helpers ───────────────────────────────────────────────────────────────────
|
| 76 |
function log(msg, type = "info") {
|
|
@@ -106,7 +108,7 @@ function resetUI() {
|
|
| 106 |
modeTabs.style.display = "none";
|
| 107 |
voiceRow.style.display = "none";
|
| 108 |
textRow.style.display = "none";
|
| 109 |
-
voiceHint.textContent = "
|
| 110 |
micMuted = false;
|
| 111 |
anamClient = null;
|
| 112 |
activeLocalDocIds = [];
|
|
@@ -233,6 +235,7 @@ function syncLlmRows() {
|
|
| 233 |
if (cb) cb.checked = (v === "__agent__");
|
| 234 |
}
|
| 235 |
llmSelect?.addEventListener("change", syncLlmRows);
|
|
|
|
| 236 |
|
| 237 |
// ── Mode tabs ─────────────────────────────────────────────────────────────────
|
| 238 |
document.querySelectorAll(".mode-tab").forEach((tab) => {
|
|
@@ -339,6 +342,7 @@ connectBtn.addEventListener("click", async () => {
|
|
| 339 |
voice_id: voiceId,
|
| 340 |
use_custom_llm: useCustomLlm,
|
| 341 |
plan: isFreePlan() ? "free" : "pro",
|
|
|
|
| 342 |
});
|
| 343 |
const sessionToken = tokData?.sessionToken;
|
| 344 |
if (!sessionToken) throw new Error("No sessionToken in response: " + JSON.stringify(tokData));
|
|
@@ -490,13 +494,13 @@ micBtn.addEventListener("click", () => {
|
|
| 490 |
anamClient.muteInputAudio();
|
| 491 |
micBtn.classList.add("muted");
|
| 492 |
micOverlay.classList.add("muted");
|
| 493 |
-
voiceHint.textContent = "
|
| 494 |
log("Microphone muted", "info");
|
| 495 |
} else {
|
| 496 |
anamClient.unmuteInputAudio();
|
| 497 |
micBtn.classList.remove("muted");
|
| 498 |
micOverlay.classList.remove("muted");
|
| 499 |
-
voiceHint.textContent = "
|
| 500 |
log("Microphone unmuted", "success");
|
| 501 |
}
|
| 502 |
});
|
|
@@ -530,6 +534,22 @@ disconnBtn.addEventListener("click", () => {
|
|
| 530 |
resetUI();
|
| 531 |
});
|
| 532 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
// ── Init ──────────────────────────────────────────────────────────────────────
|
| 534 |
tmResetBtn?.addEventListener("click", () => telemetry.reset(tmStats, tmSessions, tmRateNote));
|
| 535 |
telemetry.refresh(tmStats, tmSessions, tmRateNote);
|
|
|
|
| 3 |
import * as localRag from "./local_rag.js";
|
| 4 |
import * as clinical from "./clinical.js";
|
| 5 |
import * as telemetry from "./telemetry.js";
|
| 6 |
+
import * as i18n from "./i18n.js";
|
| 7 |
|
| 8 |
const CLINICAL_SYSTEM_PROMPT =
|
| 9 |
"You are a clinical assistant avatar for the OLMA longevity platform. " +
|
|
|
|
| 72 |
const tmSessions = $("telemetry-sessions");
|
| 73 |
const tmRateNote = $("telemetry-rate-note");
|
| 74 |
const tmResetBtn = $("telemetry-reset-btn");
|
| 75 |
+
const langSelect = $("lang-select");
|
| 76 |
|
| 77 |
// ── Helpers ───────────────────────────────────────────────────────────────────
|
| 78 |
function log(msg, type = "info") {
|
|
|
|
| 108 |
modeTabs.style.display = "none";
|
| 109 |
voiceRow.style.display = "none";
|
| 110 |
textRow.style.display = "none";
|
| 111 |
+
voiceHint.textContent = i18n.t("voice.hint");
|
| 112 |
micMuted = false;
|
| 113 |
anamClient = null;
|
| 114 |
activeLocalDocIds = [];
|
|
|
|
| 235 |
if (cb) cb.checked = (v === "__agent__");
|
| 236 |
}
|
| 237 |
llmSelect?.addEventListener("change", syncLlmRows);
|
| 238 |
+
syncLlmRows(); // reflect the "__agent__" default (our LLM) immediately, don't wait on getConfig()
|
| 239 |
|
| 240 |
// ── Mode tabs ─────────────────────────────────────────────────────────────────
|
| 241 |
document.querySelectorAll(".mode-tab").forEach((tab) => {
|
|
|
|
| 342 |
voice_id: voiceId,
|
| 343 |
use_custom_llm: useCustomLlm,
|
| 344 |
plan: isFreePlan() ? "free" : "pro",
|
| 345 |
+
language_code: i18n.getLocale(),
|
| 346 |
});
|
| 347 |
const sessionToken = tokData?.sessionToken;
|
| 348 |
if (!sessionToken) throw new Error("No sessionToken in response: " + JSON.stringify(tokData));
|
|
|
|
| 494 |
anamClient.muteInputAudio();
|
| 495 |
micBtn.classList.add("muted");
|
| 496 |
micOverlay.classList.add("muted");
|
| 497 |
+
voiceHint.textContent = i18n.t("voice.hintMuted");
|
| 498 |
log("Microphone muted", "info");
|
| 499 |
} else {
|
| 500 |
anamClient.unmuteInputAudio();
|
| 501 |
micBtn.classList.remove("muted");
|
| 502 |
micOverlay.classList.remove("muted");
|
| 503 |
+
voiceHint.textContent = i18n.t("voice.hint");
|
| 504 |
log("Microphone unmuted", "success");
|
| 505 |
}
|
| 506 |
});
|
|
|
|
| 534 |
resetUI();
|
| 535 |
});
|
| 536 |
|
| 537 |
+
// ── Language switcher ─────────────────────────────────────────────────────────
|
| 538 |
+
// One control drives everything language-related: the visible UI text (via
|
| 539 |
+
// i18n.applyTranslations) and the language_code sent with the session token
|
| 540 |
+
// above, which the backend uses for both Anam's STT and the "respond in X"
|
| 541 |
+
// system-prompt instruction (see anam/config.py language_instruction).
|
| 542 |
+
if (langSelect) {
|
| 543 |
+
langSelect.value = i18n.getLocale();
|
| 544 |
+
langSelect.addEventListener("change", () => {
|
| 545 |
+
i18n.setLocale(langSelect.value);
|
| 546 |
+
i18n.applyTranslations();
|
| 547 |
+
voiceHint.textContent = i18n.t(micMuted ? "voice.hintMuted" : "voice.hint");
|
| 548 |
+
if (!anamClient) statusText.textContent = i18n.t("status.notConnected");
|
| 549 |
+
});
|
| 550 |
+
}
|
| 551 |
+
i18n.applyTranslations();
|
| 552 |
+
|
| 553 |
// ── Init ──────────────────────────────────────────────────────────────────────
|
| 554 |
tmResetBtn?.addEventListener("click", () => telemetry.reset(tmStats, tmSessions, tmRateNote));
|
| 555 |
telemetry.refresh(tmStats, tmSessions, tmRateNote);
|
anam/static/js/i18n.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Static UI translations + language switching for the five languages the
|
| 2 |
+
// backend's language_code also understands (see anam/config.py SUPPORTED_LANGUAGES).
|
| 3 |
+
// Selecting a language here does two things: (1) re-renders every element
|
| 4 |
+
// carrying a data-i18n[-*] attribute, and (2) is read by app.js/kb.js as the
|
| 5 |
+
// language_code sent to the backend, so the avatar's spoken language and the
|
| 6 |
+
// page's visible text always switch together.
|
| 7 |
+
|
| 8 |
+
export const LOCALES = ["en", "it", "fr", "es", "de"];
|
| 9 |
+
const STORAGE_KEY = "anam_locale";
|
| 10 |
+
|
| 11 |
+
export const LOCALE_LABELS = {
|
| 12 |
+
en: "English",
|
| 13 |
+
it: "Italiano",
|
| 14 |
+
fr: "Français",
|
| 15 |
+
es: "Español",
|
| 16 |
+
de: "Deutsch",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const T = {
|
| 20 |
+
en: {
|
| 21 |
+
"lang.label": "Language",
|
| 22 |
+
"header.subtitle": "Real-time conversation with an Anam AI persona — with document Q&A",
|
| 23 |
+
"placeholder.text": "Avatar will appear here",
|
| 24 |
+
"placeholder.hint": "Click Connect to start",
|
| 25 |
+
"status.notConnected": "Not connected",
|
| 26 |
+
"plan.free": "Free · 2 min limit",
|
| 27 |
+
"plan.pro": "Pro · unlimited",
|
| 28 |
+
"plan.banner": "Free plan: the avatar will tell you up front that you have 2 minutes together, show a live countdown, and the session will disconnect automatically when time runs out.",
|
| 29 |
+
"persona.default": "— Default ephemeral —",
|
| 30 |
+
"controls.connect": "Connect",
|
| 31 |
+
"controls.disconnect": "Disconnect",
|
| 32 |
+
"advanced.toggle": "⚙ Advanced (system prompt)",
|
| 33 |
+
"advanced.clinicalPreset": "🩺 Clinical Assistant preset",
|
| 34 |
+
"advanced.systemPromptLabel": "System prompt (used when no persona selected and RAG is off)",
|
| 35 |
+
"mode.voice": "🎙 Voice",
|
| 36 |
+
"mode.text": "⌨️ Text",
|
| 37 |
+
"voice.hint": "Microphone is active — speak to the avatar",
|
| 38 |
+
"voice.hintMuted": "Microphone muted — click to unmute",
|
| 39 |
+
"text.placeholder": "Type a message to the avatar…",
|
| 40 |
+
"text.send": "Send",
|
| 41 |
+
"kb.title": "📚 Knowledge Base",
|
| 42 |
+
"kb.cloudTab": "☁ Anam Cloud",
|
| 43 |
+
"kb.localTab": "💻 Local",
|
| 44 |
+
"kb.cloudNote": "Documents indexed in Anam's cloud — avatar searches per turn",
|
| 45 |
+
"kb.reset": "Reset",
|
| 46 |
+
"kb.dropHint": "Drop documents here",
|
| 47 |
+
"kb.dropSubtext": "PDF · TXT · MD · DOCX · CSV — max 50 MB",
|
| 48 |
+
"kb.browse": "Browse files",
|
| 49 |
+
"kb.uploading": "Uploading…",
|
| 50 |
+
"kb.useRagLabel": "Use knowledge base — avatar will search your docs when answering",
|
| 51 |
+
"kb.editPrompt": "✏️ Edit RAG assistant behaviour (system prompt)",
|
| 52 |
+
"kb.promptLabel": "System prompt — controls how the avatar uses the knowledge base and answers general questions",
|
| 53 |
+
"kb.applyPrompt": "Apply to persona",
|
| 54 |
+
"kb.promptHint": "Changes apply to the next session you start.",
|
| 55 |
+
"kb.localNote": "Documents chunked & embedded locally — only the most relevant passages are retrieved per question.",
|
| 56 |
+
"kb.localNoteSub": "Ideal for clinical guidelines, protocols & reference PDFs. Embeddings persist across restarts.",
|
| 57 |
+
"kb.localDropHint": "Drop reference documents here",
|
| 58 |
+
"kb.localDropSubtext": "PDF · DOCX · TXT · MD",
|
| 59 |
+
"kb.localBrowse": "Browse files",
|
| 60 |
+
"kb.localInjectLabel": "Inject documents into avatar's context when connecting",
|
| 61 |
+
"clinical.title": "🩺 Blood Test Report",
|
| 62 |
+
"clinical.dropHint": "Drop your blood test report here",
|
| 63 |
+
"clinical.dropSubtext": "Text-based PDF — max 50 MB",
|
| 64 |
+
"clinical.browse": "Browse files",
|
| 65 |
+
"clinical.analysing": "Analysing report…",
|
| 66 |
+
"telemetry.title": "📊 Session Cost",
|
| 67 |
+
"telemetry.cost": "Estimated cost",
|
| 68 |
+
"telemetry.minutes": "Minutes billed",
|
| 69 |
+
"telemetry.sessions": "Sessions",
|
| 70 |
+
"telemetry.live": "Live session",
|
| 71 |
+
},
|
| 72 |
+
it: {
|
| 73 |
+
"lang.label": "Lingua",
|
| 74 |
+
"header.subtitle": "Conversazione in tempo reale con una persona AI di Anam — con domande sui documenti",
|
| 75 |
+
"placeholder.text": "L'avatar apparirà qui",
|
| 76 |
+
"placeholder.hint": "Clicca Connetti per iniziare",
|
| 77 |
+
"status.notConnected": "Non connesso",
|
| 78 |
+
"plan.free": "Gratis · limite 2 min",
|
| 79 |
+
"plan.pro": "Pro · illimitato",
|
| 80 |
+
"plan.banner": "Piano gratuito: l'avatar ti avviserà subito che avete 2 minuti insieme, mostrerà un conto alla rovescia dal vivo e la sessione si disconnetterà automaticamente allo scadere del tempo.",
|
| 81 |
+
"persona.default": "— Predefinito effimero —",
|
| 82 |
+
"controls.connect": "Connetti",
|
| 83 |
+
"controls.disconnect": "Disconnetti",
|
| 84 |
+
"advanced.toggle": "⚙ Avanzate (system prompt)",
|
| 85 |
+
"advanced.clinicalPreset": "🩺 Preimpostazione Assistente Clinico",
|
| 86 |
+
"advanced.systemPromptLabel": "System prompt (usato quando nessuna persona è selezionata e RAG è disattivato)",
|
| 87 |
+
"mode.voice": "🎙 Voce",
|
| 88 |
+
"mode.text": "⌨️ Testo",
|
| 89 |
+
"voice.hint": "Il microfono è attivo — parla con l'avatar",
|
| 90 |
+
"voice.hintMuted": "Microfono disattivato — clicca per riattivare",
|
| 91 |
+
"text.placeholder": "Scrivi un messaggio all'avatar…",
|
| 92 |
+
"text.send": "Invia",
|
| 93 |
+
"kb.title": "📚 Base di conoscenza",
|
| 94 |
+
"kb.cloudTab": "☁ Anam Cloud",
|
| 95 |
+
"kb.localTab": "💻 Locale",
|
| 96 |
+
"kb.cloudNote": "Documenti indicizzati nel cloud di Anam — l'avatar cerca ad ogni turno",
|
| 97 |
+
"kb.reset": "Reimposta",
|
| 98 |
+
"kb.dropHint": "Trascina qui i documenti",
|
| 99 |
+
"kb.dropSubtext": "PDF · TXT · MD · DOCX · CSV — max 50 MB",
|
| 100 |
+
"kb.browse": "Sfoglia file",
|
| 101 |
+
"kb.uploading": "Caricamento…",
|
| 102 |
+
"kb.useRagLabel": "Usa la base di conoscenza — l'avatar cercherà nei tuoi documenti quando risponde",
|
| 103 |
+
"kb.editPrompt": "✏️ Modifica comportamento assistente RAG (system prompt)",
|
| 104 |
+
"kb.promptLabel": "System prompt — controlla come l'avatar usa la base di conoscenza e risponde alle domande generali",
|
| 105 |
+
"kb.applyPrompt": "Applica alla persona",
|
| 106 |
+
"kb.promptHint": "Le modifiche si applicano alla prossima sessione che avvii.",
|
| 107 |
+
"kb.localNote": "Documenti suddivisi ed elaborati localmente — vengono recuperati solo i passaggi più rilevanti per ogni domanda.",
|
| 108 |
+
"kb.localNoteSub": "Ideale per linee guida cliniche, protocolli e PDF di riferimento. Gli embedding persistono tra i riavvii.",
|
| 109 |
+
"kb.localDropHint": "Trascina qui i documenti di riferimento",
|
| 110 |
+
"kb.localDropSubtext": "PDF · DOCX · TXT · MD",
|
| 111 |
+
"kb.localBrowse": "Sfoglia file",
|
| 112 |
+
"kb.localInjectLabel": "Inietta i documenti nel contesto dell'avatar alla connessione",
|
| 113 |
+
"clinical.title": "🩺 Referto esami del sangue",
|
| 114 |
+
"clinical.dropHint": "Trascina qui il tuo referto",
|
| 115 |
+
"clinical.dropSubtext": "PDF testuale — max 50 MB",
|
| 116 |
+
"clinical.browse": "Sfoglia file",
|
| 117 |
+
"clinical.analysing": "Analisi del referto…",
|
| 118 |
+
"telemetry.title": "📊 Costo sessione",
|
| 119 |
+
"telemetry.cost": "Costo stimato",
|
| 120 |
+
"telemetry.minutes": "Minuti fatturati",
|
| 121 |
+
"telemetry.sessions": "Sessioni",
|
| 122 |
+
"telemetry.live": "Sessione in corso",
|
| 123 |
+
},
|
| 124 |
+
fr: {
|
| 125 |
+
"lang.label": "Langue",
|
| 126 |
+
"header.subtitle": "Conversation en temps réel avec un persona IA Anam — avec questions sur vos documents",
|
| 127 |
+
"placeholder.text": "L'avatar apparaîtra ici",
|
| 128 |
+
"placeholder.hint": "Cliquez sur Connecter pour commencer",
|
| 129 |
+
"status.notConnected": "Non connecté",
|
| 130 |
+
"plan.free": "Gratuit · limite 2 min",
|
| 131 |
+
"plan.pro": "Pro · illimité",
|
| 132 |
+
"plan.banner": "Plan gratuit : l'avatar vous préviendra d'emblée que vous avez 2 minutes ensemble, affichera un compte à rebours en direct, et la session se déconnectera automatiquement à la fin du temps imparti.",
|
| 133 |
+
"persona.default": "— Par défaut éphémère —",
|
| 134 |
+
"controls.connect": "Connecter",
|
| 135 |
+
"controls.disconnect": "Déconnecter",
|
| 136 |
+
"advanced.toggle": "⚙ Avancé (system prompt)",
|
| 137 |
+
"advanced.clinicalPreset": "🩺 Préréglage Assistant clinique",
|
| 138 |
+
"advanced.systemPromptLabel": "System prompt (utilisé si aucun persona n'est sélectionné et RAG désactivé)",
|
| 139 |
+
"mode.voice": "🎙 Voix",
|
| 140 |
+
"mode.text": "⌨️ Texte",
|
| 141 |
+
"voice.hint": "Le microphone est actif — parlez à l'avatar",
|
| 142 |
+
"voice.hintMuted": "Microphone coupé — cliquez pour réactiver",
|
| 143 |
+
"text.placeholder": "Écrivez un message à l'avatar…",
|
| 144 |
+
"text.send": "Envoyer",
|
| 145 |
+
"kb.title": "📚 Base de connaissances",
|
| 146 |
+
"kb.cloudTab": "☁ Anam Cloud",
|
| 147 |
+
"kb.localTab": "💻 Local",
|
| 148 |
+
"kb.cloudNote": "Documents indexés dans le cloud d'Anam — l'avatar effectue une recherche à chaque tour",
|
| 149 |
+
"kb.reset": "Réinitialiser",
|
| 150 |
+
"kb.dropHint": "Déposez vos documents ici",
|
| 151 |
+
"kb.dropSubtext": "PDF · TXT · MD · DOCX · CSV — max 50 Mo",
|
| 152 |
+
"kb.browse": "Parcourir les fichiers",
|
| 153 |
+
"kb.uploading": "Téléversement…",
|
| 154 |
+
"kb.useRagLabel": "Utiliser la base de connaissances — l'avatar recherchera dans vos documents pour répondre",
|
| 155 |
+
"kb.editPrompt": "✏️ Modifier le comportement de l'assistant RAG (system prompt)",
|
| 156 |
+
"kb.promptLabel": "System prompt — contrôle comment l'avatar utilise la base de connaissances et répond aux questions générales",
|
| 157 |
+
"kb.applyPrompt": "Appliquer au persona",
|
| 158 |
+
"kb.promptHint": "Les modifications s'appliquent à la prochaine session que vous démarrez.",
|
| 159 |
+
"kb.localNote": "Documents découpés et vectorisés localement — seuls les passages les plus pertinents sont récupérés pour chaque question.",
|
| 160 |
+
"kb.localNoteSub": "Idéal pour les recommandations cliniques, protocoles et PDF de référence. Les embeddings persistent entre les redémarrages.",
|
| 161 |
+
"kb.localDropHint": "Déposez vos documents de référence ici",
|
| 162 |
+
"kb.localDropSubtext": "PDF · DOCX · TXT · MD",
|
| 163 |
+
"kb.localBrowse": "Parcourir les fichiers",
|
| 164 |
+
"kb.localInjectLabel": "Injecter les documents dans le contexte de l'avatar à la connexion",
|
| 165 |
+
"clinical.title": "🩺 Rapport d'analyse de sang",
|
| 166 |
+
"clinical.dropHint": "Déposez votre rapport d'analyse ici",
|
| 167 |
+
"clinical.dropSubtext": "PDF textuel — max 50 Mo",
|
| 168 |
+
"clinical.browse": "Parcourir les fichiers",
|
| 169 |
+
"clinical.analysing": "Analyse du rapport…",
|
| 170 |
+
"telemetry.title": "📊 Coût de la session",
|
| 171 |
+
"telemetry.cost": "Coût estimé",
|
| 172 |
+
"telemetry.minutes": "Minutes facturées",
|
| 173 |
+
"telemetry.sessions": "Sessions",
|
| 174 |
+
"telemetry.live": "Session en direct",
|
| 175 |
+
},
|
| 176 |
+
es: {
|
| 177 |
+
"lang.label": "Idioma",
|
| 178 |
+
"header.subtitle": "Conversación en tiempo real con una persona de IA de Anam — con preguntas sobre documentos",
|
| 179 |
+
"placeholder.text": "El avatar aparecerá aquí",
|
| 180 |
+
"placeholder.hint": "Haz clic en Conectar para empezar",
|
| 181 |
+
"status.notConnected": "No conectado",
|
| 182 |
+
"plan.free": "Gratis · límite 2 min",
|
| 183 |
+
"plan.pro": "Pro · ilimitado",
|
| 184 |
+
"plan.banner": "Plan gratuito: el avatar te avisará de antemano de que tenéis 2 minutos juntos, mostrará una cuenta atrás en vivo y la sesión se desconectará automáticamente cuando se acabe el tiempo.",
|
| 185 |
+
"persona.default": "— Predeterminado efímero —",
|
| 186 |
+
"controls.connect": "Conectar",
|
| 187 |
+
"controls.disconnect": "Desconectar",
|
| 188 |
+
"advanced.toggle": "⚙ Avanzado (system prompt)",
|
| 189 |
+
"advanced.clinicalPreset": "🩺 Preajuste Asistente clínico",
|
| 190 |
+
"advanced.systemPromptLabel": "System prompt (usado cuando no hay persona seleccionada y RAG está desactivado)",
|
| 191 |
+
"mode.voice": "🎙 Voz",
|
| 192 |
+
"mode.text": "⌨️ Texto",
|
| 193 |
+
"voice.hint": "El micrófono está activo — habla con el avatar",
|
| 194 |
+
"voice.hintMuted": "Micrófono silenciado — haz clic para reactivar",
|
| 195 |
+
"text.placeholder": "Escribe un mensaje al avatar…",
|
| 196 |
+
"text.send": "Enviar",
|
| 197 |
+
"kb.title": "📚 Base de conocimiento",
|
| 198 |
+
"kb.cloudTab": "☁ Anam Cloud",
|
| 199 |
+
"kb.localTab": "💻 Local",
|
| 200 |
+
"kb.cloudNote": "Documentos indexados en la nube de Anam — el avatar busca en cada turno",
|
| 201 |
+
"kb.reset": "Restablecer",
|
| 202 |
+
"kb.dropHint": "Suelta los documentos aquí",
|
| 203 |
+
"kb.dropSubtext": "PDF · TXT · MD · DOCX · CSV — máx. 50 MB",
|
| 204 |
+
"kb.browse": "Examinar archivos",
|
| 205 |
+
"kb.uploading": "Subiendo…",
|
| 206 |
+
"kb.useRagLabel": "Usar la base de conocimiento — el avatar buscará en tus documentos al responder",
|
| 207 |
+
"kb.editPrompt": "✏️ Editar comportamiento del asistente RAG (system prompt)",
|
| 208 |
+
"kb.promptLabel": "System prompt — controla cómo el avatar usa la base de conocimiento y responde preguntas generales",
|
| 209 |
+
"kb.applyPrompt": "Aplicar a la persona",
|
| 210 |
+
"kb.promptHint": "Los cambios se aplican en la próxima sesión que inicies.",
|
| 211 |
+
"kb.localNote": "Documentos fragmentados y vectorizados localmente — solo se recuperan los pasajes más relevantes para cada pregunta.",
|
| 212 |
+
"kb.localNoteSub": "Ideal para guías clínicas, protocolos y PDF de referencia. Los embeddings persisten entre reinicios.",
|
| 213 |
+
"kb.localDropHint": "Suelta aquí los documentos de referencia",
|
| 214 |
+
"kb.localDropSubtext": "PDF · DOCX · TXT · MD",
|
| 215 |
+
"kb.localBrowse": "Examinar archivos",
|
| 216 |
+
"kb.localInjectLabel": "Inyectar documentos en el contexto del avatar al conectar",
|
| 217 |
+
"clinical.title": "🩺 Informe de análisis de sangre",
|
| 218 |
+
"clinical.dropHint": "Suelta aquí tu informe de análisis",
|
| 219 |
+
"clinical.dropSubtext": "PDF de texto — máx. 50 MB",
|
| 220 |
+
"clinical.browse": "Examinar archivos",
|
| 221 |
+
"clinical.analysing": "Analizando el informe…",
|
| 222 |
+
"telemetry.title": "📊 Coste de la sesión",
|
| 223 |
+
"telemetry.cost": "Coste estimado",
|
| 224 |
+
"telemetry.minutes": "Minutos facturados",
|
| 225 |
+
"telemetry.sessions": "Sesiones",
|
| 226 |
+
"telemetry.live": "Sesión en vivo",
|
| 227 |
+
},
|
| 228 |
+
de: {
|
| 229 |
+
"lang.label": "Sprache",
|
| 230 |
+
"header.subtitle": "Echtzeit-Gespräch mit einer Anam-KI-Persona — mit Fragen zu Dokumenten",
|
| 231 |
+
"placeholder.text": "Der Avatar erscheint hier",
|
| 232 |
+
"placeholder.hint": "Klicke auf Verbinden, um zu starten",
|
| 233 |
+
"status.notConnected": "Nicht verbunden",
|
| 234 |
+
"plan.free": "Kostenlos · 2 Min. Limit",
|
| 235 |
+
"plan.pro": "Pro · unbegrenzt",
|
| 236 |
+
"plan.banner": "Kostenloser Plan: Der Avatar teilt euch vorab mit, dass ihr 2 Minuten zusammen habt, zeigt einen Live-Countdown, und die Sitzung wird automatisch getrennt, wenn die Zeit abgelaufen ist.",
|
| 237 |
+
"persona.default": "— Standard, flüchtig —",
|
| 238 |
+
"controls.connect": "Verbinden",
|
| 239 |
+
"controls.disconnect": "Trennen",
|
| 240 |
+
"advanced.toggle": "⚙ Erweitert (System-Prompt)",
|
| 241 |
+
"advanced.clinicalPreset": "🩺 Voreinstellung Klinischer Assistent",
|
| 242 |
+
"advanced.systemPromptLabel": "System-Prompt (verwendet, wenn keine Persona ausgewählt und RAG deaktiviert ist)",
|
| 243 |
+
"mode.voice": "🎙 Sprache",
|
| 244 |
+
"mode.text": "⌨️ Text",
|
| 245 |
+
"voice.hint": "Mikrofon ist aktiv — sprich mit dem Avatar",
|
| 246 |
+
"voice.hintMuted": "Mikrofon stummgeschaltet — klicken zum Aktivieren",
|
| 247 |
+
"text.placeholder": "Nachricht an den Avatar eingeben…",
|
| 248 |
+
"text.send": "Senden",
|
| 249 |
+
"kb.title": "📚 Wissensdatenbank",
|
| 250 |
+
"kb.cloudTab": "☁ Anam Cloud",
|
| 251 |
+
"kb.localTab": "💻 Lokal",
|
| 252 |
+
"kb.cloudNote": "Dokumente in der Anam-Cloud indexiert — der Avatar sucht bei jedem Zug",
|
| 253 |
+
"kb.reset": "Zurücksetzen",
|
| 254 |
+
"kb.dropHint": "Dokumente hierher ziehen",
|
| 255 |
+
"kb.dropSubtext": "PDF · TXT · MD · DOCX · CSV — max. 50 MB",
|
| 256 |
+
"kb.browse": "Dateien durchsuchen",
|
| 257 |
+
"kb.uploading": "Wird hochgeladen…",
|
| 258 |
+
"kb.useRagLabel": "Wissensdatenbank verwenden — der Avatar durchsucht deine Dokumente bei der Antwort",
|
| 259 |
+
"kb.editPrompt": "✏️ Verhalten des RAG-Assistenten bearbeiten (System-Prompt)",
|
| 260 |
+
"kb.promptLabel": "System-Prompt — steuert, wie der Avatar die Wissensdatenbank nutzt und allgemeine Fragen beantwortet",
|
| 261 |
+
"kb.applyPrompt": "Auf Persona anwenden",
|
| 262 |
+
"kb.promptHint": "Änderungen gelten ab der nächsten Sitzung, die du startest.",
|
| 263 |
+
"kb.localNote": "Dokumente werden lokal zerlegt und eingebettet — pro Frage werden nur die relevantesten Passagen abgerufen.",
|
| 264 |
+
"kb.localNoteSub": "Ideal für klinische Leitlinien, Protokolle und Referenz-PDFs. Embeddings bleiben über Neustarts hinweg erhalten.",
|
| 265 |
+
"kb.localDropHint": "Referenzdokumente hierher ziehen",
|
| 266 |
+
"kb.localDropSubtext": "PDF · DOCX · TXT · MD",
|
| 267 |
+
"kb.localBrowse": "Dateien durchsuchen",
|
| 268 |
+
"kb.localInjectLabel": "Dokumente beim Verbinden in den Kontext des Avatars einfügen",
|
| 269 |
+
"clinical.title": "🩺 Blutbild-Bericht",
|
| 270 |
+
"clinical.dropHint": "Blutbild-Bericht hierher ziehen",
|
| 271 |
+
"clinical.dropSubtext": "Text-basiertes PDF — max. 50 MB",
|
| 272 |
+
"clinical.browse": "Dateien durchsuchen",
|
| 273 |
+
"clinical.analysing": "Bericht wird analysiert…",
|
| 274 |
+
"telemetry.title": "📊 Sitzungskosten",
|
| 275 |
+
"telemetry.cost": "Geschätzte Kosten",
|
| 276 |
+
"telemetry.minutes": "Abgerechnete Minuten",
|
| 277 |
+
"telemetry.sessions": "Sitzungen",
|
| 278 |
+
"telemetry.live": "Laufende Sitzung",
|
| 279 |
+
},
|
| 280 |
+
};
|
| 281 |
+
|
| 282 |
+
function normalize(locale) {
|
| 283 |
+
return LOCALES.includes(locale) ? locale : "en";
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
export function getLocale() {
|
| 287 |
+
return normalize(localStorage.getItem(STORAGE_KEY));
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
export function setLocale(locale) {
|
| 291 |
+
localStorage.setItem(STORAGE_KEY, normalize(locale));
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/** Look up a single translation key for the current locale (falls back to English). */
|
| 295 |
+
export function t(key) {
|
| 296 |
+
const locale = getLocale();
|
| 297 |
+
return T[locale]?.[key] ?? T.en[key] ?? key;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/**
|
| 301 |
+
* Apply the current locale to every element in the document carrying:
|
| 302 |
+
* - data-i18n="key" → sets textContent
|
| 303 |
+
* - data-i18n-placeholder="key" → sets the placeholder attribute
|
| 304 |
+
* - data-i18n-title="key" → sets the title attribute
|
| 305 |
+
* Also updates <html lang="...">.
|
| 306 |
+
*/
|
| 307 |
+
export function applyTranslations() {
|
| 308 |
+
const locale = getLocale();
|
| 309 |
+
document.documentElement.lang = locale;
|
| 310 |
+
|
| 311 |
+
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
| 312 |
+
el.textContent = t(el.dataset.i18n);
|
| 313 |
+
});
|
| 314 |
+
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
| 315 |
+
el.placeholder = t(el.dataset.i18nPlaceholder);
|
| 316 |
+
});
|
| 317 |
+
document.querySelectorAll("[data-i18n-title]").forEach((el) => {
|
| 318 |
+
el.title = t(el.dataset.i18nTitle);
|
| 319 |
+
});
|
| 320 |
+
}
|
anam/static/js/kb.js
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
| 2 |
deleteRagDocument, getRagDocuments, getRagPrompt, getRagStatus,
|
| 3 |
resetRag, updateRagPrompt, uploadRagFile,
|
| 4 |
} from "./api.js";
|
|
|
|
| 5 |
|
| 6 |
// ── Shared state (live bindings — app.js reads these directly) ────────────────
|
| 7 |
export let ragSetupReady = false;
|
|
@@ -195,7 +196,7 @@ applyRagBtn.addEventListener("click", async () => {
|
|
| 195 |
applyRagBtn.disabled = true;
|
| 196 |
ragPromptHint.textContent = "Saving…";
|
| 197 |
try {
|
| 198 |
-
await updateRagPrompt(prompt);
|
| 199 |
ragPromptHint.textContent = "✓ Saved — reconnect for changes to take effect.";
|
| 200 |
} catch (e) {
|
| 201 |
ragPromptHint.textContent = "Save failed: " + e.message;
|
|
|
|
| 2 |
deleteRagDocument, getRagDocuments, getRagPrompt, getRagStatus,
|
| 3 |
resetRag, updateRagPrompt, uploadRagFile,
|
| 4 |
} from "./api.js";
|
| 5 |
+
import { getLocale } from "./i18n.js";
|
| 6 |
|
| 7 |
// ── Shared state (live bindings — app.js reads these directly) ────────────────
|
| 8 |
export let ragSetupReady = false;
|
|
|
|
| 196 |
applyRagBtn.disabled = true;
|
| 197 |
ragPromptHint.textContent = "Saving…";
|
| 198 |
try {
|
| 199 |
+
await updateRagPrompt(prompt, getLocale());
|
| 200 |
ragPromptHint.textContent = "✓ Saved — reconnect for changes to take effect.";
|
| 201 |
} catch (e) {
|
| 202 |
ragPromptHint.textContent = "Save failed: " + e.message;
|