mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-28 20:36:42 +08:00
feat(assistant): prototype in-process workspace assistant
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MessageCircle, Plus, Send, X, LoaderCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { backendClient, useCurrentWorkspace, userInfo } from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
|
||||
type Conversation = {
|
||||
uuid: string;
|
||||
revision: number;
|
||||
status: 'ready' | 'running' | 'approval' | 'failed';
|
||||
messages: { role: string; content: string }[];
|
||||
pending: { name: string; arguments: Record<string, unknown> }[];
|
||||
error: string | null;
|
||||
model_name: 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 [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const controller = useRef(new AbortController());
|
||||
const end = useRef<HTMLDivElement>(null);
|
||||
|
||||
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;
|
||||
setBusy(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) setBusy(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
setBusy(false);
|
||||
};
|
||||
// Load only when opening; turn requests own subsequent state updates.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
end.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, [conversation, busy]);
|
||||
|
||||
async function submit(approved?: boolean) {
|
||||
if (busy || (approved === undefined && !text.trim())) return;
|
||||
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: text.trim() } : { approved }),
|
||||
},
|
||||
timeout: 130000,
|
||||
signal: controller.current.signal,
|
||||
});
|
||||
setConversation(updated);
|
||||
if (approved === undefined) setText('');
|
||||
} 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);
|
||||
} catch {
|
||||
/* Keep the error visible; do not retry a turn. */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
localStorage.removeItem(storageKey);
|
||||
setConversation(null);
|
||||
setText('');
|
||||
setError(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 right-5 z-50">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="size-12 rounded-full shadow-lg"
|
||||
aria-label={t('assistant.title')}
|
||||
>
|
||||
<MessageCircle className="size-6" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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="font-semibold">{t('assistant.title')}</h2>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{conversation?.model_name || t('assistant.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={busy}
|
||||
onClick={reset}
|
||||
aria-label={t('assistant.newChat')}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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 && (
|
||||
<>
|
||||
<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' ? (
|
||||
<details
|
||||
key={index}
|
||||
className="rounded-lg border p-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<summary className="cursor-pointer">
|
||||
{t('assistant.toolResult')}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all">
|
||||
{message.content}
|
||||
</pre>
|
||||
</details>
|
||||
) : (
|
||||
<div
|
||||
key={index}
|
||||
className={`rounded-xl p-3 text-sm break-words ${message.role === 'user' ? 'ml-6 bg-primary/10' : 'bg-muted'}`}
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
img: () => null,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</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}
|
||||
disabled={
|
||||
busy || (!!conversation && conversation.status !== 'ready')
|
||||
}
|
||||
aria-label={t('assistant.placeholder')}
|
||||
placeholder={t('assistant.placeholder')}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -277,6 +278,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
</main>
|
||||
|
||||
<SurveyWidget />
|
||||
<WorkspaceAssistant />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
const enUS = {
|
||||
assistant: {
|
||||
title: 'Workspace assistant · Experimental',
|
||||
subtitle: 'Build with your Space model',
|
||||
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:
|
||||
'The Space model is unavailable. Check your Space account and model access in Settings, 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',
|
||||
},
|
||||
sidebar: {
|
||||
home: 'Home',
|
||||
extensions: 'Extensions',
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
const jaJP = {
|
||||
assistant: {
|
||||
title: 'ワークスペースアシスタント · 実験版',
|
||||
subtitle: 'Space モデルでアプリを構築',
|
||||
newChat: '新しい会話',
|
||||
close: '閉じる',
|
||||
welcome:
|
||||
'作りたいアプリを教えてください。現在のリソースを確認し、承認後にナレッジベースや Pipeline を設定します。',
|
||||
discover: '利用できるモデル、ナレッジベース、Pipeline は?',
|
||||
build: 'ナレッジベースを使う質問応答アプリを作りたい。',
|
||||
toolResult: '操作結果',
|
||||
review: '実行する操作とパラメータを確認してください',
|
||||
confirm: '承認して実行',
|
||||
decline: '拒否',
|
||||
working: '処理中…',
|
||||
modelUnavailable:
|
||||
'Space モデルを利用できません。設定でログインとモデルの権限を確認して、新しい会話を開始してください。',
|
||||
error:
|
||||
'処理が完了しませんでした。新しい会話の前にリソースを確認してください。変更は自動で再試行されません。',
|
||||
running:
|
||||
'処理中、または結果の保存前に中断されました。アシスタントを開き直して更新してください。',
|
||||
placeholder: '作りたいアプリを説明…',
|
||||
send: '送信',
|
||||
},
|
||||
sidebar: {
|
||||
home: 'ホーム',
|
||||
extensions: '拡張機能',
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
const zhHans = {
|
||||
assistant: {
|
||||
title: '工作区助手 · 实验版',
|
||||
subtitle: '使用 Space 模型构建应用',
|
||||
newChat: '新对话',
|
||||
close: '关闭',
|
||||
welcome:
|
||||
'告诉我你想构建什么应用。我可以查看当前工作区,并提出知识库和 Pipeline 配置方案,由你确认后执行。',
|
||||
discover: '当前有哪些可用的模型、知识库和 Pipeline?',
|
||||
build: '帮我创建一个知识库问答应用。',
|
||||
toolResult: '操作结果',
|
||||
review: '请检查具体操作及参数,确认后执行',
|
||||
confirm: '确认执行',
|
||||
decline: '拒绝',
|
||||
working: '正在处理…',
|
||||
modelUnavailable:
|
||||
'Space 模型暂不可用。请在设置中检查 Space 登录和模型权限,然后开启新对话。',
|
||||
error:
|
||||
'本轮未完成。请先检查资源和操作结果,再开启新对话;系统不会自动重试变更。',
|
||||
running:
|
||||
'本轮仍在执行,或执行结果尚未保存时发生了中断。重新打开助手可刷新;重新开始前请检查资源。',
|
||||
placeholder: '描述你想构建的应用…',
|
||||
send: '发送',
|
||||
},
|
||||
sidebar: {
|
||||
home: '首页',
|
||||
extensions: '扩展',
|
||||
|
||||
Reference in New Issue
Block a user