diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index d7c2327..fb80542 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -5755,6 +5755,48 @@ console.log('\n── Follow-ups ──'); question: 'x', history: [], refresh: async () => [PAGE[0], PAGE[0], PAGE[1]], })).length === 2); + + /* The defect that made the row appear exactly once per conversation: every + chip ever shown was banned for good, so the second turn filtered out the + same top-ranked catalogue the first turn had consumed and the row went + silent for the rest of the thread. Offered is not asked — a suggestion + passed over, with an answer since, is offerable again. */ + record('a chip offered earlier, but not under the last answer, comes back', + JSON.stringify(said(await nextSteps({ + question: 'x', + history: [ + { role: 'user', text: 'a' }, + { role: 'assistant', followUp: [PAGE[0]] }, + { role: 'user', text: 'b' }, + { role: 'assistant', followUp: [PAGE[1]] }, + ], + refresh: async () => [PAGE[0], PAGE[1]], + }))) === JSON.stringify([PAGE[0].prompt])); + + /* ...while the row directly above is still never redrawn verbatim. */ + record('...but the row under the answer directly above is not repeated', + (await nextSteps({ + question: 'x', + history: [ + { role: 'user', text: 'a' }, + { role: 'assistant', followUp: [PAGE[0], PAGE[1]] }, + ], + refresh: async () => [PAGE[0], PAGE[1]], + })) === undefined); + + /* A question actually put stays excluded however long ago it was asked — + that exclusion is the permanent one. */ + record('a question asked earlier in the thread is never offered back', + (await nextSteps({ + question: 'x', + history: [ + { role: 'user', text: 'How healthy is the platform right now?' }, + { role: 'assistant', followUp: [] }, + { role: 'user', text: 'b' }, + { role: 'assistant', followUp: [] }, + ], + refresh: async () => [PAGE[0]], + })) === undefined); } console.log('\n── Streaming ──'); diff --git a/src/components/ai-assistant/KrowAssistant.tsx b/src/components/ai-assistant/KrowAssistant.tsx index e54f137..0468bf3 100644 --- a/src/components/ai-assistant/KrowAssistant.tsx +++ b/src/components/ai-assistant/KrowAssistant.tsx @@ -31,6 +31,7 @@ import { buildIntro } from './dynamic'; import { agentScopedDisabled } from '@/lib/agents/runtime'; import { buildOwliverContext } from '@/lib/agents/context'; import { AgentBadge } from './AgentBadge'; +import { useLanguage } from './language'; import { useActiveAgent } from './AgentContext'; import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage'; import { PromptInput } from './PromptInput'; @@ -403,6 +404,13 @@ export default function KrowAssistant({ */ const [view, setView] = React.useState('chat'); + /* The account's language, chosen in the account menu rather than here. The + panel reads it and never sets it: it is an application setting that happens + to govern the one piece of text this product generates, not a property of + the conversation. It applies from the next answer onward — answers already + on screen stay as they were written. */ + const language = useLanguage(); + /** * A skill asked for something to happen. The names come from the skill file; * `runAction` decides what they mean and refuses anything the file did not @@ -637,6 +645,7 @@ export default function KrowAssistant({ agentCoversPage, agentSuggestion: defaultAgent, owliverContext, + language, }); /* A block inside an answer asking the next question, in place. Same entry diff --git a/src/components/ai-assistant/language.ts b/src/components/ai-assistant/language.ts new file mode 100644 index 0000000..54fccea --- /dev/null +++ b/src/components/ai-assistant/language.ts @@ -0,0 +1,142 @@ +import * as React from 'react'; + +/** + * The language Owliver answers in. + * + * One list, read by the header's selector and by the provider that sends the + * run. A second copy would be two lists that could disagree, and the way that + * shows up is a selector offering a language the backend does not answer in. + * + * The tags are the backend's: `internal/runtime/language.go` holds the same + * set, and the string sent here SELECTS a directive there rather than becoming + * one. That is deliberate and it is a security boundary, not a style — a tag + * this file invented would silently fall back to English rather than being + * interpolated into a prompt. + * + * This is the panel's language, not the product's. Nothing else in the + * dashboard is translated, and the selector does not claim to be: it governs + * what Owliver writes back, which is the only text here that is generated + * rather than designed. + */ + +/** The offered languages, in the order the selector shows them. */ +export const LANGUAGES = [ + /* English first and English default. Every agent's instructions are written + in English, so it is the language that needs no directive at all. */ + { tag: 'en', label: 'English' }, + { tag: 'es', label: 'Español' }, +]; + +export const DEFAULT_LANGUAGE = 'en'; + +/** + * Where the choice is kept. + * + * `localStorage`, deliberately — not thread state. Somebody who reads in + * Spanish reads in Spanish on every page and after every reload; making it + * per-conversation would ask them to set it again each time the panel opened, + * which is the behaviour of a filter rather than of a preference. + * + * Not persisted into the thread record either: replaying an old conversation + * shows the answers as they were written, and re-labelling them with today's + * preference would claim a Spanish answer had been given in English. + */ +const STORAGE_KEY = 'krow_owliver_language'; + +/** Whether `tag` is a language this build offers. */ +export function isLanguage(tag) { + return LANGUAGES.some((l) => l.tag === tag); +} + +/** + * The stored preference, or English. + * + * Tolerant of everything storage can do: a tag written by an older build, a + * browser that denies access, private mode. The question "which language" must + * always have an answer, and an unreadable store means the default rather than + * a crash on the panel's first render. + */ +export function readLanguage() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return isLanguage(stored) ? stored : DEFAULT_LANGUAGE; + } catch { + return DEFAULT_LANGUAGE; + } +} + +/** Stores the preference. A failure to write is not worth breaking a render. */ +export function writeLanguage(tag) { + try { + if (isLanguage(tag)) localStorage.setItem(STORAGE_KEY, tag); + } catch { + /* Private mode, or storage denied. The selector still works for this + session; it just will not be remembered after a reload. */ + } +} + +/** The label for a tag, for the selector and for an aria-label. */ +export function languageLabel(tag) { + return LANGUAGES.find((l) => l.tag === tag)?.label || 'English'; +} + +/** + * Subscribers to the choice. + * + * The selector lives in the account menu and the thing it governs lives in the + * Owliver panel — two subtrees with no parent between them short of the app + * root. A context provider spanning both would be a provider added for one + * string; an external store is the smaller change and it also gets cross-tab + * sync for free, since `storage` fires in the OTHER tab and nothing else would + * have told it. + */ +const listeners = new Set<() => void>(); + +/** Cached so `getSnapshot` is cheap and, more importantly, stable. */ +let current = null; + +function snapshot() { + if (current === null) current = readLanguage(); + return current; +} + +function subscribe(listener) { + listeners.add(listener); + /* Another tab changed it. `storage` does not fire in the tab that wrote, so + this handles only the other ones; `setLanguage` notifies this one. */ + const onStorage = (e) => { + if (e.key !== null && e.key !== STORAGE_KEY) return; + current = readLanguage(); + listeners.forEach((l) => l()); + }; + window.addEventListener('storage', onStorage); + return () => { + listeners.delete(listener); + window.removeEventListener('storage', onStorage); + }; +} + +/** + * Sets the language everywhere. + * + * Writes through to storage and notifies every reader in this tab. Ignores a + * tag this build does not offer, so a stale menu cannot put the app into a + * language the backend will not answer in. + */ +export function setLanguage(tag) { + if (!isLanguage(tag) || tag === snapshot()) return; + current = tag; + writeLanguage(tag); + listeners.forEach((l) => l()); +} + +/** + * The current language, re-rendering the caller when it changes. + * + * `useSyncExternalStore` rather than state plus an effect: the panel must read + * the value that is true at render time, and a value corrected in an effect is + * one question answered in the wrong language. + */ +export function useLanguage() { + return React.useSyncExternalStore(subscribe, snapshot, () => DEFAULT_LANGUAGE); +} diff --git a/src/components/ai-assistant/provider.ts b/src/components/ai-assistant/provider.ts index 4eea613..b5b3b41 100644 --- a/src/components/ai-assistant/provider.ts +++ b/src/components/ai-assistant/provider.ts @@ -22,6 +22,7 @@ * capability: string | null, // capability id, or null for free text * question: string, // what the user typed, or the chip's prompt * facts: object, // dashboard fact sheet (see insights.js) + * language: string | null, // language tag to answer in (see language.js) * signal: AbortSignal, // aborts an in-flight response * } */ @@ -68,7 +69,7 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) { }, /** The run itself. Public only so `stream` can wrap it; call `stream`. */ - async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) { + async *run({ question, agent = null, confirmation = null, agentVersion = 0, language = null, signal }) { /* No agent, no run. The panel resolves which agent covers the page before calling; reaching here without one means the routing layer changed and this should say so rather than guess at an agent id. */ @@ -98,6 +99,15 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) { stops an edit published mid-thread from silently changing which agent is answering. */ agentVersion: agentVersion || undefined, + /* The language to answer in. Omitted when it is the default, so an + English run sends exactly the body it sent before the selector + existed — and a deployment that does not know the field yet is + unaffected by the common case. + + A tag, never a sentence. The server maps it onto a closed set and + an unrecognised one answers in English; the panel cannot write + prompt text from here, which is the point of sending a tag. */ + language: language && language !== 'en' ? language : undefined, }), signal, }); diff --git a/src/components/ai-assistant/useAssistant.ts b/src/components/ai-assistant/useAssistant.ts index 3c80c59..9f33a67 100644 --- a/src/components/ai-assistant/useAssistant.ts +++ b/src/components/ai-assistant/useAssistant.ts @@ -25,6 +25,7 @@ import { agentRequest } from '@/lib/agents/runtime'; import { createAssistantProvider } from './provider'; import { preferAgent, resolveIntent } from './routing'; import { doc, text as textBlock, toSnapshots } from './blocks'; +import { DEFAULT_LANGUAGE } from './language'; /** One provider instance for the app's lifetime. */ const provider = createAssistantProvider(); @@ -199,14 +200,26 @@ const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/ * * Two rules, and the second is the one that matters. The suggestions are ranked * by the SERVER against the question just asked — the panel does not decide what - * is worth asking, it only decides what has already been said — and then - * anything this thread has asked or already offered is removed. + * is worth asking, it only decides what has already been said — and then what + * this thread has already covered is removed. * * Without that second rule the row repeats. A page carries a handful of intents * and the top of that list barely moves between turns, so the same three chips * come back after every answer, including the one the reader has just pressed. - * Removing what has been used leaves genuinely new ground each time and runs out - * honestly rather than looping. + * + * ASKED AND OFFERED ARE NOT THE SAME THING, and treating them as one is what + * made the row appear exactly once per conversation. Every chip ever *shown* + * was being banned permanently, in a set that only grew; since the server keeps + * returning the top of the same small catalogue, the second turn's suggestions + * were all already in it, the filter emptied the list, and an empty list means + * no row. Every turn after the first was silent, whatever was asked. + * + * So the two are separated. A question this thread actually PUT is excluded for + * good — it has an answer on screen, and offering to repeat it is not a follow- + * up. A chip merely DISPLAYED and passed over is held back only from the turn + * directly after it, which is enough to stop the row redrawing verbatim under + * consecutive answers; beyond that it is offerable again, because a reader + * ignoring a suggestion is not the same as having covered it. * * There is NO fallback to the page's own ranking, and that is the correction a * live run forced. Asking "Summarize hiring activity" matches nothing in the @@ -220,18 +233,34 @@ const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/ export async function nextSteps({ question, history, refresh }) { if (!refresh) return undefined; - const used = new Set([asQuestion(question)]); + /* Permanent: what this thread has actually asked, including the question + being answered right now. */ + const asked = new Set([asQuestion(question)]); for (const message of history) { - if (message.role === 'user') used.add(asQuestion(message.text)); - for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label)); + if (message.role === 'user') asked.add(asQuestion(message.text)); } + /* Transient: the row under the answer directly above this one, and only + that row. The immediately preceding assistant turn, whether or not it + carried chips — if it offered none there is nothing to hold back, and a + suggestion last seen further up the thread has had an answer between it + and now, so repeating it is not the verbatim redraw this guards against. */ + const justOffered = new Set(); + for (let i = history.length - 1; i >= 0; i -= 1) { + if (history[i]?.role !== 'assistant') continue; + for (const chip of history[i].followUp || []) { + justOffered.add(asQuestion(chip.prompt || chip.label)); + } + break; + } + + const seen = new Set(); const unused = (chips) => (chips || []).filter((chip) => { const key = asQuestion(chip.prompt || chip.label); - if (!key || used.has(key)) return false; + if (!key || asked.has(key) || justOffered.has(key) || seen.has(key)) return false; /* A list that repeats itself within one turn is the same defect at a smaller scale. */ - used.add(key); + seen.add(key); return true; }); @@ -336,6 +365,11 @@ export function useConversation({ /* The page's own name, recorded with an archived thread so History can say where a conversation happened without resolving the context again. */ pageLabel = '', + /* The language the agent answers in — a tag from `language.js`, owned by the + header's selector. Read here only to put it on the request: nothing in this + hook branches on it, because translating is the model's job and not the + panel's. */ + language = DEFAULT_LANGUAGE, }) { /** * One thread per agent per page. @@ -888,6 +922,9 @@ export function useConversation({ agent: turnAgent ? agentRequest(turnAgent, turnContext) : null, owliverContext, agentVersion: pinnedVersionRef.current, + /* The reader's choice, applied from the next answer onward. Answers + already on screen are left as they were written. */ + language, })) { if (controller.signal.aborted) break; latest = snapshot; @@ -968,7 +1005,7 @@ export function useConversation({ onScheduleInterview, workforce, setFlow, disabledSkills, customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings, - agent, agentCoversPage, agentSuggestion, owliverContext, + agent, agentCoversPage, agentSuggestion, owliverContext, language, /* The layout session changes as a page's composition and the account's skills resolve, and a stale one means the tree Owliver inspects is the empty one from the first render — so a layout request falls through to @@ -1018,6 +1055,9 @@ export function useConversation({ owliverContext, confirmation: block.token, agentVersion: pinnedVersionRef.current, + /* The reader's choice, applied from the next answer onward. Answers + already on screen are left as they were written. */ + language, signal: controller.signal, })) { if (controller.signal.aborted) break; @@ -1037,7 +1077,7 @@ export function useConversation({ setPending(null); abortRef.current = null; } - }, [contextId, facts, agent, owliverContext, persist]); + }, [contextId, facts, agent, owliverContext, language, persist]); /** * States something in the thread without a question having been asked. diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 0f0f8d4..d4b62c1 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; import { - Bell, ChevronDown, LayoutGrid, LogOut, Menu, Search, Settings, Shield, + Bell, ChevronDown, Globe, LayoutGrid, LogOut, Menu, Search, Settings, Shield, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { base44 } from '@/api/base44Client'; @@ -11,10 +11,13 @@ import { Avatar } from '@/components/ds/Avatar'; import { Badge } from '@/components/ui/badge'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, + DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant'; +import { LANGUAGES, setLanguage, useLanguage } from '@/components/ai-assistant/language'; import { UiEditingProvider } from '@/components/ui-tree/UiEditingProvider'; import { pageKeyForRoute } from '@/lib/skills/registry'; import { endAdminSession } from '@/lib/admin/session'; @@ -164,6 +167,11 @@ export default function AdminLayout() { const navigate = useNavigate(); const [menuOpen, setMenuOpen] = useState(false); + /* The account's language. Held outside React so the Owliver panel — a + different subtree entirely — sees the change without a provider spanning + both, and so a second tab picks it up. See ai-assistant/language. */ + const language = useLanguage(); + /* Shared cache rather than a local fetch, so saving the profile updates the header avatar and account menu without a reload. */ const { data: user } = useCurrentUser(); @@ -289,6 +297,45 @@ export default function AdminLayout() { navigate('/admin/workspace/agents')} className="cursor-pointer"> Agent Registry + + {/* Language. + + A submenu rather than a row that navigates, because there + is nothing to navigate TO: the whole setting is one choice + from a closed list, and a page built to hold a single + radio group would be a page nobody returns to. + + A radio group rather than two items, so the control states + what it is — one of these, exactly one selected — to a + screen reader and to the eye, without the menu having to + draw its own tick. + + What it governs is Owliver's replies. Nothing else in the + product is generated text, so nothing else changes; the + row is in the account menu rather than in the panel + because it is a property of the account and outlives any + one conversation. */} + + + Language + + + + + {LANGUAGES.map((lang) => ( + + {lang.label} + + ))} + + + + +