feat(monitoring): add shared SSE in-flight monitor with draggable dock

This commit is contained in:
Hyu
2026-10-10 21:30:03 +08:00
parent d2ec53ec91
commit 38aa06e149
24 changed files with 1289 additions and 14 deletions
+5
View File
@@ -177,6 +177,11 @@ logs and action results. These times are not internal plugin profiling data.
## Unified execution monitoring
Use `get_inflight_executions` for a bounded current-workspace snapshot of active
and recently started executions, including `progress_event`. Progress is a
reported stage, not an estimated completion percentage. The UI uses the shared
`GET /api/v1/monitoring/in-flight/stream` SSE feed instead of polling per viewer.
Use `get_monitoring_executions` for the execution list and
its legacy `pipeline_ids` parameter to filter any processor kind (Agent,
Pipeline or event processor); the summary uses the same processor scope. Use
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...persistence.datetime_utils import as_naive_utc
from ...utils.inflight import inflight_hub
from ...entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime
@@ -149,6 +150,7 @@ class RunLedgerStore:
)
session.add(run)
await session.commit()
inflight_hub.notify(run.workspace_id)
return self._run_to_dict(run)
async def claim_next_run(
@@ -206,6 +208,7 @@ class RunLedgerStore:
run.last_claimed_at = as_naive_utc(now)
run.updated_at = as_naive_utc(now)
await session.commit()
inflight_hub.notify(run.workspace_id)
return self._run_to_dict(run, include_claim_token=True)
async def renew_claim(
@@ -226,6 +229,7 @@ class RunLedgerStore:
run.claim_lease_expires_at = as_naive_utc(now + datetime.timedelta(seconds=max(int(lease_seconds), 1)))
run.updated_at = as_naive_utc(now)
await session.commit()
inflight_hub.notify(run.workspace_id)
return self._run_to_dict(run)
async def release_claim(
@@ -254,6 +258,7 @@ class RunLedgerStore:
if status in TERMINAL_STATUSES:
run.finished_at = run.finished_at or as_naive_utc(now)
await session.commit()
inflight_hub.notify(run.workspace_id)
return self._run_to_dict(run)
async def release_expired_claims(
@@ -332,6 +337,7 @@ class RunLedgerStore:
)
session.add(row)
await session.commit()
inflight_hub.notify_run(run_id, event_type)
return self._event_to_dict(row)
async def append_audit_event(
@@ -366,6 +372,7 @@ class RunLedgerStore:
)
session.add(row)
await session.commit()
inflight_hub.notify_run(run_id, event_type)
return self._event_to_dict(row)
async def finalize_run(
@@ -410,6 +417,7 @@ class RunLedgerStore:
else:
run.metadata_json = _json_dumps(metadata)
await session.commit()
inflight_hub.notify(run.workspace_id)
return self._run_to_dict(run)
async def validate_active_claim(
@@ -443,6 +451,7 @@ class RunLedgerStore:
run.updated_at = as_naive_utc(now)
run.status_reason = status_reason or run.status_reason
await session.commit()
inflight_hub.notify(run.workspace_id)
return self._run_to_dict(run)
async def get_run(self, run_id: str) -> dict[str, typing.Any] | None:
@@ -59,6 +59,31 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=metrics)
@self.route('/in-flight/stream', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def stream_inflight(request_context: RequestContext):
from .....utils.inflight import inflight_hub
from ...service.tenant import require_workspace_uuid
import json
workspace = require_workspace_uuid(request_context)
previous = []
async def snapshot():
nonlocal previous
result = await self.ap.monitoring_service.get_inflight_snapshot(request_context, previous)
previous = [row for row in result['items'] if row['status_group'] in {'running', 'queued'}]
return result
async def frames():
async for frame in inflight_hub.watch(workspace, snapshot):
yield 'data: ' + json.dumps(frame, ensure_ascii=False) + '\n\n'
response = quart.Response(frames(), content_type='text/event-stream')
response.headers['Cache-Control'] = 'no-cache, no-store'
response.headers['X-Accel-Buffering'] = 'no'
response.timeout = None
return response
@self.route('/executions', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_executions(request_context: RequestContext) -> str:
"""Unified execution list across agent runs and pipeline queries."""
+58 -2
View File
@@ -127,8 +127,13 @@ def _workspace_transaction(method):
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if callable(tenant_uow):
async with tenant_uow(workspace_uuid):
return await method(self, context, *args, **kwargs)
return await method(self, context, *args, **kwargs)
result = await method(self, context, *args, **kwargs)
else:
result = await method(self, context, *args, **kwargs)
if method.__name__ in {'record_message', 'update_message_status'}:
from ....utils.inflight import inflight_hub
inflight_hub.notify(workspace_uuid)
return result
return wrapped
@@ -1533,6 +1538,7 @@ class MonitoringService(ExecutionDetailsMixin):
end_time: datetime.datetime | None = None,
limit: int = 50,
offset: int = 0,
include_summary: bool = True,
) -> dict:
"""List executions from both object types (agent runs and pipeline queries)."""
workspace_uuid = require_workspace_uuid(context)
@@ -1633,6 +1639,8 @@ class MonitoringService(ExecutionDetailsMixin):
by_key[(kind, item['id'])] = item
page = [by_key[(ref.source, ref.id)] for ref in refs if (ref.source, ref.id) in by_key]
await self.enrich_execution_rows(workspace_uuid, page)
if not include_summary:
return {'items': page, 'total': total, 'has_more': total > offset + len(page)}
return {
'items': page,
'total': total,
@@ -1661,6 +1669,54 @@ class MonitoringService(ExecutionDetailsMixin):
},
}
async def get_inflight_snapshot(self, context: TenantContext, previous: list[dict] | None = None) -> dict:
"""Bounded Workspace snapshot without dashboard aggregates or token payloads."""
from ....utils.inflight import inflight_hub
workspace = require_workspace_uuid(context)
active = await self.get_executions(context, statuses=['running', 'queued'], limit=100, include_summary=False)
recent = await self.get_executions(
context, start_time=datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=30),
limit=100, include_summary=False,
)
items = {(r['source'], r['id']): r for r in recent['items'] + active['items'] if r['source'] != 'event'}
# Keep the actual terminal state of older runs that just left the active set.
for kind, model, column, scope in [
('agent', persistence_agent_run.AgentRun, persistence_agent_run.AgentRun.run_id, persistence_agent_run.AgentRun.workspace_id),
('pipeline', persistence_monitoring.MonitoringMessage, persistence_monitoring.MonitoringMessage.id, persistence_monitoring.MonitoringMessage.workspace_uuid),
]:
missing = [r['id'] for r in (previous or [])[:100] if r['source'] == kind and (kind, r['id']) not in items]
if missing:
records = await self._execution_rows(workspace, sqlalchemy.select(model).where(scope == workspace, column.in_(missing)))
names = await self._agent_names(workspace, records) if kind == 'agent' else {}
for record in records:
row = self._serialize_agent_execution(record, names) if kind == 'agent' else self._serialize_pipeline_execution(record)
items[(kind, row['id'])] = row
rows = list(items.values())
Run = persistence_agent_run.AgentRun
Message = persistence_monitoring.MonitoringMessage
agent_ids = [r['id'] for r in rows if r['source'] == 'agent']
pipeline_ids = [r['id'] for r in rows if r['source'] == 'pipeline']
linked = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Run.run_id, Message.id.label('message_id')).outerjoin(
Message, sqlalchemy.and_(Message.workspace_uuid == workspace, Message.run_id == Run.run_id)
).where(Run.workspace_id == workspace, sqlalchemy.or_(Run.run_id.in_(agent_ids), Message.id.in_(pipeline_ids)))
)
identities = {r.run_id: ('pipeline', r.message_id) if r.message_id in pipeline_ids else ('agent', r.run_id) for r in linked.all()}
inflight_hub.track(workspace, identities)
Event = persistence_agent_run.AgentRunEvent
latest = sqlalchemy.select(Event.run_id, sqlalchemy.func.max(Event.sequence).label('seq')).where(
Event.run_id.in_(identities)
).group_by(Event.run_id).subquery()
phases = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Event.run_id, Event.type).join(latest, sqlalchemy.and_(Event.run_id == latest.c.run_id, Event.sequence == latest.c.seq))
)
for run_id, event_type in phases.all():
row = items.get(identities[run_id])
if row is not None:
row['progress_event'] = event_type
return {'items': rows, 'active_total': active['total'], 'truncated': active['has_more'] or recent['has_more']}
async def get_token_statistics(
self,
context: TenantContext,
+4
View File
@@ -207,6 +207,10 @@ class LangBotMCPServer:
return _dump(await ap.monitoring_service.get_message_details(context, identifier))
return _dump(await ap.monitoring_service.get_session_analysis(context, identifier))
@mcp.tool(description='Read a bounded snapshot of active and recently started Workspace executions, including the latest progress event. No percentage is estimated.')
async def get_inflight_executions() -> str:
return _dump(await ap.monitoring_service.get_inflight_snapshot(_authorized(Permission.RESOURCE_VIEW)))
@mcp.tool(
description='List Workspace executions, including events that did not start a processor. The legacy pipeline_ids filter accepts Agent, Pipeline and event processor IDs. Returns bounded rows and summary metrics.'
)
+94
View File
@@ -0,0 +1,94 @@
"""Bounded, process-local fan-out. One snapshot producer per watched Workspace.
Writes wake the producer; quiet workspaces reconcile every 15 seconds to cover
external workers. A shared broker can replace notify without changing clients.
No payloads or subscriptions survive the final viewer disconnecting.
"""
import asyncio
import contextlib
import time
class InflightHub:
def __init__(self):
self.channels = {}
self.run_workspaces = {}
def notify(self, workspace):
channel = self.channels.get(workspace)
if channel:
channel['dirty'].set()
def notify_run(self, run_id, event_type):
workspace = self.run_workspaces.get(run_id)
channel = self.channels.get(workspace)
if channel and channel['phases'].get(run_id) != event_type:
channel['phases'][run_id] = event_type
channel['dirty'].set()
def track(self, workspace, run_ids):
self.run_workspaces = {key: value for key, value in self.run_workspaces.items() if value != workspace}
if workspace not in self.channels:
return
self.run_workspaces.update({run_id: workspace for run_id in run_ids})
if workspace in self.channels:
phases = self.channels[workspace]['phases']
self.channels[workspace]['phases'] = {key: value for key, value in phases.items() if key in run_ids}
async def watch(self, workspace, loader):
if sum(len(c['queues']) for c in self.channels.values()) >= 1024:
raise RuntimeError('Too many monitoring connections')
channel = self.channels.get(workspace)
if channel is None:
channel = dict(queues=set(), dirty=asyncio.Event(), phases={}, snapshot=None)
self.channels[workspace] = channel
channel['task'] = asyncio.create_task(self._produce(channel, loader))
if len(channel['queues']) >= 64:
raise RuntimeError('Too many Workspace monitoring connections')
queue = asyncio.Queue(maxsize=1)
channel['queues'].add(queue)
if channel['snapshot'] is not None:
queue.put_nowait(channel['snapshot'])
try:
# Reconnect periodically so HTTP authentication/authorization is renewed.
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
try:
yield await asyncio.wait_for(queue.get(), timeout=min(10, deadline - time.monotonic()))
except asyncio.TimeoutError:
yield {'kind': 'heartbeat'}
finally:
channel['queues'].discard(queue)
if not channel['queues']:
self.channels.pop(workspace, None)
self.track(workspace, [])
channel['task'].cancel()
with contextlib.suppress(asyncio.CancelledError):
await channel['task']
async def _produce(self, channel, loader):
previous = None
while True:
channel['dirty'].clear()
try:
snapshot = await loader()
frame = {'kind': 'snapshot', 'data': snapshot}
except Exception:
frame = {'kind': 'unavailable'}
# Heartbeats are sent separately; unchanged snapshots generate no traffic.
if frame != previous:
channel['snapshot'] = frame
for queue in tuple(channel['queues']):
if queue.full():
queue.get_nowait()
queue.put_nowait(frame)
previous = frame
await asyncio.sleep(0.75)
try:
await asyncio.wait_for(channel['dirty'].wait(), timeout=15)
except asyncio.TimeoutError:
pass
inflight_hub = InflightHub()
@@ -736,3 +736,60 @@ async def test_pipeline_debug_is_consistent_across_rows_rollups_and_telemetry(en
assert real_calls == 1 and real_rows[0]['id'] == 'llm-2'
traffic = await get_traffic_series(service.ap, WORKSPACE, mode='debug', pipeline_ids=['pipeline-1'])
assert sum(p['llm_calls'] for p in traffic['points']) == 1
@pytest.mark.asyncio
async def test_inflight_snapshot_is_scoped_and_keeps_terminal_transition(engine, service):
import sqlalchemy as sa
from unittest.mock import AsyncMock
service._get_execution_summary = AsyncMock(side_effect=AssertionError('No dashboard aggregates'))
service._get_token_coverage = AsyncMock(side_effect=AssertionError('No dashboard aggregates'))
async with AsyncSession(engine) as session:
for workspace, run_id in [(WORKSPACE, 'flight-visible'), ('other-workspace', 'flight-secret')]:
session.add(persistence_agent_run.AgentRun(
run_id=run_id, workspace_id=workspace, runner_id='runner', status='running', created_at=_dt(1),
))
session.add(persistence_agent_run.AgentRunEvent(run_id='flight-visible', sequence=1, type='tool.started'))
await session.commit()
snapshot = await service.get_inflight_snapshot(WORKSPACE)
rows = {r['id']: r for r in snapshot['items']}
assert 'flight-secret' not in rows
assert rows['flight-visible']['progress_event'] == 'tool.started'
async with AsyncSession(engine) as session:
await session.execute(sa.update(persistence_agent_run.AgentRun).where(
persistence_agent_run.AgentRun.run_id == 'flight-visible'
).values(status='completed'))
await session.commit()
next_snapshot = await service.get_inflight_snapshot(WORKSPACE, [rows['flight-visible']])
assert next(r for r in next_snapshot['items'] if r['id'] == 'flight-visible')['status_group'] == 'completed'
@pytest.mark.asyncio
async def test_inflight_viewers_share_work_and_cleanup():
import asyncio
from unittest.mock import AsyncMock
from langbot.pkg.utils.inflight import InflightHub
hub = InflightHub()
loader = AsyncMock(return_value={'items': []})
first = hub.watch('one', loader)
second = hub.watch('one', loader)
assert (await anext(first))['kind'] == 'snapshot'
assert (await anext(second))['kind'] == 'snapshot'
assert loader.await_count == 1
other_loader = AsyncMock(return_value={'items': ['private']})
other = hub.watch('two', other_loader)
assert (await anext(other))['data']['items'] == ['private']
hub.track('one', ['run'])
hub.notify_run('run', 'tool.started')
loader.return_value = {'items': ['updated']}
frame = await asyncio.wait_for(anext(first), 2)
assert frame['data']['items'] == ['updated']
assert (await anext(second)) == frame
await first.aclose()
assert 'one' in hub.channels
await second.aclose()
assert 'one' not in hub.channels and 'run' not in hub.run_workspaces
await other.aclose()
assert not hub.channels
+58 -12
View File
@@ -1,6 +1,6 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
@@ -22,7 +22,14 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses
import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Settings, FileText, Users, RefreshCw, Trash2, Loader2 } from 'lucide-react';
import {
Settings,
FileText,
Users,
RefreshCw,
Trash2,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { showBotError } from './bot-error';
@@ -56,7 +63,23 @@ export default function BotDetailContent({ id }: { id: string }) {
return () => setDetailEntityName(null);
}, [id, isCreateMode, bots, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config');
const [searchParams, setSearchParams] = useSearchParams();
const requestedTab = searchParams.get('tab');
const activeTab =
!isCreateMode &&
canViewMonitoring &&
['logs', 'sessions'].includes(requestedTab || '')
? requestedTab!
: 'config';
const setActiveTab = (tab: string) =>
setSearchParams(
(previous) => {
const next = new URLSearchParams(previous);
next.set('tab', tab);
return next;
},
{ replace: true },
);
const [adapterLabel, setAdapterLabel] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
@@ -101,7 +124,9 @@ export default function BotDetailContent({ id }: { id: string }) {
setBot((current) =>
current ? { ...current, enable: checked } : current,
);
toast.success(t(checked ? 'bots.enableConfirmed' : 'bots.disableConfirmed'));
toast.success(
t(checked ? 'bots.enableConfirmed' : 'bots.disableConfirmed'),
);
refreshBots();
} catch (error) {
setBotEnabled(prev);
@@ -217,17 +242,34 @@ export default function BotDetailContent({ id }: { id: string }) {
{enableLoaded && (
<div className="flex items-center gap-2">
<Badge
status={isTogglingEnable ? 'pending' : botEnabled ? 'enabled' : 'disabled'}
status={
isTogglingEnable
? 'pending'
: botEnabled
? 'enabled'
: 'disabled'
}
className="gap-1.5 rounded-full"
role="status"
aria-live="polite"
>
{isTogglingEnable
? <Loader2 className="animate-spin" aria-hidden="true" />
: <span className="size-1.5 rounded-full bg-current" aria-hidden="true" />}
{t(isTogglingEnable
? botEnabled ? 'bots.enabling' : 'bots.disabling'
: botEnabled ? 'bots.enableConfirmed' : 'bots.disableConfirmed')}
{isTogglingEnable ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<span
className="size-1.5 rounded-full bg-current"
aria-hidden="true"
/>
)}
{t(
isTogglingEnable
? botEnabled
? 'bots.enabling'
: 'bots.disabling'
: botEnabled
? 'bots.enableConfirmed'
: 'bots.disableConfirmed',
)}
</Badge>
<Switch
id="bot-enable-switch"
@@ -256,7 +298,11 @@ export default function BotDetailContent({ id }: { id: string }) {
className={activeTab !== 'config' ? 'invisible' : ''}
data-guide="bot-config-save"
>
{t(formDirty && !botEnabled ? 'bots.saveAndEnable' : 'common.save')}
{t(
formDirty && !botEnabled
? 'bots.saveAndEnable'
: 'common.save',
)}
</Button>
<Button
type="button"
@@ -0,0 +1,32 @@
export default function DockIcon({ kind }: { kind: string }) {
const paths: Record<string, string> = {
event: 'M13 2 4 14h7l-1 8 10-12h-7l0-8z',
activity: 'M3 12h4l3-8 4 16 3-8h4',
bot: 'M8 4h8v3H8z M5 7h14v13H5z M9 12h.01 M15 12h.01 M9 16h6',
pipeline: 'M4 3h6v6H4z M14 15h6v6h-6z M7 9v9h7',
agent: 'M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M4 21v-2a8 8 0 0 1 16 0v2',
processor: 'M9 3h6v6h6v6h-6v6H9v-6H3V9h6z',
completed: 'm5 12 4 4L19 6',
failed: 'm6 6 12 12 M6 18 18 6',
cancelled: 'm6 6 12 12 M6 18 18 6',
queued: 'M8 5v14 M16 5v14',
waiting: 'M8 5v14 M16 5v14',
move: 'm8 6 4-4 4 4 M12 2v20 m-4-4 4 4 4-4',
collapse: 'm9 5 7 7-7 7',
};
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={paths[kind] || paths.activity} />
</svg>
);
}
@@ -0,0 +1,84 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useSidebarData } from '../home-sidebar/SidebarDataContext';
import type { ExecutionRow } from '@/app/infra/entities/api/monitoring-executions';
import { executionProcessorLabel } from '../../monitoring/components/executions/ExecutionTable';
import DockIcon from './DockIcon';
import styles from './inflight.module.css';
export default function ExecutionNodes({
row,
processorUrl,
running,
}: {
row: ExecutionRow;
processorUrl: string | null;
running: boolean;
}) {
const { t } = useTranslation();
const { bots, pipelines } = useSidebarData();
const bot = bots.find((item) => item.id === row.bot_id);
const processor = pipelines.find((item) => item.id === row.target_id);
const nodes = [
{
name: bot?.name || row.bot_name || t('inflight.debug'),
iconURL: bot?.iconURL,
emoji: bot?.emoji,
kind: 'bot',
url: row.bot_id
? `/home/bots?id=${encodeURIComponent(row.bot_id)}&tab=logs`
: null,
},
{
name: processor?.name || executionProcessorLabel(row),
iconURL: processor?.iconURL,
emoji: processor?.emoji,
kind:
row.target_kind === 'event_processor' ? 'processor' : row.target_kind,
url: processorUrl,
},
];
const node = (index: number) => {
const item = nodes[index];
const content = (
<>
<Avatar className="size-5 shrink-0 rounded">
{item.iconURL && (
<AvatarImage src={item.iconURL} alt="" className="object-contain" />
)}
<AvatarFallback className="rounded bg-transparent text-base [&_svg]:size-4">
{item.emoji || <DockIcon kind={item.kind} />}
</AvatarFallback>
</Avatar>
<span className="truncate text-[11px]">{item.name}</span>
</>
);
const className =
'flex h-8 min-w-0 max-w-[125px] items-center gap-1.5 rounded-md bg-muted/40 px-1.5';
return item.url ? (
<Link
to={item.url}
title={`${index === 0 ? t('inflight.botLogs') : t('inflight.processorLogs')}: ${item.name}`}
aria-label={`${index === 0 ? t('inflight.botLogs') : t('inflight.processorLogs')}: ${item.name}`}
className={`${className} hover:bg-muted hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
>
{content}
</Link>
) : (
<div className={className}>{content}</div>
);
};
return (
<div className="mt-1.5 flex items-center">
{node(0)}
<span
className={`w-5 shrink-0 ${styles.connection} ${running ? styles.flowing : ''}`}
aria-hidden="true"
>
<i />
</span>
{node(1)}
</div>
);
}
@@ -0,0 +1,363 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import type { InflightSnapshot } from '@/app/infra/entities/api/monitoring-executions';
import { Button } from '@/components/ui/button';
import ExecutionDetailSheet, {
type ExecutionSelection,
} from '@/app/home/monitoring/components/executions/ExecutionDetailSheet';
import { executionStatusLabel } from '@/app/home/monitoring/components/executions/ExecutionTable';
import styles from './inflight.module.css';
import ExecutionNodes from './ExecutionNodes';
import { useEdgeDock } from './useEdgeDock';
import DockIcon from './DockIcon';
type Row = InflightSnapshot['items'][number] & { settledAt?: number };
const keyOf = (row: Row) => `${row.source}:${row.id}`;
const active = (row: Row) => ['running', 'queued'].includes(row.status_group);
function stage(row: Row): string {
if (!active(row)) return row.status_group;
if (row.status_group === 'queued') return 'queued';
const event = row.progress_event || '';
if (event.startsWith('interaction.')) return 'waiting';
if (event.startsWith('tool.') && !event.endsWith('completed')) return 'tool';
if (event.includes('reasoning')) return 'thinking';
if (event === 'message.completed') return 'replying';
if (event.startsWith('message.')) return 'generating';
return 'running';
}
function WorkspaceMonitor() {
const { t } = useTranslation();
const [rows, setRows] = useState<Row[]>([]);
const dock = useEdgeDock(true);
const { collapsed, setCollapsed } = dock;
const [connected, setConnected] = useState(true);
const [total, setTotal] = useState(0);
const [truncated, setTruncated] = useState(false);
const [selected, setSelected] = useState<ExecutionSelection | null>(null);
const [now, setNow] = useState(Date.now());
const known = useRef(new Map<string, Row>());
const [autoCollapse, setAutoCollapse] = useState(() => {
try {
return localStorage.getItem('langbot.inflight.autoCollapse') !== 'false';
} catch {
return true;
}
});
// Keep terminal results visible until their exit animation has finished.
const hasWork = total > 0 || rows.length > 0;
useEffect(() => {
try {
localStorage.setItem(
'langbot.inflight.autoCollapse',
String(autoCollapse),
);
} catch {
/* Optional preference. */
}
}, [autoCollapse]);
useEffect(() => {
if (autoCollapse) setCollapsed(!hasWork);
}, [autoCollapse, hasWork, setCollapsed]);
useEffect(() => {
let disposed = false;
let first = true;
let controller: AbortController | undefined;
let retry: ReturnType<typeof setTimeout> | undefined;
let failures = 0;
const seen = new Set<string>();
const accept = (snapshot: InflightSnapshot) => {
const time = Date.now();
const present = new Set(snapshot.items.map(keyOf));
// A reconnect is a fresh authoritative snapshot. Do not leave vanished
// executions spinning forever or infer successful completion from absence.
if (!snapshot.truncated) {
for (const [key, row] of known.current) {
if (active(row) && !present.has(key)) known.current.delete(key);
}
}
for (const row of snapshot.items) {
const key = keyOf(row);
const previous = known.current.get(key);
if (active(row) || previous || (!first && !seen.has(key))) {
known.current.set(key, {
...row,
settledAt: active(row) ? undefined : (previous?.settledAt ?? time),
});
}
seen.add(key);
}
// Retain terminal results briefly, including runs completed between frames.
for (const [key, row] of known.current) {
if (row.settledAt && time - row.settledAt > 8000)
known.current.delete(key);
}
while (seen.size > 2000) seen.delete(seen.values().next().value!);
while (known.current.size > 200)
known.current.delete(known.current.keys().next().value!);
first = false;
setRows([...known.current.values()]);
setTotal(snapshot.active_total);
setTruncated(snapshot.truncated);
};
const connect = async () => {
if (disposed || document.hidden) return;
controller = new AbortController();
const connection = controller;
try {
await backendClient.streamInflight((frame) => {
if (disposed || connection.signal.aborted) return;
failures = 0;
if (frame.kind !== 'heartbeat')
setConnected(frame.kind !== 'unavailable');
if (frame.kind === 'snapshot') accept(frame.data);
}, connection.signal);
} catch {
if (!disposed && !connection.signal.aborted) {
setConnected(false);
failures += 1;
}
}
if (!disposed && !connection.signal.aborted && !document.hidden) {
retry = setTimeout(
connect,
Math.min(30000, 1000 * 2 ** failures) + Math.random() * 500,
);
}
};
const visibility = () => {
clearTimeout(retry);
controller?.abort();
if (!document.hidden) retry = setTimeout(connect, 100);
};
void connect();
document.addEventListener('visibilitychange', visibility);
const tick = setInterval(() => {
const time = Date.now();
let changed = false;
for (const [key, row] of known.current) {
if (row.settledAt && time - row.settledAt > 8000) {
known.current.delete(key);
changed = true;
}
}
if (changed) setRows([...known.current.values()]);
if (known.current.size) setNow(time);
}, 1000);
return () => {
disposed = true;
controller?.abort();
clearTimeout(retry);
clearInterval(tick);
document.removeEventListener('visibilitychange', visibility);
};
}, []);
const ordered = [...rows].sort(
(a, b) =>
Number(active(b)) - Number(active(a)) ||
(b.created_at_ms ?? 0) - (a.created_at_ms ?? 0),
);
return (
<>
<aside
ref={dock.panel}
style={{ width: collapsed ? 26 : 'min(320px, calc(100vw - 24px))' }}
className={`fixed top-0 right-0 z-40 max-h-[calc(100dvh-24px)] overflow-y-auto rounded-l-xl border border-r-0 bg-popover text-popover-foreground shadow-lg ${styles.panel}`}
aria-label={t('inflight.title')}
>
<div
key={collapsed ? 'collapsed' : 'expanded'}
className={styles.dockContent}
>
{collapsed ? (
<button
type="button"
{...dock.handle}
className="flex h-12 w-full touch-none select-none flex-col items-center justify-center gap-0.5 bg-[#2288ee] text-white hover:bg-[#2277e0] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring active:cursor-grabbing"
title={`${t('inflight.expand')} · ${t('inflight.move')}`}
aria-label={`${t('inflight.title')}: ${total}. ${t('inflight.expand')}. ${t('inflight.move')}`}
aria-expanded={false}
onClick={(event) => {
if (event.detail === 0 || !dock.suppressClick.current)
setCollapsed(false);
}}
>
<DockIcon kind="activity" />
<span
className={`text-[10px] font-mono ${connected ? '' : 'text-amber-100'}`}
>
{total}
</span>
</button>
) : (
<div className="flex items-center justify-between gap-2 px-3 py-2">
<button
type="button"
{...dock.handle}
aria-label={t('inflight.move')}
title={t('inflight.move')}
className="flex min-w-0 flex-1 touch-none select-none items-center gap-2 rounded py-1 text-xs font-medium cursor-grab active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring"
>
<span className={connected ? 'text-primary' : 'text-amber-600'}>
<DockIcon kind="activity" />
</span>
{t('inflight.title')}{' '}
<span className="font-mono text-muted-foreground">{total}</span>
<span className="ml-auto text-muted-foreground">
<DockIcon kind="move" />
</span>
</button>
<Button
variant="ghost"
size="sm"
className={`h-7 shrink-0 gap-1 px-2 text-[10px] ${autoCollapse ? 'bg-primary/10 text-primary hover:bg-primary/15' : 'text-muted-foreground'}`}
aria-pressed={autoCollapse}
title={t('inflight.autoCollapse')}
onClick={() => setAutoCollapse((enabled) => !enabled)}
>
{autoCollapse && <DockIcon kind="completed" />}
{t('inflight.autoCollapse')}
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label={t('inflight.collapse')}
title={t('inflight.collapse')}
aria-expanded={true}
onClick={() => setCollapsed(true)}
>
<DockIcon kind="collapse" />
</Button>
</div>
)}
{!collapsed && (
<>
{!connected && (
<p
className="border-t px-4 py-2 text-xs text-amber-600"
role="status"
>
{t('inflight.reconnecting')}
</p>
)}
<div className="max-h-[min(420px,55dvh)] overflow-y-auto border-t">
{rows.length === 0 && connected && (
<div
className="flex flex-col items-center gap-2 px-4 py-8 text-muted-foreground"
role="status"
>
<DockIcon kind="completed" />
<p className="text-xs">{t('inflight.empty')}</p>
</div>
)}
{ordered.slice(0, 20).map((row) => {
const phase = stage(row);
const stageKey = `inflight.${phase}`;
const label = t(stageKey, {
defaultValue: executionStatusLabel(row, t),
});
const elapsed = Math.max(
0,
Math.floor(
((row.finished_at_ms ?? row.settledAt ?? now) -
(row.started_at_ms ?? row.created_at_ms ?? now)) /
1000,
),
);
const eventKey = `bots.eventNames.${(row.event_type || row.title).replaceAll('.', '_')}`;
const processorUrl =
row.target_id &&
[
'pipeline',
'agent',
'processor',
'event_processor',
].includes(row.target_kind)
? `/home/${row.target_kind === 'pipeline' ? 'pipelines' : 'agents'}?id=${encodeURIComponent(row.target_id)}&tab=logs`
: null;
const leaving =
!!row.settledAt && now - row.settledAt >= 7000;
return (
<div
key={keyOf(row)}
className={`${styles.task} ${leaving ? styles.leaving : ''} border-b px-3 py-2 last:border-b-0`}
>
<div className="flex items-center gap-2">
<span
className={
row.has_error ? 'text-destructive' : 'text-primary'
}
title={label}
role="img"
aria-label={label}
>
<DockIcon kind={active(row) ? 'event' : phase} />
</span>
<button
type="button"
onClick={() => setSelected(row)}
className="min-w-0 flex-1 truncate rounded py-1 text-left text-xs hover:underline focus-visible:ring-2 focus-visible:ring-ring"
title={`${t('inflight.details')}: ${row.input_preview || row.event_type || row.title}`}
aria-label={`${t('inflight.details')}: ${row.event_type || row.title}`}
>
{t(eventKey, {
defaultValue: row.event_type || row.title,
})}
</button>
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
{elapsed}s
</span>
</div>
<ExecutionNodes
row={row}
processorUrl={processorUrl}
running={active(row) && connected}
/>
<div
className="mt-1.5 h-1 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label={label}
{...(!active(row)
? {
'aria-valuenow': 100,
'aria-valuemin': 0,
'aria-valuemax': 100,
}
: {})}
>
<div
className={`h-full rounded-full ${row.has_error ? 'bg-destructive' : 'bg-primary'} ${active(row) && connected ? styles.active : 'w-full'}`}
/>
</div>
</div>
);
})}
</div>
{(truncated || rows.length > 20) && (
<p className="border-t px-4 py-2 text-xs text-muted-foreground">
{t('inflight.limited')}
</p>
)}
</>
)}
</div>
</aside>
<ExecutionDetailSheet
row={selected}
onSelect={setSelected}
onClose={() => setSelected(null)}
/>
</>
);
}
export default function InFlightMonitor() {
const workspace = useCurrentWorkspace();
if (!workspace?.permissions.includes('resource.view')) return null;
return <WorkspaceMonitor key={workspace.workspace.uuid} />;
}
@@ -0,0 +1,125 @@
.active {
width: 40%;
animation: travel 1.6s ease-in-out infinite;
}
@keyframes travel {
from {
transform: translateX(-100%);
}
to {
transform: translateX(350%);
}
}
@media (prefers-reduced-motion: reduce) {
.active {
animation: none;
width: 40%;
}
}
.panel {
contain: layout;
overflow-x: hidden;
transition:
width 180ms ease-out,
transform 180ms ease-out;
}
.task {
animation: arrive 180ms ease-out both;
}
.leaving {
animation: depart 180ms ease-in both;
pointer-events: none;
}
@keyframes arrive {
from {
opacity: 0;
transform: translateX(8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes depart {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(8px);
}
}
@media (prefers-reduced-motion: reduce) {
.task,
.leaving {
animation: none;
}
}
.panel[data-dragging='true'] {
transition: none;
}
.dockContent {
animation: dockReveal 160ms ease-out both;
}
@keyframes dockReveal {
from {
opacity: 0.35;
transform: translateX(5px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.panel {
transition: none;
}
.dockContent {
animation: none;
}
}
.connection {
position: relative;
height: 2px;
background: var(--border);
}
.connection::after {
content: '';
position: absolute;
right: 1px;
top: -2px;
width: 6px;
height: 6px;
border-top: 1.5px solid var(--muted-foreground);
border-right: 1.5px solid var(--muted-foreground);
transform: rotate(45deg);
}
.flowing {
overflow: hidden;
background: color-mix(in srgb, var(--primary) 20%, transparent);
}
.flowing i {
display: block;
width: 8px;
height: 2px;
background: var(--primary);
animation: nodeFlow 1.4s ease-in-out infinite;
}
@keyframes nodeFlow {
from {
transform: translateX(-8px);
}
to {
transform: translateX(24px);
}
}
@media (prefers-reduced-motion: reduce) {
.flowing i {
animation: none;
transform: translateX(8px);
}
}
@@ -0,0 +1,128 @@
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type PointerEvent,
type KeyboardEvent,
} from 'react';
const KEY = 'langbot.inflight.dock';
function saved() {
try {
return JSON.parse(localStorage.getItem(KEY) || '{}');
} catch {
return {};
}
}
export function useEdgeDock(visible: boolean) {
const [preference] = useState(saved);
const [collapsed, setCollapsed] = useState(preference?.collapsed === true);
const panel = useRef<HTMLElement>(null);
const position = useRef(
Number.isFinite(preference?.position)
? Math.min(1, Math.max(0, preference.position))
: 0.55,
);
const drag = useRef<{
id: number;
y: number;
top: number;
moved: boolean;
} | null>(null);
const suppressClick = useRef(false);
// Anchor the top edge to the viewport, never to the changing content height.
const range = () => Math.max(0, window.innerHeight - 48 - 24);
const place = (top = 12 + position.current * range()) => {
const bounded = Math.max(12, Math.min(top, 12 + range()));
if (panel.current) {
panel.current.style.transform = `translateY(${bounded}px)`;
panel.current.style.maxHeight = `${Math.max(48, window.innerHeight - bounded - 12)}px`;
}
return bounded;
};
const persist = () => {
try {
localStorage.setItem(
KEY,
JSON.stringify({ collapsed, position: position.current }),
);
} catch {
/* Optional preference. */
}
};
useEffect(() => {
persist();
}, [collapsed]);
useLayoutEffect(() => {
if (!visible || !panel.current) return;
const update = () => {
place();
};
window.addEventListener('resize', update);
update();
return () => {
window.removeEventListener('resize', update);
};
}, [visible, collapsed]);
const onPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
if (panel.current) panel.current.dataset.dragging = 'true';
suppressClick.current = false;
drag.current = {
id: event.pointerId,
y: event.clientY,
top: panel.current?.getBoundingClientRect().top || 12,
moved: false,
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: PointerEvent<HTMLButtonElement>) => {
const current = drag.current;
if (!current || current.id !== event.pointerId) return;
const delta = event.clientY - current.y;
if (Math.abs(delta) > 5) current.moved = true;
if (current.moved) {
const top = place(current.top + delta);
position.current = range() ? (top - 12) / range() : 0;
}
};
const finish = (event: PointerEvent<HTMLButtonElement>) => {
if (!drag.current || drag.current.id !== event.pointerId) return;
suppressClick.current = drag.current.moved;
drag.current = null;
if (panel.current) delete panel.current.dataset.dragging;
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
persist();
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const top =
event.key === 'Home'
? 12
: event.key === 'End'
? 12 + range()
: (panel.current?.getBoundingClientRect().top || 12) +
(event.key === 'ArrowUp' ? -24 : 24);
const bounded = place(top);
position.current = range() ? (bounded - 12) / range() : 0;
persist();
};
return {
panel,
collapsed,
setCollapsed,
suppressClick,
handle: {
onPointerDown,
onPointerMove,
onPointerUp: finish,
onPointerCancel: finish,
onLostPointerCapture: finish,
onKeyDown,
},
};
}
+2
View File
@@ -1,5 +1,6 @@
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
import InFlightMonitor from '@/app/home/components/inflight/InFlightMonitor';
import WorkspaceAssistant from '@/app/home/components/WorkspaceAssistant';
import React, {
useState,
@@ -280,6 +281,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
<SurveyWidget />
<WorkspaceAssistant />
<InFlightMonitor />
</SidebarInset>
</SidebarProvider>
);
@@ -163,3 +163,12 @@ export interface ExecutionDetail {
export type AgentExecutionDetail = ExecutionDetail & { source: 'agent' };
export type PipelineExecutionDetail = ExecutionDetail & { source: 'pipeline' };
export interface InflightSnapshot {
items: (ExecutionRow & { progress_event?: string })[];
active_total: number;
truncated: boolean;
}
export type InflightFrame =
| { kind: 'snapshot'; data: InflightSnapshot }
| { kind: 'heartbeat' | 'unavailable' };
+28
View File
@@ -6,6 +6,7 @@ import type {
} from '@/app/infra/entities/api/pipeline-migration';
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
import type {
InflightFrame,
ExecutionDetail,
ExecutionListResult,
ExecutionModeFilter,
@@ -2203,6 +2204,33 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`);
}
public async streamInflight(onFrame: (frame: InflightFrame) => void, signal: AbortSignal): Promise<void> {
const response = await this.instance.get<ReadableStream<Uint8Array>>(
'/api/v1/monitoring/in-flight/stream',
{ adapter: 'fetch', responseType: 'stream', timeout: 0, signal, headers: { Accept: 'text/event-stream' } },
);
const reader = response.data.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (!signal.aborted) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let end: number;
while ((end = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
if (frame.startsWith('data: ')) onFrame(JSON.parse(frame.slice(6)) as InflightFrame);
}
if (buffer.length > 2_000_000) throw new Error('Monitoring frame exceeds limit');
}
} finally {
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}
public getExecutions(params: {
botId?: string[];
pipelineId?: string[];
+26
View File
@@ -1,5 +1,31 @@
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
inflight: {
autoCollapse: "Auto-collapse",
empty: "No events are being processed",
move: "Drag vertically; use arrow keys to move",
botLogs: "Robot logs",
processorLogs: "Processor logs",
"title": "In flight",
"expand": "Expand",
"collapse": "Collapse",
"reconnecting": "Reconnecting; showing the last received state.",
"details": "View details",
"debug": "Debug event",
"limited": "Showing a limited set. Open the dashboard for all executions.",
"queued": "Queued",
"running": "Processing",
"waiting": "Waiting for input",
"tool": "Calling a tool",
"thinking": "Thinking",
"replying": "Preparing reply",
"generating": "Generating reply",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled",
"ignored": "Ignored"
},
assistant: {
movePosition: 'Move assistant horizontally',
resizeHeight: 'Resize assistant height',
+26
View File
@@ -1,5 +1,31 @@
import pipelineMigration from './pipeline-migration/es-ES';
const esES = {
inflight: {
autoCollapse: "Plegar auto.",
empty: "No hay eventos en proceso",
move: "Arrastra verticalmente o usa las flechas",
botLogs: "Registros del bot",
processorLogs: "Registros del procesador",
"title": "En curso",
"expand": "Expandir",
"collapse": "Contraer",
"reconnecting": "Reconectando; se muestra el último estado recibido.",
"details": "Ver detalles",
"debug": "Evento de depuración",
"limited": "Se muestra un subconjunto. Consulte todas las ejecuciones en el panel.",
"queued": "En cola",
"running": "Procesando",
"waiting": "Esperando entrada",
"tool": "Ejecutando herramienta",
"thinking": "Pensando",
"replying": "Preparando respuesta",
"generating": "Generando respuesta",
"completed": "Completado",
"failed": "Fallido",
"cancelled": "Cancelado",
"ignored": "Ignorado"
},
assistant: {
movePosition: 'Mover el asistente horizontalmente',
resizeHeight: 'Ajustar la altura del asistente',
+26
View File
@@ -1,5 +1,31 @@
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
inflight: {
autoCollapse: "自動折りたたみ",
empty: "処理中のイベントはありません",
move: "上下にドラッグ、または矢印キーで移動",
botLogs: "ボットのログ",
processorLogs: "プロセッサーのログ",
"title": "実行中",
"expand": "展開",
"collapse": "折りたたむ",
"reconnecting": "再接続中。最後に受信した状態を表示しています。",
"details": "詳細を見る",
"debug": "デバッグイベント",
"limited": "一部のみ表示しています。全実行はダッシュボードで確認できます。",
"queued": "待機中",
"running": "処理中",
"waiting": "入力待ち",
"tool": "ツール呼び出し中",
"thinking": "思考中",
"replying": "返信準備中",
"generating": "返信生成中",
"completed": "完了",
"failed": "失敗",
"cancelled": "キャンセル済み",
"ignored": "無視"
},
assistant: {
movePosition: 'アシスタントを左右に移動',
resizeHeight: 'アシスタントの高さを調整',
+26
View File
@@ -1,5 +1,31 @@
import pipelineMigration from './pipeline-migration/ru-RU';
const ruRU = {
inflight: {
autoCollapse: "Автосворачивание",
empty: "Нет событий в обработке",
move: "Перетащите по вертикали или используйте стрелки",
botLogs: "Журнал бота",
processorLogs: "Журнал обработчика",
"title": "В работе",
"expand": "Развернуть",
"collapse": "Свернуть",
"reconnecting": "Переподключение; показано последнее полученное состояние.",
"details": "Подробнее",
"debug": "Отладочное событие",
"limited": "Показана часть запусков. Все запуски доступны на панели мониторинга.",
"queued": "В очереди",
"running": "Обработка",
"waiting": "Ожидание ввода",
"tool": "Вызов инструмента",
"thinking": "Размышление",
"replying": "Подготовка ответа",
"generating": "Генерация ответа",
"completed": "Завершено",
"failed": "Ошибка",
"cancelled": "Отменено",
"ignored": "Пропущено"
},
assistant: {
movePosition: 'Переместить помощника по горизонтали',
resizeHeight: 'Изменить высоту помощника',
+26
View File
@@ -1,5 +1,31 @@
import pipelineMigration from './pipeline-migration/th-TH';
const thTH = {
inflight: {
autoCollapse: "ยุบอัตโนมัติ",
empty: "ไม่มีเหตุการณ์ที่กำลังประมวลผล",
move: "ลากขึ้นลงหรือใช้ปุ่มลูกศรเพื่อย้าย",
botLogs: "บันทึกบอต",
processorLogs: "บันทึกตัวประมวลผล",
"title": "กำลังดำเนินการ",
"expand": "ขยาย",
"collapse": "ย่อ",
"reconnecting": "กำลังเชื่อมต่อใหม่ แสดงสถานะล่าสุดที่ได้รับ",
"details": "ดูรายละเอียด",
"debug": "เหตุการณ์ทดสอบ",
"limited": "แสดงบางรายการ ดูทั้งหมดได้ที่แดชบอร์ด",
"queued": "อยู่ในคิว",
"running": "กำลังประมวลผล",
"waiting": "รอข้อมูล",
"tool": "กำลังเรียกเครื่องมือ",
"thinking": "กำลังคิด",
"replying": "กำลังเตรียมคำตอบ",
"generating": "กำลังสร้างคำตอบ",
"completed": "เสร็จสิ้น",
"failed": "ล้มเหลว",
"cancelled": "ยกเลิกแล้ว",
"ignored": "ละเว้นแล้ว"
},
assistant: {
movePosition: 'เลื่อนผู้ช่วยในแนวนอน',
resizeHeight: 'ปรับความสูงของผู้ช่วย',
+26
View File
@@ -1,5 +1,31 @@
import pipelineMigration from './pipeline-migration/vi-VN';
const viVN = {
inflight: {
autoCollapse: "Tự thu gọn",
empty: "Không có sự kiện đang xử lý",
move: "Kéo lên xuống hoặc dùng phím mũi tên",
botLogs: "Nhật ký bot",
processorLogs: "Nhật ký bộ xử lý",
"title": "Đang thực hiện",
"expand": "Mở rộng",
"collapse": "Thu gọn",
"reconnecting": "Đang kết nối lại; hiển thị trạng thái nhận được gần nhất.",
"details": "Xem chi tiết",
"debug": "Sự kiện gỡ lỗi",
"limited": "Chỉ hiển thị một phần. Xem bảng điều khiển để biết tất cả lượt chạy.",
"queued": "Đang chờ",
"running": "Đang xử lý",
"waiting": "Chờ nhập liệu",
"tool": "Đang gọi công cụ",
"thinking": "Đang suy nghĩ",
"replying": "Chuẩn bị trả lời",
"generating": "Đang tạo câu trả lời",
"completed": "Hoàn tất",
"failed": "Thất bại",
"cancelled": "Đã hủy",
"ignored": "Đã bỏ qua"
},
assistant: {
movePosition: 'Di chuyển trợ lý theo chiều ngang',
resizeHeight: 'Điều chỉnh chiều cao trợ lý',
+26
View File
@@ -1,5 +1,31 @@
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
inflight: {
autoCollapse: "自动折叠",
empty: "当前没有正在处理的事件",
move: "上下拖动;也可用方向键移动",
botLogs: "机器人日志",
processorLogs: "处理器日志",
"title": "处理中",
"expand": "展开",
"collapse": "收起",
"reconnecting": "连接中断,正在重连;显示的是最后收到的状态。",
"details": "查看详情",
"debug": "调试事件",
"limited": "仅展示部分执行,请在仪表盘查看全部记录。",
"queued": "排队中",
"running": "处理中",
"waiting": "等待用户输入",
"tool": "调用工具",
"thinking": "思考中",
"replying": "准备回复",
"generating": "生成回复",
"completed": "已完成",
"failed": "失败",
"cancelled": "已取消",
"ignored": "已忽略"
},
assistant: {
movePosition: '左右移动助手',
resizeHeight: '调整助手高度',
+26
View File
@@ -1,5 +1,31 @@
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
inflight: {
autoCollapse: "自動摺疊",
empty: "目前沒有正在處理的事件",
move: "上下拖動;也可用方向鍵移動",
botLogs: "機器人日誌",
processorLogs: "處理器日誌",
"title": "處理中",
"expand": "展開",
"collapse": "收起",
"reconnecting": "連線中斷,正在重新連線;顯示最後收到的狀態。",
"details": "查看詳情",
"debug": "除錯事件",
"limited": "僅顯示部分執行,請在儀表板查看全部記錄。",
"queued": "排隊中",
"running": "處理中",
"waiting": "等待使用者輸入",
"tool": "呼叫工具",
"thinking": "思考中",
"replying": "準備回覆",
"generating": "產生回覆",
"completed": "已完成",
"failed": "失敗",
"cancelled": "已取消",
"ignored": "已忽略"
},
assistant: {
movePosition: '左右移動助手',
resizeHeight: '調整助手高度',