Merge pull request #2549 from langbot-app/experiment/in-process-assistant

feat(web): add an in-process workspace assistant
This commit is contained in:
Type_rBody
2026-09-25 13:56:30 +08:00
committed by GitHub
20 changed files with 2198 additions and 6 deletions
@@ -0,0 +1,171 @@
import { useEffect, useState } from 'react';
import {
CheckCircle2,
ChevronDown,
CircleAlert,
MinusCircle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
export type AssistantTool = {
name: string;
arguments: Record<string, unknown>;
result: unknown;
};
export default function AssistantToolResult({
tool,
content,
defaultCollapsed = false,
}: {
tool?: AssistantTool;
content: string;
/** Turn finished: collapse to one line until the user expands it again. */
defaultCollapsed?: boolean;
}) {
const { t } = useTranslation();
// Track manual toggles so a completing turn cannot fight the user's choice.
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const [manual, setManual] = useState(false);
useEffect(() => {
if (!manual) setCollapsed(defaultCollapsed);
}, [defaultCollapsed, manual]);
const result = tool?.result;
const data =
result && typeof result === 'object' && !Array.isArray(result)
? (result as Record<string, unknown>)
: {};
const failed = !!data.error;
const denied = data.status === 'denied';
const partial = !!data.truncated;
const Icon =
failed || partial ? CircleAlert : denied ? MinusCircle : CheckCircle2;
const items = Array.isArray(result)
? result
: Array.isArray(data.items)
? data.items
: null;
const total = typeof data.total === 'number' ? data.total : items?.length;
const kind = tool?.arguments.kind;
const label =
tool?.name === 'list_resources' && typeof kind === 'string'
? t(`assistant.resources.${kind}`, { defaultValue: kind })
: t(`assistant.operations.${tool?.name}`, {
defaultValue: t('assistant.toolResult'),
});
const status = failed
? 'failed'
: denied
? 'denied'
: partial
? 'partial'
: 'completed';
const url =
typeof data.url === 'string' &&
/^\/home\/(pipelines|knowledge)\?id=[\w-]+$/.test(data.url)
? data.url
: null;
const name =
typeof data.name === 'string'
? data.name
: typeof tool?.arguments.name === 'string'
? tool.arguments.name
: null;
if (collapsed) {
return (
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg border bg-background px-3 py-1.5 text-left text-xs hover:bg-muted"
aria-expanded={false}
onClick={() => {
setManual(true);
setCollapsed(false);
}}
>
<Icon
className={`size-3.5 shrink-0 ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
/>
<span className="truncate font-medium">{label}</span>
<span className="ml-auto shrink-0 text-muted-foreground">
{tool && t(`assistant.${status}`)}
</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</button>
);
}
return (
<section className="space-y-2 rounded-xl border bg-background p-3 text-sm">
<div className="flex items-center gap-2">
<Icon
className={`size-4 shrink-0 ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
/>
<span className="font-medium">{label}</span>
<span className="ml-auto text-xs text-muted-foreground">
{tool && t(`assistant.${status}`)}
</span>
<button
type="button"
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label={t('assistant.details')}
aria-expanded
onClick={() => {
setManual(true);
setCollapsed(true);
}}
>
<ChevronDown className="size-3.5 rotate-180" />
</button>
</div>
{failed ? (
<p className="text-destructive">{t('assistant.operationFailed')}</p>
) : denied ? (
<p className="text-muted-foreground">
{t('assistant.operationDenied')}
</p>
) : partial ? (
<p className="text-muted-foreground">{t('assistant.partial')}</p>
) : (
<>
{total !== undefined && (
<p>{t('assistant.found', { count: total })}</p>
)}
{name && <p className="break-words">{name}</p>}
{items && (
<ul className="space-y-1 text-xs text-muted-foreground">
{items.slice(0, 6).map((item: unknown, index: number) => {
const entry =
item && typeof item === 'object'
? (item as Record<string, unknown>)
: {};
return (
<li key={index} className="truncate">
{String(entry.name || entry.uuid || '—')}
</li>
);
})}
</ul>
)}
{url && (
<a
className="inline-block text-primary underline"
href={url}
target="_blank"
rel="noopener noreferrer"
>
{t('assistant.openResource')}
</a>
)}
</>
)}
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">{t('assistant.details')}</summary>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all">
{tool ? JSON.stringify(tool.result, null, 2) : content}
</pre>
</details>
</section>
);
}
@@ -0,0 +1,756 @@
import {
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { MessageCircle, Plus, Send, X, LoaderCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { backendClient, useCurrentWorkspace, userInfo } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import DynamicFormItemComponent from './dynamic-form/DynamicFormItemComponent';
import { DynamicFormItemType } from '@/app/infra/entities/form/dynamic';
import AssistantToolResult, { AssistantTool } from './AssistantToolResult';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
ASSISTANT_RAIL_WIDTH,
clampAssistantPosition as clampInViewport,
resolveAssistantEdge,
shouldCollapseRail,
shouldExpandRail,
type AssistantDragPosition,
type AssistantEdge,
} from './assistant-dock';
const ASSISTANT_LONG_PRESS_MS = 260;
function viewport(): { width: number; height: number } {
return { width: window.innerWidth, height: window.innerHeight };
}
function clampAssistantPosition(x: number, y: number): AssistantDragPosition {
return clampInViewport(x, y, viewport());
}
type Conversation = {
uuid: string;
revision: number;
status: 'ready' | 'running' | 'approval' | 'failed';
messages: { role: string; content: string; tool?: AssistantTool }[];
pending: { name: string; arguments: Record<string, unknown> }[];
error: string | null;
model_name: string | null;
model_uuid: string | null;
};
export default function WorkspaceAssistant() {
const workspace = useCurrentWorkspace();
if (
!workspace?.permissions.includes('runtime.operate') ||
!userInfo?.account_uuid
)
return null;
const identity = `${workspace.workspace.uuid}:${userInfo.account_uuid}`;
return (
<AssistantPanel
key={identity}
storageKey={`langbot-assistant:${identity}`}
/>
);
}
function AssistantPanel({ storageKey }: { storageKey: string }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [conversation, setConversation] = useState<Conversation | null>(null);
const [text, setText] = useState('');
const [sending, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const busy = sending || loading;
const [error, setError] = useState(false);
const [modelUuid, setModelUuid] = useState('');
const [pendingText, setPendingText] = useState<string | null>(null);
const controller = useRef(new AbortController());
const end = useRef<HTMLDivElement>(null);
const [dragPosition, setDragPosition] =
useState<AssistantDragPosition | null>(null);
const [dragging, setDragging] = useState(false);
const [dockedEdge, setDockedEdge] = useState<AssistantEdge>(null);
// The rail collapses only while the pointer is away. Hovering any part of the
// control restores the full button, and the two states never race because
// every transition is derived from the same `dockedEdge` snapshot.
const [railExpanded, setRailExpanded] = useState(false);
const pointerOver = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const hoverLocked = useRef(false);
const dragState = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
active: boolean;
} | null>(null);
const longPressTimer = useRef<number | null>(null);
const suppressClick = useRef(false);
const buttonRef = useRef<HTMLButtonElement>(null);
function clearLongPressTimer() {
if (longPressTimer.current !== null) {
window.clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
}
useEffect(() => clearLongPressTimer, []);
/*
* Seed the resting position from the rendered default (bottom-right) so the
* very first visit already docks and collapses. Without this the button would
* only ever dock after a manual drag, which reads as "collapse is broken".
*/
useEffect(() => {
if (window.localStorage.getItem(`${storageKey}:button-position`)) return;
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
const position = clampAssistantPosition(rect.left, rect.top);
setDragPosition(position);
const edge = resolveAssistantEdge(position.x, viewport());
setDockedEdge(edge);
if (edge) {
try {
window.localStorage.setItem(`${storageKey}:button-docked-edge`, edge);
} catch {
// Persisting the dock is best-effort only.
}
}
}, [storageKey]);
useEffect(() => {
const stored = window.localStorage.getItem(`${storageKey}:button-position`);
if (stored) {
try {
const parsed = JSON.parse(stored) as AssistantDragPosition;
if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') {
const position = clampAssistantPosition(parsed.x, parsed.y);
setDragPosition(position);
setDockedEdge(resolveAssistantEdge(position.x, viewport()));
}
} catch {
window.localStorage.removeItem(`${storageKey}:button-position`);
}
}
const storedEdge = window.localStorage.getItem(
`${storageKey}:button-docked-edge`,
);
if (storedEdge === 'left' || storedEdge === 'right') {
setDockedEdge((current) => current ?? storedEdge);
}
}, [storageKey]);
useEffect(() => {
const onResize = () => {
setDragPosition((prev) => {
if (!prev) return prev;
const next = clampAssistantPosition(prev.x, prev.y);
setDockedEdge(resolveAssistantEdge(next.x, viewport()));
return next;
});
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
function persistDragPosition(position: AssistantDragPosition) {
try {
window.localStorage.setItem(
`${storageKey}:button-position`,
JSON.stringify(position),
);
} catch {
// Ignore storage failures (private mode or quota); dragging still works.
}
}
function applyRestingPosition(position: AssistantDragPosition) {
const edge = resolveAssistantEdge(position.x, viewport());
setDragPosition(position);
setDockedEdge(edge);
// A fresh dock always collapses; the rail re-expands on the next hover.
setRailExpanded(false);
// Hold the collapse until the pointer leaves, otherwise the still-hovering
// cursor would fight the new state.
if (edge) lockHoverUntilPointerExit();
try {
if (edge) {
window.localStorage.setItem(`${storageKey}:button-docked-edge`, edge);
} else {
window.localStorage.removeItem(`${storageKey}:button-docked-edge`);
}
} catch {
// Persisting the dock is best-effort only.
}
}
function endDrag(commit: boolean, clientX?: number, clientY?: number) {
const state = dragState.current;
clearLongPressTimer();
dragState.current = null;
if (!state?.active) return;
setDragging(false);
const next = clampAssistantPosition(
state.originX + ((clientX ?? state.startX) - state.startX),
state.originY + ((clientY ?? state.startY) - state.startY),
);
if (commit) {
applyRestingPosition(next);
persistDragPosition(next);
} else {
setDragPosition(next);
}
}
/*
* Rail hover recovery. The subtle race: a drag usually ends with the pointer
* still sitting on the button, so the browser fires no new `pointerenter`
* once the button collapses. Re-expanding on `pointermove` would therefore
* undo the collapse immediately.
*
* Instead the drop "locks" hover until the pointer physically leaves the
* control. A window-level move listener watches for that exit (the element
* can shift under a stationary cursor, so `pointerleave` alone is not
* reliable) and clears the lock; only then does hovering reveal the button.
*/
function handlePointerEnter() {
pointerOver.current = true;
if (hoverLocked.current) return;
if (shouldExpandRail({ dockedEdge, railExpanded, dragging }))
setRailExpanded(true);
}
function handlePointerLeave() {
pointerOver.current = false;
hoverLocked.current = false;
// Never collapse mid-drag; the drop handler owns the final state.
if (dragState.current || dragging) return;
if (shouldCollapseRail({ dockedEdge, railExpanded, dragging }))
setRailExpanded(false);
}
/*
* A click or an aborted swipe never arms the long press, so no drop handler
* runs. Re-sync the rail from the real pointer position on release, otherwise
* a button expanded by hover would stay expanded with the cursor gone.
*/
function settleRailAfterRelease() {
if (pointerOver.current || dragState.current || dragging) return;
if (shouldCollapseRail({ dockedEdge, railExpanded, dragging }))
setRailExpanded(false);
}
function lockHoverUntilPointerExit() {
hoverLocked.current = true;
const releaseOnExit = (moveEvent: PointerEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const outside =
moveEvent.clientX < rect.left ||
moveEvent.clientX > rect.right ||
moveEvent.clientY < rect.top ||
moveEvent.clientY > rect.bottom;
if (!outside) return;
hoverLocked.current = false;
window.removeEventListener('pointermove', releaseOnExit);
};
window.addEventListener('pointermove', releaseOnExit);
}
function onButtonPointerDown(event: ReactPointerEvent<HTMLButtonElement>) {
if (event.button !== 0) return;
suppressClick.current = false;
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
const state = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: rect.left,
originY: rect.top,
active: false,
};
dragState.current = state;
clearLongPressTimer();
const onWindowMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== state.pointerId) return;
const deltaX = moveEvent.clientX - state.startX;
const deltaY = moveEvent.clientY - state.startY;
if (!state.active) {
// Cancel the long-press when the user is clearly scrolling or swiping.
if (Math.hypot(deltaX, deltaY) > 8) clearLongPressTimer();
return;
}
moveEvent.preventDefault();
setDragPosition(
clampAssistantPosition(state.originX + deltaX, state.originY + deltaY),
);
};
const onWindowUp = (upEvent: PointerEvent) => {
if (upEvent.pointerId !== state.pointerId) return;
window.removeEventListener('pointermove', onWindowMove);
window.removeEventListener('pointerup', onWindowUp);
window.removeEventListener('pointercancel', onWindowUp);
endDrag(true, upEvent.clientX, upEvent.clientY);
};
window.addEventListener('pointermove', onWindowMove, { passive: false });
window.addEventListener('pointerup', onWindowUp);
window.addEventListener('pointercancel', onWindowUp);
// Release outside a drag still needs to reconcile the rail.
window.addEventListener('pointerup', settleRailAfterRelease, {
once: true,
});
longPressTimer.current = window.setTimeout(() => {
if (dragState.current !== state) return;
state.active = true;
setDragging(true);
suppressClick.current = true;
setDragPosition(clampAssistantPosition(state.originX, state.originY));
}, ASSISTANT_LONG_PRESS_MS);
}
function onButtonClick(event: ReactMouseEvent<HTMLButtonElement>) {
// After a drag the trailing click must not toggle the panel. Radix's
// trigger skips its own toggle when the event default is prevented.
if (suppressClick.current) {
suppressClick.current = false;
event.preventDefault();
event.stopPropagation();
}
}
useEffect(() => {
if (conversation?.model_uuid) setModelUuid(conversation.model_uuid);
}, [conversation?.model_uuid]);
useEffect(() => {
const abort = new AbortController();
controller.current = abort;
return () => abort.abort();
}, []);
useEffect(() => {
if (!open || busy || (conversation && conversation.status !== 'running'))
return;
const id = localStorage.getItem(storageKey);
if (!id) return;
let active = true;
setLoading(true);
backendClient
.request<Conversation>({
method: 'GET',
url: `/api/v1/assistant/conversations/${encodeURIComponent(id)}`,
signal: controller.current.signal,
})
.then((value) => {
if (active) setConversation(value);
})
.catch(() => {
if (active) {
localStorage.removeItem(storageKey);
setError(true);
}
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
setLoading(false);
};
// Load only when opening; turn requests own subsequent state updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, storageKey]);
// `open` is a dependency so a freshly opened panel jumps to the newest turn
// instead of leaving the user at the oldest message. The rAF waits for the
// popover to lay out before measuring the sentinel.
useEffect(() => {
if (!open) return;
const frame = window.requestAnimationFrame(() => {
end.current?.scrollIntoView({ behavior: 'auto', block: 'nearest' });
});
return () => window.cancelAnimationFrame(frame);
}, [open, conversation, busy, pendingText]);
async function submit(approved?: boolean) {
if (
busy ||
(approved === undefined &&
(!text.trim() || (conversation && conversation.status !== 'ready')))
)
return;
const sentText = approved === undefined ? text.trim() : null;
const sentRevision = conversation?.revision ?? 0;
if (sentText) {
setPendingText(sentText);
setText('');
}
setBusy(true);
setError(false);
try {
let current = conversation;
if (!current) {
current = await backendClient.request<Conversation>({
method: 'POST',
url: '/api/v1/assistant/conversations',
signal: controller.current.signal,
});
localStorage.setItem(storageKey, current.uuid);
setConversation(current);
}
const updated = await backendClient.request<Conversation>({
method: 'POST',
url: `/api/v1/assistant/conversations/${current.uuid}/turn`,
data: {
revision: current.revision,
...(approved === undefined
? {
text: sentText,
...(modelUuid ? { model_uuid: modelUuid } : {}),
}
: { approved }),
},
timeout: 130000,
signal: controller.current.signal,
});
setConversation(updated);
if (sentText) setPendingText(null);
} catch {
setError(true);
// A lost response may already have executed a write. Refresh, never replay.
const id = localStorage.getItem(storageKey);
if (id && !controller.current.signal.aborted) {
try {
const latest = await backendClient.request<Conversation>({
method: 'GET',
url: `/api/v1/assistant/conversations/${encodeURIComponent(id)}`,
signal: controller.current.signal,
});
setConversation(latest);
if (
sentText &&
latest.revision > sentRevision &&
latest.messages.some(
(message) =>
message.role === 'user' && message.content === sentText,
)
)
setPendingText(null);
} catch {
/* Keep the error visible; do not retry a turn. */
}
}
} finally {
setBusy(false);
}
}
function reset() {
localStorage.removeItem(storageKey);
setConversation(null);
setText('');
setError(false);
setPendingText(null);
}
// Collapse only when docked, idle and not hovered. The container keeps its
// resting box, so the hidden button and the visible strip share one anchor
// and cannot drift apart; only the strip is painted while collapsed.
const railCollapsed = !!dockedEdge && !railExpanded && !dragging;
const inlinePosition = dragPosition
? { left: dragPosition.x, top: dragPosition.y }
: undefined;
return (
<div
ref={containerRef}
className={dragPosition ? 'fixed z-50' : 'fixed bottom-20 right-5 z-50'}
style={inlinePosition}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={buttonRef}
size="icon"
aria-label={t('assistant.title')}
aria-expanded={railCollapsed ? false : undefined}
className={`size-12 rounded-full shadow-lg ${
dragging
? 'cursor-grabbing scale-105'
: railCollapsed
? 'cursor-pointer'
: 'cursor-grab'
} transition-[transform,opacity] duration-200`}
style={{
touchAction: 'none',
opacity: railCollapsed ? 0 : 1,
}}
onClick={onButtonClick}
onPointerDown={onButtonPointerDown}
onContextMenu={(event) => event.preventDefault()}
>
<MessageCircle className="size-6" />
</Button>
</PopoverTrigger>
{railCollapsed && (
<span
role="presentation"
aria-hidden="true"
className={`pointer-events-none absolute top-1/2 h-14 -translate-y-1/2 rounded-full bg-[#3b82f6] shadow-md ${
dockedEdge === 'right' ? 'right-0' : 'left-0'
}`}
style={{ width: ASSISTANT_RAIL_WIDTH }}
/>
)}
<PopoverContent
align="end"
side="top"
sideOffset={12}
className="flex h-[min(680px,80dvh)] w-[min(440px,calc(100vw-24px))] flex-col overflow-hidden rounded-2xl p-0 shadow-xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<header className="flex items-center gap-2 border-b p-3">
<MessageCircle className="size-5 text-primary" />
<div className="min-w-0 flex-1">
<h2 className="truncate font-semibold">{t('assistant.title')}</h2>
<p className="truncate text-xs text-muted-foreground">
{t('assistant.subtitle')}
</p>
</div>
{open && (
<div
className="w-28 min-w-0 shrink-0"
title={t('assistant.modelHint')}
>
<DynamicFormItemComponent
config={{
id: 'assistant-model',
name: 'assistant-model',
type: DynamicFormItemType.LLM_MODEL_SELECTOR,
default: '',
required: false,
label: { en_US: 'Assistant model', zh_Hans: '助手模型' },
}}
field={{
name: 'assistant-model',
value: modelUuid,
onChange: setModelUuid,
onBlur: () => {},
ref: () => {},
disabled:
busy ||
(!!conversation && conversation.status !== 'ready'),
}}
requiredModelAbility="func_call"
compactModelSelector
/>
</div>
)}
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
disabled={busy}
onClick={reset}
aria-label={t('assistant.newChat')}
>
<Plus />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() => setOpen(false)}
aria-label={t('assistant.close')}
>
<X />
</Button>
</header>
<div
className="flex-1 space-y-3 overflow-y-auto p-4"
aria-live="polite"
>
{!conversation?.messages.length && !pendingText && (
<>
<p className="text-sm text-muted-foreground">
{t('assistant.welcome')}
</p>
{(['discover', 'build'] as const).map((key) => (
<button
key={key}
type="button"
disabled={busy}
className="w-full rounded-lg border p-3 text-left text-sm hover:bg-muted"
onClick={() => setText(t(`assistant.${key}`))}
>
{t(`assistant.${key}`)}
</button>
))}
</>
)}
{conversation?.messages.map((message, index) =>
message.role === 'tool' ? (
<AssistantToolResult
key={index}
tool={message.tool}
content={message.content}
defaultCollapsed={!busy && conversation.status === 'ready'}
/>
) : (
<div
key={index}
className={`rounded-xl p-3 text-sm break-words ${message.role === 'user' ? 'ml-6 bg-primary/10' : 'bg-muted'}`}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: () => null,
a: ({ href, children }) => (
<a
href={href}
className="text-primary underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
}}
>
{message.content}
</ReactMarkdown>
</div>
),
)}
{pendingText && (
<div className="ml-6 rounded-xl bg-primary/10 p-3 text-sm whitespace-pre-wrap break-words">
{pendingText}
{error && (
<p className="mt-1 text-xs text-destructive">
{t('assistant.sendUnconfirmed')}
</p>
)}
</div>
)}
{conversation?.status === 'approval' && (
<div className="space-y-3 rounded-xl border border-primary/30 p-3">
<p className="text-sm font-medium">{t('assistant.review')}</p>
{conversation.pending.map((call, index) => (
<div key={index}>
<p className="text-sm font-medium">{call.name}</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all text-xs">
{JSON.stringify(call.arguments, null, 2)}
</pre>
</div>
))}
<div className="flex gap-2">
<Button
size="sm"
disabled={busy}
onClick={() => submit(true)}
>
{t('assistant.confirm')}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => submit(false)}
>
{t('assistant.decline')}
</Button>
</div>
</div>
)}
{busy && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
{t('assistant.working')}
</p>
)}
{(error || conversation?.status === 'failed') && (
<p role="alert" className="text-sm text-destructive">
{conversation?.error === 'model_unavailable'
? t('assistant.modelUnavailable')
: t('assistant.error')}
</p>
)}
{!busy && conversation?.status === 'running' && (
<p className="text-sm text-muted-foreground">
{t('assistant.running')}
</p>
)}
<div ref={end} />
</div>
<form
className="flex items-end gap-2 border-t p-3"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
maxLength={8000}
rows={2}
aria-label={t('assistant.placeholder')}
placeholder={t(
busy ? 'assistant.draftPlaceholder' : 'assistant.placeholder',
)}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void submit();
}
}}
className="min-w-0 flex-1 resize-none rounded-lg border bg-background p-2 text-sm focus-visible:outline-2 focus-visible:outline-primary"
/>
<Button
type="submit"
size="icon"
aria-label={t('assistant.send')}
disabled={
busy ||
!text.trim() ||
(!!conversation && conversation.status !== 'ready')
}
>
<Send className="size-4" />
</Button>
</form>
</PopoverContent>
</Popover>
</div>
);
}
@@ -0,0 +1,89 @@
/**
* Pure geometry + state helpers for the floating workspace assistant button.
*
* The component keeps these decisions here so the dock/rail transitions stay
* testable without a DOM. The key invariant is that the *same* edge value
* drives the collapsed strip and the expanded button, so they can never drift
* apart or fight each other (the race the UI is careful to avoid).
*/
export const ASSISTANT_BUTTON_SIZE = 48;
/** Distance to the viewport edge that snaps the button into the rail. */
export const ASSISTANT_SNAP_THRESHOLD = 24;
/** Rail strip that stays visible while collapsed, in pixels. */
export const ASSISTANT_RAIL_WIDTH = 6;
export type AssistantDragPosition = { x: number; y: number };
export type AssistantEdge = 'left' | 'right' | null;
/** Viewport box the button may occupy. Kept explicit so tests can pin a size. */
export type AssistantViewport = { width: number; height: number };
export function clampAssistantPosition(
x: number,
y: number,
viewport: AssistantViewport,
): AssistantDragPosition {
const maxX = Math.max(0, viewport.width - ASSISTANT_BUTTON_SIZE);
const maxY = Math.max(0, viewport.height - ASSISTANT_BUTTON_SIZE);
return {
x: Math.min(Math.max(0, x), maxX),
y: Math.min(Math.max(0, y), maxY),
};
}
/**
* Decide whether a resting position is docked against a vertical edge.
* The left edge wins ties so a centred drag always resolves deterministically.
*/
export function resolveAssistantEdge(
x: number,
viewport: AssistantViewport,
): AssistantEdge {
const maxX = Math.max(0, viewport.width - ASSISTANT_BUTTON_SIZE);
const leftGap = x;
const rightGap = maxX - x;
if (leftGap <= ASSISTANT_SNAP_THRESHOLD && leftGap <= rightGap) return 'left';
if (rightGap <= ASSISTANT_SNAP_THRESHOLD) return 'right';
return null;
}
export type AssistantHoverInput = {
dockedEdge: AssistantEdge;
railExpanded: boolean;
/** A long press has armed the drag, or the pointer is actively moving. */
dragging: boolean;
};
/** Distance to the viewport edge that snaps the button into the rail. */
/**
* Hovering any part of the control must reveal the full button again.
* Only a docked *and still collapsed* button needs to expand, so a pointer that
* merely jitters inside an already expanded button does no state churn.
*/
export function shouldExpandRail(input: AssistantHoverInput): boolean {
if (input.dragging) return false;
return !!input.dockedEdge && !input.railExpanded;
}
/**
* A docked button collapses to the strip when the pointer leaves. This is the
* mirror of `shouldExpandRail` and is keyed on `railExpanded` being *true* —
* the earlier `!railExpanded` form made a revealed button impossible to
* re-collapse, which is the bug this guards against. The panel being open pins
* it, because the popover stays anchored to the button.
*/
export function shouldCollapseRail(input: AssistantHoverInput): boolean {
if (input.dragging) return false;
return !!input.dockedEdge && input.railExpanded;
}
/**
* After a drop the button always starts collapsed, even though the pointer is
* still over it. The rail is then held collapsed until the pointer exits, which
* avoids the "drops under the cursor and never comes back" race.
*/
export function restingRailExpanded(): boolean {
return false;
}
@@ -139,6 +139,8 @@ export default function DynamicFormItemComponent({
onFileUploaded,
setFormValue,
systemContext,
requiredModelAbility,
compactModelSelector = false,
}: {
config: IDynamicFormItemSchema;
field: ControllerRenderProps<any, any>;
@@ -146,6 +148,8 @@ export default function DynamicFormItemComponent({
onFileUploaded?: (fileKey: string) => void;
setFormValue?: (name: string, value: unknown) => void;
systemContext?: Record<string, unknown>;
requiredModelAbility?: string;
compactModelSelector?: boolean;
}) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
@@ -583,14 +587,19 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.LLM_MODEL_SELECTOR:
const selectableModels = llmModels.filter(
(model) =>
!requiredModelAbility ||
model.abilities?.includes(requiredModelAbility),
);
// Separate space models from regular models
const spaceModels = sortModelsByCatalog(
llmModels.filter(
selectableModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
),
langbotModelMetadata,
);
const regularModels = llmModels.filter(
const regularModels = selectableModels.filter(
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
@@ -630,8 +639,19 @@ export default function DynamicFormItemComponent({
return (
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className={MODEL_SELECT_TRIGGER_CLASS}>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={field.disabled}
>
<SelectTrigger
aria-label={t('models.selectModel')}
className={
compactModelSelector
? 'w-full min-w-0 gap-1 border-0 bg-transparent px-1 text-xs text-muted-foreground shadow-none hover:bg-muted data-[size=default]:h-7 [&_[data-slot=select-value]_svg]:hidden'
: MODEL_SELECT_TRIGGER_CLASS
}
>
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
@@ -760,7 +780,7 @@ export default function DynamicFormItemComponent({
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
className={compactModelSelector ? 'hidden' : 'h-9 w-9 shrink-0'}
onClick={() => {
setSettingsSection('models');
setModelsDialogOpen(true);
+2
View File
@@ -1,5 +1,6 @@
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
import WorkspaceAssistant from '@/app/home/components/WorkspaceAssistant';
import React, {
useState,
useCallback,
@@ -278,6 +279,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
</main>
<SurveyWidget />
<WorkspaceAssistant />
</SidebarInset>
</SidebarProvider>
);
+51
View File
@@ -1,6 +1,57 @@
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
assistant: {
modelHint: 'Switch assistant model for your next message',
details: 'View raw data',
found: 'Found {{count}} items',
completed: 'Completed',
failed: 'Failed',
denied: 'Not executed',
partial: 'Partial result',
operationFailed:
'Operation failed. Check the details and resource state before retrying a change.',
operationDenied: 'Declined. Nothing in this batch was executed.',
openResource: 'Open resource',
sendUnconfirmed:
'Delivery is unconfirmed. Check your connection and reopen the conversation.',
draftPlaceholder: 'Draft your next message while waiting…',
operations: {
create_pipeline: 'Create Pipeline',
configure_pipeline: 'Configure Pipeline',
create_knowledge_base: 'Create knowledge base',
get_pipeline: 'Read Pipeline',
get_knowledge_schema: 'Read knowledge engine schema',
},
resources: {
models: 'Find chat models',
embedding_models: 'Find embedding models',
pipelines: 'Find Pipelines',
knowledge_bases: 'Find knowledge bases',
knowledge_engines: 'Find knowledge engines',
},
title: 'Workspace assistant',
subtitle: 'Build and manage your apps',
newChat: 'New conversation',
close: 'Close',
welcome:
'Tell me what you want to build. I can inspect this Workspace and propose knowledge-base and Pipeline changes for you to confirm.',
discover: 'What models, knowledge bases and Pipelines are available?',
build: 'Help me create a knowledge-base question-answering application.',
toolResult: 'Operation result',
review: 'Review the exact operations before executing',
confirm: 'Confirm and execute',
decline: 'Decline',
working: 'Working…',
modelUnavailable:
'Model unavailable. Check its configuration and access, then start a new conversation.',
error:
'This turn did not complete. Check the resource results before starting a new conversation; changes are not retried automatically.',
running:
'This turn is running, or was interrupted before its result was saved. Reopen the assistant to refresh; check resources before starting over.',
placeholder: 'Describe your application…',
send: 'Send',
},
sidebarGuide: {
steps: {
monitoring: {
+51
View File
@@ -1,6 +1,57 @@
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
assistant: {
modelHint: '次のメッセージからモデルを切り替え',
details: '元のデータを見る',
found: '{{count}} 件見つかりました',
completed: '完了',
failed: '失敗',
denied: '未実行',
partial: '結果が不完全です',
operationFailed:
'操作に失敗しました。変更を再試行する前に詳細とリソースを確認してください。',
operationDenied: '拒否されました。この操作は実行されていません。',
openResource: 'リソースを開く',
sendUnconfirmed:
'送信を確認できません。接続を確認して会話を開き直してください。',
draftPlaceholder: '待機中に次のメッセージを入力できます…',
operations: {
create_pipeline: 'Pipeline 作成',
configure_pipeline: 'Pipeline 設定',
create_knowledge_base: 'ナレッジベース作成',
get_pipeline: 'Pipeline 参照',
get_knowledge_schema: 'エンジン設定の参照',
},
resources: {
models: 'チャットモデル検索',
embedding_models: '埋め込みモデル検索',
pipelines: 'Pipeline 検索',
knowledge_bases: 'ナレッジベース検索',
knowledge_engines: 'エンジン検索',
},
title: 'ワークスペースアシスタント',
subtitle: 'アプリを構築・管理',
newChat: '新しい会話',
close: '閉じる',
welcome:
'作りたいアプリを教えてください。現在のリソースを確認し、承認後にナレッジベースや Pipeline を設定します。',
discover: '利用できるモデル、ナレッジベース、Pipeline は?',
build: 'ナレッジベースを使う質問応答アプリを作りたい。',
toolResult: '操作結果',
review: '実行する操作とパラメータを確認してください',
confirm: '承認して実行',
decline: '拒否',
working: '処理中…',
modelUnavailable:
'モデルを利用できません。設定と権限を確認して、新しい会話を開始してください。',
error:
'処理が完了しませんでした。新しい会話の前にリソースを確認してください。変更は自動で再試行されません。',
running:
'処理中、または結果の保存前に中断されました。アシスタントを開き直して更新してください。',
placeholder: '作りたいアプリを説明…',
send: '送信',
},
sidebarGuide: {
steps: {
monitoring: {
+49
View File
@@ -1,6 +1,55 @@
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
assistant: {
modelHint: '切换助手模型,从下一条消息生效',
details: '查看原始数据',
found: '找到 {{count}} 项',
completed: '已完成',
failed: '失败',
denied: '未执行',
partial: '结果不完整',
operationFailed: '操作失败,请查看详情;涉及变更时先检查资源状态。',
operationDenied: '已拒绝,本批操作没有执行。',
openResource: '打开资源',
sendUnconfirmed: '发送状态未确认,请检查连接后重新打开会话。',
draftPlaceholder: '可以先写下一条,回复完成后发送…',
operations: {
create_pipeline: '创建 Pipeline',
configure_pipeline: '配置 Pipeline',
create_knowledge_base: '创建知识库',
get_pipeline: '查看 Pipeline',
get_knowledge_schema: '查看知识引擎配置',
},
resources: {
models: '查询聊天模型',
embedding_models: '查询嵌入模型',
pipelines: '查询 Pipeline',
knowledge_bases: '查询知识库',
knowledge_engines: '查询知识引擎',
},
title: '工作区助手',
subtitle: '构建和管理你的应用',
newChat: '新对话',
close: '关闭',
welcome:
'告诉我你想构建什么应用。我可以查看当前工作区,并提出知识库和 Pipeline 配置方案,由你确认后执行。',
discover: '当前有哪些可用的模型、知识库和 Pipeline?',
build: '帮我创建一个知识库问答应用。',
toolResult: '操作结果',
review: '请检查具体操作及参数,确认后执行',
confirm: '确认执行',
decline: '拒绝',
working: '正在处理…',
modelUnavailable:
'模型暂不可用。请检查模型配置和访问权限,然后开启新对话。',
error:
'本轮未完成。请先检查资源和操作结果,再开启新对话;系统不会自动重试变更。',
running:
'本轮仍在执行,或执行结果尚未保存时发生了中断。重新打开助手可刷新;重新开始前请检查资源。',
placeholder: '描述你想构建的应用…',
send: '发送',
},
sidebarGuide: {
steps: {
monitoring: {
+175
View File
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
// The floating assistant button docks against a viewport edge, collapses into a
// short blue rail, and re-expands on hover. The transition rules are pure so
// they can be pinned here: the race this guards against is a button that drops
// under the cursor and never becomes visible again.
const sourcePath = new URL(
'../../src/app/home/components/assistant-dock.ts',
import.meta.url,
);
assert.ok(fs.existsSync(sourcePath), 'Missing assistant dock module');
const compiled = ts.transpileModule(fs.readFileSync(sourcePath, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loaded = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
() => {
throw new Error('assistant-dock must stay dependency free');
},
loaded,
loaded.exports,
);
const {
ASSISTANT_BUTTON_SIZE,
ASSISTANT_SNAP_THRESHOLD,
ASSISTANT_RAIL_WIDTH,
clampAssistantPosition,
resolveAssistantEdge,
shouldCollapseRail,
shouldExpandRail,
restingRailExpanded,
} = loaded.exports;
const viewport = { width: 1280, height: 800 };
const maxX = viewport.width - ASSISTANT_BUTTON_SIZE;
test('clamping keeps the button fully inside the viewport', () => {
assert.deepEqual(clampAssistantPosition(-50, -50, viewport), { x: 0, y: 0 });
assert.deepEqual(clampAssistantPosition(9999, 9999, viewport), {
x: maxX,
y: viewport.height - ASSISTANT_BUTTON_SIZE,
});
assert.deepEqual(clampAssistantPosition(400, 300, viewport), {
x: 400,
y: 300,
});
});
test('a narrow viewport never produces a negative travel range', () => {
assert.deepEqual(clampAssistantPosition(10, 10, { width: 20, height: 20 }), {
x: 0,
y: 0,
});
});
test('positions within the snap threshold dock to the nearest edge', () => {
assert.equal(resolveAssistantEdge(0, viewport), 'left');
assert.equal(
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD, viewport),
'left',
);
assert.equal(resolveAssistantEdge(maxX, viewport), 'right');
assert.equal(
resolveAssistantEdge(maxX - ASSISTANT_SNAP_THRESHOLD, viewport),
'right',
);
});
test('a centred position stays free floating', () => {
assert.equal(resolveAssistantEdge(Math.round(maxX / 2), viewport), null);
assert.equal(
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD + 1, viewport),
null,
);
});
test('the left edge wins ties so a centred drop is deterministic', () => {
// A viewport sized so both gaps are inside the snap threshold at x = 0.
const tight = { width: ASSISTANT_BUTTON_SIZE, height: 400 };
assert.equal(resolveAssistantEdge(0, tight), 'left');
});
test('hovering a docked rail expands it, hovering a free button does not', () => {
assert.equal(
shouldExpandRail({
dockedEdge: 'right',
railExpanded: false,
dragging: false,
}),
true,
);
assert.equal(
shouldExpandRail({
dockedEdge: null,
railExpanded: false,
dragging: false,
}),
false,
);
// Already expanded: nothing to do, so no redundant state churn on every move.
assert.equal(
shouldExpandRail({
dockedEdge: 'left',
railExpanded: true,
dragging: false,
}),
false,
);
});
test('a drag in progress never expands or collapses the rail', () => {
const dragging = { dockedEdge: 'left', railExpanded: false, dragging: true };
assert.equal(shouldExpandRail(dragging), false);
assert.equal(shouldCollapseRail(dragging), false);
});
test('only an *expanded* docked button collapses when the pointer leaves', () => {
// Regression: keying this on `!railExpanded` made a revealed button
// impossible to collapse again, so it stayed open forever after one hover.
assert.equal(
shouldCollapseRail({
dockedEdge: 'left',
railExpanded: true,
dragging: false,
}),
true,
);
assert.equal(
shouldCollapseRail({
dockedEdge: 'left',
railExpanded: false,
dragging: false,
}),
false,
);
assert.equal(
shouldCollapseRail({
dockedEdge: null,
railExpanded: true,
dragging: false,
}),
false,
);
});
test('expand then leave round-trips back to the collapsed rail', () => {
const docked = { dockedEdge: 'right', railExpanded: false, dragging: false };
// Hover: reveal the button.
assert.equal(shouldExpandRail(docked), true);
// Pointer leaves: the revealed button must collapse again.
assert.equal(shouldCollapseRail({ ...docked, railExpanded: true }), true);
// A second hover re-reveals it, so the cycle is repeatable.
assert.equal(shouldExpandRail(docked), true);
});
test('dropping always starts collapsed so the rail is never stuck open', () => {
// The pointer is still over the button on drop; the next pointermove expands.
assert.equal(restingRailExpanded(), false);
assert.equal(
shouldExpandRail({
dockedEdge: 'right',
railExpanded: restingRailExpanded(),
dragging: false,
}),
true,
);
});
test('the rail strip stays thinner than the button it replaces', () => {
assert.ok(ASSISTANT_RAIL_WIDTH > 0);
assert.ok(ASSISTANT_RAIL_WIDTH < ASSISTANT_BUTTON_SIZE);
});