Merge remote-tracking branch 'origin/master' into feat/totp-two-factor-auth

# Conflicts:
#	uv.lock
This commit is contained in:
TyperBody
2026-09-25 14:31:03 +08:00
32 changed files with 2540 additions and 62 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: {
+53
View File
@@ -1,6 +1,59 @@
import pipelineMigration from './pipeline-migration/es-ES';
const esES = {
assistant: {
modelHint: 'Cambia el modelo del asistente para el próximo mensaje',
details: 'Ver datos sin procesar',
found: 'Se encontraron {{count}} elementos',
completed: 'Completado',
failed: 'Fallido',
denied: 'No ejecutado',
partial: 'Resultado parcial',
operationFailed:
'La operación falló. Revisa los detalles y el estado de los recursos antes de reintentar un cambio.',
operationDenied: 'Rechazado. Nada de este lote se ejecutó.',
openResource: 'Abrir recurso',
sendUnconfirmed:
'Entrega sin confirmar. Comprueba tu conexión y vuelve a abrir la conversación.',
draftPlaceholder: 'Escribe tu próximo mensaje mientras esperas…',
operations: {
create_pipeline: 'Crear Pipeline',
configure_pipeline: 'Configurar Pipeline',
create_knowledge_base: 'Crear base de conocimiento',
get_pipeline: 'Consultar Pipeline',
get_knowledge_schema: 'Consultar el esquema del motor de conocimiento',
},
resources: {
models: 'Buscar modelos de chat',
embedding_models: 'Buscar modelos de embeddings',
pipelines: 'Buscar Pipelines',
knowledge_bases: 'Buscar bases de conocimiento',
knowledge_engines: 'Buscar motores de conocimiento',
},
title: 'Asistente del espacio de trabajo',
subtitle: 'Crea y gestiona tus aplicaciones',
newChat: 'Nueva conversación',
close: 'Cerrar',
welcome:
'Dime qué quieres crear. Puedo inspeccionar este espacio de trabajo y proponer cambios en la base de conocimiento y el Pipeline para que los confirmes.',
discover:
'¿Qué modelos, bases de conocimiento y Pipelines hay disponibles?',
build:
'Ayúdame a crear una aplicación de preguntas y respuestas con base de conocimiento.',
toolResult: 'Resultado de la operación',
review: 'Revisa las operaciones exactas antes de ejecutarlas',
confirm: 'Confirmar y ejecutar',
decline: 'Rechazar',
working: 'Procesando…',
modelUnavailable:
'Modelo no disponible. Revisa su configuración y acceso, y luego inicia una nueva conversación.',
error:
'Este turno no se completó. Revisa los resultados de los recursos antes de iniciar una nueva conversación; los cambios no se reintentan automáticamente.',
running:
'Este turno sigue en ejecución o se interrumpió antes de guardar su resultado. Vuelve a abrir el asistente para actualizar; revisa los recursos antes de empezar de nuevo.',
placeholder: 'Describe tu aplicación…',
send: 'Enviar',
},
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: {
+51
View File
@@ -1,6 +1,57 @@
import pipelineMigration from './pipeline-migration/ru-RU';
const ruRU = {
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: 'Найти Pipelines',
knowledge_bases: 'Найти базы знаний',
knowledge_engines: 'Найти движки знаний',
},
title: 'Ассистент рабочего пространства',
subtitle: 'Создавайте приложения и управляйте ими',
newChat: 'Новый диалог',
close: 'Закрыть',
welcome:
'Расскажите, что вы хотите создать. Я изучу это рабочее пространство и предложу изменения для базы знаний и Pipeline, которые вы подтвердите.',
discover: 'Какие модели, базы знаний и Pipelines доступны?',
build: 'Помогите создать приложение для ответов на вопросы по базе знаний.',
toolResult: 'Результат операции',
review: 'Проверьте точные операции перед выполнением',
confirm: 'Подтвердить и выполнить',
decline: 'Отклонить',
working: 'Обработка…',
modelUnavailable:
'Модель недоступна. Проверьте её настройки и доступ, затем начните новый диалог.',
error:
'Этот ход не завершён. Проверьте результаты ресурсов перед новым диалогом; изменения не повторяются автоматически.',
running:
'Этот ход ещё выполняется или был прерван до сохранения результата. Откройте ассистента заново для обновления; проверьте ресурсы перед повтором.',
placeholder: 'Опишите ваше приложение…',
send: 'Отправить',
},
sidebarGuide: {
steps: {
monitoring: {
+51
View File
@@ -1,6 +1,57 @@
import pipelineMigration from './pipeline-migration/th-TH';
const thTH = {
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: 'ดูสคีมาของ knowledge engine',
},
resources: {
models: 'ค้นหาโมเดลแชต',
embedding_models: 'ค้นหาโมเดล embedding',
pipelines: 'ค้นหา Pipeline',
knowledge_bases: 'ค้นหาฐานความรู้',
knowledge_engines: 'ค้นหา knowledge engine',
},
title: 'ผู้ช่วยพื้นที่ทำงาน',
subtitle: 'สร้างและจัดการแอปของคุณ',
newChat: 'บทสนทนาใหม่',
close: 'ปิด',
welcome:
'บอกฉันว่าคุณอยากสร้างอะไร ฉันตรวจสอบพื้นที่ทำงานนี้และเสนอการตั้งค่าฐานความรู้และ Pipeline ให้คุณยืนยันก่อนดำเนินการ',
discover: 'มีโมเดล ฐานความรู้ และ Pipeline อะไรที่ใช้ได้บ้าง?',
build: 'ช่วยสร้างแอปตอบคำถามจากฐานความรู้ให้หน่อย',
toolResult: 'ผลลัพธ์การดำเนินการ',
review: 'ตรวจสอบการดำเนินการและพารามิเตอร์ก่อนยืนยัน',
confirm: 'ยืนยันและดำเนินการ',
decline: 'ปฏิเสธ',
working: 'กำลังประมวลผล…',
modelUnavailable:
'โมเดลใช้งานไม่ได้ ตรวจสอบการตั้งค่าและสิทธิ์การเข้าถึง แล้วเริ่มบทสนทนาใหม่',
error:
'รอบนี้ไม่สำเร็จ ตรวจสอบผลลัพธ์ของทรัพยากรก่อนเริ่มบทสนทนาใหม่ ระบบจะไม่ลองเปลี่ยนแปลงซ้ำโดยอัตโนมัติ',
running:
'รอบนี้ยังทำงานอยู่ หรือถูกขัดจังหวะก่อนบันทึกผลลัพธ์ เปิดผู้ช่วยใหม่เพื่อรีเฟรช และตรวจสอบทรัพยากรก่อนเริ่มใหม่',
placeholder: 'อธิบายแอปที่คุณต้องการ…',
send: 'ส่ง',
},
sidebarGuide: {
steps: {
monitoring: {
+52
View File
@@ -1,6 +1,58 @@
import pipelineMigration from './pipeline-migration/vi-VN';
const viVN = {
assistant: {
modelHint: 'Đổi mô hình trợ lý cho tin nhắn tiếp theo',
details: 'Xem dữ liệu thô',
found: 'Tìm thấy {{count}} mục',
completed: 'Hoàn tất',
failed: 'Thất bại',
denied: 'Không thực thi',
partial: 'Kết quả chưa đầy đủ',
operationFailed:
'Thao tác thất bại. Kiểm tra chi tiết và trạng thái tài nguyên trước khi thử thay đổi lại.',
operationDenied:
'Đã từ chối. Không có thao tác nào trong lô này được thực thi.',
openResource: 'Mở tài nguyên',
sendUnconfirmed:
'Chưa xác nhận gửi. Kiểm tra kết nối rồi mở lại cuộc trò chuyện.',
draftPlaceholder: 'Có thể soạn tin nhắn tiếp theo trong lúc chờ…',
operations: {
create_pipeline: 'Tạo Pipeline',
configure_pipeline: 'Cấu hình Pipeline',
create_knowledge_base: 'Tạo cơ sở tri thức',
get_pipeline: 'Xem Pipeline',
get_knowledge_schema: 'Xem lược đồ knowledge engine',
},
resources: {
models: 'Tìm mô hình trò chuyện',
embedding_models: 'Tìm mô hình embedding',
pipelines: 'Tìm Pipeline',
knowledge_bases: 'Tìm cơ sở tri thức',
knowledge_engines: 'Tìm knowledge engine',
},
title: 'Trợ lý không gian làm việc',
subtitle: 'Xây dựng và quản lý ứng dụng của bạn',
newChat: 'Cuộc trò chuyện mới',
close: 'Đóng',
welcome:
'Hãy cho tôi biết bạn muốn xây dựng gì. Tôi có thể xem không gian làm việc này và đề xuất cấu hình cơ sở tri thức cùng Pipeline để bạn xác nhận.',
discover: 'Có những mô hình, cơ sở tri thức và Pipeline nào khả dụng?',
build: 'Giúp tôi tạo ứng dụng hỏi đáp dựa trên cơ sở tri thức.',
toolResult: 'Kết quả thao tác',
review: 'Kiểm tra thao tác và tham số cụ thể trước khi xác nhận',
confirm: 'Xác nhận và thực thi',
decline: 'Từ chối',
working: 'Đang xử lý…',
modelUnavailable:
'Mô hình không khả dụng. Kiểm tra cấu hình và quyền truy cập rồi bắt đầu cuộc trò chuyện mới.',
error:
'Lượt này chưa hoàn tất. Kiểm tra kết quả tài nguyên trước khi bắt đầu cuộc trò chuyện mới; hệ thống không tự động thử lại thay đổi.',
running:
'Lượt này vẫn đang chạy hoặc bị gián đoạn trước khi lưu kết quả. Mở lại trợ lý để làm mới; kiểm tra tài nguyên trước khi bắt đầu lại.',
placeholder: 'Mô tả ứng dụng của bạn…',
send: 'Gửi',
},
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: {
+49
View File
@@ -1,6 +1,55 @@
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
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: {
@@ -1,5 +1,5 @@
{
"planner_version": "3",
"planner_version": "4",
"fixture_only": true,
"legacy_sections": {
"local-agent": {
@@ -86,7 +86,7 @@
"metadata": {
"author": "langbot-team",
"name": "LocalAgent",
"version": "0.1.7",
"version": "0.1.10",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/LocalAgent",
"label": {
"en_US": "Local Agent",
@@ -588,7 +588,7 @@
"metadata": {
"author": "langbot-team",
"name": "DifyAgent",
"version": "0.1.7",
"version": "0.1.10",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/dify-agent",
"label": {
"en_US": "Dify Agent",
@@ -958,7 +958,7 @@
"metadata": {
"author": "langbot-team",
"name": "CozeAgent",
"version": "0.1.7",
"version": "0.1.10",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/coze-agent",
"label": {
"en_US": "Coze Agent",
@@ -1269,7 +1269,7 @@
"metadata": {
"author": "langbot-team",
"name": "DashScopeAgent",
"version": "0.1.7",
"version": "0.1.10",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/dashscope-agent",
"label": {
"en_US": "Dashscope Agent",
@@ -1562,7 +1562,7 @@
"metadata": {
"author": "langbot-team",
"name": "N8nAgent",
"version": "0.1.7",
"version": "0.1.10",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/n8n-agent",
"label": {
"en_US": "N8n Agent",
@@ -2038,7 +2038,7 @@
"metadata": {
"author": "langbot-team",
"name": "LangflowAgent",
"version": "0.1.7",
"version": "0.1.10",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/langflow-agent",
"label": {
"en_US": "Langflow Agent",
@@ -2316,7 +2316,7 @@
"metadata": {
"author": "langbot-team",
"name": "DeerFlowAgent",
"version": "0.1.7",
"version": "0.1.10",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/deerflow-agent",
"label": {
"en_US": "DeerFlow Agent",
@@ -2598,7 +2598,7 @@
"metadata": {
"author": "langbot-team",
"name": "TboxAgent",
"version": "0.1.5",
"version": "0.1.8",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/tbox-agent",
"label": {
"en_US": "Tbox Agent",
@@ -2766,7 +2766,7 @@
"metadata": {
"author": "langbot-team",
"name": "WeKnoraAgent",
"version": "0.1.7",
"version": "0.1.10",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/weknora-agent",
"label": {
"en_US": "WeKnora Agent",
@@ -3155,7 +3155,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "LocalAgent",
"version": "0.1.7"
"version": "0.1.10"
},
"config": {
"trigger": {
@@ -3459,7 +3459,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "DifyAgent",
"version": "0.1.7"
"version": "0.1.10"
},
"config": {
"trigger": {
@@ -3742,7 +3742,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "CozeAgent",
"version": "0.1.7"
"version": "0.1.10"
},
"config": {
"trigger": {
@@ -4025,7 +4025,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "DashScopeAgent",
"version": "0.1.7"
"version": "0.1.10"
},
"config": {
"trigger": {
@@ -4299,7 +4299,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "N8nAgent",
"version": "0.1.7"
"version": "0.1.10"
},
"config": {
"trigger": {
@@ -4584,7 +4584,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "LangflowAgent",
"version": "0.1.7"
"version": "0.1.10"
},
"config": {
"trigger": {
@@ -4862,7 +4862,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "DeerFlowAgent",
"version": "0.1.7"
"version": "0.1.10"
},
"config": {
"trigger": {
@@ -5131,7 +5131,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "TboxAgent",
"version": "0.1.5"
"version": "0.1.8"
},
"config": {
"trigger": {
@@ -5402,7 +5402,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "WeKnoraAgent",
"version": "0.1.7"
"version": "0.1.10"
},
"config": {
"trigger": {
+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);
});