feat(assistant): improve chat feedback and model selection

This commit is contained in:
huanghuoguoguo
2026-09-16 22:37:02 +08:00
parent 48140fbfd4
commit 0be53c6ef4
9 changed files with 424 additions and 31 deletions
@@ -1,6 +1,7 @@
"""Web-session-only assistant endpoints; resource tools use the existing service layer."""
import quart
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from .. import group
@@ -14,6 +15,7 @@ class TurnInput(BaseModel):
revision: int = Field(ge=0, strict=True)
text: str | None = Field(default=None, min_length=1, max_length=8000)
approved: bool | None = Field(default=None, strict=True)
model_uuid: UUID | None = None
@group.group_class('assistant', '/api/v1/assistant')
@@ -47,6 +49,7 @@ class AssistantRouterGroup(group.RouterGroup):
body.revision,
body.text,
body.approved,
str(body.model_uuid) if body.model_uuid else None,
)
return self.success(data=service.public_view(conversation))
except ValidationError:
+41 -3
View File
@@ -86,12 +86,26 @@ class AssistantService:
@staticmethod
def public_view(conversation):
messages = []
calls = {}
for message in conversation['messages']:
calls.update({call['id']: call['function'] for call in message.get('tool_calls') or []})
content = message.get('content') or ''
if isinstance(content, list):
content = '\n'.join(item.get('text') or '' for item in content if item.get('type') == 'text')
if content:
messages.append({'role': message['role'], 'content': content})
visible = {'role': message['role'], 'content': content}
if message['role'] == 'tool':
function = calls.get(message.get('tool_call_id'), {})
try:
arguments = json.loads(function.get('arguments') or '{}')
except json.JSONDecodeError:
arguments = {'unparsed': function['arguments']}
visible['tool'] = {
'name': function.get('name', ''),
'arguments': arguments,
'result': json.loads(content),
}
messages.append(visible)
pending = []
if conversation['status'] == 'approval':
for call in conversation['messages'][-1].get('tool_calls') or []:
@@ -109,6 +123,7 @@ class AssistantService:
'pending': pending,
'error': conversation['error'],
'model_name': conversation['model_name'],
'model_uuid': conversation['model_uuid'],
}
async def _save(self, context, conversation, status, error=None):
@@ -131,8 +146,10 @@ class AssistantService:
raise AssistantError('stale_turn')
conversation.update(status=status, error=error)
async def turn(self, context, conversation_id, revision, text=None, approved=None):
async def turn(self, context, conversation_id, revision, text=None, approved=None, model_uuid=None):
require_permission(context, Permission.RUNTIME_OPERATE)
if model_uuid is not None and text is None:
raise AssistantError('invalid_input', 400)
if self._slots.locked():
raise AssistantError('busy', 429)
async with self._slots:
@@ -142,6 +159,16 @@ class AssistantService:
raise AssistantError('stale_turn')
if text is not None and len(conversation['messages']) >= 100:
raise AssistantError('conversation_full')
selected_model = None
if model_uuid is not None:
try:
selected_model = await self.ap.model_mgr.get_model_by_uuid(
ExecutionContext.from_request(context), model_uuid
)
if 'func_call' not in (selected_model.model_entity.abilities or []):
raise ValueError('Model does not support tool calls')
except Exception as exc:
raise AssistantError('model_unavailable', 400) from exc
result = await self.ap.persistence_mgr.execute_async(
sa.update(Conversation)
.where(
@@ -156,6 +183,15 @@ class AssistantService:
conversation['revision'] += 1
try:
async with asyncio.timeout(120):
if model_uuid is not None and model_uuid != conversation['model_uuid']:
# Provider signatures and response IDs belong to the previous model.
for message in conversation['messages']:
message['provider_specific_fields'] = None
message['resp_message_id'] = None
for call in message.get('tool_calls') or []:
call['provider_specific_fields'] = None
conversation['model_uuid'] = model_uuid
conversation['model_name'] = selected_model.model_entity.name
if text is not None:
conversation['messages'].append(Message(role='user', content=text).model_dump(mode='json'))
await self._save(context, conversation, 'running')
@@ -164,7 +200,9 @@ class AssistantService:
recommended = await self.ap.space_service.get_recommended_chat_model(context)
conversation['model_uuid'] = recommended['uuid']
execution = ExecutionContext.from_request(context)
model = await self.ap.model_mgr.get_model_by_uuid(execution, conversation['model_uuid'])
model = selected_model or await self.ap.model_mgr.get_model_by_uuid(
execution, conversation['model_uuid']
)
if 'func_call' not in (model.model_entity.abilities or []):
raise ValueError('Recommended model does not support tool calls')
conversation['model_name'] = model.model_entity.name
@@ -107,6 +107,12 @@ async def test_confirmation_is_exact_once_and_private(assistant):
assert saved['messages'][1]['provider_specific_fields']['thought_signature'] == 'preserved'
assert provider.invoke_llm.call_args.kwargs['query'] is None
assert provider.invoke_llm.call_args.kwargs['execution_context'].workspace_uuid == ctx.workspace_uuid
tool_message = next(message for message in service.public_view(saved)['messages'] if message['role'] == 'tool')
assert tool_message['tool'] == {
'name': 'create_pipeline',
'arguments': {'name': 'Demo', 'description': 'Test draft'},
'result': {'uuid': 'created-pipeline', 'url': '/home/pipelines?id=created-pipeline', 'configured': False},
}
@pytest.mark.asyncio
@@ -147,6 +153,23 @@ def test_tool_arguments_cannot_select_identity_or_shell():
validate_call(context(), 'exec', {'command': 'echo unsafe'})
def test_rejected_malformed_tool_call_remains_readable():
message = proposal().model_dump(mode='json')
message['tool_calls'][0]['function']['arguments'] = '{invalid'
conversation = dict(
uuid='chat',
revision=1,
status='ready',
error=None,
model_name=None,
model_uuid=None,
messages=[message, {'role': 'tool', 'tool_call_id': 'call-1', 'content': '{"error":"Invalid arguments"}'}],
)
visible = AssistantService.public_view(conversation)['messages'][-1]['tool']
assert visible['result']['error'] == 'Invalid arguments'
assert visible['arguments'] == {'unparsed': '{invalid'}
@pytest.mark.asyncio
async def test_resource_readers_match_application_services():
from langbot.pkg.core.app import Application
@@ -175,3 +198,39 @@ async def test_resource_readers_match_application_services():
'items': [{'name': kind}],
}
reader.assert_awaited_once_with(ctx)
@pytest.mark.asyncio
async def test_model_switch_preserves_history_and_rejects_invalid_selection(assistant):
service, ap, provider = assistant
ctx = context()
conversation = await service.create(ctx)
cid = conversation['uuid']
await service.turn(ctx, cid, 0, text='Create')
with pytest.raises(AssistantError, match='invalid_input'):
await service.turn(ctx, cid, 1, approved=True, model_uuid='other')
ap.pipeline_service.create_pipeline.assert_not_awaited()
await service.turn(ctx, cid, 1, approved=True)
for invalid in (ValueError('not in workspace'), SimpleNamespace(model_entity=SimpleNamespace(abilities=[]))):
ap.model_mgr.get_model_by_uuid.side_effect = [invalid]
with pytest.raises(AssistantError, match='model_unavailable'):
await service.turn(ctx, cid, 2, text='Continue', model_uuid='invalid')
saved = await service.get(ctx, cid)
assert saved['revision'] == 2 and saved['status'] == 'ready'
next_provider = SimpleNamespace(invoke_llm=AsyncMock(return_value=Message(role='assistant', content='Switched')))
ap.model_mgr.get_model_by_uuid.side_effect = None
ap.model_mgr.get_model_by_uuid.return_value = SimpleNamespace(
provider=next_provider,
model_entity=SimpleNamespace(name='second-model', abilities=['func_call'], extra_args={}),
)
switched = await service.turn(ctx, cid, 2, text='Continue', model_uuid='second')
assert switched['status'] == 'ready'
assert service.public_view(switched)['model_uuid'] == 'second'
assert switched['model_name'] == 'second-model'
history = next_provider.invoke_llm.call_args.kwargs['messages']
assert [m.content for m in history if m.role == 'user'] == ['Create', 'Continue']
assert any(m.role == 'tool' for m in history)
assert all(m.provider_specific_fields is None for m in history)
ap.model_mgr.get_model_by_uuid.assert_awaited_with(
next_provider.invoke_llm.call_args.kwargs['execution_context'], 'second'
)
@@ -0,0 +1,120 @@
import { CheckCircle2, 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,
}: {
tool?: AssistantTool;
content: string;
}) {
const { t } = useTranslation();
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;
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>
</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>
);
}
@@ -5,6 +5,9 @@ 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,
@@ -15,10 +18,11 @@ type Conversation = {
uuid: string;
revision: number;
status: 'ready' | 'running' | 'approval' | 'failed';
messages: { role: string; content: string }[];
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() {
@@ -42,11 +46,19 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
const [open, setOpen] = useState(false);
const [conversation, setConversation] = useState<Conversation | null>(null);
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
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);
useEffect(() => {
if (conversation?.model_uuid) setModelUuid(conversation.model_uuid);
}, [conversation?.model_uuid]);
useEffect(() => {
const abort = new AbortController();
controller.current = abort;
@@ -59,7 +71,7 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
const id = localStorage.getItem(storageKey);
if (!id) return;
let active = true;
setBusy(true);
setLoading(true);
backendClient
.request<Conversation>({
method: 'GET',
@@ -76,11 +88,11 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
}
})
.finally(() => {
if (active) setBusy(false);
if (active) setLoading(false);
});
return () => {
active = false;
setBusy(false);
setLoading(false);
};
// Load only when opening; turn requests own subsequent state updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -88,10 +100,21 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
useEffect(() => {
end.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [conversation, busy]);
}, [conversation, busy, pendingText]);
async function submit(approved?: boolean) {
if (busy || (approved === undefined && !text.trim())) return;
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 {
@@ -110,13 +133,18 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
url: `/api/v1/assistant/conversations/${current.uuid}/turn`,
data: {
revision: current.revision,
...(approved === undefined ? { text: text.trim() } : { approved }),
...(approved === undefined
? {
text: sentText,
...(modelUuid ? { model_uuid: modelUuid } : {}),
}
: { approved }),
},
timeout: 130000,
signal: controller.current.signal,
});
setConversation(updated);
if (approved === undefined) setText('');
if (sentText) setPendingText(null);
} catch {
setError(true);
// A lost response may already have executed a write. Refresh, never replay.
@@ -129,6 +157,15 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
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. */
}
@@ -143,6 +180,7 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
setConversation(null);
setText('');
setError(false);
setPendingText(null);
}
return (
@@ -169,7 +207,7 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
<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')}
{t('assistant.subtitle')}
</p>
</div>
<Button
@@ -190,11 +228,38 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
<X />
</Button>
</header>
{open && (
<div className="space-y-1 border-b px-3 py-2">
<span className="text-xs text-muted-foreground">
{t('assistant.modelHint')}
</span>
<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"
/>
</div>
)}
<div
className="flex-1 space-y-3 overflow-y-auto p-4"
aria-live="polite"
>
{!conversation?.messages.length && (
{!conversation?.messages.length && !pendingText && (
<>
<p className="text-sm text-muted-foreground">
{t('assistant.welcome')}
@@ -214,17 +279,11 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
)}
{conversation?.messages.map((message, index) =>
message.role === 'tool' ? (
<details
<AssistantToolResult
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>
tool={message.tool}
content={message.content}
/>
) : (
<div
key={index}
@@ -251,6 +310,16 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
</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>
@@ -313,11 +382,20 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
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')}
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
@@ -88,6 +88,7 @@ export default function DynamicFormItemComponent({
onFileUploaded,
setFormValue,
systemContext,
requiredModelAbility,
}: {
config: IDynamicFormItemSchema;
field: ControllerRenderProps<any, any>;
@@ -95,6 +96,7 @@ export default function DynamicFormItemComponent({
onFileUploaded?: (fileKey: string) => void;
setFormValue?: (name: string, value: unknown) => void;
systemContext?: Record<string, unknown>;
requiredModelAbility?: string;
}) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
@@ -412,11 +414,16 @@ 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 = llmModels.filter(
const spaceModels = selectableModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const regularModels = llmModels.filter(
const regularModels = selectableModels.filter(
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
@@ -456,7 +463,11 @@ 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}>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={field.disabled}
>
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
+29
View File
@@ -1,5 +1,34 @@
const enUS = {
assistant: {
modelHint:
'Assistant model · Space recommendation by default; changes apply to 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 · Experimental',
subtitle: 'Build with your Space model',
newChat: 'New conversation',
+29
View File
@@ -1,5 +1,34 @@
const jaJP = {
assistant: {
modelHint:
'アシスタントモデル · 既定は Space 推奨。変更は次のメッセージから適用',
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: 'Space モデルでアプリを構築',
newChat: '新しい会話',
+26
View File
@@ -1,5 +1,31 @@
const zhHans = {
assistant: {
modelHint: '助手模型 · 默认使用 Space 推荐模型,切换从下一条消息生效',
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: '使用 Space 模型构建应用',
newChat: '新对话',