feat(assistant): add streaming background sessions and refresh workspace UI

This commit is contained in:
RockChinQ
2026-10-08 13:24:46 +08:00
parent d05d98fcb9
commit f96f663a80
24 changed files with 2351 additions and 826 deletions
+39
View File
@@ -360,3 +360,42 @@ curl -X POST \
- API-key-enabled endpoints use the same resource shapes as the web UI
- No need to learn different API paths - use the existing API documentation with API key authentication
- API keys never select a Workspace from a request header; their persisted binding is authoritative
## Workspace assistant browser session
The Workspace assistant endpoints under `/api/v1/assistant` are account-session
features, not API-key/MCP automation tools. They require a Workspace-scoped user
token and `runtime.operate` for recommendation and turns. Conversation reads
also enforce account ownership.
- `GET /recommended-model` resolves and validates the recommended tool-capable
model when the panel opens. An explicit `model_uuid` in a turn always takes
precedence; it never silently falls back if unavailable.
- `POST /conversations/{id}/turn/stream` accepts the same revision, text/model,
or approval decision as `/turn`, and returns NDJSON. `snapshot` frames contain
the public conversation; `text` frames contain the cumulative text for the
current model invocation; `phase` frames identify the model round or running
tool. A snapshot replaces transient text/tool progress. `completed` contains
the final conversation (including approval or failed states); `error` is a
transport/admission failure. Blank lines are heartbeats.
- `GET /conversations` lists the latest 100 conversations owned by the current
account in this Workspace. `GET /conversations/{id}` includes transient
`progress` (cumulative text, round and running tool) while execution is active.
- Disconnecting a streaming response only detaches the browser. The backend owns
the task until completion, approval, timeout or explicit stop. Slow or absent
consumers never block execution; reconnect using the conversation snapshot.
- `POST /conversations/{id}/stop` accepts `{ "revision": N }` for the active turn.
It enforces ownership and revision, cancels execution and records `stopped`.
Stopping cannot roll back tool effects and clients must never replay a write
automatically. Writes still require confirmation of saved arguments.
- Background execution lives in the current backend process, with at most four
simultaneous turns and the existing 120-second turn limit. This is not a durable
job queue: backend restarts do not resume interrupted tasks.
The original JSON `/turn` endpoint remains compatible for existing callers.
The assistant recommendation uses Space `/api/v1/models/assistant/recommendation`,
which selects only from the assistant pool using fresh successful health checks.
An unavailable recommendation returns 503 without falling back to general chat rankings.
The browser checks Workspace model providers before mounting the assistant; an empty
provider list shows Account sign-in and model settings actions instead of a composer.
@@ -1,5 +1,8 @@
"""Web-session-only assistant endpoints; resource tools use the existing service layer."""
import asyncio
import json
import quart
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, ValidationError
@@ -10,6 +13,11 @@ from ...context import RequestContext
from ...service.assistant import AssistantError, AssistantService
class StopInput(BaseModel):
model_config = ConfigDict(extra='forbid')
revision: int = Field(ge=0, strict=True)
class TurnInput(BaseModel):
model_config = ConfigDict(extra='forbid')
revision: int = Field(ge=0, strict=True)
@@ -23,6 +31,27 @@ class AssistantRouterGroup(group.RouterGroup):
async def initialize(self):
service = AssistantService(self.ap)
@self.route('/conversations', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def list_conversations(request_context: RequestContext):
return self.success(data={'conversations': await service.list_conversations(request_context)})
@self.route('/conversations/<conversation_id>/stop', methods=['POST'], permission=Permission.RUNTIME_OPERATE)
async def stop(conversation_id: str, request_context: RequestContext):
try:
body = StopInput.model_validate(await quart.request.get_json())
return self.success(data=await service.stop(request_context, conversation_id, body.revision))
except ValidationError:
return self.http_status(400, 'invalid_input', 'Invalid stop request')
except AssistantError as exc:
return self.http_status(exc.status, exc.code, exc.code)
@self.route('/recommended-model', methods=['GET'], permission=Permission.RUNTIME_OPERATE)
async def recommended_model(request_context: RequestContext):
try:
return self.success(data=await service.recommended_model(request_context))
except AssistantError as exc:
return self.http_status(exc.status, exc.code, exc.code)
@self.route('/conversations', methods=['POST'], permission=Permission.RUNTIME_OPERATE)
async def create(request_context: RequestContext):
try:
@@ -33,7 +62,7 @@ class AssistantRouterGroup(group.RouterGroup):
@self.route('/conversations/<conversation_id>', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get(conversation_id: str, request_context: RequestContext):
try:
return self.success(data=service.public_view(await service.get(request_context, conversation_id)))
return self.success(data=await service.progress(request_context, conversation_id))
except AssistantError as exc:
return self.http_status(exc.status, exc.code, exc.code)
@@ -56,3 +85,43 @@ class AssistantRouterGroup(group.RouterGroup):
return self.http_status(400, 'invalid_input', 'Invalid assistant request')
except AssistantError as exc:
return self.http_status(exc.status, exc.code, exc.code)
@self.route(
'/conversations/<conversation_id>/turn/stream', methods=['POST'], permission=Permission.RUNTIME_OPERATE
)
async def stream_turn(conversation_id: str, request_context: RequestContext):
try:
body = TurnInput.model_validate(await quart.request.get_json())
if (body.text is None) == (body.approved is None) or (body.text is not None and not body.text.strip()):
return self.http_status(400, 'invalid_input', 'Provide text or an approval decision')
await service.get(request_context, conversation_id)
except ValidationError:
return self.http_status(400, 'invalid_input', 'Invalid assistant request')
except AssistantError as exc:
return self.http_status(exc.status, exc.code, exc.code)
try:
return assistant_stream_response(service, request_context, conversation_id, body)
except AssistantError as exc:
return self.http_status(exc.status, exc.code, exc.code)
def assistant_stream_response(service, context, conversation_id, body):
"""Detach execution from the subscriber; only an explicit stop cancels a run."""
queue = service.start_run(context, conversation_id, body)
async def stream():
while True:
try:
frame = await asyncio.wait_for(queue.get(), timeout=15)
except TimeoutError:
yield '\n'
continue
yield json.dumps(frame, ensure_ascii=False) + '\n'
if frame['kind'] in {'completed', 'error'}:
break
response = quart.Response(stream(), content_type='application/x-ndjson; charset=utf-8')
response.timeout = None
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Accel-Buffering'] = 'no'
return response
+222 -15
View File
@@ -1,6 +1,7 @@
"""In-process management assistant using the existing model and resource services."""
import asyncio
import contextlib
import json
import uuid
@@ -44,6 +45,187 @@ class AssistantService:
self.ap = ap
# ponytail: per-process admission; shared quotas if multiple workers need a global ceiling.
self._slots = asyncio.Semaphore(4)
self._runs = {}
async def list_conversations(self, context):
self._scope(context, '')
result = await self.ap.persistence_mgr.execute_async(
sa.select(Conversation)
.where(
Conversation.workspace_uuid == context.workspace_uuid,
Conversation.account_uuid == context.account_uuid,
)
.order_by(Conversation.updated_at.desc(), Conversation.uuid)
.limit(100)
)
return [
dict(
uuid=row['uuid'],
status=row['status'],
revision=row['revision'],
title=next(
(
m.get('content', '')[:80]
for m in row['messages']
if m['role'] == 'user' and isinstance(m.get('content'), str)
),
'',
),
)
for row in result.mappings()
]
async def progress(self, context, conversation_id):
conversation = await self.get(context, conversation_id)
run = self._runs.get(conversation_id)
if run:
return {**(run['snapshot'] or self.public_view(conversation)), 'progress': run['progress']}
return self.public_view(conversation)
def start_run(self, context, conversation_id, body):
"""Own execution independently of any HTTP subscriber; never block on a slow browser."""
if conversation_id in self._runs:
raise AssistantError('stale_turn')
if len(self._runs) >= 4:
raise AssistantError('busy', 429)
queue = asyncio.Queue(maxsize=32)
run = {
'queue': queue,
'snapshot': None,
'progress': {},
'stopping': False,
'revision': body.revision + 1,
'started': asyncio.Event(),
}
self._runs[conversation_id] = run
async def emit(frame):
if frame['kind'] == 'snapshot':
run['snapshot'] = frame['data']
run['progress'] = {}
elif frame['kind'] == 'text':
run['progress']['text'] = frame['data']['text']
elif frame['kind'] == 'phase':
run['progress'].update(frame['data'])
if queue.full():
queue.get_nowait()
queue.put_nowait(frame)
async def execute():
run['started'].set()
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
scope = tenant_scope(context.workspace_uuid) if callable(tenant_scope) else contextlib.nullcontext()
try:
async with scope:
try:
conversation = await self.turn(
context,
conversation_id,
body.revision,
body.text,
body.approved,
str(body.model_uuid) if body.model_uuid else None,
on_event=emit,
)
except asyncio.CancelledError:
conversation = await self.get(context, conversation_id)
# Preserve interrupted tool outcomes; stopping cannot undo an external write.
if run['stopping'] and (
conversation['error'] == 'result_unknown'
or (conversation['status'] == 'running' and conversation['revision'] == run['revision'])
):
await self._save(context, conversation, 'failed', 'stopped')
await emit({'kind': 'completed', 'data': self.public_view(conversation)})
except AssistantError as exc:
await emit({'kind': 'error', 'code': exc.code})
except Exception:
self.ap.logger.exception('Assistant background run failed')
await emit({'kind': 'error', 'code': 'turn_failed'})
finally:
self._runs.pop(conversation_id, None)
run['task'] = asyncio.create_task(execute())
return queue
async def stop(self, context, conversation_id, revision):
require_permission(context, Permission.RUNTIME_OPERATE)
conversation = await self.get(context, conversation_id)
run = self._runs.get(conversation_id)
if run and run['revision'] != revision:
raise AssistantError('stale_turn')
if not run:
if conversation['revision'] != revision:
raise AssistantError('stale_turn')
return self.public_view(conversation)
await run['started'].wait()
if not run['stopping']:
run['stopping'] = True
run['task'].cancel()
await asyncio.shield(run['task'])
return self.public_view(await self.get(context, conversation_id))
async def recommended_model(self, context):
require_permission(context, Permission.RUNTIME_OPERATE)
try:
recommended = await self.ap.space_service.get_recommended_assistant_model(context)
model = await self.ap.model_mgr.get_model_by_uuid(
ExecutionContext.from_request(context), recommended['uuid']
)
if 'func_call' not in (model.model_entity.abilities or []):
raise ValueError('Recommended model does not support tools')
return {'uuid': recommended['uuid'], 'name': model.model_entity.name}
except Exception as exc:
raise AssistantError('model_unavailable', 503) from exc
async def _invoke(self, model, kwargs, on_event):
if on_event is None:
return await model.provider.invoke_llm(**kwargs)
message = {'role': 'assistant', 'content': ''}
calls = {}
finished = False
async for chunk in model.provider.invoke_llm_stream(**kwargs):
finished = finished or chunk.is_final
content = chunk.content or ''
if isinstance(content, list):
content = ''.join(item.text or '' for item in content if item.type == 'text')
if chunk.all_content is not None:
message['content'] = chunk.all_content
else:
message['content'] += content
for field in ('resp_message_id', 'provider_specific_fields'):
value = getattr(chunk, field, None)
if value is not None:
if field == 'provider_specific_fields':
message[field] = {**message.get(field, {}), **value}
else:
message[field] = value
for call in chunk.tool_calls or []:
value = call.model_dump(mode='json')
if call.id in calls:
saved = calls[call.id]
saved['function']['arguments'] += call.function.arguments or ''
if call.function.name:
saved['function']['name'] = call.function.name
if value.get('provider_specific_fields'):
saved['provider_specific_fields'] = {
**(saved.get('provider_specific_fields') or {}),
**value['provider_specific_fields'],
}
else:
calls[call.id] = value
if len(calls) > 8 or len(json.dumps(message, ensure_ascii=False)) + len(json.dumps(calls)) > 64000:
raise AssistantError('response_too_large')
if content or chunk.all_content is not None:
await on_event({'kind': 'text', 'data': {'text': message['content']}})
if not finished:
raise AssistantError('stream_interrupted')
if calls:
message['tool_calls'] = list(calls.values())
return Message.model_validate(message)
async def _snapshot(self, conversation, on_event):
if on_event is not None:
await on_event({'kind': 'snapshot', 'data': self.public_view(conversation)})
@staticmethod
def _scope(context, conversation_id):
@@ -150,7 +332,7 @@ class AssistantService:
raise AssistantError('stale_turn')
conversation.update(status=status, error=error)
async def turn(self, context, conversation_id, revision, text=None, approved=None, model_uuid=None):
async def turn(self, context, conversation_id, revision, text=None, approved=None, model_uuid=None, on_event=None):
require_permission(context, Permission.RUNTIME_OPERATE)
if model_uuid is not None and text is None:
raise AssistantError('invalid_input', 400)
@@ -201,7 +383,7 @@ class AssistantService:
await self._save(context, conversation, 'running')
try:
if not conversation['model_uuid']:
recommended = await self.ap.space_service.get_recommended_chat_model(context)
recommended = await self.recommended_model(context)
conversation['model_uuid'] = recommended['uuid']
execution = ExecutionContext.from_request(context)
model = selected_model or await self.ap.model_mgr.get_model_by_uuid(
@@ -215,22 +397,30 @@ class AssistantService:
return conversation
if approved is not None:
calls = self._calls(conversation['messages'][-1])
await self._execute(context, conversation, calls, approved)
for _ in range(8):
response = await model.provider.invoke_llm(
query=None,
model=model,
messages=[Message(role='system', content=SYSTEM_PROMPT)]
+ [Message.model_validate(message) for message in conversation['messages']],
funcs=tool_definitions(context),
extra_args=model.model_entity.extra_args or {},
remove_think=True,
execution_context=execution,
await self._execute(context, conversation, calls, approved, on_event)
for round_index in range(8):
await self._snapshot(conversation, on_event)
if on_event is not None:
await on_event({'kind': 'phase', 'data': {'phase': 'thinking', 'round': round_index + 1}})
response = await self._invoke(
model,
dict(
query=None,
model=model,
messages=[Message(role='system', content=SYSTEM_PROMPT)]
+ [Message.model_validate(message) for message in conversation['messages']],
funcs=tool_definitions(context),
extra_args=model.model_entity.extra_args or {},
remove_think=True,
execution_context=execution,
),
on_event,
)
message = response.model_dump(mode='json')
if len(json.dumps(message, ensure_ascii=False)) > 64000:
raise AssistantError('response_too_large')
conversation['messages'].append(message)
await self._snapshot(conversation, on_event)
calls = self._calls(message)
if not calls:
await self._save(context, conversation, 'ready')
@@ -249,9 +439,12 @@ class AssistantService:
if any(TOOLS[call['function']['name']][2] for call in calls):
await self._save(context, conversation, 'approval')
return conversation
await self._execute(context, conversation, calls, True)
await self._execute(context, conversation, calls, True, on_event)
await self._save(context, conversation, 'failed', 'round_limit')
except asyncio.CancelledError:
partial = self._runs.get(conversation_id, {}).get('progress', {}).get('text')
if partial:
conversation['messages'].append(Message(role='assistant', content=partial).model_dump(mode='json'))
await asyncio.shield(self._save(context, conversation, 'failed', 'result_unknown'))
raise
except TimeoutError:
@@ -280,7 +473,7 @@ class AssistantService:
).model_dump(mode='json')
)
async def _execute(self, context, conversation, calls, approved):
async def _execute(self, context, conversation, calls, approved, on_event=None):
# Validate the complete saved batch before any write, including after approval.
if approved:
for call in calls:
@@ -293,6 +486,19 @@ class AssistantService:
else:
# Persist before effects. A crash leaves a running/unknown operation, never a replayable approval.
await self._save(context, conversation, 'running')
if on_event is not None:
await on_event(
{
'kind': 'phase',
'data': {
'phase': 'tool',
'tool': {
'name': func['name'],
'arguments': redact_secrets(json.loads(func['arguments'] or '{}')),
},
},
}
)
try:
result = await execute_tool(self.ap, context, func['name'], json.loads(func['arguments'] or '{}'))
except Exception as exc:
@@ -308,3 +514,4 @@ class AssistantService:
raise AssistantError('tool_failed')
self._append_result(conversation, call, result)
await self._save(context, conversation, 'running')
await self._snapshot(conversation, on_event)
+17 -1
View File
@@ -290,7 +290,23 @@ class SpaceService:
selection = await self.get_model_selection('chat')
if not selection:
raise ValueError('No recommended chat model is available')
recommended = selection[0]
return await self._resolve_recommended_model(context, selection[0])
async def get_recommended_assistant_model(self, context: typing.Any) -> dict:
"""Resolve the dedicated, health-filtered assistant recommendation without fallback."""
space_url = self._get_space_config()['url']
session = httpclient.get_session()
async with session.get(f'{space_url}/api/v1/models/assistant/recommendation') as response:
if response.status != 200:
raise ValueError('No recommended assistant model is available')
payload = await httpclient.read_json_limited(response)
if payload.get('code') != 0:
raise ValueError('No recommended assistant model is available')
recommended = SpaceModel.model_validate(payload['data']['model'])
return await self._resolve_recommended_model(context, recommended)
async def _resolve_recommended_model(self, context: typing.Any, recommended: SpaceModel) -> dict:
"""Map a public catalog model to this Workspace, refreshing the catalog once."""
async def find_local_model():
result = await self.ap.persistence_mgr.execute_async(
@@ -1025,3 +1025,45 @@ class TestSpaceServiceCreditsCache:
assert result == 500
assert '[email protected]' in service._credits_cache
assert service._credits_cache['[email protected]'][0] == 500
@pytest.mark.parametrize('status', [200, 503])
async def test_assistant_recommendation_uses_dedicated_endpoint_without_fallback(status):
service = SpaceService(SimpleNamespace(instance_config=SimpleNamespace(data={})))
service.get_model_selection = AsyncMock()
service._resolve_recommended_model = AsyncMock(return_value={'uuid': 'local', 'name': 'deepseek-v4-flash'})
response = MagicMock(status=status)
_set_response_body(
response,
{
'code': 0,
'data': {
'model': {
'uuid': 'upstream',
'model_id': 'deepseek-v4-flash',
'category': 'chat',
'provider': 'deepseek',
'status': 'active',
}
},
},
)
session = MagicMock()
session.get.return_value.__aenter__ = AsyncMock(return_value=response)
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
context = object()
with patch('langbot.pkg.api.http.service.space.httpclient.get_session', return_value=session):
if status == 200:
assert await service.get_recommended_assistant_model(context) == {
'uuid': 'local',
'name': 'deepseek-v4-flash',
}
args = service._resolve_recommended_model.await_args.args
assert args[0] is context
assert args[1].model_id == 'deepseek-v4-flash'
else:
with pytest.raises(ValueError, match='No recommended assistant model'):
await service.get_recommended_assistant_model(context)
service._resolve_recommended_model.assert_not_awaited()
service.get_model_selection.assert_not_awaited()
session.get.assert_called_once_with('https://space.langbot.app/api/v1/models/assistant/recommendation')
@@ -74,7 +74,7 @@ async def assistant(tmp_path):
ap = SimpleNamespace(
persistence_mgr=SimpleNamespace(execute_async=execute),
logger=logging.getLogger('assistant-test'),
space_service=SimpleNamespace(get_recommended_chat_model=AsyncMock(return_value={'uuid': 'model'})),
space_service=SimpleNamespace(get_recommended_assistant_model=AsyncMock(return_value={'uuid': 'model'})),
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
pipeline_service=SimpleNamespace(create_pipeline=AsyncMock(return_value='created-pipeline')),
)
@@ -234,3 +234,250 @@ async def test_model_switch_preserves_history_and_rejects_invalid_selection(assi
ap.model_mgr.get_model_by_uuid.assert_awaited_with(
next_provider.invoke_llm.call_args.kwargs['execution_context'], 'second'
)
@pytest.mark.asyncio
async def test_recommendation_resolved_before_any_turn(assistant):
service, ap, provider = assistant
assert await service.recommended_model(context()) == {'uuid': 'model', 'name': 'test-model'}
provider.invoke_llm.assert_not_awaited()
model = ap.model_mgr.get_model_by_uuid.return_value
model.model_entity.abilities = []
with pytest.raises(AssistantError, match='model_unavailable'):
await service.recommended_model(context())
@pytest.mark.asyncio
async def test_stream_selected_model_deltas_tools_and_approval(assistant):
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
import copy
service, ap, old_provider = assistant
events = []
inputs = []
async def stream(**kwargs):
inputs.append(kwargs)
if len(inputs) == 1:
yield MessageChunk(role='assistant', content='Create ')
yield MessageChunk(role='assistant', content='this?')
call = proposal().tool_calls[0].model_dump()
call['function']['arguments'] = '{"name":"Demo",'
yield MessageChunk(role='assistant', tool_calls=[call])
call['function']['arguments'] = '"description":"Test draft"}'
yield MessageChunk(
role='assistant', tool_calls=[call], is_final=True, provider_specific_fields={'signature': 'kept'}
)
else:
yield MessageChunk(role='assistant', content='Done')
yield MessageChunk(role='assistant', content='', all_content='Done', is_final=True)
chosen = SimpleNamespace(
provider=SimpleNamespace(invoke_llm_stream=stream),
model_entity=SimpleNamespace(name='manual-model', abilities=['func_call'], extra_args={}),
)
ap.model_mgr.get_model_by_uuid.return_value = chosen
async def emit(event):
events.append(copy.deepcopy(event))
ctx = context()
cid = (await service.create(ctx))['uuid']
pending = await service.turn(ctx, cid, 0, text='Create', model_uuid='manual', on_event=emit)
assert pending['status'] == 'approval'
assert pending['model_uuid'] == 'manual'
assert inputs[0]['model'] is chosen
ap.space_service.get_recommended_assistant_model.assert_not_awaited()
old_provider.invoke_llm.assert_not_awaited()
assert [e['data']['text'] for e in events if e['kind'] == 'text'] == ['Create ', 'Create this?']
ap.pipeline_service.create_pipeline.assert_not_awaited()
events.clear()
complete = await service.turn(ctx, cid, 1, approved=True, on_event=emit)
assert complete['status'] == 'ready'
assert complete['messages'][-1]['content'] == 'Done'
ap.pipeline_service.create_pipeline.assert_awaited_once()
assert inputs[1]['messages'][2].provider_specific_fields == {'signature': 'kept'}
tool_start = next(i for i, e in enumerate(events) if e['kind'] == 'phase' and e['data']['phase'] == 'tool')
result = next(
i
for i, e in enumerate(events)
if e['kind'] == 'snapshot' and any(m['role'] == 'tool' for m in e['data']['messages'])
)
assert tool_start < result
with pytest.raises(AssistantError, match='stale_turn'):
await service.turn(ctx, cid, 1, approved=True, on_event=emit)
@pytest.mark.asyncio
async def test_truncated_stream_never_executes_proposed_write(assistant):
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
service, ap, provider = assistant
async def stream(**kwargs):
yield MessageChunk(role='assistant', tool_calls=proposal().tool_calls)
provider.invoke_llm_stream = stream
cid = (await service.create(context()))['uuid']
result = await service.turn(context(), cid, 0, text='Create', on_event=AsyncMock())
assert result['status'] == 'failed'
assert result['error'] == 'stream_interrupted'
ap.pipeline_service.create_pipeline.assert_not_awaited()
@pytest.mark.asyncio
async def test_cancelled_stream_persists_non_replayable_state(assistant):
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
service, ap, provider = assistant
started = asyncio.Event()
async def stream(**kwargs):
yield MessageChunk(role='assistant', content='Hello')
started.set()
await asyncio.Event().wait()
provider.invoke_llm_stream = stream
ctx = context()
cid = (await service.create(ctx))['uuid']
task = asyncio.create_task(service.turn(ctx, cid, 0, text='Hello', on_event=AsyncMock()))
await asyncio.wait_for(started.wait(), 5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
saved = await service.get(ctx, cid)
assert saved['status'] == 'failed'
assert saved['error'] == 'result_unknown'
with pytest.raises(AssistantError, match='stale_turn'):
await service.turn(ctx, cid, 0, text='Hello')
@pytest.mark.asyncio
async def test_http_stream_emits_before_provider_completion_and_restores_tenant(assistant):
import contextlib
import json
import quart
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
from langbot.pkg.api.http.controller.groups.assistant import assistant_stream_response, TurnInput
service, ap, provider = assistant
released = asyncio.Event()
scoped = []
@contextlib.asynccontextmanager
async def tenant_scope(workspace):
scoped.append(workspace)
try:
yield
finally:
scoped.pop()
async def stream(**kwargs):
assert scoped == ['workspace-a']
yield MessageChunk(role='assistant', content='First')
await released.wait()
yield MessageChunk(role='assistant', content=' second', is_final=True)
ap.persistence_mgr.tenant_scope = tenant_scope
provider.invoke_llm_stream = stream
ctx = context()
cid = (await service.create(ctx))['uuid']
app = quart.Quart(__name__)
@app.post('/stream')
async def route():
return assistant_stream_response(service, ctx, cid, TurnInput(revision=0, text='Hi'))
frames = []
async with app.test_client().request('/stream', method='POST') as connection:
while not any(frame['kind'] == 'text' for frame in frames):
chunk = await asyncio.wait_for(connection.receive(), 5)
frames.extend(json.loads(line) for line in chunk.decode().splitlines() if line.strip())
assert frames[-1]['data']['text'] == 'First'
assert not released.is_set()
released.set()
while not any(frame['kind'] == 'completed' for frame in frames):
chunk = await asyncio.wait_for(connection.receive(), 5)
frames.extend(json.loads(line) for line in chunk.decode().splitlines() if line.strip())
assert frames[-1]['data']['messages'][-1]['content'] == 'First second'
assert scoped == []
@pytest.mark.asyncio
async def test_background_run_survives_http_disconnect_and_finishes(assistant):
import json
import quart
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
from langbot.pkg.api.http.controller.groups.assistant import assistant_stream_response, TurnInput
service, ap, provider = assistant
released = asyncio.Event()
async def stream(**kwargs):
yield MessageChunk(role='assistant', content='Background')
await released.wait()
yield MessageChunk(role='assistant', content=' done', is_final=True)
provider.invoke_llm_stream = stream
ctx = context()
cid = (await service.create(ctx))['uuid']
app = quart.Quart(__name__)
@app.post('/stream')
async def route():
return assistant_stream_response(service, ctx, cid, TurnInput(revision=0, text='Hi'))
async with app.test_client().request('/stream', method='POST') as connection:
while True:
chunk = await asyncio.wait_for(connection.receive(), 5)
if any(json.loads(line)['kind'] == 'text' for line in chunk.decode().splitlines() if line.strip()):
break
task = service._runs[cid]['task']
await connection.disconnect()
assert not task.done()
assert (await service.progress(ctx, cid))['progress']['text'] == 'Background'
released.set()
await asyncio.wait_for(task, 5)
completed = await service.get(ctx, cid)
assert completed['status'] == 'ready'
assert completed['messages'][-1]['content'] == 'Background done'
@pytest.mark.asyncio
async def test_stop_is_private_revision_scoped_and_does_not_stop_other_chat(assistant):
from langbot_plugin.api.entities.builtin.provider.message import MessageChunk
from langbot.pkg.api.http.controller.groups.assistant import TurnInput
service, ap, provider = assistant
released = asyncio.Event()
async def stream(**kwargs):
yield MessageChunk(role='assistant', content='Partial')
await released.wait()
yield MessageChunk(role='assistant', content=' done', is_final=True)
provider.invoke_llm_stream = stream
ctx = context()
ids = [(await service.create(ctx))['uuid'] for _ in range(2)]
tasks = []
for cid in ids:
queue = service.start_run(ctx, cid, TurnInput(revision=0, text='Background task'))
tasks.append(service._runs[cid]['task'])
while (await asyncio.wait_for(queue.get(), 5))['kind'] != 'text':
pass
for other in (context(account='bob'), context(workspace='workspace-b')):
assert await service.list_conversations(other) == []
with pytest.raises(AssistantError, match='conversation_not_found'):
await service.stop(other, ids[0], 1)
with pytest.raises(AssistantError, match='stale_turn'):
await service.stop(ctx, ids[0], 0)
result = await service.stop(ctx, ids[0], 1)
assert result['status'] == 'failed'
assert result['error'] == 'stopped'
assert result['messages'][-1]['content'] == 'Partial'
assert not tasks[1].done()
assert len(await service.list_conversations(ctx)) == 2
assert (await service.stop(ctx, ids[0], 1))['error'] == 'stopped'
released.set()
await asyncio.wait_for(tasks[1], 5)
assert (await service.get(ctx, ids[1]))['status'] == 'ready'
+1
View File
@@ -55,6 +55,7 @@
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.7",
"@remixicon/react": "^4.9.0",
"@simplewebauthn/browser": "^14.0.0",
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/react-table": "^8.21.3",
+11
View File
@@ -93,6 +93,9 @@ dependencies:
'@radix-ui/react-tooltip':
specifier: ^1.2.7
version: 1.2.8(@types/[email protected])(@types/[email protected])([email protected])([email protected])
'@remixicon/react':
specifier: ^4.9.0
version: 4.9.0([email protected])
'@simplewebauthn/browser':
specifier: ^14.0.0
version: 14.0.0
@@ -2921,6 +2924,14 @@ packages:
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
dev: false
/@remixicon/[email protected]([email protected]):
resolution: {integrity: sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q==}
peerDependencies:
react: '>=18.2.0'
dependencies:
react: 19.2.1
dev: false
/@rolldown/[email protected]:
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4,6 +4,7 @@ import {
ChevronDown,
CircleAlert,
MinusCircle,
LoaderCircle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -16,11 +17,11 @@ export type AssistantTool = {
export default function AssistantToolResult({
tool,
content,
defaultCollapsed = false,
defaultCollapsed = true,
}: {
tool?: AssistantTool;
content: string;
/** Turn finished: collapse to one line until the user expands it again. */
/** Keep tool steps quiet until the user requests details. */
defaultCollapsed?: boolean;
}) {
const { t } = useTranslation();
@@ -36,11 +37,17 @@ export default function AssistantToolResult({
result && typeof result === 'object' && !Array.isArray(result)
? (result as Record<string, unknown>)
: {};
const running = data.status === 'running';
const failed = !!data.error;
const denied = data.status === 'denied';
const partial = !!data.truncated;
const Icon =
failed || partial ? CircleAlert : denied ? MinusCircle : CheckCircle2;
const Icon = running
? LoaderCircle
: failed || partial
? CircleAlert
: denied
? MinusCircle
: CheckCircle2;
const items = Array.isArray(result)
? result
: Array.isArray(data.items)
@@ -56,13 +63,15 @@ export default function AssistantToolResult({
: t(`assistant.operations.${tool?.name}`, {
defaultValue: t('assistant.toolResult'),
});
const status = failed
? 'failed'
: denied
? 'denied'
: partial
? 'partial'
: 'completed';
const status = running
? 'toolRunning'
: failed
? 'failed'
: denied
? 'denied'
: partial
? 'partial'
: 'completed';
const url =
typeof data.url === 'string' &&
/^\/home\/(pipelines|knowledge)\?id=[\w-]+$/.test(data.url)
@@ -79,7 +88,7 @@ export default function AssistantToolResult({
return (
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg border bg-background px-3 py-1.5 text-left text-xs hover:bg-muted"
className="flex w-full items-center gap-1.5 rounded-md px-0.5 py-1 text-left text-[11px] leading-4 text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary"
aria-expanded={false}
onClick={() => {
setManual(true);
@@ -87,25 +96,25 @@ export default function AssistantToolResult({
}}
>
<Icon
className={`size-3.5 shrink-0 ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
className={`size-3 shrink-0 ${running ? 'animate-spin motion-reduce:animate-none' : ''} ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
/>
<span className="truncate font-medium">{label}</span>
<span className="min-w-0 truncate">{label}</span>
<span className="ml-auto shrink-0 text-muted-foreground">
{tool && t(`assistant.${status}`)}
</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
);
}
return (
<section className="space-y-2 rounded-xl border bg-background p-3 text-sm">
<div className="flex items-center gap-2">
<section className="min-w-0 space-y-1.5 px-0.5 py-1 text-[11px] leading-4 text-muted-foreground">
<div className="flex items-center gap-1.5">
<Icon
className={`size-4 shrink-0 ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
className={`size-3 shrink-0 ${running ? 'animate-spin motion-reduce:animate-none' : ''} ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
/>
<span className="font-medium">{label}</span>
<span className="ml-auto text-xs text-muted-foreground">
<span className="min-w-0 flex-1 break-words">{label}</span>
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">
{tool && t(`assistant.${status}`)}
</span>
<button
@@ -118,10 +127,12 @@ export default function AssistantToolResult({
setCollapsed(true);
}}
>
<ChevronDown className="size-3.5 rotate-180" />
<ChevronDown className="size-3 rotate-180" />
</button>
</div>
{failed ? (
{running ? (
<p>{t('assistant.toolRunning')}</p>
) : failed ? (
<p className="text-destructive">{t('assistant.operationFailed')}</p>
) : denied ? (
<p className="text-muted-foreground">
@@ -136,7 +147,7 @@ export default function AssistantToolResult({
)}
{name && <p className="break-words">{name}</p>}
{items && (
<ul className="space-y-1 text-xs text-muted-foreground">
<ul className="space-y-0.5 text-[11px] text-muted-foreground">
{items.slice(0, 6).map((item: unknown, index: number) => {
const entry =
item && typeof item === 'object'
@@ -168,9 +179,9 @@ export default function AssistantToolResult({
)}
</>
)}
<details className="text-xs text-muted-foreground">
<details className="text-[11px] 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">
<pre className="mt-1.5 max-h-32 overflow-auto whitespace-pre-wrap break-all">
{tool ? JSON.stringify(tool.result, null, 2) : content}
</pre>
</details>
File diff suppressed because it is too large Load Diff
@@ -1,89 +0,0 @@
/**
* Pure geometry + state helpers for the floating workspace assistant button.
*
* The component keeps these decisions here so the dock/rail transitions stay
* testable without a DOM. The key invariant is that the *same* edge value
* drives the collapsed strip and the expanded button, so they can never drift
* apart or fight each other (the race the UI is careful to avoid).
*/
export const ASSISTANT_BUTTON_SIZE = 48;
/** Distance to the viewport edge that snaps the button into the rail. */
export const ASSISTANT_SNAP_THRESHOLD = 24;
/** Rail strip that stays visible while collapsed, in pixels. */
export const ASSISTANT_RAIL_WIDTH = 6;
export type AssistantDragPosition = { x: number; y: number };
export type AssistantEdge = 'left' | 'right' | null;
/** Viewport box the button may occupy. Kept explicit so tests can pin a size. */
export type AssistantViewport = { width: number; height: number };
export function clampAssistantPosition(
x: number,
y: number,
viewport: AssistantViewport,
): AssistantDragPosition {
const maxX = Math.max(0, viewport.width - ASSISTANT_BUTTON_SIZE);
const maxY = Math.max(0, viewport.height - ASSISTANT_BUTTON_SIZE);
return {
x: Math.min(Math.max(0, x), maxX),
y: Math.min(Math.max(0, y), maxY),
};
}
/**
* Decide whether a resting position is docked against a vertical edge.
* The left edge wins ties so a centred drag always resolves deterministically.
*/
export function resolveAssistantEdge(
x: number,
viewport: AssistantViewport,
): AssistantEdge {
const maxX = Math.max(0, viewport.width - ASSISTANT_BUTTON_SIZE);
const leftGap = x;
const rightGap = maxX - x;
if (leftGap <= ASSISTANT_SNAP_THRESHOLD && leftGap <= rightGap) return 'left';
if (rightGap <= ASSISTANT_SNAP_THRESHOLD) return 'right';
return null;
}
export type AssistantHoverInput = {
dockedEdge: AssistantEdge;
railExpanded: boolean;
/** A long press has armed the drag, or the pointer is actively moving. */
dragging: boolean;
};
/** Distance to the viewport edge that snaps the button into the rail. */
/**
* Hovering any part of the control must reveal the full button again.
* Only a docked *and still collapsed* button needs to expand, so a pointer that
* merely jitters inside an already expanded button does no state churn.
*/
export function shouldExpandRail(input: AssistantHoverInput): boolean {
if (input.dragging) return false;
return !!input.dockedEdge && !input.railExpanded;
}
/**
* A docked button collapses to the strip when the pointer leaves. This is the
* mirror of `shouldExpandRail` and is keyed on `railExpanded` being *true* —
* the earlier `!railExpanded` form made a revealed button impossible to
* re-collapse, which is the bug this guards against. The panel being open pins
* it, because the popover stays anchored to the button.
*/
export function shouldCollapseRail(input: AssistantHoverInput): boolean {
if (input.dragging) return false;
return !!input.dockedEdge && input.railExpanded;
}
/**
* After a drop the button always starts collapsed, even though the pointer is
* still over it. The rail is then held collapsed until the pointer exits, which
* avoids the "drops under the cursor and never comes back" race.
*/
export function restingRailExpanded(): boolean {
return false;
}
@@ -132,6 +132,7 @@ export default function DynamicFormItemComponent({
systemContext,
requiredModelAbility,
compactModelSelector = false,
selectedModelLabel,
}: {
config: IDynamicFormItemSchema;
field: ControllerRenderProps<any, any>;
@@ -141,6 +142,7 @@ export default function DynamicFormItemComponent({
systemContext?: Record<string, unknown>;
requiredModelAbility?: string;
compactModelSelector?: boolean;
selectedModelLabel?: string;
}) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
@@ -639,11 +641,15 @@ export default function DynamicFormItemComponent({
aria-label={t('models.selectModel')}
className={
compactModelSelector
? 'w-full min-w-0 gap-1 border-0 bg-transparent px-1 text-xs text-muted-foreground shadow-none hover:bg-muted data-[size=default]:h-7 [&_[data-slot=select-value]_svg]:hidden'
? 'w-full min-w-0 gap-1 rounded-lg border border-border bg-background px-2 text-xs text-foreground shadow-[0_1px_2px_rgba(0,0,0,0.04)] hover:border-muted-foreground/40 hover:bg-background dark:bg-background dark:hover:bg-background data-[state=open]:border-muted-foreground/50 focus-visible:border-muted-foreground/50 focus-visible:ring-1 focus-visible:ring-muted-foreground/20 data-[size=default]:h-7 [&_[data-slot=select-value]_svg]:hidden'
: MODEL_SELECT_TRIGGER_CLASS
}
>
<SelectValue placeholder={t('models.selectModel')} />
<SelectValue placeholder={t('models.selectModel')}>
{compactModelSelector && selectedModelLabel
? selectedModelLabel
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModels).map(([providerName, models]) => (
@@ -0,0 +1,238 @@
/* Brief gestures separated by long rests keep the collapsed dock quiet. */
.magic {
animation: assistant-wave 9s ease-in-out infinite;
transform-origin: bottom center;
}
.sparkle {
animation: assistant-sparkle 9s ease-in-out infinite;
}
.dock:hover .magic,
.dock:hover .sparkle,
.dock:focus-visible .magic,
.dock:focus-visible .sparkle {
animation-play-state: paused;
}
@keyframes assistant-wave {
0%,
84%,
100% {
transform: translateY(0) rotate(0);
}
87% {
transform: translateY(-2px) rotate(-10deg);
}
90% {
transform: translateY(-2px) rotate(8deg);
}
93% {
transform: translateY(-1px) rotate(-5deg);
}
96% {
transform: translateY(0) rotate(0);
}
}
@keyframes assistant-sparkle {
0%,
86%,
100% {
transform: scale(1);
}
90% {
transform: scale(1.2) rotate(12deg);
}
96% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.magic,
.sparkle {
animation: none;
}
}
.panel {
border-color: var(--border);
background: var(--background);
box-shadow: 0 -4px 20px #0000000d;
transform-origin: bottom center;
animation: assistant-arrive 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.closing {
animation: assistant-leave 180ms ease-in both;
pointer-events: none;
}
.dock {
animation: assistant-arrive 160ms ease-out both;
}
.conversationPanel {
height: min(420px, calc(100dvh - 48px));
}
.header {
background: var(--background);
}
.avatar {
display: grid;
place-items: center;
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 10px;
color: #2288ee;
background: color-mix(in srgb, #2288ee 8%, var(--background));
}
.welcome {
padding: 6px 0;
display: grid;
gap: 8px;
}
.prompt {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 10px;
text-align: left;
font-size: 12px;
line-height: 1.5;
background: var(--muted);
border: 1px solid transparent;
cursor: pointer;
transition:
transform 160ms ease,
border-color 160ms ease;
}
.promptIcon {
display: flex;
flex-shrink: 0;
color: #2288ee;
}
.prompt:hover:not(:disabled) {
transform: translateY(-1px);
border-color: var(--border);
}
.prompt:focus-visible {
outline: 2px solid #2288ee;
outline-offset: 2px;
}
.prompt:disabled {
opacity: 0.5;
cursor: default;
}
.userMessage {
background: color-mix(in srgb, #2288ee 8%, var(--background));
border-bottom-right-radius: 0;
}
.assistantMessage {
background: var(--muted);
border-bottom-left-radius: 0;
}
/* Use the debug dialog's Markdown styling, scaled to the compact assistant. */
.messageContent:global(.markdown-body) {
color: var(--foreground);
font-size: 13px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.messageContent:global(.markdown-body) :is(p, ul, ol, blockquote, pre, table) {
margin-top: 0;
margin-bottom: 10px;
}
.messageContent:global(.markdown-body) :is(h1, h2, h3, h4, h5, h6) {
margin-top: 16px;
margin-bottom: 8px;
padding: 0;
border: 0;
font-size: 14px;
line-height: 1.5;
}
.messageContent:global(.markdown-body) > :first-child {
margin-top: 0;
}
.messageContent:global(.markdown-body) > :last-child {
margin-bottom: 0;
}
.messageContent:global(.markdown-body) :is(ul, ol) {
padding-left: 1.5em;
}
.messageContent:global(.markdown-body) li + li {
margin-top: 4px;
}
.messageContent:global(.markdown-body) li > p {
margin-bottom: 4px;
}
.messageContent:global(.markdown-body) pre {
max-width: 100%;
overflow-x: auto;
padding: 10px;
border-radius: 8px;
background: var(--background);
font-size: 12px;
}
.messageContent:global(.markdown-body) table {
display: block;
max-width: 100%;
overflow-x: auto;
font-size: 12px;
}
.messageContent:global(.markdown-body) :is(th, td) {
padding: 6px 8px;
}
.composer {
background: var(--muted);
border: 1px solid transparent;
}
.input {
border: 0;
box-shadow: none;
}
.send,
.dock {
background: #2288ee;
box-shadow: none;
}
.send:hover:not(:disabled),
.dock:hover {
background: #1878d8;
}
@keyframes assistant-arrive {
from {
opacity: 0;
transform: translateY(14px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes assistant-leave {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(18px) scale(0.98);
}
}
@media (prefers-reduced-motion: reduce) {
.panel,
.closing,
.dock {
animation: none;
}
.prompt,
.composer {
transition: none;
}
.prompt:hover:not(:disabled) {
transform: none;
}
}
+52
View File
@@ -439,6 +439,58 @@ export class BackendClient extends BaseHttpClient {
return result;
}
public async streamAssistant<T>(
uuid: string,
payload: {
revision: number;
text?: string;
approved?: boolean;
model_uuid?: string;
},
onEvent: (event: { kind: string; data: unknown }) => void,
signal: AbortSignal,
): Promise<T> {
let offset = 0;
let result: T | undefined;
let failure: { code: string; msg: string } | undefined;
const consume = (text: string) => {
let end: number;
while ((end = text.indexOf('\n', offset)) !== -1) {
const line = text.slice(offset, end).trim();
offset = end + 1;
if (!line) continue;
const frame = JSON.parse(line);
if (frame.kind === 'completed') result = frame.data;
else if (frame.kind === 'error') failure = frame;
else onEvent(frame);
}
};
const response = await this.instance.post<string>(
`/api/v1/assistant/conversations/${encodeURIComponent(uuid)}/turn/stream`,
payload,
{
adapter: 'xhr',
responseType: 'text',
timeout: 0,
signal,
headers: { Accept: 'application/x-ndjson' },
transformResponse: [(data) => data],
onDownloadProgress: (progress) => {
const xhr = progress.event?.target as XMLHttpRequest | undefined;
if (xhr?.status === 200) consume(xhr.responseText);
},
},
);
consume(response.data);
if (failure) throw failure;
if (!result)
throw {
code: 'assistant_protocol_error',
msg: 'Assistant stream ended before completion',
};
return result;
}
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
return this.get('/api/v1/pipelines/_/metadata');
+20 -1
View File
@@ -1,6 +1,25 @@
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
assistant: {
sessions: 'Conversations',
currentChat: 'Current conversation',
stop: 'Stop task',
stopped:
'Stopped. An in-progress tool may already have taken effect. Check its resource before retrying.',
setupRequired:
'Sign in to LangBot Account to use LangBot Models, or configure your own model provider to start chatting.',
loginAccount: 'Sign in to LangBot Account',
configureModels: 'Configure models',
providerCheckFailed: 'Could not check model providers. Please try again.',
retrySetup: 'Retry',
preparing: 'Preparing assistant…',
thinking: 'Model responding · Round {{round}}',
executing: 'Executing tool…',
toolRunning: 'Running',
recommendationFailed:
'Could not load a recommended model. Please select a model.',
expand: 'Expand workspace assistant',
collapse: 'Collapse workspace assistant',
modelHint: 'Switch assistant model for your next message',
details: 'View raw data',
found: 'Found {{count}} items',
@@ -35,7 +54,7 @@ const enUS = {
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.',
'Tell me what you want to build. I can inspect this Workspace and propose configurations for bots, Agents, knowledge bases, Pipelines and more, then apply them after your confirmation.',
discover: 'What models, knowledge bases and Pipelines are available?',
build: 'Help me create a knowledge-base question-answering application.',
toolResult: 'Operation result',
+21 -1
View File
@@ -1,6 +1,26 @@
import pipelineMigration from './pipeline-migration/es-ES';
const esES = {
assistant: {
sessions: 'Conversaciones',
currentChat: 'Conversación actual',
stop: 'Detener tarea',
stopped:
'Detenido. Una herramienta en curso puede haber surtido efecto. Comprueba el recurso antes de reintentar.',
setupRequired:
'Inicia sesión en LangBot Account para usar LangBot Models o configura tu proveedor de modelos para empezar a conversar.',
loginAccount: 'Iniciar sesión en LangBot Account',
configureModels: 'Configurar modelos',
providerCheckFailed:
'No se pudieron comprobar los proveedores. Inténtalo de nuevo.',
retrySetup: 'Reintentar',
preparing: 'Preparando asistente…',
thinking: 'Modelo respondiendo · Ronda {{round}}',
executing: 'Ejecutando herramienta…',
toolRunning: 'En ejecución',
recommendationFailed:
'No se pudo cargar el modelo recomendado. Selecciona un modelo.',
expand: 'Expandir asistente del espacio de trabajo',
collapse: 'Contraer asistente del espacio de trabajo',
modelHint: 'Cambia el modelo del asistente para el próximo mensaje',
details: 'Ver datos sin procesar',
found: 'Se encontraron {{count}} elementos',
@@ -35,7 +55,7 @@ const esES = {
newChat: 'Nueva conversación',
close: 'Cerrar',
welcome:
'Dime qué quieres crear. Puedo inspeccionar este espacio de trabajo y proponer cambios en la base de conocimiento y el Pipeline para que los confirmes.',
'Dime qué quieres crear. Puedo revisar este espacio de trabajo y proponer configuraciones para bots, Agents, bases de conocimiento, Pipelines y más, y aplicarlas tras tu confirmación.',
discover:
'¿Qué modelos, bases de conocimiento y Pipelines hay disponibles?',
build:
+21 -1
View File
@@ -1,6 +1,26 @@
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
assistant: {
sessions: '会話を切り替え',
currentChat: '現在の会話',
stop: 'タスクを停止',
stopped:
'停止しました。実行中のツールが反映済みの場合があります。再試行前にリソースを確認してください。',
setupRequired:
'LangBot Account にログインして LangBot Models を使うか、モデルプロバイダーを設定すると会話を始められます。',
loginAccount: 'LangBot Account にログイン',
configureModels: 'モデルを設定',
providerCheckFailed:
'モデルプロバイダーを確認できません。再試行してください。',
retrySetup: '再試行',
preparing: '準備中…',
thinking: 'モデル応答中 · {{round}} 回目',
executing: 'ツール実行中…',
toolRunning: '実行中',
recommendationFailed:
'推奨モデルを取得できません。モデルを選択してください。',
expand: 'ワークスペースアシスタントを展開',
collapse: 'ワークスペースアシスタントを折りたたむ',
modelHint: '次のメッセージからモデルを切り替え',
details: '元のデータを見る',
found: '{{count}} 件見つかりました',
@@ -35,7 +55,7 @@ const jaJP = {
newChat: '新しい会話',
close: '閉じる',
welcome:
'作りたいアプリを教えてください。現在のリソースを確認し、承認後にナレッジベースや Pipeline を設定します。',
'作りたいアプリを教えてください。現在のワークスペースを確認し、ボット、Agent、ナレッジベース、Pipeline などの設定を提案し、承認後に適用します。',
discover: '利用できるモデル、ナレッジベース、Pipeline は?',
build: 'ナレッジベースを使う質問応答アプリを作りたい。',
toolResult: '操作結果',
+21 -1
View File
@@ -1,6 +1,26 @@
import pipelineMigration from './pipeline-migration/ru-RU';
const ruRU = {
assistant: {
sessions: 'Диалоги',
currentChat: 'Текущий диалог',
stop: 'Остановить задачу',
stopped:
'Остановлено. Инструмент мог уже внести изменения. Проверьте ресурс перед повтором.',
setupRequired:
'Войдите в LangBot Account для доступа к LangBot Models или настройте своего провайдера моделей, чтобы начать диалог.',
loginAccount: 'Войти в LangBot Account',
configureModels: 'Настроить модели',
providerCheckFailed:
'Не удалось проверить провайдеров. Попробуйте ещё раз.',
retrySetup: 'Повторить',
preparing: 'Подготовка помощника…',
thinking: 'Ответ модели · Шаг {{round}}',
executing: 'Выполнение инструмента…',
toolRunning: 'Выполняется',
recommendationFailed:
'Не удалось загрузить рекомендуемую модель. Выберите модель.',
expand: 'Развернуть помощника рабочего пространства',
collapse: 'Свернуть помощника рабочего пространства',
modelHint: 'Сменить модель ассистента для следующего сообщения',
details: 'Показать исходные данные',
found: 'Найдено элементов: {{count}}',
@@ -35,7 +55,7 @@ const ruRU = {
newChat: 'Новый диалог',
close: 'Закрыть',
welcome:
'Расскажите, что вы хотите создать. Я изучу это рабочее пространство и предложу изменения для базы знаний и Pipeline, которые вы подтвердите.',
'Расскажите, что вы хотите создать. Я могу изучить рабочее пространство и предложить настройки ботов, Agents, баз знаний, Pipelines и других ресурсов, а затем применить их после вашего подтверждения.',
discover: 'Какие модели, базы знаний и Pipelines доступны?',
build: 'Помогите создать приложение для ответов на вопросы по базе знаний.',
toolResult: 'Результат операции',
+19 -1
View File
@@ -1,6 +1,24 @@
import pipelineMigration from './pipeline-migration/th-TH';
const thTH = {
assistant: {
sessions: 'การสนทนา',
currentChat: 'การสนทนาปัจจุบัน',
stop: 'หยุดงาน',
stopped:
'หยุดแล้ว เครื่องมืออาจดำเนินการไปแล้ว โปรดตรวจสอบทรัพยากรก่อนลองอีกครั้ง',
setupRequired:
'เข้าสู่ระบบ LangBot Account เพื่อใช้ LangBot Models หรือตั้งค่าผู้ให้บริการโมเดลของคุณเพื่อเริ่มสนทนา',
loginAccount: 'เข้าสู่ระบบ LangBot Account',
configureModels: 'ตั้งค่าโมเดล',
providerCheckFailed: 'ไม่สามารถตรวจสอบผู้ให้บริการโมเดลได้ โปรดลองอีกครั้ง',
retrySetup: 'ลองอีกครั้ง',
preparing: 'กำลังเตรียมผู้ช่วย…',
thinking: 'โมเดลกำลังตอบ · รอบ {{round}}',
executing: 'กำลังใช้เครื่องมือ…',
toolRunning: 'กำลังทำงาน',
recommendationFailed: 'โหลดโมเดลแนะนำไม่สำเร็จ โปรดเลือกโมเดล',
expand: 'ขยายผู้ช่วยพื้นที่ทำงาน',
collapse: 'ย่อผู้ช่วยพื้นที่ทำงาน',
modelHint: 'สลับโมเดลผู้ช่วยสำหรับข้อความถัดไป',
details: 'ดูข้อมูลดิบ',
found: 'พบ {{count}} รายการ',
@@ -35,7 +53,7 @@ const thTH = {
newChat: 'บทสนทนาใหม่',
close: 'ปิด',
welcome:
'บอกฉันว่าคุณอยากสร้างอะไร ฉันตรวจสอบพื้นที่ทำงานนี้และเสนอการตั้งค่าฐานความรู้และ Pipeline ให้คุณยืนยันก่อนดำเนินการ',
'บอกฉันว่าคุณต้องการสร้างแอปอะไร ฉันสามารถตรวจสอบพื้นที่ทำงานนี้และเสนอการตั้งค่าสำหรับบอต, Agent, ฐานความรู้, Pipeline และอื่น ๆ แล้วดำเนินการหลังจากคุณยืนยันได้',
discover: 'มีโมเดล ฐานความรู้ และ Pipeline อะไรที่ใช้ได้บ้าง?',
build: 'ช่วยสร้างแอปตอบคำถามจากฐานความรู้ให้หน่อย',
toolResult: 'ผลลัพธ์การดำเนินการ',
+20 -1
View File
@@ -1,6 +1,25 @@
import pipelineMigration from './pipeline-migration/vi-VN';
const viVN = {
assistant: {
sessions: 'Cuộc trò chuyện',
currentChat: 'Cuộc trò chuyện hiện tại',
stop: 'Dừng tác vụ',
stopped:
'Đã dừng. Công cụ có thể đã thực hiện thay đổi. Kiểm tra tài nguyên trước khi thử lại.',
setupRequired:
'Đăng nhập LangBot Account để dùng LangBot Models hoặc cấu hình nhà cung cấp mô hình riêng để bắt đầu trò chuyện.',
loginAccount: 'Đăng nhập LangBot Account',
configureModels: 'Cấu hình mô hình',
providerCheckFailed: 'Không thể kiểm tra nhà cung cấp. Vui lòng thử lại.',
retrySetup: 'Thử lại',
preparing: 'Đang chuẩn bị trợ lý…',
thinking: 'Mô hình đang trả lời · Vòng {{round}}',
executing: 'Đang chạy công cụ…',
toolRunning: 'Đang chạy',
recommendationFailed:
'Không tải được mô hình đề xuất. Vui lòng chọn mô hình.',
expand: 'Mở rộng trợ lý không gian làm việc',
collapse: 'Thu gọn trợ lý không gian làm việc',
modelHint: 'Đổi mô hình trợ lý cho tin nhắn tiếp theo',
details: 'Xem dữ liệu thô',
found: 'Tìm thấy {{count}} mục',
@@ -36,7 +55,7 @@ const viVN = {
newChat: 'Cuộc trò chuyện mới',
close: 'Đóng',
welcome:
'Hãy cho tôi biết bạn muốn xây dựng gì. Tôi có thể xem không gian làm việc này và đề xuất cấu hình cơ sở tri thức cùng Pipeline để bạn xác nhận.',
'Hãy cho tôi biết bạn muốn xây dựng gì. Tôi có thể xem không gian làm việc này, đề xuất cấu hình cho bot, Agent, cơ sở tri thức, Pipeline và các tài nguyên khác, rồi áp dụng sau khi bạn xác nhận.',
discover: 'Có những mô hình, cơ sở tri thức và Pipeline nào khả dụng?',
build: 'Giúp tôi tạo ứng dụng hỏi đáp dựa trên cơ sở tri thức.',
toolResult: 'Kết quả thao tác',
+18 -1
View File
@@ -1,6 +1,23 @@
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
assistant: {
sessions: '切换会话',
currentChat: '当前会话',
stop: '停止任务',
stopped: '已停止。执行中的工具可能已经生效,请检查相关资源后再尝试。',
setupRequired:
'登录 LangBot Account 使用 LangBot Models,或先配置自己的模型供应商,即可开始对话。',
loginAccount: '登录 LangBot Account',
configureModels: '配置模型',
providerCheckFailed: '无法检查模型供应商,请重试。',
retrySetup: '重试',
preparing: '正在准备助手…',
thinking: '模型响应中 · 第 {{round}} 轮',
executing: '正在执行工具…',
toolRunning: '执行中',
recommendationFailed: '推荐模型加载失败,请手动选择模型。',
expand: '展开工作区助手',
collapse: '收起工作区助手',
modelHint: '切换助手模型,从下一条消息生效',
details: '查看原始数据',
found: '找到 {{count}} 项',
@@ -33,7 +50,7 @@ const zhHans = {
newChat: '新对话',
close: '关闭',
welcome:
'告诉我你想构建什么应用。我可以查看当前工作区,并提出知识库和 Pipeline 配置方案,由你确认后执行。',
'告诉我你想构建什么应用。我可以查看当前工作区,为机器人、Agent、知识库和 Pipeline 等提供配置方案,由你确认后执行。',
discover: '当前有哪些可用的模型、知识库和 Pipeline?',
build: '帮我创建一个知识库问答应用。',
toolResult: '操作结果',
+18 -1
View File
@@ -1,6 +1,23 @@
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
assistant: {
sessions: '切換對話',
currentChat: '目前對話',
stop: '停止任務',
stopped: '已停止。執行中的工具可能已經生效,請檢查相關資源後再嘗試。',
setupRequired:
'登入 LangBot Account 使用 LangBot Models,或先設定自己的模型供應商,即可開始對話。',
loginAccount: '登入 LangBot Account',
configureModels: '設定模型',
providerCheckFailed: '無法檢查模型供應商,請重試。',
retrySetup: '重試',
preparing: '正在準備助手…',
thinking: '模型回應中 · 第 {{round}} 輪',
executing: '正在執行工具…',
toolRunning: '執行中',
recommendationFailed: '推薦模型載入失敗,請手動選擇模型。',
expand: '展開工作區助手',
collapse: '收起工作區助手',
modelHint: '切換助手模型,從下一條訊息生效',
details: '檢視原始資料',
found: '找到 {{count}} 項',
@@ -33,7 +50,7 @@ const zhHant = {
newChat: '新對話',
close: '關閉',
welcome:
'告訴我你想建構什麼應用。我可以查看目前工作區,並提出知識庫和 Pipeline 設定方案,由你確認後執行。',
'告訴我你想建構什麼應用。我可以查看目前工作區,為機器人、Agent、知識庫和 Pipeline 等提供設定方案,由你確認後執行。',
discover: '目前有哪些可用的模型、知識庫和 Pipeline?',
build: '幫我建立一個知識庫問答應用。',
toolResult: '操作結果',
+540
View File
@@ -0,0 +1,540 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const chat = {
uuid: 'assistant-test',
revision: 0,
status: 'ready',
messages: [],
pending: [],
error: null,
model_uuid: null,
model_name: null,
};
test('selects the recommendation on entry and submits the visible model', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
}),
);
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({ json: { code: 0, data: chat } }),
);
await page.route(
'**/api/v1/assistant/conversations/*/turn/stream',
async (route) => {
expect(route.request().postDataJSON().model_uuid).toBe('llm-valid');
const result = {
...chat,
revision: 1,
model_uuid: 'llm-valid',
model_name: 'Recommended model',
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hello back' },
],
};
await route.fulfill({
contentType: 'application/x-ndjson',
body: JSON.stringify({ kind: 'completed', data: result }) + '\n',
});
},
);
await page.goto('/home/bots');
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
await expect(
assistant.getByRole('combobox', { name: 'Select Model' }),
).toContainText('Recommended model');
await assistant.getByRole('textbox').fill('Hello');
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
await expect(
assistant.getByText('Hello back', { exact: true }),
).toBeVisible();
});
test('late recommendation cannot overwrite manual selection, including request payload', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/api/v1/assistant/recommended-model', async (route) => {
await gate;
await route.fulfill({
json: {
code: 0,
data: { uuid: 'recommended-other', name: 'Other recommendation' },
},
});
});
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({ json: { code: 0, data: chat } }),
);
await page.route(
'**/api/v1/assistant/conversations/*/turn/stream',
async (route) => {
expect(route.request().postDataJSON().model_uuid).toBe('llm-valid');
await route.fulfill({
contentType: 'application/x-ndjson',
body:
JSON.stringify({
kind: 'completed',
data: {
...chat,
revision: 1,
model_uuid: 'llm-valid',
model_name: 'Valid Mock Model',
messages: [{ role: 'assistant', content: 'Manual model used' }],
},
}) + '\n',
});
},
);
await page.goto('/home/bots');
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
await assistant.getByRole('combobox', { name: 'Select Model' }).click();
await page.getByRole('option', { name: /Valid Mock Model/ }).click();
release();
await expect(assistant.getByText('Preparing assistant…')).toHaveCount(0);
await expect(
assistant.getByRole('combobox', { name: 'Select Model' }),
).toContainText('Valid Mock Model');
await assistant.getByRole('textbox').fill('Hello');
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
await expect(
assistant.getByText('Manual model used', { exact: true }),
).toBeVisible();
});
test('renders streaming text and tool progress before completion', async ({
page,
}) => {
const { createServer } = await import('node:http');
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const started = {
...chat,
revision: 1,
status: 'running',
model_uuid: 'llm-valid',
model_name: 'Recommended model',
messages: [{ role: 'user', content: 'Inspect resources' }],
};
const server = createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader(
'Access-Control-Allow-Headers',
req.headers['access-control-request-headers'] || '*',
);
if (req.method === 'OPTIONS') {
res.end();
return;
}
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
const emit = (kind: string, data: unknown) =>
res.write(JSON.stringify({ kind, data }) + '\n');
emit('snapshot', started);
emit('phase', { phase: 'thinking', round: 1 });
emit('text', { text: 'Inspecting your resources' });
emit('phase', {
phase: 'tool',
tool: { name: 'list_resources', arguments: { kind: 'models' } },
});
await gate;
emit('completed', {
...started,
status: 'ready',
messages: [
...started.messages,
{
role: 'tool',
content: '{}',
tool: {
name: 'list_resources',
arguments: { kind: 'models' },
result: { total: 1, items: [] },
},
},
{ role: 'assistant', content: 'Found your model' },
],
});
res.end();
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address() as { port: number };
try {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: {
code: 0,
data: { uuid: 'llm-valid', name: 'Recommended model' },
},
}),
);
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({ json: { code: 0, data: chat } }),
);
await page.route(
'**/api/v1/assistant/conversations/*/turn/stream',
(route) =>
route.continue({ url: `http://127.0.0.1:${address.port}/stream` }),
);
await page.goto('/home/bots');
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
await expect(
assistant.getByRole('combobox', { name: 'Select Model' }),
).toContainText('Recommended model');
await assistant.getByRole('textbox').fill('Inspect resources');
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
await expect(
assistant.getByText('Inspecting your resources', { exact: true }),
).toBeVisible();
await expect(
assistant.getByRole('button', { name: /Find chat models Running/ }),
).toBeVisible();
await expect(
assistant.getByRole('button', { name: 'Stop task', exact: true }),
).toBeEnabled();
release();
await expect(
assistant.getByText('Found your model', { exact: true }),
).toBeVisible();
await expect(
assistant.getByText('Inspecting your resources', { exact: true }),
).toHaveCount(0);
const bubble = assistant
.locator('[class*="assistantMessage"]')
.filter({ hasText: 'Found your model' });
await expect(
bubble.getByRole('button', { name: /Find chat models Completed/ }),
).toBeVisible();
} finally {
release();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test('without providers shows setup only and rechecks after model settings close', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/provider/requesters', (route) =>
route.fulfill({ json: { code: 0, data: { requesters: [] } } }),
);
let configured = false;
let recommendations = 0;
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: {
code: 0,
data: {
providers: configured
? [
{
uuid: 'provider',
name: 'Custom provider',
requester: 'openai-chat-completions',
config: {},
},
]
: [],
},
},
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) => {
recommendations++;
return route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
});
});
await page.goto('/home/bots');
const setup = page.getByRole('complementary', {
name: 'Workspace assistant',
});
await expect(
setup.getByRole('button', { name: 'Sign in to LangBot Account' }),
).toBeVisible();
await expect(
page.getByRole('dialog', { name: 'Workspace assistant' }),
).toHaveCount(0);
expect(recommendations).toBe(0);
await setup.getByRole('button', { name: 'Configure models' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
configured = true;
await page.keyboard.press('Escape');
await expect(
page
.getByRole('dialog', { name: 'Workspace assistant' })
.getByRole('combobox', { name: 'Select Model' }),
).toContainText('Recommended model');
});
test('setup login starts the existing LangBot Account authorization flow', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [] } },
}),
);
await page.goto('/home/bots');
const request = page.waitForRequest(
(request) =>
request.url().includes('/space/') && request.url().includes('authorize'),
);
await page
.getByRole('complementary', { name: 'Workspace assistant' })
.getByRole('button', { name: 'Sign in to LangBot Account' })
.click();
expect((await request).url()).toContain('authorize');
});
test('switches conversations, restores background progress after reload and stops only the selected task', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
}),
);
const running = {
...chat,
uuid: 'running-chat',
revision: 3,
status: 'running',
model_uuid: 'llm-valid',
model_name: 'Recommended model',
messages: [{ role: 'user', content: 'Background request' }],
progress: {
text: 'Working in the background',
phase: 'thinking',
round: 2,
},
};
const ready = {
...chat,
uuid: 'ready-chat',
model_uuid: 'llm-valid',
model_name: 'Recommended model',
messages: [{ role: 'assistant', content: 'Previous answer' }],
};
let stopped = false;
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({
json: {
code: 0,
data: {
conversations: [
{
uuid: running.uuid,
title: 'Background request',
status: stopped ? 'failed' : 'running',
},
{
uuid: ready.uuid,
title: 'Previous conversation',
status: 'ready',
},
],
},
},
}),
);
await page.route('**/api/v1/assistant/conversations/*', (route) => {
const data = route.request().url().endsWith('ready-chat')
? ready
: stopped
? {
...running,
status: 'failed',
error: 'stopped',
progress: undefined,
}
: running;
return route.fulfill({ json: { code: 0, data } });
});
await page.route(
'**/api/v1/assistant/conversations/running-chat/stop',
(route) => {
expect(route.request().postDataJSON()).toEqual({ revision: 3 });
stopped = true;
return route.fulfill({
json: {
code: 0,
data: {
...running,
status: 'failed',
error: 'stopped',
progress: undefined,
},
},
});
},
);
await page.goto('/home/bots');
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
await assistant
.getByRole('combobox', { name: 'Conversations', exact: true })
.click();
await page
.getByRole('option', { name: '◌ Background request', exact: true })
.click();
await expect(
assistant.getByText('Working in the background', { exact: true }),
).toBeVisible();
await assistant
.getByRole('combobox', { name: 'Conversations', exact: true })
.click();
await page
.getByRole('option', { name: 'Previous conversation', exact: true })
.click();
await expect(
assistant.getByText('Previous answer', { exact: true }),
).toBeVisible();
expect(stopped).toBe(false);
await assistant
.getByRole('combobox', { name: 'Conversations', exact: true })
.click();
await page
.getByRole('option', { name: '◌ Background request', exact: true })
.click();
await page.reload();
await expect(
assistant.getByText('Working in the background', { exact: true }),
).toBeVisible();
await assistant
.getByRole('button', { name: 'Stop task', exact: true })
.click();
await expect(assistant.getByRole('alert')).toContainText('Stopped.');
await expect(
assistant.getByRole('button', { name: 'Stop task', exact: true }),
).toHaveCount(0);
await assistant
.getByRole('button', { name: 'New conversation', exact: true })
.click();
await expect(assistant.getByRole('textbox')).toBeEmpty();
});
test('keeps the reading position through background updates and resumes following at the bottom', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Model' } },
}),
);
let updates = 0;
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({
json: {
code: 0,
data: {
conversations: [
{
uuid: 'scroll-chat',
title: 'Long conversation',
status: 'running',
},
],
},
},
}),
);
await page.route('**/api/v1/assistant/conversations/scroll-chat', (route) => {
updates++;
return route.fulfill({
json: {
code: 0,
data: {
...chat,
uuid: 'scroll-chat',
status: 'running',
model_uuid: 'llm-valid',
model_name: 'Model',
messages: [
{
role: 'assistant',
content: Array.from(
{ length: 40 },
(_, i) => `Paragraph ${i}`,
).join('\n\n'),
},
],
progress: {
text: Array.from({ length: updates }, (_, i) => `Update ${i}`).join(
'\n\n',
),
phase: 'thinking',
},
},
},
});
});
await page.goto('/home/bots');
const panel = page.getByRole('dialog', { name: 'Workspace assistant' });
await panel
.getByRole('combobox', { name: 'Conversations', exact: true })
.click();
await page
.getByRole('option', { name: '◌ Long conversation', exact: true })
.click();
const messages = panel.locator('[aria-live="polite"]');
const gap = () =>
messages.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop);
await expect.poll(gap).toBeLessThan(2);
await messages.hover();
await page.mouse.wheel(0, -600);
await expect.poll(gap).toBeGreaterThan(200);
const position = await messages.evaluate((el) => el.scrollTop);
const previousUpdates = updates;
await expect.poll(() => updates).toBeGreaterThan(previousUpdates + 1);
await expect
.poll(() => messages.evaluate((el) => el.scrollTop))
.toBeCloseTo(position, 0);
await messages.hover();
await page.mouse.wheel(0, 10000);
await expect.poll(gap).toBeLessThan(2);
const beforeFollow = updates;
await expect.poll(() => updates).toBeGreaterThan(beforeFollow + 1);
await expect.poll(gap).toBeLessThan(2);
});
-175
View File
@@ -1,175 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
// The floating assistant button docks against a viewport edge, collapses into a
// short blue rail, and re-expands on hover. The transition rules are pure so
// they can be pinned here: the race this guards against is a button that drops
// under the cursor and never becomes visible again.
const sourcePath = new URL(
'../../src/app/home/components/assistant-dock.ts',
import.meta.url,
);
assert.ok(fs.existsSync(sourcePath), 'Missing assistant dock module');
const compiled = ts.transpileModule(fs.readFileSync(sourcePath, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loaded = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
() => {
throw new Error('assistant-dock must stay dependency free');
},
loaded,
loaded.exports,
);
const {
ASSISTANT_BUTTON_SIZE,
ASSISTANT_SNAP_THRESHOLD,
ASSISTANT_RAIL_WIDTH,
clampAssistantPosition,
resolveAssistantEdge,
shouldCollapseRail,
shouldExpandRail,
restingRailExpanded,
} = loaded.exports;
const viewport = { width: 1280, height: 800 };
const maxX = viewport.width - ASSISTANT_BUTTON_SIZE;
test('clamping keeps the button fully inside the viewport', () => {
assert.deepEqual(clampAssistantPosition(-50, -50, viewport), { x: 0, y: 0 });
assert.deepEqual(clampAssistantPosition(9999, 9999, viewport), {
x: maxX,
y: viewport.height - ASSISTANT_BUTTON_SIZE,
});
assert.deepEqual(clampAssistantPosition(400, 300, viewport), {
x: 400,
y: 300,
});
});
test('a narrow viewport never produces a negative travel range', () => {
assert.deepEqual(clampAssistantPosition(10, 10, { width: 20, height: 20 }), {
x: 0,
y: 0,
});
});
test('positions within the snap threshold dock to the nearest edge', () => {
assert.equal(resolveAssistantEdge(0, viewport), 'left');
assert.equal(
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD, viewport),
'left',
);
assert.equal(resolveAssistantEdge(maxX, viewport), 'right');
assert.equal(
resolveAssistantEdge(maxX - ASSISTANT_SNAP_THRESHOLD, viewport),
'right',
);
});
test('a centred position stays free floating', () => {
assert.equal(resolveAssistantEdge(Math.round(maxX / 2), viewport), null);
assert.equal(
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD + 1, viewport),
null,
);
});
test('the left edge wins ties so a centred drop is deterministic', () => {
// A viewport sized so both gaps are inside the snap threshold at x = 0.
const tight = { width: ASSISTANT_BUTTON_SIZE, height: 400 };
assert.equal(resolveAssistantEdge(0, tight), 'left');
});
test('hovering a docked rail expands it, hovering a free button does not', () => {
assert.equal(
shouldExpandRail({
dockedEdge: 'right',
railExpanded: false,
dragging: false,
}),
true,
);
assert.equal(
shouldExpandRail({
dockedEdge: null,
railExpanded: false,
dragging: false,
}),
false,
);
// Already expanded: nothing to do, so no redundant state churn on every move.
assert.equal(
shouldExpandRail({
dockedEdge: 'left',
railExpanded: true,
dragging: false,
}),
false,
);
});
test('a drag in progress never expands or collapses the rail', () => {
const dragging = { dockedEdge: 'left', railExpanded: false, dragging: true };
assert.equal(shouldExpandRail(dragging), false);
assert.equal(shouldCollapseRail(dragging), false);
});
test('only an *expanded* docked button collapses when the pointer leaves', () => {
// Regression: keying this on `!railExpanded` made a revealed button
// impossible to collapse again, so it stayed open forever after one hover.
assert.equal(
shouldCollapseRail({
dockedEdge: 'left',
railExpanded: true,
dragging: false,
}),
true,
);
assert.equal(
shouldCollapseRail({
dockedEdge: 'left',
railExpanded: false,
dragging: false,
}),
false,
);
assert.equal(
shouldCollapseRail({
dockedEdge: null,
railExpanded: true,
dragging: false,
}),
false,
);
});
test('expand then leave round-trips back to the collapsed rail', () => {
const docked = { dockedEdge: 'right', railExpanded: false, dragging: false };
// Hover: reveal the button.
assert.equal(shouldExpandRail(docked), true);
// Pointer leaves: the revealed button must collapse again.
assert.equal(shouldCollapseRail({ ...docked, railExpanded: true }), true);
// A second hover re-reveals it, so the cycle is repeatable.
assert.equal(shouldExpandRail(docked), true);
});
test('dropping always starts collapsed so the rail is never stuck open', () => {
// The pointer is still over the button on drop; the next pointermove expands.
assert.equal(restingRailExpanded(), false);
assert.equal(
shouldExpandRail({
dockedEdge: 'right',
railExpanded: restingRailExpanded(),
dragging: false,
}),
true,
);
});
test('the rail strip stays thinner than the button it replaces', () => {
assert.ok(ASSISTANT_RAIL_WIDTH > 0);
assert.ok(ASSISTANT_RAIL_WIDTH < ASSISTANT_BUTTON_SIZE);
});