Merge pull request 'Let the reader choose Owliver's language, and stop the suggestion row going silent' (#1) from owliver-language-and-suggestions into main
Some checks failed
CI / check (push) Failing after 5m5s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-10-06 09:59:56 +00:00
6 changed files with 304 additions and 14 deletions

View File

@@ -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 ──');

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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,
});

View File

@@ -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.

View File

@@ -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() {
<DropdownMenuItem onClick={() => navigate('/admin/workspace/agents')} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" /> Agent Registry
</DropdownMenuItem>
{/* 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. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<Globe className="mr-2 h-4 w-4" /> Language
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={language} onValueChange={setLanguage}>
{LANGUAGES.map((lang) => (
<DropdownMenuRadioItem
key={lang.tag}
value={lang.tag}
className="cursor-pointer"
>
{lang.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={signOut}