import { useEffect, useRef, useState, useId, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { Plus, Send, ChevronDown, LoaderCircle, Square } from 'lucide-react'; import { RiSparkling2Line, RiArrowUpSLine, RiCompass3Line, RiMagicLine, RiArrowRightUpLine, } from '@remixicon/react'; import styles from './workspace-assistant.module.css'; import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; import '@/styles/github-markdown.css'; import { backendClient, systemInfo, useCurrentWorkspace, userInfo, } from '@/app/infra/http'; import { toast } from 'sonner'; import { httpClient } from '@/app/infra/http/HttpClient'; import { MODEL_CONFIGURATION_CHANGED } from '@/app/infra/http/modelConfigEvents'; import SettingsDialog, { SettingsSection, } from './settings-dialog/SettingsDialog'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import DynamicFormItemComponent from './dynamic-form/DynamicFormItemComponent'; import { DynamicFormItemType } from '@/app/infra/entities/form/dynamic'; import AssistantToolResult, { AssistantTool } from './AssistantToolResult'; import { LANGBOT_MODELS_PROVIDER_REQUESTER } from './models-dialog/types'; type Conversation = { uuid: string; revision: number; status: 'ready' | 'running' | 'approval' | 'failed'; messages: { role: string; content: string; tool?: AssistantTool }[]; pending: { name: string; arguments: Record }[]; error: string | null; model_name: string | null; model_uuid: string | null; progress?: { text?: string; phase?: 'thinking' | 'tool'; round?: number; tool?: Omit; }; }; 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 ( ); } function AssistantEntry({ storageKey }: { storageKey: string }) { const { t } = useTranslation(); const [available, setAvailable] = useState(null); const [localModel, setLocalModel] = useState< { uuid: string; name: string } | undefined >(); const [failed, setFailed] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [section, setSection] = useState('models'); const [loginBusy, setLoginBusy] = useState(false); const [retry, setRetry] = useState(0); useEffect(() => { let active = true; let latestCheck = 0; async function check() { const currentCheck = ++latestCheck; try { const { models } = await backendClient.getProviderLLMModels(); if (active && currentCheck === latestCheck) { const usableModels = models.filter( (model) => !!model.uuid?.trim() && model.abilities?.includes('func_call') && (model.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER || (userInfo?.account_type === 'space' && !systemInfo.disable_models_service)), ); setAvailable(usableModels.length > 0); const firstLocalModel = userInfo?.account_type !== 'space' ? usableModels[0] : undefined; setLocalModel( firstLocalModel ? { uuid: firstLocalModel.uuid, name: firstLocalModel.name } : undefined, ); setFailed(false); } } catch { if (active && currentCheck === latestCheck) setFailed(true); } } if (!settingsOpen) void check(); window.addEventListener('focus', check); window.addEventListener(MODEL_CONFIGURATION_CHANGED, check); return () => { active = false; window.removeEventListener('focus', check); window.removeEventListener(MODEL_CONFIGURATION_CHANGED, check); }; }, [settingsOpen, retry]); async function login() { setLoginBusy(true); try { const response = await httpClient.getSpaceBindAuthorizeUrl( `${window.location.origin}/auth/space/callback?mode=bind`, ); window.location.href = response.authorize_url; } catch { toast.error(t('common.spaceLoginFailed')); setLoginBusy(false); } } return ( <>

{t( failed ? 'assistant.providerCheckFailed' : available === null ? 'assistant.preparing' : 'assistant.setupRequired', )}

{failed ? ( ) : available === false ? ( <> ) : null}
} /> ); } type AssistantPanelProps = { storageKey: string; canUseModels: boolean; initialModel?: { uuid: string; name: string }; setup: ReactNode; }; function AssistantSessions(props: AssistantPanelProps) { const { storageKey } = props; const [version, setVersion] = useState(0); function select(id: string | null) { if (id) localStorage.setItem(storageKey, id); else localStorage.removeItem(storageKey); setVersion((value) => value + 1); } return ; } const ASSISTANT_HEIGHT_STORAGE_KEY = 'langbot-assistant-panel-height'; const ASSISTANT_POSITION_STORAGE_KEY = 'langbot-assistant-panel-position'; const ASSISTANT_OPEN_STORAGE_KEY = 'langbot-assistant-panel-open'; function AssistantPanel({ storageKey, canUseModels, initialModel, setup, onSelect, }: AssistantPanelProps & { onSelect: (id: string | null) => void; }) { const { t } = useTranslation(); const initialModelUuid = initialModel?.uuid; const initialModelName = initialModel?.name; const [open, setOpen] = useState(() => { try { return localStorage.getItem(ASSISTANT_OPEN_STORAGE_KEY) !== 'false'; } catch { return true; } }); useEffect(() => { try { localStorage.setItem(ASSISTANT_OPEN_STORAGE_KEY, String(open)); } catch { // The panel remains usable when browser storage is unavailable. } }, [open]); const [closing, setClosing] = useState(false); const [horizontalPosition, setHorizontalPosition] = useState(() => { try { const saved = Number( localStorage.getItem(ASSISTANT_POSITION_STORAGE_KEY), ); return Number.isFinite(saved) ? Math.max(-1, Math.min(1, saved)) : 0; } catch { return 0; } }); const [viewportWidth, setViewportWidth] = useState(window.innerWidth); useEffect(() => { const updateWidth = () => setViewportWidth(window.innerWidth); window.addEventListener('resize', updateWidth); return () => window.removeEventListener('resize', updateWidth); }, []); useEffect(() => { try { localStorage.setItem( ASSISTANT_POSITION_STORAGE_KEY, String(horizontalPosition), ); } catch { // Positioning remains available when storage is blocked. } }, [horizontalPosition]); // Store a relative position so it follows screen changes while keeping a // 12px margin. The collapsed dock remains aligned with the expanded panel. const horizontalRange = Math.max(0, (viewportWidth - 480 - 24) / 2); const moveStart = useRef<{ x: number; offset: number } | null>(null); const dockWasDragged = useRef(false); const [panelHeight, setPanelHeight] = useState(() => { try { const saved = Number(localStorage.getItem(ASSISTANT_HEIGHT_STORAGE_KEY)); return Number.isFinite(saved) && saved > 0 ? Math.max(280, saved) : undefined; } catch { return undefined; } }); useEffect(() => { if (panelHeight === undefined) return; try { localStorage.setItem(ASSISTANT_HEIGHT_STORAGE_KEY, String(panelHeight)); } catch { // Resizing still works when browser storage is unavailable. } }, [panelHeight]); const panelRef = useRef(null); const resizeStart = useRef<{ y: number; height: number } | null>(null); const resizeHeight = (height: number) => { const maximum = Math.max(160, window.innerHeight - 48); setPanelHeight(Math.min(maximum, Math.max(Math.min(280, maximum), height))); }; const closeTimer = useRef(null); const [conversation, setConversation] = useState(null); const [text, setText] = useState(''); const [sending, setBusy] = useState(false); const [loading, setLoading] = useState(false); const busy = sending || loading || conversation?.status === 'running'; const [stopping, setStopping] = useState(false); const [sessions, setSessions] = useState< { uuid: string; title: string; status: Conversation['status'] }[] >([]); const [error, setError] = useState(false); const [modelUuid, setModelUuid] = useState(''); const [modelName, setModelName] = useState(); const manualModel = useRef(false); const [modelError, setModelError] = useState(false); const [liveText, setLiveText] = useState(''); const [liveTool, setLiveTool] = useState(null); const [phase, setPhase] = useState<'thinking' | 'tool' | null>(null); const [round, setRound] = useState(1); const [pendingText, setPendingText] = useState(null); const controller = useRef(new AbortController()); const messageList = useRef(null); const followLatest = useRef(true); const scrollPosition = useRef(0); const panelId = useId(); const expandButton = useRef(null); const input = useRef(null); useEffect( () => () => { if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); }, [], ); function collapse() { if (closeTimer.current !== null) return; const finish = () => { closeTimer.current = null; setOpen(false); setClosing(false); window.requestAnimationFrame(() => expandButton.current?.focus()); }; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { finish(); return; } setClosing(true); closeTimer.current = window.setTimeout(finish, 180); } function expand() { setOpen(true); window.requestAnimationFrame(() => input.current?.focus()); } useEffect(() => { const abort = new AbortController(); controller.current = abort; setLoading(true); async function initialize() { try { const id = localStorage.getItem(storageKey); let saved: Conversation | null = null; if (id) { saved = await backendClient.request({ method: 'GET', url: `/api/v1/assistant/conversations/${encodeURIComponent(id)}`, signal: abort.signal, }); if (abort.signal.aborted) return; setConversation(saved); } if (!canUseModels) return; const model = saved?.model_uuid ? { uuid: saved.model_uuid, name: saved.model_name ?? undefined } : initialModelUuid ? { uuid: initialModelUuid, name: initialModelName } : await backendClient.request<{ uuid: string; name: string }>({ method: 'GET', url: '/api/v1/assistant/recommended-model', signal: abort.signal, }); if (!abort.signal.aborted && !manualModel.current) { setModelUuid(model.uuid); setModelName(model.name); } } catch { if (!abort.signal.aborted) setModelError(true); } finally { if (!abort.signal.aborted) setLoading(false); } } void initialize(); return () => abort.abort(); }, [storageKey, canUseModels, initialModelUuid, initialModelName]); useEffect(() => { let active = true; let timer: ReturnType; async function refresh() { try { const result = await backendClient.request<{ conversations: typeof sessions; }>({ method: 'GET', url: '/api/v1/assistant/conversations', signal: controller.current.signal, }); if (active) setSessions(result.conversations ?? []); if (!sending && conversation?.uuid) { const latest = await backendClient.request({ method: 'GET', url: `/api/v1/assistant/conversations/${conversation.uuid}`, signal: controller.current.signal, }); if (active) { setConversation(latest); setLiveText(latest.progress?.text ?? ''); setPhase(latest.progress?.phase ?? null); setRound(latest.progress?.round ?? 1); setLiveTool( latest.progress?.tool ? { ...latest.progress.tool, result: { status: 'running' } } : null, ); } } } catch { // Polling is read-only. A failed refresh never retries a turn. } finally { if (active) timer = setTimeout(refresh, 1500); } } void refresh(); return () => { active = false; clearTimeout(timer); }; }, [conversation?.uuid, sending]); async function stop() { if (!conversation || stopping) return; setStopping(true); try { const latest = await backendClient.request({ method: 'POST', url: `/api/v1/assistant/conversations/${conversation.uuid}/stop`, data: { revision: conversation.revision }, signal: controller.current.signal, }); setConversation(latest); setLiveText(''); setLiveTool(null); setPhase(null); } catch { setError(true); } finally { setStopping(false); } } // Restore the reading position when reopening; new conversations start at the bottom. useEffect(() => { if (open && messageList.current && !followLatest.current) { messageList.current.scrollTop = scrollPosition.current; } }, [open]); useEffect(() => { if (!open) return; const frame = window.requestAnimationFrame(() => { const list = messageList.current; if (list && followLatest.current) { list.scrollTop = list.scrollHeight; scrollPosition.current = list.scrollTop; } }); return () => window.cancelAnimationFrame(frame); }, [open, conversation, busy, pendingText, liveText, liveTool, panelHeight]); async function submit(approved?: boolean) { if ( !canUseModels || busy || (approved === undefined && (!text.trim() || !modelUuid || (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); setRound(1); setPhase('thinking'); try { let current = conversation; if (!current) { current = await backendClient.request({ method: 'POST', url: '/api/v1/assistant/conversations', signal: controller.current.signal, }); localStorage.setItem(storageKey, current.uuid); setConversation(current); } const updated = await backendClient.streamAssistant( current.uuid, { revision: current.revision, ...(approved === undefined ? { text: sentText!, model_uuid: modelUuid } : { approved }), }, (event) => { if (event.kind === 'snapshot') { const snapshot = event.data as Conversation; setConversation(snapshot); setPendingText(null); setLiveText(''); setLiveTool(null); if (snapshot.model_uuid) { setModelUuid(snapshot.model_uuid); setModelName(snapshot.model_name ?? undefined); } } else if (event.kind === 'text') { setLiveText((event.data as { text: string }).text); } else if (event.kind === 'phase') { const state = event.data as { phase: 'thinking' | 'tool'; round?: number; tool?: Omit; }; setPhase(state.phase); if (state.round) setRound(state.round); setLiveTool( state.tool ? { ...state.tool, result: { status: 'running' } } : null, ); } }, controller.current.signal, ); setConversation(updated); if (sentText) setPendingText(null); } catch { if (controller.current.signal.aborted) return; 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({ 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); setLiveText(''); setLiveTool(null); setPhase(null); } } function reset() { onSelect(null); } // A user message starts a turn; its tool steps and replies share one bubble. const messageGroups: { key: number; role: 'user' | 'assistant'; messages: Conversation['messages']; }[] = []; const visibleMessages: Conversation['messages'] = [ ...(conversation?.messages ?? []), ]; if (liveText) visibleMessages.push({ role: 'assistant', content: liveText }); if (liveTool) visibleMessages.push({ role: 'tool', content: '', tool: liveTool }); for (const [index, message] of visibleMessages.entries()) { const role = message.role === 'user' ? 'user' : 'assistant'; const previous = messageGroups.at(-1); if (role === 'assistant' && previous?.role === 'assistant') { previous.messages.push(message); } else { messageGroups.push({ key: index, role, messages: [message] }); } } return createPortal(
{!open && ( )} {open && (