feat(assistant): prototype in-process workspace assistant

This commit is contained in:
huanghuoguoguo
2026-09-16 22:37:02 +08:00
parent 9b7ba0d647
commit e82d71029e
15 changed files with 1077 additions and 1 deletions
@@ -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>
);
}
+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,
@@ -277,6 +278,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
</main>
<SurveyWidget />
<WorkspaceAssistant />
</SidebarInset>
</SidebarProvider>
);
+23
View File
@@ -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',
+23
View File
@@ -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: '拡張機能',
+23
View File
@@ -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: '扩展',