mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-28 04:16:50 +08:00
feat(assistant): improve chat feedback and model selection
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '新しい会話',
|
||||
|
||||
@@ -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: '新对话',
|
||||
|
||||
Reference in New Issue
Block a user