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
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:
@@ -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 ──');
|
||||
|
||||
@@ -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
|
||||
|
||||
142
src/components/ai-assistant/language.ts
Normal file
142
src/components/ai-assistant/language.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user