diff --git a/locales/en.json b/locales/en.json index a98bb77c6..312ee0691 100644 --- a/locales/en.json +++ b/locales/en.json @@ -200,6 +200,8 @@ "chat.feedback.tag.wrongDataset": "Wrong dataset", "chat.feedback.thumbsDown": "Bad response", "chat.feedback.thumbsUp": "Good response", + "chat.greeting.disclosure": "Orbit remembers what you've explored on this device to personalize this greeting — it's never sent to a server. Manage it in Tools → Privacy.", + "chat.greeting.disclosure.link": "View privacy", "chat.header.subtitle": "· digital docent", "chat.header.title": "Orbit", "chat.input.label": "Ask Orbit", diff --git a/src/main.ts b/src/main.ts index 136f6e018..38521df22 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,7 +33,7 @@ import { import { updateMapControlsPosition } from './ui/mapControlsUI' import { initToolsMenu, syncToolsMenuState, syncToolsMenuLayout, pulseBrowseButton } from './ui/toolsMenuUI' import { openCreditsPanel } from './ui/creditsPanel' -import { initChatUI, openChat, openChatSettings, notifyDatasetChanged, showChatTrigger, hideChatTrigger, closeChat, flushPendingGlobeActions } from './ui/chatUI' +import { initChatUI, openChat, openChatSettings, notifyDatasetChanged, showChatTrigger, hideChatTrigger, closeChat, flushPendingGlobeActions, playReturningGreeting } from './ui/chatUI' import { loadViewPreferences, saveViewPreferences, type ViewPreferences } from './utils/viewPreferences' import { initHelpUI, setActiveDataset as setHelpActiveDataset } from './ui/helpUI' import { showDisclosureBannerIfNeeded } from './ui/disclosureBanner' @@ -64,7 +64,7 @@ import { showTourControls, hideTourControls, hideAllTourTextBoxes, hideAllTourIm import { initLegendForDataset, clearLegendCache, loadConfig } from './services/docentService' import { isMobile, IS_MOBILE_NATIVE, getCloudTextureUrl } from './utils/deviceCapability' import { initDeepLinks } from './services/deepLinkService' -import { recordVisit, writeLastSession } from './services/visitMemory' +import { recordVisit, writeLastSession, getLastSession, getRecent } from './services/visitMemory' import { getCatalogMode, setCatalogMode } from './utils/catalogMode' import { hideCatalogTabs, @@ -624,6 +624,52 @@ class InteractiveSphere { }, openChatWithQuery: (query) => this.openChatWithQuery(query), }) + + // §9.3 — proactive returning-visitor greeting. Catalog mode only, + // and only when the last session ended > 24 h ago. Fire-and-forget; + // playReturningGreeting / triggerOpeningTurn self-gate on the LLM + // being configured and on once-per-session, so this is safe to call + // unconditionally here. + this.maybeGreetReturningVisitor() + } + + /** + * §9.3 — decide whether to fire Orbit's returning-user greeting and, + * if so, hand the local visit context to the chat UI. All inputs are + * local-only (visit memory); nothing is sent server-side except the + * resulting prompt block to the configured LLM. + */ + private maybeGreetReturningVisitor(): void { + if (!getCatalogMode()) return + const last = getLastSession() + if (!last) return + const lastMs = Date.parse(last) + if (!Number.isFinite(lastMs)) return + const DAY_MS = 24 * 60 * 60 * 1000 + const elapsed = Date.now() - lastMs + if (elapsed <= DAY_MS) return // need a gap of more than 24 h + + const datasets = this.appState.datasets + const daysSince = Math.max(1, Math.floor(elapsed / DAY_MS)) + // New since the last visit: catalog rows whose dateAdded is later + // than lastSession (cap 5 titles; let the LLM pick what to surface). + const newSinceTitles = datasets + .filter((d) => { + if (d.isHidden) return false + const added = d.enriched?.dateAdded + if (!added) return false + const addedMs = Date.parse(added) + return Number.isFinite(addedMs) && addedMs > lastMs + }) + .slice(0, 5) + .map((d) => d.title) + // Recently viewed (cap 3), resolved to current catalog titles. + const byId = new Map(datasets.map((d) => [d.id, d])) + const recentTitles = getRecent(3) + .map((id) => byId.get(id)?.title) + .filter((title): title is string => !!title) + + void playReturningGreeting({ daysSince, newSinceTitles, recentTitles }) } /** diff --git a/src/services/docentContext.test.ts b/src/services/docentContext.test.ts index 936b4016a..7405917f0 100644 --- a/src/services/docentContext.test.ts +++ b/src/services/docentContext.test.ts @@ -3,6 +3,7 @@ import type { Dataset, ChatMessage } from '../types' import { buildCategorySummary, buildCurrentDatasetContext, + buildReturningUserBlock, buildSystemPrompt, buildMessageHistory, buildCompressedHistory, @@ -171,12 +172,41 @@ describe('getListFeaturedDatasetsTool (Phase 1c)', () => { }) }) +describe('buildReturningUserBlock', () => { + it('includes the days-since line and the supplied titles', () => { + const block = buildReturningUserBlock({ + daysSince: 3, + newSinceTitles: ['Alpha', 'Bravo'], + recentTitles: ['Charlie'], + }) + expect(block).toContain('returning visitor') + expect(block).toContain('3 days ago') + expect(block).toContain('Alpha; Bravo') + expect(block).toContain('Charlie') + }) + + it('singularizes one day and omits empty new/recent lines', () => { + const block = buildReturningUserBlock({ daysSince: 1, newSinceTitles: [], recentTitles: [] }) + expect(block).toContain('1 day ago') + expect(block).not.toContain('New datasets') + expect(block).not.toContain('Recently viewed') + }) +}) + describe('buildSystemPrompt', () => { it('includes docent role description', () => { const prompt = buildSystemPrompt(datasets, null) expect(prompt).toContain('Orbit') }) + it('prepends the returning-user block only when provided', () => { + const without = buildSystemPrompt(datasets, null) + expect(without).not.toContain('returning visitor') + const block = buildReturningUserBlock({ daysSince: 2, newSinceTitles: [], recentTitles: [] }) + const withBlock = buildSystemPrompt(datasets, null, 'general', false, null, null, null, undefined, block) + expect(withBlock).toContain('returning visitor') + }) + it('places the language directive BEFORE the role description for non-English locales', async () => { // Wave 5/6 testing showed mid-tier models (Llama-3.1-70B, etc.) // ignored a respond-in-language directive appended at the end of diff --git a/src/services/docentContext.ts b/src/services/docentContext.ts index f23e3ecb5..f584b3a58 100644 --- a/src/services/docentContext.ts +++ b/src/services/docentContext.ts @@ -135,6 +135,42 @@ Respond at a professional/graduate science level. Use precise scientific termino * `datasets` is still passed in for the (currently unused) categorySummary * helper that other surfaces consume; the prompt itself ignores it. */ +/** Context for a returning-visitor greeting (§9.3). All fields come + * from local visit memory — never sent server-side except as this + * prompt block to the configured LLM. */ +export interface ReturningUserContext { + /** Whole days since the previous session end. */ + daysSince: number + /** Titles of catalog datasets added since the last visit (capped). */ + newSinceTitles: readonly string[] + /** Titles of datasets the user recently opened (capped). */ + recentTitles: readonly string[] +} + +/** + * Build the returning-user context block prepended to the turn-0 + * system prompt when the §9.3 greeting trigger fires. Pure string + * builder — the caller decides whether to include it (cost discipline: + * don't compose it when the trigger doesn't fire). Lines with no data + * are omitted so the LLM isn't told "New datasets since: 0". + */ +export function buildReturningUserBlock(ctx: ReturningUserContext): string { + const lines: string[] = ['You are greeting a returning visitor. Context:'] + lines.push(` Last visited: about ${ctx.daysSince} day${ctx.daysSince === 1 ? '' : 's'} ago`) + if (ctx.newSinceTitles.length > 0) { + lines.push(` New datasets since their last visit: ${ctx.newSinceTitles.length} (e.g. ${ctx.newSinceTitles.join('; ')})`) + } + if (ctx.recentTitles.length > 0) { + lines.push(` Recently viewed by this user: ${ctx.recentTitles.join('; ')}`) + } + lines.push( + 'Open with one short, warm paragraph welcoming them back, then offer ONE or TWO concrete next steps (a new dataset to try, returning to something they viewed, or a guided tour). ' + + 'Do not list everything — pick a highlight. ' + + 'When you name a dataset, you MUST still confirm its id via a discovery tool (or the [RELEVANT DATASETS] block) before emitting a <> marker — the titles above are context, not valid ids.', + ) + return lines.join('\n') +} + export function buildSystemPrompt( _datasets: Dataset[], currentDataset: Dataset | null, @@ -144,6 +180,7 @@ export function buildSystemPrompt( currentTime?: string | null, qaContext?: string | null, mapViewContext?: Parameters[0], + returningUserBlock?: string | null, ): string { const currentContext = buildCurrentDatasetContext(currentDataset, legendDescription, currentTime) const languagePreface = buildLanguagePreface() @@ -152,7 +189,7 @@ export function buildSystemPrompt( return `${languagePreface}You are Orbit, a Digital Docent for Science on a Sphere — an interactive 3D globe that visualizes Earth science datasets from NOAA. Your role is to be a warm, knowledgeable guide. You help visitors explore and understand environmental data by explaining what they're seeing and recommending relevant datasets to load onto the globe. - +${returningUserBlock ? `\n${returningUserBlock}\n` : ''} IMPORTANT: All datasets are GLOBAL — they cover the entire Earth, rendered on a 3D sphere. The user's current view only shows one side of the globe, but the data extends everywhere. Never say a dataset "only shows" one region or "doesn't cover" a location. The user can rotate the globe or use <> to view any part of the world. ## STRICT RULES — FOLLOW EXACTLY diff --git a/src/services/docentService.test.ts b/src/services/docentService.test.ts index 1179785ab..16b99b5da 100644 --- a/src/services/docentService.test.ts +++ b/src/services/docentService.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { Dataset, ChatMessage, DocentConfig } from '../types' -import { processMessage, loadConfig, saveConfig, getDefaultConfig, validateAndCleanText, captureViewContext, readCurrentTime, executeSearchDatasets, executeListFeaturedDatasets, clearPreSearchCache } from './docentService' +import { processMessage, triggerOpeningTurn, resetOpeningTurnForTests, loadConfig, saveConfig, getDefaultConfig, validateAndCleanText, captureViewContext, readCurrentTime, executeSearchDatasets, executeListFeaturedDatasets, clearPreSearchCache } from './docentService' import { getDegradedReason, resetForTests as resetDegradedForTests } from './docentDegradedState' import type { DocentStreamChunk } from './docentService' @@ -2560,3 +2560,65 @@ describe('processMessage — backend tool round-trip', () => { expect(toolMsg.content).toContain('Climate Reanalysis 2024') }) }) + +describe('triggerOpeningTurn (§9.3 returning-user greeting)', () => { + const returning = { + daysSince: 3, + newSinceTitles: ['Wildfire Tracker'], + recentTitles: ['Sea Surface Temperature'], + } + const enabledConfig: DocentConfig = { + apiUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'test', + enabled: true, + readingLevel: 'general', + visionEnabled: false, + } + + beforeEach(() => resetOpeningTurnForTests()) + + it('yields nothing when the LLM is unconfigured', async () => { + const chunks: DocentStreamChunk[] = [] + for await (const c of triggerOpeningTurn({ datasets, returning, config: disabledConfig })) { + chunks.push(c) + } + expect(chunks).toEqual([]) + }) + + it('streams a greeting and refuses to re-fire within the session', async () => { + const { streamChat } = await import('./llmProvider') + vi.mocked(streamChat).mockImplementation(async function* () { + yield { type: 'delta' as const, text: 'Welcome back!' } + yield { type: 'done' as const } + }) + + const first: DocentStreamChunk[] = [] + for await (const c of triggerOpeningTurn({ datasets, returning, config: enabledConfig })) { + first.push(c) + } + const text = first.filter(c => c.type === 'delta').map(c => (c as { text: string }).text).join('') + expect(text).toContain('Welcome back!') + + // Second call this session is a no-op (idempotent latch). + const second: DocentStreamChunk[] = [] + for await (const c of triggerOpeningTurn({ datasets, returning, config: enabledConfig })) { + second.push(c) + } + expect(second).toEqual([]) + }) + + it('injects the returning-user block into the system prompt', async () => { + const { streamChat } = await import('./llmProvider') + let captured: Array<{ role: string; content: unknown }> | null = null + vi.mocked(streamChat).mockImplementation(async function* (messages: Array<{ role: string; content: unknown }>) { + captured = messages + yield { type: 'delta' as const, text: 'Hi' } + yield { type: 'done' as const } + }) + for await (const _c of triggerOpeningTurn({ datasets, returning, config: enabledConfig })) { /* drain */ } + const system = captured!.find(m => m.role === 'system') + expect(String(system?.content)).toContain('returning visitor') + expect(String(system?.content)).toContain('Wildfire Tracker') + }) +}) diff --git a/src/services/docentService.ts b/src/services/docentService.ts index f13e5484d..88525448f 100644 --- a/src/services/docentService.ts +++ b/src/services/docentService.ts @@ -8,7 +8,7 @@ import type { Dataset, ChatMessage, ChatAction, DocentConfig, LegendCache, MapViewContext, LLMContextSnapshot, ReadingLevel } from '../types' import { streamChat, checkAvailability, type AvailabilityResult, type LLMMessage, type LLMContentPart, type LLMToolCall } from './llmProvider' import { isAvailable as isAppleIntelligenceAvailable, streamChatLocal } from './appleIntelligenceProvider' -import { buildSystemPrompt, buildCompressedHistory, buildLanguageReminderMessage, getSearchCatalogTool, getSearchDatasetsTool, getListFeaturedDatasetsTool, getLoadDatasetTool, getLoadFrameTool, getFlyToTool, getSetTimeTool, getFitBoundsTool, getAddMarkerTool, getToggleLabelsTool, getHighlightRegionTool } from './docentContext' +import { buildSystemPrompt, buildReturningUserBlock, buildCompressedHistory, buildLanguageReminderMessage, getSearchCatalogTool, getSearchDatasetsTool, getListFeaturedDatasetsTool, getLoadDatasetTool, getLoadFrameTool, getFlyToTool, getSetTimeTool, getFitBoundsTool, getAddMarkerTool, getToggleLabelsTool, getHighlightRegionTool, type ReturningUserContext } from './docentContext' import { parseIntent, generateResponse, searchDatasets, evaluateAutoLoad } from './docentEngine' import { clearDegraded as clearDegradedState, markDegraded as markDegradedState } from './docentDegradedState' import { apiFetch } from './catalogSource' @@ -1269,6 +1269,59 @@ async function* emitValidatedActions( } } +/** Session guard so the proactive greeting fires at most once per + * launch, no matter how many times catalog mode is (re)opened. */ +let openingTurnFired = false + +/** Test-only — reset the once-per-session opening-turn guard. */ +export function resetOpeningTurnForTests(): void { + openingTurnFired = false +} + +/** Synthetic instruction that drives the §9.3 opening turn. Never + * shown to the user — the actual greeting wording (and its language) + * comes from the LLM, steered by the returning-user block in the + * system prompt. */ +// i18n-exempt: internal LLM prompt, not user-visible UI text. +const OPENING_TURN_INSTRUCTION = + 'Greet me — I just reopened the catalog. Use the returning-visitor context in your system prompt.' + +/** + * Fire Orbit's proactive opening turn for a returning visitor (§9.3). + * Idempotent within a session (refuses to re-trigger). Yields the same + * {@link DocentStreamChunk} union as {@link processMessage} so the chat + * UI renders it identically to a reply. + * + * Skips entirely (yields nothing) when the LLM is unconfigured — the + * local-engine fallback isn't a good fit for a proactive greeting (it + * would feel canned). The caller should also gate on the trigger + * conditions (catalog mode + lastSession > 24 h); this guard is the + * last line of defence + the once-per-session latch. + */ +export async function* triggerOpeningTurn( + opts: { datasets: Dataset[]; returning: ReturningUserContext; config?: DocentConfig }, +): AsyncGenerator { + if (openingTurnFired) return + const cfg = opts.config ?? await loadConfigWithKey() + if (!cfg.enabled || !cfg.apiUrl) return // skip when LLM unconfigured + openingTurnFired = true + const block = buildReturningUserBlock(opts.returning) + // Delegate to processMessage so the greeting reuses the full LLM + // path (streaming, discovery tools, marker → load-chip extraction). + // Empty history → turn 0; no current dataset (catalog mode). + yield* processMessage( + OPENING_TURN_INSTRUCTION, + [], + opts.datasets, + null, + cfg, + null, + undefined, + undefined, + block, + ) +} + export async function* processMessage( input: string, history: ChatMessage[], @@ -1278,6 +1331,7 @@ export async function* processMessage( screenshotDataUrl?: string | null, viewContext?: string, mapViewContext?: MapViewContext | null, + returningUserBlock?: string | null, ): AsyncGenerator { const cfg = config ?? await loadConfigWithKey() @@ -1353,6 +1407,7 @@ export async function* processMessage( !visionActive ? currentTime : null, qaContext || null, mapViewContext, + returningUserBlock ?? null, ) if (cfg.debugPrompt) { diff --git a/src/styles/chat.css b/src/styles/chat.css index ed93e3fe4..6d7783139 100644 --- a/src/styles/chat.css +++ b/src/styles/chat.css @@ -731,3 +731,20 @@ padding-bottom: env(safe-area-inset-bottom); } } + +/* §9.3 — one-time disclosure footnote under the first proactive + returning-user greeting. Muted + small so it reads as a footnote, + not part of Orbit's message. */ +.chat-disclosure-footnote { + margin-top: calc(0.4rem * var(--ui-scale)); + font-size: calc(0.7rem * var(--ui-scale)); + color: var(--color-text-muted); + line-height: 1.4; +} +.chat-disclosure-footnote a { + color: var(--color-accent); + text-decoration: none; +} +.chat-disclosure-footnote a:hover { + text-decoration: underline; +} diff --git a/src/types/index.ts b/src/types/index.ts index 111f816c4..4f5bab255 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -455,6 +455,10 @@ export interface ChatMessage { timestamp: number /** LLM context that produced this response (docent messages only, in-memory only) */ llmContext?: LLMContextSnapshot + /** §9.3 — when true, render the one-time "Orbit remembers locally" + * disclosure footnote under this message (the first proactive + * returning-user greeting only). */ + disclosureFootnote?: boolean } /** diff --git a/src/ui/chatUI.test.ts b/src/ui/chatUI.test.ts index 1081a743e..675d432c3 100644 --- a/src/ui/chatUI.test.ts +++ b/src/ui/chatUI.test.ts @@ -8,6 +8,7 @@ import { clearChat, notifyDatasetChanged, submitFeedback, + playReturningGreeting, } from './chatUI' import type { ChatCallbacks } from './chatUI' import { @@ -21,6 +22,7 @@ vi.mock('../services/docentService', async (importOriginal) => { return { ...actual, processMessage: vi.fn(), + triggerOpeningTurn: vi.fn(), } }) @@ -911,3 +913,68 @@ describe('feedback mechanism', () => { fetchSpy.mockRestore() }) }) + +describe('playReturningGreeting (§9.3)', () => { + it('opens the chat, streams the greeting, and shows the one-time disclosure footnote', async () => { + const { triggerOpeningTurn } = await import('../services/docentService') + vi.mocked(triggerOpeningTurn).mockImplementation(async function* () { + yield { type: 'delta' as const, text: 'Welcome back!' } + yield { type: 'done' as const, fallback: false } + }) + const cb = makeCallbacks() + initChatUI(cb) + await playReturningGreeting({ daysSince: 2, newSinceTitles: [], recentTitles: [] }) + + const msgs = getMessages() + expect(msgs).toHaveLength(1) + expect(msgs[0].role).toBe('docent') + expect(msgs[0].text).toBe('Welcome back!') + expect(msgs[0].disclosureFootnote).toBe(true) + expect(document.getElementById('chat-panel')?.classList.contains('hidden')).toBe(false) + expect(document.querySelector('.chat-disclosure-footnote')).not.toBeNull() + }) + + it('omits the disclosure footnote once it has been shown before', async () => { + localStorage.setItem('sos-orbit-greeting-disclosed.v1', '1') + const { triggerOpeningTurn } = await import('../services/docentService') + vi.mocked(triggerOpeningTurn).mockImplementation(async function* () { + yield { type: 'delta' as const, text: 'Hello again.' } + yield { type: 'done' as const, fallback: false } + }) + const cb = makeCallbacks() + initChatUI(cb) + await playReturningGreeting({ daysSince: 5, newSinceTitles: [], recentTitles: [] }) + + expect(getMessages()[0].disclosureFootnote).toBeUndefined() + expect(document.querySelector('.chat-disclosure-footnote')).toBeNull() + }) + + it('converts <> markers to inline placeholders and applies rewrite', async () => { + const { triggerOpeningTurn } = await import('../services/docentService') + vi.mocked(triggerOpeningTurn).mockImplementation(async function* () { + yield { type: 'delta' as const, text: 'Welcome back! Try <> ' } + // rewrite delivers the validated text (invalid id stripped server-side) + yield { type: 'rewrite' as const, text: 'Welcome back! Try <>' } + yield { type: 'done' as const, fallback: false } + }) + const cb = makeCallbacks() + initChatUI(cb) + await playReturningGreeting({ daysSince: 2, newSinceTitles: [], recentTitles: [] }) + + const msg = getMessages()[0] + // Raw marker syntax must never leak into the rendered greeting text. + expect(msg.text).not.toContain('< { + const { triggerOpeningTurn } = await import('../services/docentService') + // eslint-disable-next-line require-yield + vi.mocked(triggerOpeningTurn).mockImplementation(async function* () { /* nothing */ }) + const cb = makeCallbacks() + initChatUI(cb) + await playReturningGreeting({ daysSince: 2, newSinceTitles: [], recentTitles: [] }) + + expect(getMessages()).toHaveLength(0) + }) +}) diff --git a/src/ui/chatUI.ts b/src/ui/chatUI.ts index c9a0ad88d..ee8e4991a 100644 --- a/src/ui/chatUI.ts +++ b/src/ui/chatUI.ts @@ -10,7 +10,8 @@ import type { ChatMessage, ChatAction, ChatSession, DocentConfig, MapViewContext import type { Dataset } from '../types' import { escapeHtml, escapeAttr } from './domUtils' import { createMessageId } from '../services/docentEngine' -import { processMessage, loadConfig, loadConfigWithKey, saveConfig, testConnection, getDefaultConfig, isLocalDev, IS_TAURI, captureViewContext } from '../services/docentService' +import { processMessage, triggerOpeningTurn, loadConfig, loadConfigWithKey, saveConfig, testConnection, getDefaultConfig, isLocalDev, IS_TAURI, captureViewContext } from '../services/docentService' +import type { ReturningUserContext } from '../services/docentContext' import { getDegradedReason, subscribe as subscribeDegraded, @@ -362,6 +363,8 @@ function restoreSession(): void { if (raw) { const session: ChatSession = JSON.parse(raw) messages = session.messages ?? [] + } else { + messages = [] } } catch { messages = [] @@ -910,6 +913,128 @@ async function handleSend(): Promise { callbacks.announce(t('chat.announce.docentResponded')) } +/** localStorage flag — set after the first proactive greeting shows + * its disclosure footnote, so subsequent greetings omit it (§9.3). */ +const GREETING_DISCLOSED_KEY = 'sos-orbit-greeting-disclosed.v1' + +function greetingAlreadyDisclosed(): boolean { + try { + return localStorage.getItem(GREETING_DISCLOSED_KEY) === '1' + } catch { + return false + } +} + +function markGreetingDisclosed(): void { + try { + localStorage.setItem(GREETING_DISCLOSED_KEY, '1') + } catch { + /* private mode / quota — degrade by re-showing the footnote */ + } +} + +/** + * §9.3 — play Orbit's proactive returning-user greeting: open the chat + * and stream a turn-0 message (no user input). No-ops when the LLM is + * unconfigured (so we don't open an empty panel), when a conversation + * is already underway, or when a stream is in flight. The one-time + * disclosure footnote attaches to the first greeting only. + */ +export async function playReturningGreeting(returning: ReturningUserContext): Promise { + if (!callbacks || isStreaming) return + // Don't inject a greeting into an existing conversation, and skip + // when the LLM is unconfigured (the local engine would feel canned). + if (messages.some((m) => m.role === 'user')) return + const cfg = loadConfig() + if (!cfg.enabled || !cfg.apiUrl) return + + openChat() + const docentMsg: ChatMessage = { + id: createMessageId(), + role: 'docent', + text: '', + actions: [], + timestamp: Date.now(), + } + messages.push(docentMsg) + isStreaming = true + showTyping() + setSendEnabled(false) + + try { + const stream = triggerOpeningTurn({ datasets: callbacks.getDatasets(), returning }) + let firstChunk = true + for await (const chunk of stream) { + if (firstChunk && chunk.type !== 'done') { + hideTyping() + firstChunk = false + } + if (chunk.type === 'delta') { + docentMsg.text += chunk.text + updateStreamingMessage(docentMsg) + scrollToBottom() + } else if (chunk.type === 'action' && chunk.action.type === 'load-dataset') { + if (!docentMsg.actions) docentMsg.actions = [] + docentMsg.actions.push(chunk.action) + updateStreamingMessage(docentMsg) + scrollToBottom() + } else if (chunk.type === 'rewrite') { + // Validated final text (invalid dataset IDs stripped) — same as + // handleSend, so the greeting can't reference a non-existent id. + docentMsg.text = chunk.text + updateStreamingMessage(docentMsg) + } else if (chunk.type === 'done') { + // Attach LLM context so feedback payloads carry it (non-enumerable + // so saveSession won't serialize it to sessionStorage). + Object.defineProperty(docentMsg, 'llmContext', { + value: chunk.llmContext ?? { systemPrompt: '', model: '', readingLevel: 'general', visionEnabled: false, fallback: true, historyCompressed: false }, + writable: true, + configurable: true, + enumerable: false, + }) + // Convert <> markers to inline [[LOAD:...]] placeholders + // so load buttons render in place — otherwise the raw marker + // syntax would leak into the greeting text. + if (docentMsg.text) { + docentMsg.text = docentMsg.text.replace( + /]+)>>?\n?/g, + (_, id) => `[[LOAD:${id.trim()}]]`, + ).trim() + updateStreamingMessage(docentMsg) + } + } + // Greeting ignores auto-load / globe-control actions — it should + // offer, not act, and no dataset is loaded in catalog mode. + } + } catch (err) { + logger.warn('[chat] returning-user greeting failed:', err) + } + + hideTyping() + isStreaming = false + setSendEnabled(true) + if (docentMsg.actions?.length === 0) delete docentMsg.actions + + // Nothing came back (e.g. LLM error / empty stream) — drop the blank + // placeholder rather than leave an empty bubble. + if (!docentMsg.text && !docentMsg.actions?.length) { + messages = messages.filter((m) => m.id !== docentMsg.id) + renderMessages() + return + } + + if (!greetingAlreadyDisclosed()) { + docentMsg.disclosureFootnote = true + markGreetingDisclosed() + } + renderMessages() + scrollToBottom() + saveSession() + // Announce for screen-reader users, mirroring handleSend — the + // greeting auto-opens, so without this an AT user gets no cue. + callbacks.announce(t('chat.announce.docentResponded')) +} + function setSendEnabled(enabled: boolean): void { const btn = document.getElementById('chat-send') as HTMLButtonElement | null if (btn) btn.disabled = !enabled @@ -976,10 +1101,17 @@ function renderMessage(msg: ChatMessage): string { ` : '' + // §9.3 — one-time disclosure footnote under the first proactive + // returning-user greeting. `t(...)` keeps it localized; the privacy + // link points at the same surface as the Tools → Privacy panel. + const disclosureHtml = msg.disclosureFootnote + ? `
${escapeHtml(t('chat.greeting.disclosure'))} ${escapeHtml(t('chat.greeting.disclosure.link'))}
` + : '' return `
${textHtml}
${feedbackHtml} ${actionsHtml} + ${disclosureHtml}
` }