Compare commits

..

1 Commits

Author SHA1 Message Date
huanghuoguoguo ff0c5a6f0a test: format test suite 2026-06-16 11:13:05 +08:00
52 changed files with 1062 additions and 4030 deletions
@@ -313,30 +313,18 @@ class MonitoringRouterGroup(group.RouterGroup):
offset=0, offset=0,
) )
# Get traces
traces, traces_total = await self.ap.monitoring_service.get_traces(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=0,
)
return self.success( return self.success(
data={ data={
'overview': overview, 'overview': overview,
'messages': messages, 'messages': messages,
'llmCalls': llm_calls, 'llmCalls': llm_calls,
'embeddingCalls': embedding_calls, 'embeddingCalls': embedding_calls,
'traces': traces,
'sessions': sessions, 'sessions': sessions,
'errors': errors, 'errors': errors,
'totalCount': { 'totalCount': {
'messages': messages_total, 'messages': messages_total,
'llmCalls': llm_calls_total, 'llmCalls': llm_calls_total,
'embeddingCalls': embedding_calls_total, 'embeddingCalls': embedding_calls_total,
'traces': traces_total,
'sessions': sessions_total, 'sessions': sessions_total,
'errors': errors_total, 'errors': errors_total,
}, },
@@ -362,49 +350,6 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=details) return self.success(data=details)
@self.route('/traces', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_traces() -> str:
"""Get end-to-end trace records."""
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
session_ids = quart.request.args.getlist('sessionId')
statuses = quart.request.args.getlist('status')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
traces, total = await self.ap.monitoring_service.get_traces(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
session_ids=session_ids if session_ids else None,
statuses=statuses if statuses else None,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=offset,
)
return self.success(
data={
'traces': traces,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/traces/<trace_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_trace_details(trace_id: str) -> str:
"""Get one trace with all spans."""
details = await self.ap.monitoring_service.get_trace_details(trace_id)
if not details.get('found'):
return self.http_status(404, -1, f'Trace {trace_id} not found')
return self.success(data=details)
@self.route('/export', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route('/export', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def export_data() -> tuple[str, int]: async def export_data() -> tuple[str, int]:
"""Export monitoring data as CSV""" """Export monitoring data as CSV"""
@@ -350,24 +350,8 @@ class PluginsRouterGroup(group.RouterGroup):
if not endpoint.startswith('/') or '..' in endpoint: if not endpoint.startswith('/') or '..' in endpoint:
return self.http_status(400, -1, 'invalid endpoint') return self.http_status(400, -1, 'invalid endpoint')
caller = {
'plugin_author': author,
'plugin_name': plugin_name,
'page_id': page_id,
'origin': _get_request_origin(),
}
headers = {
key: value
for key, value in {
'user-agent': quart.request.headers.get('User-Agent'),
'x-request-id': quart.request.headers.get('X-Request-ID'),
'x-forwarded-for': quart.request.headers.get('X-Forwarded-For'),
}.items()
if value
}
result = await self.ap.plugin_connector.handle_page_api( result = await self.ap.plugin_connector.handle_page_api(
author, plugin_name, page_id, endpoint, method.upper(), body, caller, headers author, plugin_name, page_id, endpoint, method.upper(), body
) )
if result.get('error'): if result.get('error'):
return self.http_status(400, -1, result['error']) return self.http_status(400, -1, result['error'])
@@ -3,55 +3,11 @@ from __future__ import annotations
import uuid import uuid
import datetime import datetime
import sqlalchemy import sqlalchemy
import json
from ....core import app from ....core import app
from ....entity.persistence import monitoring as persistence_monitoring from ....entity.persistence import monitoring as persistence_monitoring
# TODO: Move shared trace/time helpers into a small monitoring utility module
# when trace propagation expands beyond the current query/retrieval path.
def _utc_now() -> datetime.datetime:
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
def _json_dumps(value: dict | list | None) -> str | None:
if value is None:
return None
try:
return json.dumps(value, ensure_ascii=False, default=str)
except Exception:
return json.dumps({'serialization_error': str(value)}, ensure_ascii=False)
def _json_loads(value: str | None) -> dict | list | None:
if not value:
return None
try:
return json.loads(value)
except Exception:
return None
def new_trace_id() -> str:
return f'trace-{uuid.uuid4().hex[:16]}'
def new_span_id() -> str:
return f'span-{uuid.uuid4().hex[:16]}'
def normalize_trace_status(status: str | None) -> str:
"""Normalize operation status to the monitoring UI vocabulary."""
if status in ('completed', 'ok'):
return 'success'
if status in ('failed', 'failure', 'exception'):
return 'error'
if status in ('running', 'success', 'error'):
return status
return 'success'
class MonitoringService: class MonitoringService:
"""Monitoring service""" """Monitoring service"""
@@ -118,18 +74,6 @@ class MonitoringService:
persistence_monitoring.MonitoringFeedback.timestamp, persistence_monitoring.MonitoringFeedback.timestamp,
persistence_monitoring.MonitoringFeedback.id, persistence_monitoring.MonitoringFeedback.id,
), ),
(
'monitoring_traces',
persistence_monitoring.MonitoringTrace,
persistence_monitoring.MonitoringTrace.started_at,
persistence_monitoring.MonitoringTrace.trace_id,
),
(
'monitoring_spans',
persistence_monitoring.MonitoringSpan,
persistence_monitoring.MonitoringSpan.started_at,
persistence_monitoring.MonitoringSpan.span_id,
),
] ]
deleted_counts: dict[str, int] = {} deleted_counts: dict[str, int] = {}
@@ -189,116 +133,6 @@ class MonitoringService:
# ========== Recording Methods ========== # ========== Recording Methods ==========
async def start_trace(
self,
trace_id: str | None = None,
name: str = 'LangBot query',
bot_id: str | None = None,
bot_name: str | None = None,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
session_id: str | None = None,
message_id: str | None = None,
query_id: str | int | None = None,
attributes: dict | None = None,
) -> str:
"""Create or update a trace header row."""
trace_id = trace_id or new_trace_id()
trace_data = {
'trace_id': trace_id,
'started_at': _utc_now(),
'ended_at': None,
'duration': None,
'status': 'running',
'name': name,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'message_id': message_id,
'query_id': str(query_id) if query_id is not None else None,
'attributes': _json_dumps(attributes),
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringTrace).values(trace_data)
)
return trace_id
async def finish_trace(
self,
trace_id: str,
status: str = 'success',
duration: int | None = None,
message_id: str | None = None,
attributes: dict | None = None,
) -> None:
"""Mark a trace complete."""
update_values: dict = {
'ended_at': _utc_now(),
'status': normalize_trace_status(status),
}
if duration is not None:
update_values['duration'] = duration
if message_id is not None:
update_values['message_id'] = message_id
if attributes is not None:
update_values['attributes'] = _json_dumps(attributes)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_monitoring.MonitoringTrace)
.where(persistence_monitoring.MonitoringTrace.trace_id == trace_id)
.values(update_values)
)
async def record_span(
self,
trace_id: str,
name: str,
kind: str,
status: str = 'success',
span_id: str | None = None,
parent_span_id: str | None = None,
started_at: datetime.datetime | None = None,
ended_at: datetime.datetime | None = None,
duration: int | None = None,
message_id: str | None = None,
session_id: str | None = None,
bot_id: str | None = None,
pipeline_id: str | None = None,
attributes: dict | None = None,
error_message: str | None = None,
) -> str:
"""Record a single completed span."""
started_at = started_at or _utc_now()
if duration is None and ended_at is not None:
duration = int((ended_at - started_at).total_seconds() * 1000)
elif duration is not None:
duration = int(round(float(duration)))
span_data = {
'span_id': span_id or new_span_id(),
'trace_id': trace_id,
'parent_span_id': parent_span_id,
'name': name,
'kind': kind,
'status': normalize_trace_status(status),
'started_at': started_at,
'ended_at': ended_at or _utc_now(),
'duration': duration,
'message_id': message_id,
'session_id': session_id,
'bot_id': bot_id,
'pipeline_id': pipeline_id,
'attributes': _json_dumps(attributes),
'error_message': error_message,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringSpan).values(span_data)
)
return span_data['span_id']
async def record_message( async def record_message(
self, self,
bot_id: str, bot_id: str,
@@ -1242,19 +1076,6 @@ class MonitoringService:
for row in error_rows for row in error_rows
] ]
trace_query = (
sqlalchemy.select(persistence_monitoring.MonitoringTrace)
.where(persistence_monitoring.MonitoringTrace.message_id == message_id)
.order_by(persistence_monitoring.MonitoringTrace.started_at.desc())
.limit(1)
)
trace_result = await self.ap.persistence_mgr.execute_async(trace_query)
trace_row = trace_result.first()
trace = None
if trace_row:
trace_model = trace_row[0] if isinstance(trace_row, tuple) else trace_row
trace = self._serialize_trace(trace_model)
return { return {
'message_id': message_id, 'message_id': message_id,
'found': True, 'found': True,
@@ -1269,84 +1090,6 @@ class MonitoringService:
'average_duration_ms': int(total_duration / len(llm_rows)) if len(llm_rows) > 0 else 0, 'average_duration_ms': int(total_duration / len(llm_rows)) if len(llm_rows) > 0 else 0,
}, },
'errors': errors, 'errors': errors,
'trace': trace,
}
def _serialize_trace(self, trace: persistence_monitoring.MonitoringTrace) -> dict:
data = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringTrace, trace)
data['attributes'] = _json_loads(data.get('attributes')) or {}
return data
def _serialize_span(self, span: persistence_monitoring.MonitoringSpan) -> dict:
data = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSpan, span)
data['attributes'] = _json_loads(data.get('attributes')) or {}
return data
async def get_traces(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
session_ids: list[str] | None = None,
statuses: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get trace headers with filters."""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringTrace.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringTrace.pipeline_id.in_(pipeline_ids))
if session_ids:
conditions.append(persistence_monitoring.MonitoringTrace.session_id.in_(session_ids))
if statuses:
conditions.append(persistence_monitoring.MonitoringTrace.status.in_(statuses))
if start_time:
conditions.append(persistence_monitoring.MonitoringTrace.started_at >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringTrace.started_at <= end_time)
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringTrace.trace_id))
query = sqlalchemy.select(persistence_monitoring.MonitoringTrace)
if conditions:
clause = sqlalchemy.and_(*conditions)
count_query = count_query.where(clause)
query = query.where(clause)
total_result = await self.ap.persistence_mgr.execute_async(count_query)
total = total_result.scalar() or 0
query = query.order_by(persistence_monitoring.MonitoringTrace.started_at.desc()).limit(limit).offset(offset)
result = await self.ap.persistence_mgr.execute_async(query)
traces = [self._serialize_trace(row[0] if isinstance(row, tuple) else row) for row in result.all()]
return traces, total
async def get_trace_details(self, trace_id: str) -> dict:
"""Get a single trace and all spans in chronological order."""
trace_query = sqlalchemy.select(persistence_monitoring.MonitoringTrace).where(
persistence_monitoring.MonitoringTrace.trace_id == trace_id
)
trace_result = await self.ap.persistence_mgr.execute_async(trace_query)
trace_row = trace_result.first()
if not trace_row:
return {'trace_id': trace_id, 'found': False}
trace = trace_row[0] if isinstance(trace_row, tuple) else trace_row
span_query = (
sqlalchemy.select(persistence_monitoring.MonitoringSpan)
.where(persistence_monitoring.MonitoringSpan.trace_id == trace_id)
.order_by(persistence_monitoring.MonitoringSpan.started_at.asc())
)
span_result = await self.ap.persistence_mgr.execute_async(span_query)
spans = [self._serialize_span(row[0] if isinstance(row, tuple) else row) for row in span_result.all()]
return {
'trace_id': trace_id,
'found': True,
'trace': self._serialize_trace(trace),
'spans': spans,
} }
# ========== Export Methods ========== # ========== Export Methods ==========
@@ -3,49 +3,6 @@ import sqlalchemy
from .base import Base from .base import Base
class MonitoringTrace(Base):
"""End-to-end monitoring trace records"""
__tablename__ = 'monitoring_traces'
trace_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
started_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
ended_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True)
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=True) # milliseconds
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) # running, success, error
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
query_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
attributes = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
class MonitoringSpan(Base):
"""Trace span records for pipeline, RAG, model, plugin and tool operations"""
__tablename__ = 'monitoring_spans'
span_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
trace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
parent_span_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
kind = sqlalchemy.Column(sqlalchemy.String(80), nullable=False, index=True)
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True)
started_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
ended_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=True) # milliseconds
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
attributes = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
class MonitoringMessage(Base): class MonitoringMessage(Base):
"""Monitoring message records""" """Monitoring message records"""
@@ -1,88 +0,0 @@
"""add monitoring traces and spans
Revision ID: 0006_monitoring_traces
Revises: 0005_add_llm_context_length
Create Date: 2026-06-16
"""
import sqlalchemy as sa
from alembic import op
revision = '0006_monitoring_traces'
down_revision = '0005_add_llm_context_length'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = set(inspector.get_table_names())
if 'monitoring_traces' not in tables:
op.create_table(
'monitoring_traces',
sa.Column('trace_id', sa.String(length=255), nullable=False),
sa.Column('started_at', sa.DateTime(), nullable=False),
sa.Column('ended_at', sa.DateTime(), nullable=True),
sa.Column('duration', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('bot_id', sa.String(length=255), nullable=True),
sa.Column('bot_name', sa.String(length=255), nullable=True),
sa.Column('pipeline_id', sa.String(length=255), nullable=True),
sa.Column('pipeline_name', sa.String(length=255), nullable=True),
sa.Column('session_id', sa.String(length=255), nullable=True),
sa.Column('message_id', sa.String(length=255), nullable=True),
sa.Column('query_id', sa.String(length=255), nullable=True),
sa.Column('attributes', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('trace_id'),
)
op.create_index('ix_monitoring_traces_started_at', 'monitoring_traces', ['started_at'])
op.create_index('ix_monitoring_traces_ended_at', 'monitoring_traces', ['ended_at'])
op.create_index('ix_monitoring_traces_status', 'monitoring_traces', ['status'])
op.create_index('ix_monitoring_traces_bot_id', 'monitoring_traces', ['bot_id'])
op.create_index('ix_monitoring_traces_pipeline_id', 'monitoring_traces', ['pipeline_id'])
op.create_index('ix_monitoring_traces_session_id', 'monitoring_traces', ['session_id'])
op.create_index('ix_monitoring_traces_message_id', 'monitoring_traces', ['message_id'])
op.create_index('ix_monitoring_traces_query_id', 'monitoring_traces', ['query_id'])
if 'monitoring_spans' not in tables:
op.create_table(
'monitoring_spans',
sa.Column('span_id', sa.String(length=255), nullable=False),
sa.Column('trace_id', sa.String(length=255), nullable=False),
sa.Column('parent_span_id', sa.String(length=255), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('kind', sa.String(length=80), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('started_at', sa.DateTime(), nullable=False),
sa.Column('ended_at', sa.DateTime(), nullable=True),
sa.Column('duration', sa.Integer(), nullable=True),
sa.Column('message_id', sa.String(length=255), nullable=True),
sa.Column('session_id', sa.String(length=255), nullable=True),
sa.Column('bot_id', sa.String(length=255), nullable=True),
sa.Column('pipeline_id', sa.String(length=255), nullable=True),
sa.Column('attributes', sa.Text(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('span_id'),
)
op.create_index('ix_monitoring_spans_trace_id', 'monitoring_spans', ['trace_id'])
op.create_index('ix_monitoring_spans_parent_span_id', 'monitoring_spans', ['parent_span_id'])
op.create_index('ix_monitoring_spans_kind', 'monitoring_spans', ['kind'])
op.create_index('ix_monitoring_spans_status', 'monitoring_spans', ['status'])
op.create_index('ix_monitoring_spans_started_at', 'monitoring_spans', ['started_at'])
op.create_index('ix_monitoring_spans_message_id', 'monitoring_spans', ['message_id'])
op.create_index('ix_monitoring_spans_session_id', 'monitoring_spans', ['session_id'])
op.create_index('ix_monitoring_spans_bot_id', 'monitoring_spans', ['bot_id'])
op.create_index('ix_monitoring_spans_pipeline_id', 'monitoring_spans', ['pipeline_id'])
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = set(inspector.get_table_names())
if 'monitoring_spans' in tables:
op.drop_table('monitoring_spans')
if 'monitoring_traces' in tables:
op.drop_table('monitoring_traces')
+26 -155
View File
@@ -2,9 +2,6 @@ from __future__ import annotations
import typing import typing
import traceback import traceback
import time
import uuid
import datetime
import sqlalchemy import sqlalchemy
@@ -82,19 +79,6 @@ class RuntimePipeline:
enable_all_plugins: bool enable_all_plugins: bool
"""是否启用所有插件""" """是否启用所有插件"""
@staticmethod
def _new_span_id() -> str:
return f'span-{uuid.uuid4().hex[:16]}'
@staticmethod
def _utc_now() -> datetime.datetime:
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
@staticmethod
def _query_session_id(query: pipeline_query.Query) -> str:
launcher_type = query.launcher_type.value if hasattr(query.launcher_type, 'value') else str(query.launcher_type)
return f'{launcher_type}_{query.launcher_id}'
enable_all_mcp_servers: bool enable_all_mcp_servers: bool
"""是否启用所有MCP服务器""" """是否启用所有MCP服务器"""
@@ -250,102 +234,44 @@ class RuntimePipeline:
stage_container = self.stage_containers[i] stage_container = self.stage_containers[i]
query.current_stage_name = stage_container.inst_name # 标记到 Query 对象里 query.current_stage_name = stage_container.inst_name # 标记到 Query 对象里
span_started_at = self._utc_now()
span_started = time.perf_counter()
span_status = 'success'
span_error = None
span_result_type = None
try: result = stage_container.inst.process(query, stage_container.inst_name)
result = stage_container.inst.process(query, stage_container.inst_name)
if isinstance(result, typing.Coroutine): if isinstance(result, typing.Coroutine):
result = await result result = await result
if isinstance(result, pipeline_entities.StageProcessResult): # 直接返回结果 if isinstance(result, pipeline_entities.StageProcessResult): # 直接返回结果
span_result_type = str( self.ap.logger.debug(
result.result_type.value if hasattr(result.result_type, 'value') else result.result_type f'Stage {stage_container.inst_name} processed query {query.query_id} res {result.result_type}'
) )
await self._check_output(query, result)
if result.result_type == pipeline_entities.ResultType.INTERRUPT:
self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
break
elif result.result_type == pipeline_entities.ResultType.CONTINUE:
query = result.new_query
elif isinstance(result, typing.AsyncGenerator): # 生成器
self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen')
async for sub_result in result:
self.ap.logger.debug( self.ap.logger.debug(
f'Stage {stage_container.inst_name} processed query {query.query_id} res {result.result_type}' f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}'
) )
await self._check_output(query, result) await self._check_output(query, sub_result)
if result.error_notice:
span_status = 'error'
span_error = result.error_notice
if result.result_type == pipeline_entities.ResultType.INTERRUPT: if sub_result.result_type == pipeline_entities.ResultType.INTERRUPT:
self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}') self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
break break
elif result.result_type == pipeline_entities.ResultType.CONTINUE: elif sub_result.result_type == pipeline_entities.ResultType.CONTINUE:
query = result.new_query query = sub_result.new_query
elif isinstance(result, typing.AsyncGenerator): # 生成器 await self._execute_from_stage(i + 1, query)
span_result_type = 'generator' break
self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen')
async for sub_result in result:
span_result_type = str(
sub_result.result_type.value
if hasattr(sub_result.result_type, 'value')
else sub_result.result_type
)
self.ap.logger.debug(
f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}'
)
await self._check_output(query, sub_result)
if sub_result.error_notice:
span_status = 'error'
span_error = sub_result.error_notice
if sub_result.result_type == pipeline_entities.ResultType.INTERRUPT:
self.ap.logger.debug(
f'Stage {stage_container.inst_name} interrupted query {query.query_id}'
)
break
elif sub_result.result_type == pipeline_entities.ResultType.CONTINUE:
query = sub_result.new_query
await self._execute_from_stage(i + 1, query)
break
except Exception as e:
span_status = 'error'
span_error = str(e)
raise
finally:
trace_id = (query.variables or {}).get('_monitoring_trace_id')
root_span_id = (query.variables or {}).get('_monitoring_root_span_id')
if trace_id:
try:
await self.ap.monitoring_service.record_span(
trace_id=trace_id,
parent_span_id=root_span_id,
name=stage_container.inst_name,
kind='pipeline.stage',
status=span_status,
started_at=span_started_at,
duration=int((time.perf_counter() - span_started) * 1000),
message_id=(query.variables or {}).get('_monitoring_message_id'),
session_id=self._query_session_id(query),
bot_id=query.bot_uuid,
pipeline_id=self.pipeline_entity.uuid,
attributes={
'stage_class': stage_container.inst.__class__.__name__,
'result_type': span_result_type,
'query_id': query.query_id,
},
error_message=span_error,
)
except Exception as monitor_err:
self.ap.logger.error(f'Failed to record stage span: {monitor_err}')
i += 1 i += 1
async def process_query(self, query: pipeline_query.Query): async def process_query(self, query: pipeline_query.Query):
"""处理请求""" """处理请求"""
trace_started_at = self._utc_now()
trace_started = time.perf_counter()
root_span_id = self._new_span_id()
trace_id = None
trace_status = 'success'
# Get monitoring metadata # Get monitoring metadata
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown') bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown') pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
@@ -377,28 +303,6 @@ class RuntimePipeline:
except Exception as e: except Exception as e:
self.ap.logger.error(f'Failed to record query start: {e}') self.ap.logger.error(f'Failed to record query start: {e}')
try:
trace_id = await self.ap.monitoring_service.start_trace(
name='LangBot query',
bot_id=query.bot_uuid or 'unknown',
bot_name=bot_name,
pipeline_id=self.pipeline_entity.uuid,
pipeline_name=pipeline_name,
session_id=self._query_session_id(query),
message_id=message_id or None,
query_id=query.query_id,
attributes={
'launcher_type': query.launcher_type.value
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
'runner_name': runner_name,
},
)
query.variables['_monitoring_trace_id'] = trace_id
query.variables['_monitoring_root_span_id'] = root_span_id
except Exception as e:
self.ap.logger.error(f'Failed to start query trace: {e}')
try: try:
# Get bound plugins for this pipeline # Get bound plugins for this pipeline
bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
@@ -432,10 +336,7 @@ class RuntimePipeline:
await self._execute_from_stage(0, query) await self._execute_from_stage(0, query)
# Record query success only if no error occurred during processing # Record query success only if no error occurred during processing
has_monitoring_error = query.variables.get('_monitoring_has_error', False) if not query.variables.get('_monitoring_has_error', False):
if has_monitoring_error:
trace_status = 'error'
else:
try: try:
await monitoring_helper.MonitoringHelper.record_query_success( await monitoring_helper.MonitoringHelper.record_query_success(
ap=self.ap, ap=self.ap,
@@ -460,7 +361,6 @@ class RuntimePipeline:
self.ap.logger.error(f'Failed to record query response: {e}') self.ap.logger.error(f'Failed to record query response: {e}')
except Exception as e: except Exception as e:
trace_status = 'error'
inst_name = query.current_stage_name if query.current_stage_name else 'unknown' inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}') self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
self.ap.logger.error(f'Traceback: {traceback.format_exc()}') self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
@@ -483,35 +383,6 @@ class RuntimePipeline:
self.ap.logger.error(f'Failed to record query error: {me}') self.ap.logger.error(f'Failed to record query error: {me}')
finally: finally:
if trace_id:
try:
duration_ms = int((time.perf_counter() - trace_started) * 1000)
await self.ap.monitoring_service.record_span(
trace_id=trace_id,
span_id=root_span_id,
name='LangBot query',
kind='pipeline.query',
status=trace_status,
started_at=trace_started_at,
duration=duration_ms,
message_id=message_id or None,
session_id=self._query_session_id(query),
bot_id=query.bot_uuid,
pipeline_id=self.pipeline_entity.uuid,
attributes={
'query_id': query.query_id,
'pipeline_name': pipeline_name,
'runner_name': runner_name,
},
)
await self.ap.monitoring_service.finish_trace(
trace_id=trace_id,
status=trace_status,
duration=duration_ms,
message_id=message_id or None,
)
except Exception as monitor_err:
self.ap.logger.error(f'Failed to finish query trace: {monitor_err}')
self.ap.logger.debug(f'Query {query.query_id} processed') self.ap.logger.debug(f'Query {query.query_id} processed')
del self.ap.query_pool.cached_queries[query.query_id] del self.ap.query_pool.cached_queries[query.query_id]
+1 -12
View File
@@ -711,19 +711,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
endpoint: str, endpoint: str,
method: str, method: str,
body: Any = None, body: Any = None,
caller: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return await self.handler.handle_page_api( return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
plugin_author,
plugin_name,
page_id,
endpoint,
method,
body,
caller,
headers or {},
)
async def get_debug_info(self) -> dict[str, Any]: async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL""" """Get debug information including debug key and WS URL"""
-19
View File
@@ -755,21 +755,6 @@ class RuntimeConnectionHandler(handler.Handler):
'session_name': session_name, 'session_name': session_name,
'bot_uuid': query.bot_uuid or '', 'bot_uuid': query.bot_uuid or '',
'sender_id': str(query.sender_id), 'sender_id': str(query.sender_id),
'_trace_context': {
'trace_id': query.variables.get('_monitoring_trace_id') if query.variables else None,
'parent_span_id': query.variables.get('_monitoring_root_span_id')
if query.variables
else None,
'message_id': query.variables.get('_monitoring_message_id') if query.variables else None,
'query_id': query.query_id,
'session_id': session_name,
'bot_id': query.bot_uuid or '',
'pipeline_id': query.pipeline_uuid or '',
'knowledge_base_id': kb_id,
'attributes': {
'source': 'plugin-api',
},
},
}, },
) )
results = [entry.model_dump(mode='json') for entry in entries] results = [entry.model_dump(mode='json') for entry in entries]
@@ -1026,8 +1011,6 @@ class RuntimeConnectionHandler(handler.Handler):
endpoint: str, endpoint: str,
method: str, method: str,
body: Any = None, body: Any = None,
caller: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Forward a page API call to the plugin via runtime.""" """Forward a page API call to the plugin via runtime."""
result = await self.call_action( result = await self.call_action(
@@ -1039,8 +1022,6 @@ class RuntimeConnectionHandler(handler.Handler):
'endpoint': endpoint, 'endpoint': endpoint,
'method': method, 'method': method,
'body': body, 'body': body,
'caller': caller,
'headers': headers or {},
}, },
timeout=30, timeout=30,
) )
@@ -3,7 +3,6 @@ from __future__ import annotations
import abc import abc
import typing import typing
import time import time
import datetime
from ...core import app from ...core import app
from ...entity.persistence import model as persistence_model from ...entity.persistence import model as persistence_model
@@ -17,15 +16,6 @@ LLM_USAGE_QUERY_VARIABLE = '_llm_usage'
STREAM_USAGE_QUERY_VARIABLE = '_stream_usage' STREAM_USAGE_QUERY_VARIABLE = '_stream_usage'
def _utc_now() -> datetime.datetime:
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
def _query_session_id(query: pipeline_query.Query) -> str:
launcher_type = query.launcher_type.value if hasattr(query.launcher_type, 'value') else str(query.launcher_type)
return f'{launcher_type}_{query.launcher_id}'
def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None: def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None:
"""Store the latest provider usage on the query for upstream action handlers.""" """Store the latest provider usage on the query for upstream action handlers."""
if query is None or not usage_info: if query is None or not usage_info:
@@ -69,7 +59,6 @@ class RuntimeProvider:
"""Bridge method for invoking LLM with monitoring""" """Bridge method for invoking LLM with monitoring"""
# Start timing for monitoring # Start timing for monitoring
start_time = time.time() start_time = time.time()
span_started_at = _utc_now()
input_tokens = 0 input_tokens = 0
output_tokens = 0 output_tokens = 0
status = 'success' status = 'success'
@@ -136,30 +125,6 @@ class RuntimeProvider:
error_message=error_message, error_message=error_message,
message_id=message_id, message_id=message_id,
) )
trace_id = query.variables.get('_monitoring_trace_id') if query.variables else None
parent_span_id = query.variables.get('_monitoring_root_span_id') if query.variables else None
if trace_id:
await self.requester.ap.monitoring_service.record_span(
trace_id=trace_id,
parent_span_id=parent_span_id,
name=f'LLM {model.model_entity.name}',
kind='model.llm',
status=status,
started_at=span_started_at,
duration=duration_ms,
message_id=message_id,
session_id=_query_session_id(query),
bot_id=query.bot_uuid,
pipeline_id=query.pipeline_uuid,
attributes={
'model_name': model.model_entity.name,
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'total_tokens': input_tokens + output_tokens,
'stream': False,
},
error_message=error_message,
)
except Exception as monitor_err: except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {monitor_err}') self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {monitor_err}')
@@ -175,7 +140,6 @@ class RuntimeProvider:
"""Bridge method for invoking LLM stream with monitoring""" """Bridge method for invoking LLM stream with monitoring"""
# Start timing for monitoring # Start timing for monitoring
start_time = time.time() start_time = time.time()
span_started_at = _utc_now()
status = 'success' status = 'success'
error_message = None error_message = None
input_tokens = 0 input_tokens = 0
@@ -240,30 +204,6 @@ class RuntimeProvider:
error_message=error_message, error_message=error_message,
message_id=message_id, message_id=message_id,
) )
trace_id = query.variables.get('_monitoring_trace_id') if query.variables else None
parent_span_id = query.variables.get('_monitoring_root_span_id') if query.variables else None
if trace_id:
await self.requester.ap.monitoring_service.record_span(
trace_id=trace_id,
parent_span_id=parent_span_id,
name=f'LLM stream {model.model_entity.name}',
kind='model.llm',
status=status,
started_at=span_started_at,
duration=duration_ms,
message_id=message_id,
session_id=_query_session_id(query),
bot_id=query.bot_uuid,
pipeline_id=query.pipeline_uuid,
attributes={
'model_name': model.model_entity.name,
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'total_tokens': input_tokens + output_tokens,
'stream': True,
},
error_message=error_message,
)
except Exception as monitor_err: except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM stream call: {monitor_err}') self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM stream call: {monitor_err}')
@@ -268,21 +268,6 @@ class LocalAgentRunner(runner.RequestRunner):
'bot_uuid': query.bot_uuid or '', 'bot_uuid': query.bot_uuid or '',
'sender_id': str(query.sender_id), 'sender_id': str(query.sender_id),
'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}', 'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
'_trace_context': {
'trace_id': query.variables.get('_monitoring_trace_id') if query.variables else None,
'parent_span_id': query.variables.get('_monitoring_root_span_id')
if query.variables
else None,
'message_id': query.variables.get('_monitoring_message_id') if query.variables else None,
'query_id': query.query_id,
'session_id': f'{query.launcher_type.value}_{query.launcher_id}',
'bot_id': query.bot_uuid or '',
'pipeline_id': query.pipeline_uuid or '',
'knowledge_base_id': kb_uuid,
'attributes': {
'source': 'local-agent',
},
},
}, },
) )
+4 -123
View File
@@ -1,12 +1,10 @@
from __future__ import annotations from __future__ import annotations
import mimetypes import mimetypes
import os.path import os.path
import time
import traceback import traceback
import uuid import uuid
import zipfile import zipfile
import io import io
import datetime
from typing import Any from typing import Any
from langbot.pkg.core import app from langbot.pkg.core import app
import sqlalchemy import sqlalchemy
@@ -27,10 +25,6 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
super().__init__(ap) super().__init__(ap)
self.knowledge_base_entity = knowledge_base_entity self.knowledge_base_entity = knowledge_base_entity
@staticmethod
def _utc_now() -> datetime.datetime:
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
async def initialize(self): async def initialize(self):
pass pass
@@ -340,25 +334,6 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
# are passed directly to vector_search by some plugins (e.g. LangRAG) # are passed directly to vector_search by some plugins (e.g. LangRAG)
# and would cause empty results when the metadata field doesn't exist. # and would cause empty results when the metadata field doesn't exist.
filters = settings.pop('filters', {}) filters = settings.pop('filters', {})
trace_context = settings.pop('_trace_context', None)
host_span_started_at = self._utc_now()
host_span_started = time.perf_counter()
host_span_id = None
if trace_context and trace_context.get('trace_id'):
host_parent_span_id = trace_context.get('parent_span_id')
host_span_id = trace_context.get('rag_span_id') or f'span-{uuid.uuid4().hex[:16]}'
trace_context = {
'trace_id': trace_context.get('trace_id'),
'parent_span_id': host_span_id,
'host_parent_span_id': host_parent_span_id,
'message_id': trace_context.get('message_id'),
'query_id': trace_context.get('query_id'),
'session_id': trace_context.get('session_id'),
'bot_id': trace_context.get('bot_id'),
'pipeline_id': trace_context.get('pipeline_id'),
'knowledge_base_id': kb.uuid,
'attributes': trace_context.get('attributes') or {},
}
retrieval_context = { retrieval_context = {
'query': query, 'query': query,
@@ -368,107 +343,13 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
'creation_settings': kb.creation_settings or {}, 'creation_settings': kb.creation_settings or {},
'filters': filters, 'filters': filters,
} }
if trace_context:
retrieval_context['trace_context'] = trace_context
try: result = await self.ap.plugin_connector.call_rag_retrieve(
result = await self.ap.plugin_connector.call_rag_retrieve( plugin_id,
plugin_id, retrieval_context,
retrieval_context, )
)
except Exception as e:
if trace_context:
await self._record_rag_trace_result(
trace_context=trace_context,
host_span_id=host_span_id,
started_at=host_span_started_at,
duration=int((time.perf_counter() - host_span_started) * 1000),
plugin_id=plugin_id,
result={
'results': [],
'metadata': {
'status': 'error',
'error_message': str(e),
},
},
)
raise
if trace_context:
await self._record_rag_trace_result(
trace_context=trace_context,
host_span_id=host_span_id,
started_at=host_span_started_at,
duration=int((time.perf_counter() - host_span_started) * 1000),
plugin_id=plugin_id,
result=result,
)
return result return result
async def _record_rag_trace_result(
self,
trace_context: dict[str, Any],
host_span_id: str | None,
started_at: datetime.datetime,
duration: int,
plugin_id: str,
result: dict[str, Any],
) -> None:
"""Persist host RAG span and plugin-provided child spans."""
trace_id = trace_context.get('trace_id')
if not trace_id:
return
metadata = result.get('metadata') if isinstance(result, dict) else {}
metadata = metadata if isinstance(metadata, dict) else {}
plugin_spans = metadata.get('trace_spans') if isinstance(metadata.get('trace_spans'), list) else []
parent_span_id = trace_context.get('parent_span_id')
host_parent_span_id = trace_context.get('host_parent_span_id')
try:
await self.ap.monitoring_service.record_span(
trace_id=trace_id,
span_id=host_span_id,
parent_span_id=host_parent_span_id,
name=f'Knowledge retrieval {self.knowledge_base_entity.name}',
kind='rag.retrieval',
status=metadata.get('status', 'success'),
started_at=started_at,
duration=duration,
message_id=trace_context.get('message_id'),
session_id=trace_context.get('session_id'),
bot_id=trace_context.get('bot_id'),
pipeline_id=trace_context.get('pipeline_id'),
attributes={
'knowledge_base_id': self.knowledge_base_entity.uuid,
'knowledge_base_name': self.knowledge_base_entity.name,
'plugin_id': plugin_id,
'returned_count': len(result.get('results', []) if isinstance(result, dict) else []),
'total_found': result.get('total_found') if isinstance(result, dict) else None,
},
error_message=metadata.get('error_message'),
)
for span in plugin_spans:
if not isinstance(span, dict):
continue
await self.ap.monitoring_service.record_span(
trace_id=trace_id,
span_id=span.get('span_id'),
parent_span_id=span.get('parent_span_id') or host_span_id or parent_span_id,
name=span.get('name') or 'RAG plugin stage',
kind=span.get('kind') or 'rag.stage',
status=span.get('status') or 'success',
started_at=started_at,
duration=span.get('duration_ms'),
message_id=trace_context.get('message_id'),
session_id=trace_context.get('session_id'),
bot_id=trace_context.get('bot_id'),
pipeline_id=trace_context.get('pipeline_id'),
attributes=span.get('attributes') if isinstance(span.get('attributes'), dict) else {},
error_message=span.get('error_message'),
)
except Exception as e:
self.ap.logger.error(f'Failed to record RAG trace spans: {e}')
async def _delete_document(self, document_id: str) -> bool: async def _delete_document(self, document_id: str) -> bool:
"""Call plugin to delete document.""" """Call plugin to delete document."""
kb = self.knowledge_base_entity kb = self.knowledge_base_entity
-65
View File
@@ -8,7 +8,6 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q
from __future__ import annotations from __future__ import annotations
import datetime
import pytest import pytest
from unittest.mock import MagicMock, AsyncMock, Mock from unittest.mock import MagicMock, AsyncMock, Mock
@@ -83,15 +82,6 @@ def fake_monitoring_app():
app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100)) app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100))
app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50)) app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50))
app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10)) app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10))
app.monitoring_service.get_traces = AsyncMock(return_value=([{'trace_id': 'trace-1'}], 1))
app.monitoring_service.get_trace_details = AsyncMock(
side_effect=lambda trace_id: {
'found': trace_id == 'trace-1',
'trace_id': trace_id,
'trace': {'trace_id': trace_id} if trace_id == 'trace-1' else None,
'spans': [] if trace_id == 'trace-1' else None,
}
)
app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20)) app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20))
app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2)) app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2))
app.monitoring_service.get_session_analysis = AsyncMock( app.monitoring_service.get_session_analysis = AsyncMock(
@@ -232,7 +222,6 @@ class TestMonitoringAllDataEndpoint:
assert response.status_code == 200 assert response.status_code == 200
data = await response.get_json() data = await response.get_json()
assert 'overview' in data['data'] assert 'overview' in data['data']
assert 'traces' in data['data']
@pytest.mark.usefixtures('mock_circular_import_chain') @pytest.mark.usefixtures('mock_circular_import_chain')
@@ -257,60 +246,6 @@ class TestMonitoringDetailsEndpoints:
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_trace_details(self, quart_test_client):
"""GET /api/v1/monitoring/traces/{id}."""
response = await quart_test_client.get(
'/api/v1/monitoring/traces/trace-1', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringTraceEndpoints:
"""Tests for trace list and detail endpoints."""
@pytest.mark.asyncio
async def test_get_traces_forwards_filters(self, quart_test_client, fake_monitoring_app):
"""GET /api/v1/monitoring/traces forwards filters to service."""
response = await quart_test_client.get(
'/api/v1/monitoring/traces'
'?botId=bot-1'
'&pipelineId=pipeline-1'
'&sessionId=session-1'
'&status=success'
'&startTime=2026-01-01T00:00:00Z'
'&endTime=2026-01-02T00:00:00Z'
'&limit=25'
'&offset=5',
headers={'Authorization': 'Bearer test_token'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['data']['traces'] == [{'trace_id': 'trace-1'}]
assert data['data']['total'] == 1
fake_monitoring_app.monitoring_service.get_traces.assert_awaited_with(
bot_ids=['bot-1'],
pipeline_ids=['pipeline-1'],
session_ids=['session-1'],
statuses=['success'],
start_time=datetime.datetime(2026, 1, 1, 0, 0),
end_time=datetime.datetime(2026, 1, 2, 0, 0),
limit=25,
offset=5,
)
@pytest.mark.asyncio
async def test_get_trace_details_not_found(self, quart_test_client):
"""GET /api/v1/monitoring/traces/{id} returns 404 when missing."""
response = await quart_test_client.get(
'/api/v1/monitoring/traces/trace-missing', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 404
@pytest.mark.usefixtures('mock_circular_import_chain') @pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringFeedbackEndpoints: class TestMonitoringFeedbackEndpoints:
@@ -104,7 +104,7 @@ class TestSQLiteMigrationUpgrade:
rev = await get_alembic_current(sqlite_engine) rev = await get_alembic_current(sqlite_engine)
assert rev is not None, 'Expected a revision after upgrade' assert rev is not None, 'Expected a revision after upgrade'
# Head should be the latest migration # Head should be the latest migration
assert rev.startswith('0006'), f'Expected head to be 0006_*, got {rev}' assert rev.startswith('0005'), f'Expected head to be 0005_*, got {rev}'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upgrade_idempotent(self, sqlite_engine): async def test_upgrade_idempotent(self, sqlite_engine):
@@ -144,8 +144,8 @@ class TestPostgreSQLMigrationUpgrade:
# Verify revision # Verify revision
rev = await get_alembic_current(postgres_engine) rev = await get_alembic_current(postgres_engine)
assert rev is not None, 'Expected a revision after upgrade' assert rev is not None, 'Expected a revision after upgrade'
# Head should be the latest migration. # Head should be the latest migration (0005 for current state)
assert rev.startswith('0006'), f'Expected head to be 0006_*, got {rev}' assert rev.startswith('0005'), f'Expected head to be 0005_*, got {rev}'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_postgres_upgrade_idempotent(self, postgres_engine, clean_tables, clean_alembic_version): async def test_postgres_upgrade_idempotent(self, postgres_engine, clean_tables, clean_alembic_version):
@@ -1,207 +0,0 @@
"""Unit tests for MonitoringService trace observability."""
from __future__ import annotations
import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.service.monitoring import MonitoringService
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence import monitoring as persistence_monitoring
pytestmark = pytest.mark.asyncio
class _SQLitePersistence:
def __init__(self, engine):
self._engine = engine
def get_db_engine(self):
return self._engine
async def execute_async(self, *args, **kwargs):
async with self._engine.connect() as conn:
result = await conn.execute(*args, **kwargs)
await conn.commit()
return result
def serialize_model(self, model, data, masked_columns=None):
masked_columns = masked_columns or []
return {
column.name: getattr(data, column.name).isoformat()
if isinstance(getattr(data, column.name), datetime.datetime)
else getattr(data, column.name)
for column in model.__table__.columns
if column.name not in masked_columns
}
@pytest.fixture
async def monitoring_service(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "monitoring.db"}')
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
ap = SimpleNamespace(
persistence_mgr=_SQLitePersistence(engine),
instance_config=SimpleNamespace(data={'database': {'use': 'sqlite'}}),
logger=Mock(),
)
service = MonitoringService(ap)
yield service
await engine.dispose()
async def test_trace_lifecycle_records_spans_and_returns_details(monitoring_service):
started_at = datetime.datetime(2026, 1, 1, 12, 0, 0)
ended_at = started_at + datetime.timedelta(milliseconds=125)
trace_id = await monitoring_service.start_trace(
trace_id='trace-test',
name='Pipeline query',
bot_id='bot-1',
bot_name='Bot',
pipeline_id='pipeline-1',
pipeline_name='Default',
session_id='session-1',
message_id='message-1',
query_id=42,
attributes={'source': 'unit-test'},
)
assert trace_id == 'trace-test'
root_span_id = await monitoring_service.record_span(
trace_id=trace_id,
span_id='span-root',
name='Pipeline',
kind='pipeline',
status='completed',
started_at=started_at,
ended_at=ended_at,
message_id='message-1',
session_id='session-1',
bot_id='bot-1',
pipeline_id='pipeline-1',
attributes={'stage_count': 2},
)
await monitoring_service.record_span(
trace_id=trace_id,
span_id='span-rag',
parent_span_id=root_span_id,
name='RAG retrieval',
kind='rag.retrieval',
status='failed',
started_at=started_at + datetime.timedelta(seconds=1),
duration=12.7,
attributes={'top_k': 5},
error_message='vector store timeout',
)
await monitoring_service.finish_trace(
trace_id,
status='completed',
duration=250,
message_id='message-final',
attributes={'result_type': 'reply'},
)
traces, total = await monitoring_service.get_traces(
bot_ids=['bot-1'],
pipeline_ids=['pipeline-1'],
session_ids=['session-1'],
statuses=['success'],
limit=10,
offset=0,
)
assert total == 1
assert traces[0]['trace_id'] == trace_id
assert traces[0]['status'] == 'success'
assert traces[0]['message_id'] == 'message-final'
assert traces[0]['query_id'] == '42'
assert traces[0]['attributes'] == {'result_type': 'reply'}
details = await monitoring_service.get_trace_details(trace_id)
assert details['found'] is True
assert details['trace']['trace_id'] == trace_id
assert [span['span_id'] for span in details['spans']] == ['span-root', 'span-rag']
assert details['spans'][0]['status'] == 'success'
assert details['spans'][0]['duration'] == 125
assert details['spans'][0]['attributes'] == {'stage_count': 2}
assert details['spans'][1]['status'] == 'error'
assert details['spans'][1]['duration'] == 13
assert details['spans'][1]['parent_span_id'] == 'span-root'
assert details['spans'][1]['error_message'] == 'vector store timeout'
async def test_get_trace_details_returns_not_found_for_missing_trace(monitoring_service):
details = await monitoring_service.get_trace_details('trace-missing')
assert details == {'trace_id': 'trace-missing', 'found': False}
async def test_cleanup_expired_records_includes_traces_and_spans(monitoring_service):
old_time = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(days=30)
recent_time = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
await monitoring_service.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringTrace),
[
{
'trace_id': 'trace-old',
'started_at': old_time,
'ended_at': old_time,
'duration': 10,
'status': 'success',
'name': 'Old trace',
},
{
'trace_id': 'trace-recent',
'started_at': recent_time,
'ended_at': recent_time,
'duration': 10,
'status': 'success',
'name': 'Recent trace',
},
],
)
await monitoring_service.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringSpan),
[
{
'span_id': 'span-old',
'trace_id': 'trace-old',
'name': 'Old span',
'kind': 'pipeline',
'status': 'success',
'started_at': old_time,
'ended_at': old_time,
},
{
'span_id': 'span-recent',
'trace_id': 'trace-recent',
'name': 'Recent span',
'kind': 'pipeline',
'status': 'success',
'started_at': recent_time,
'ended_at': recent_time,
},
],
)
monitoring_service._release_sqlite_space = AsyncMock()
deleted = await monitoring_service.cleanup_expired_records(retention_days=7, batch_size=1)
assert deleted['monitoring_traces'] == 1
assert deleted['monitoring_spans'] == 1
monitoring_service._release_sqlite_space.assert_awaited_once()
remaining = await monitoring_service.get_trace_details('trace-recent')
assert remaining['found'] is True
assert remaining['spans'][0]['span_id'] == 'span-recent'
@@ -1,111 +0,0 @@
"""Unit tests for monitoring trace HTTP routes."""
from __future__ import annotations
import datetime
from unittest.mock import AsyncMock, Mock
import pytest
import quart
from tests.factories import FakeApp
from tests.utils.import_isolation import MockLifecycleControlScope, isolated_sys_modules
pytestmark = pytest.mark.asyncio
@pytest.fixture
async def monitoring_client():
mock_app = Mock()
mock_app.Application = type('FakeMinimalApplication', (), {})
mock_entities = Mock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.monitoring',
'langbot.pkg.api.http.controller.main',
]
app = FakeApp()
app.user_service = Mock()
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.monitoring_service = Mock()
app.monitoring_service.get_traces = AsyncMock(return_value=([{'trace_id': 'trace-1'}], 1))
app.monitoring_service.get_trace_details = AsyncMock(
side_effect=lambda trace_id: {
'found': trace_id == 'trace-1',
'trace_id': trace_id,
'trace': {'trace_id': trace_id} if trace_id == 'trace-1' else None,
'spans': [] if trace_id == 'trace-1' else None,
}
)
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
from langbot.pkg.api.http.controller.groups.monitoring import MonitoringRouterGroup
quart_app = quart.Quart(__name__)
group = MonitoringRouterGroup(app, quart_app)
await group.initialize()
yield app, quart_app.test_client()
async def test_get_traces_route_forwards_filters(monitoring_client):
app, client = monitoring_client
response = await client.get(
'/api/v1/monitoring/traces'
'?botId=bot-1'
'&pipelineId=pipeline-1'
'&sessionId=session-1'
'&status=success'
'&startTime=2026-01-01T00:00:00Z'
'&endTime=2026-01-02T00:00:00Z'
'&limit=25'
'&offset=5',
headers={'Authorization': 'Bearer test_token'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['data'] == {
'traces': [{'trace_id': 'trace-1'}],
'total': 1,
'limit': 25,
'offset': 5,
}
app.monitoring_service.get_traces.assert_awaited_once_with(
bot_ids=['bot-1'],
pipeline_ids=['pipeline-1'],
session_ids=['session-1'],
statuses=['success'],
start_time=datetime.datetime(2026, 1, 1, 0, 0),
end_time=datetime.datetime(2026, 1, 2, 0, 0),
limit=25,
offset=5,
)
async def test_get_trace_details_route_returns_404_for_missing_trace(monitoring_client):
_app, client = monitoring_client
response = await client.get(
'/api/v1/monitoring/traces/trace-missing',
headers={'Authorization': 'Bearer test_token'},
)
assert response.status_code == 404
data = await response.get_json()
assert data['code'] == -1
assert data['msg'] == 'Trace trace-missing not found'
@@ -1,87 +0,0 @@
"""Unit tests for the monitoring trace Alembic migration."""
from __future__ import annotations
from importlib import import_module
class _FakeInspector:
def __init__(self, tables):
self._tables = tables
def get_table_names(self):
return list(self._tables)
class _FakeOp:
def __init__(self):
self.created_tables = []
self.created_indexes = []
self.dropped_tables = []
def get_bind(self):
return object()
def create_table(self, table_name, *columns):
self.created_tables.append((table_name, columns))
def create_index(self, index_name, table_name, columns):
self.created_indexes.append((index_name, table_name, columns))
def drop_table(self, table_name):
self.dropped_tables.append(table_name)
def _migration_module():
return import_module('langbot.pkg.persistence.alembic.versions.0006_monitoring_traces')
def test_upgrade_creates_monitoring_trace_tables_and_indexes(monkeypatch):
migration = _migration_module()
fake_op = _FakeOp()
monkeypatch.setattr(migration, 'op', fake_op)
monkeypatch.setattr(migration.sa, 'inspect', lambda _conn: _FakeInspector(tables=set()))
migration.upgrade()
assert [table_name for table_name, _columns in fake_op.created_tables] == [
'monitoring_traces',
'monitoring_spans',
]
assert ('ix_monitoring_traces_started_at', 'monitoring_traces', ['started_at']) in fake_op.created_indexes
assert ('ix_monitoring_spans_trace_id', 'monitoring_spans', ['trace_id']) in fake_op.created_indexes
assert ('ix_monitoring_spans_pipeline_id', 'monitoring_spans', ['pipeline_id']) in fake_op.created_indexes
def test_upgrade_skips_existing_monitoring_trace_tables(monkeypatch):
migration = _migration_module()
fake_op = _FakeOp()
monkeypatch.setattr(migration, 'op', fake_op)
monkeypatch.setattr(
migration.sa,
'inspect',
lambda _conn: _FakeInspector(tables={'monitoring_traces', 'monitoring_spans'}),
)
migration.upgrade()
assert fake_op.created_tables == []
assert fake_op.created_indexes == []
def test_downgrade_drops_spans_before_traces(monkeypatch):
migration = _migration_module()
fake_op = _FakeOp()
monkeypatch.setattr(migration, 'op', fake_op)
monkeypatch.setattr(
migration.sa,
'inspect',
lambda _conn: _FakeInspector(tables={'monitoring_traces', 'monitoring_spans'}),
)
migration.downgrade()
assert fake_op.dropped_tables == ['monitoring_spans', 'monitoring_traces']
@@ -162,61 +162,3 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
# Verify stage was called # Verify stage was called
mock_stage.process.assert_called_once() mock_stage.process.assert_called_once()
@pytest.mark.asyncio
async def test_runtime_pipeline_marks_trace_error_when_stage_returns_error_notice(mock_app, sample_query):
"""Trace status follows handled stage errors, not only raised exceptions."""
pipelinemgr = get_pipelinemgr_module()
stage = get_stage_module()
persistence_pipeline = get_persistence_pipeline_module()
entities = get_entities_module()
error_result = entities.StageProcessResult(
result_type=entities.ResultType.INTERRUPT,
new_query=sample_query,
user_notice='',
console_notice='',
debug_notice='traceback',
error_notice='model request failed',
)
mock_stage = Mock(spec=stage.PipelineStage)
mock_stage.process = AsyncMock(return_value=error_result)
stage_container = pipelinemgr.StageInstContainer(inst_name='FailingStage', inst=mock_stage)
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.uuid = 'test-pipeline-uuid'
pipeline_entity.name = 'Test Pipeline'
pipeline_entity.config = sample_query.pipeline_config
pipeline_entity.extensions_preferences = {'plugins': []}
mock_app.bot_service = AsyncMock()
mock_app.bot_service.get_bot = AsyncMock(return_value={'name': 'Test Bot'})
mock_app.monitoring_service = AsyncMock()
mock_app.monitoring_service.record_message = AsyncMock(return_value='message-1')
mock_app.monitoring_service.update_session_activity = AsyncMock(return_value=True)
mock_app.monitoring_service.start_trace = AsyncMock(return_value='trace-1')
mock_app.monitoring_service.record_span = AsyncMock()
mock_app.monitoring_service.finish_trace = AsyncMock()
mock_app.monitoring_service.update_message_status = AsyncMock()
mock_app.monitoring_service.record_error = AsyncMock()
event_ctx = Mock()
event_ctx.is_prevented_default = Mock(return_value=False)
mock_app.plugin_connector.emit_event = AsyncMock(return_value=event_ctx)
mock_app.query_pool.cached_queries[sample_query.query_id] = sample_query
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [stage_container])
await runtime_pipeline.run(sample_query)
mock_app.monitoring_service.finish_trace.assert_awaited_once()
assert mock_app.monitoring_service.finish_trace.await_args.kwargs['status'] == 'error'
span_calls = mock_app.monitoring_service.record_span.await_args_list
stage_span_call = next(call for call in span_calls if call.kwargs['name'] == 'FailingStage')
root_span_call = next(call for call in span_calls if call.kwargs['kind'] == 'pipeline.query')
assert stage_span_call.kwargs['status'] == 'error'
assert stage_span_call.kwargs['error_message'] == 'model request failed'
assert root_span_call.kwargs['status'] == 'error'
-26
View File
@@ -407,32 +407,6 @@ class TestRuntimeKnowledgeBaseRetrieve:
call_args = mock_app.plugin_connector.call_rag_retrieve.call_args call_args = mock_app.plugin_connector.call_rag_retrieve.call_args
assert call_args[0][1]['retrieval_settings']['top_k'] == 5 assert call_args[0][1]['retrieval_settings']['top_k'] == 5
@pytest.mark.asyncio
async def test_retrieve_records_host_rag_duration(self, monkeypatch):
"""Test host RAG span duration is measured even if plugin omits it."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_app.monitoring_service = AsyncMock()
mock_kb = create_mock_kb_entity()
mock_app.plugin_connector.call_rag_retrieve = AsyncMock(
return_value={'results': [], 'metadata': {'status': 'success'}}
)
monkeypatch.setattr(rag_module.time, 'perf_counter', Mock(side_effect=[10.0, 10.25]))
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
await runtime_kb._retrieve(
'query text',
{
'_trace_context': {
'trace_id': 'trace-1',
'parent_span_id': 'span-root',
}
},
)
assert mock_app.monitoring_service.record_span.await_args.kwargs['duration'] == 250
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_retrieve_converts_dict_to_entry(self): async def test_retrieve_converts_dict_to_entry(self):
"""Test that dict results are converted to RetrievalResultEntry.""" """Test that dict results are converted to RetrievalResultEntry."""
@@ -48,6 +48,7 @@ interface PipelineOption {
} }
interface RoutingRulesEditorProps { interface RoutingRulesEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: UseFormReturn<any>; form: UseFormReturn<any>;
pipelineNameList: PipelineOption[]; pipelineNameList: PipelineOption[];
} }
@@ -0,0 +1,181 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
interface AccountSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function AccountSettingsDialog({
open,
onOpenChange,
}: AccountSettingsDialogProps) {
const { t } = useTranslation();
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [hasPassword, setHasPassword] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
useEffect(() => {
if (open) {
loadUserInfo();
}
}, [open]);
async function loadUserInfo() {
setLoading(true);
try {
const info = await httpClient.getUserInfo();
setAccountType(info.account_type);
setHasPassword(info.has_password);
setUserEmail(info.user);
} catch {
toast.error(t('common.error'));
} finally {
setLoading(false);
}
}
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
setSpaceBindLoading(false);
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
// Pass token as state for security verification
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
setSpaceBindLoading(false);
}
};
const handlePasswordDialogClose = (dialogOpen: boolean) => {
setPasswordDialogOpen(dialogOpen);
if (!dialogOpen) {
// Reload user info to update password status
loadUserInfo();
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('account.settings')}</DialogTitle>
<DialogDescription>{userEmail}</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
{/* Password Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<KeyRound className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
<ItemDescription>
{hasPassword
? t('account.passwordSetDescription')
: t('account.setPasswordHint')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={() => setPasswordDialogOpen(true)}
disabled={!systemInfo.allow_modify_login_info}
>
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</Button>
</ItemActions>
</Item>
{/* Space Account Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<Layers className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
<ItemDescription>
{accountType === 'space'
? t('account.spaceBoundDescription')
: t('account.bindSpaceDescription')}
</ItemDescription>
</ItemContent>
{accountType === 'local' && (
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={handleBindSpace}
disabled={
spaceBindLoading || !systemInfo.allow_modify_login_info
}
>
{spaceBindLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
</Button>
</ItemActions>
)}
</Item>
</div>
)}
</DialogContent>
</Dialog>
<PasswordChangeDialog
open={passwordDialogOpen}
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
</>
);
}
@@ -1,171 +0,0 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
interface AccountSettingsPanelProps {
// True when this panel is the active section and the dialog is open.
active: boolean;
onEmailResolved?: (email: string) => void;
}
export default function AccountSettingsPanel({
active,
onEmailResolved,
}: AccountSettingsPanelProps) {
const { t } = useTranslation();
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [hasPassword, setHasPassword] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
useEffect(() => {
if (active) {
loadUserInfo();
}
}, [active]);
async function loadUserInfo() {
setLoading(true);
try {
const info = await httpClient.getUserInfo();
setAccountType(info.account_type);
setHasPassword(info.has_password);
setUserEmail(info.user);
onEmailResolved?.(info.user);
} catch {
toast.error(t('common.error'));
} finally {
setLoading(false);
}
}
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
setSpaceBindLoading(false);
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
// Pass token as state for security verification
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
setSpaceBindLoading(false);
}
};
const handlePasswordDialogClose = (dialogOpen: boolean) => {
setPasswordDialogOpen(dialogOpen);
if (!dialogOpen) {
// Reload user info to update password status
loadUserInfo();
}
};
return (
<PanelBody>
{userEmail && (
<p className="mb-4 text-sm text-muted-foreground">{userEmail}</p>
)}
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
{/* Password Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<KeyRound className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
<ItemDescription>
{hasPassword
? t('account.passwordSetDescription')
: t('account.setPasswordHint')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={() => setPasswordDialogOpen(true)}
disabled={!systemInfo.allow_modify_login_info}
>
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</Button>
</ItemActions>
</Item>
{/* Space Account Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<Layers className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
<ItemDescription>
{accountType === 'space'
? t('account.spaceBoundDescription')
: t('account.bindSpaceDescription')}
</ItemDescription>
</ItemContent>
{accountType === 'local' && (
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={handleBindSpace}
disabled={
spaceBindLoading || !systemInfo.allow_modify_login_info
}
>
{spaceBindLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
</Button>
</ItemActions>
)}
</Item>
</div>
)}
<PasswordChangeDialog
open={passwordDialogOpen}
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
</PanelBody>
);
}
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Copy, Check, Trash2, Plus } from 'lucide-react'; import { Copy, Check, Trash2, Plus } from 'lucide-react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -36,7 +37,6 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { backendClient } from '@/app/infra/http'; import { backendClient } from '@/app/infra/http';
import { PanelToolbar } from '../settings-dialog/panel-layout';
interface ApiKey { interface ApiKey {
id: number; id: number;
@@ -55,15 +55,20 @@ interface Webhook {
created_at: string; created_at: string;
} }
interface ApiIntegrationPanelProps { interface ApiIntegrationDialogProps {
// True when this panel is the active section and the dialog is open. open: boolean;
active: boolean; onOpenChange: (open: boolean) => void;
} }
export default function ApiIntegrationPanel({ export default function ApiIntegrationDialog({
active, open,
}: ApiIntegrationPanelProps) { onOpenChange,
}: ApiIntegrationDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState('apikeys'); const [activeTab, setActiveTab] = useState('apikeys');
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]); const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]); const [webhooks, setWebhooks] = useState<Webhook[]>([]);
@@ -86,7 +91,33 @@ export default function ApiIntegrationPanel({
); );
const [copiedKey, setCopiedKey] = useState<string | null>(null); const [copiedKey, setCopiedKey] = useState<string | null>(null);
// 清理 body 样式,防止嵌套对话框关闭后页面无法交互 // Sync URL with dialog state
useEffect(() => {
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showApiIntegrationSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
}
}, [open]);
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
return;
}
if (!newOpen) {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
}
onOpenChange(newOpen);
};
// 清理 body 样式,防止对话框关闭后页面无法交互
useEffect(() => { useEffect(() => {
if (!deleteKeyId && !deleteWebhookId) { if (!deleteKeyId && !deleteWebhookId) {
const cleanup = () => { const cleanup = () => {
@@ -100,11 +131,11 @@ export default function ApiIntegrationPanel({
}, [deleteKeyId, deleteWebhookId]); }, [deleteKeyId, deleteWebhookId]);
useEffect(() => { useEffect(() => {
if (active) { if (open) {
loadApiKeys(); loadApiKeys();
loadWebhooks(); loadWebhooks();
} }
}, [active]); }, [open]);
const loadApiKeys = async () => { const loadApiKeys = async () => {
setLoading(true); setLoading(true);
@@ -253,209 +284,233 @@ export default function ApiIntegrationPanel({
return ( return (
<> <>
<Tabs <Dialog open={open} onOpenChange={handleOpenChange}>
value={activeTab} <DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
onValueChange={setActiveTab} <DialogHeader>
className="flex h-full min-h-0 w-full flex-col overflow-hidden" <DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
> </DialogHeader>
<PanelToolbar>
<TabsList>
<TabsTrigger value="apikeys">{t('common.apiKeys')}</TabsTrigger>
<TabsTrigger value="webhooks">{t('common.webhooks')}</TabsTrigger>
</TabsList>
{activeTab === 'apikeys' ? (
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
) : (
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
)}
</PanelToolbar>
{/* API Keys Tab */} <Tabs
<TabsContent value={activeTab}
value="apikeys" onValueChange={setActiveTab}
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5" className="w-full flex-1 flex flex-col overflow-hidden"
> >
<p className="text-sm text-muted-foreground"> <TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
{t('common.apiKeyHint')} <TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
</p> {t('common.apiKeys')}
</TabsTrigger>
<TabsTrigger
className="px-5 py-4 cursor-pointer"
value="webhooks"
>
{t('common.webhooks')}
</TabsTrigger>
</TabsList>
{loading ? ( {/* API Keys Tab */}
<div className="text-center py-8 text-muted-foreground"> <TabsContent
{t('common.loading')} value="apikeys"
</div> className="space-y-4 flex-1 flex flex-col overflow-hidden"
) : apiKeys.length === 0 ? ( >
<div className="text-center py-8 text-muted-foreground"> <div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.noApiKeys')} {t('common.apiKeyHint')}
</div> </div>
) : (
<div className="flex-1 overflow-auto rounded-md border"> <div className="flex justify-end">
<Table> <Button
<TableHeader> onClick={() => setShowCreateDialog(true)}
<TableRow> size="sm"
<TableHead className="min-w-[120px]"> className="gap-2"
{t('common.name')} >
</TableHead> <Plus className="h-4 w-4" />
<TableHead className="min-w-[200px]"> {t('common.createApiKey')}
{t('common.apiKeyValue')} </Button>
</TableHead> </div>
<TableHead className="w-[100px]">
{t('common.actions')} {loading ? (
</TableHead> <div className="text-center py-8 text-muted-foreground">
</TableRow> {t('common.loading')}
</TableHeader> </div>
<TableBody> ) : apiKeys.length === 0 ? (
{apiKeys.map((item) => ( <div className="text-center py-8 text-muted-foreground">
<TableRow key={item.id}> {t('common.noApiKeys')}
<TableCell> </div>
<div> ) : (
<div className="font-medium">{item.name}</div> <div className="border rounded-md overflow-auto flex-1">
{item.description && ( <Table>
<div className="text-sm text-muted-foreground"> <TableHeader>
{item.description} <TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div>
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="text-sm text-muted-foreground">
{item.description}
</div>
)}
</div> </div>
)} </TableCell>
</div> <TableCell>
</TableCell> <code className="text-sm bg-muted px-2 py-1 rounded">
<TableCell> {maskApiKey(item.key)}
<code className="text-sm bg-muted px-2 py-1 rounded"> </code>
{maskApiKey(item.key)} </TableCell>
</code> <TableCell>
</TableCell> <div className="flex gap-2">
<TableCell> <Button
<div className="flex gap-2"> variant="ghost"
<Button size="sm"
variant="ghost" type="button"
size="sm" onClick={() => handleCopyKey(item.key)}
type="button" title={t('common.copyApiKey')}
onClick={() => handleCopyKey(item.key)} >
title={t('common.copyApiKey')} {copiedKey === item.key ? (
> <Check className="h-4 w-4 text-green-600" />
{copiedKey === item.key ? ( ) : (
<Check className="h-4 w-4 text-green-600" /> <Copy className="h-4 w-4" />
) : ( )}
<Copy className="h-4 w-4" /> </Button>
)} <Button
</Button> variant="ghost"
<Button size="sm"
variant="ghost" onClick={() => setDeleteKeyId(item.id)}
size="sm" title={t('common.delete')}
onClick={() => setDeleteKeyId(item.id)} >
title={t('common.delete')} <Trash2 className="h-4 w-4" />
> </Button>
<Trash2 className="h-4 w-4" /> </div>
</Button> </TableCell>
</div> </TableRow>
</TableCell> ))}
</TableRow> </TableBody>
))} </Table>
</TableBody> </div>
</Table> )}
</div> </TabsContent>
)}
</TabsContent>
{/* Webhooks Tab */} {/* Webhooks Tab */}
<TabsContent <TabsContent
value="webhooks" value="webhooks"
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5" className="space-y-4 flex-1 flex flex-col overflow-hidden"
> >
<p className="text-sm text-muted-foreground"> <div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.webhookHint')} {t('common.webhookHint')}
</p> </div>
{loading ? ( <div className="flex justify-end">
<div className="text-center py-8 text-muted-foreground"> <Button
{t('common.loading')} onClick={() => setShowCreateWebhookDialog(true)}
</div> size="sm"
) : webhooks.length === 0 ? ( className="gap-2"
<div className="text-center py-8 text-muted-foreground"> >
{t('common.noWebhooks')} <Plus className="h-4 w-4" />
</div> {t('common.createWebhook')}
) : ( </Button>
<div className="max-w-full flex-1 overflow-auto rounded-md border"> </div>
<Table className="table-fixed w-full">
<TableHeader> {loading ? (
<TableRow> <div className="text-center py-8 text-muted-foreground">
<TableHead className="w-[150px]"> {t('common.loading')}
{t('common.name')} </div>
</TableHead> ) : webhooks.length === 0 ? (
<TableHead className="w-[380px]"> <div className="text-center py-8 text-muted-foreground">
{t('common.webhookUrl')} {t('common.noWebhooks')}
</TableHead> </div>
<TableHead className="w-[80px]"> ) : (
{t('common.webhookEnabled')} <div className="border rounded-md overflow-auto flex-1 max-w-full">
</TableHead> <Table className="table-fixed w-full">
<TableHead className="w-[80px]"> <TableHeader>
{t('common.actions')} <TableRow>
</TableHead> <TableHead className="w-[150px]">
</TableRow> {t('common.name')}
</TableHeader> </TableHead>
<TableBody> <TableHead className="w-[380px]">
{webhooks.map((webhook) => ( {t('common.webhookUrl')}
<TableRow key={webhook.id}> </TableHead>
<TableCell className="truncate"> <TableHead className="w-[80px]">
<div className="truncate"> {t('common.webhookEnabled')}
<div </TableHead>
className="font-medium truncate" <TableHead className="w-[80px]">
title={webhook.name} {t('common.actions')}
> </TableHead>
{webhook.name} </TableRow>
</div> </TableHeader>
{webhook.description && ( <TableBody>
<div {webhooks.map((webhook) => (
className="text-sm text-muted-foreground truncate" <TableRow key={webhook.id}>
title={webhook.description} <TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div>
{webhook.description && (
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() =>
handleToggleWebhook(webhook)
}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
> >
{webhook.description} <Trash2 className="h-4 w-4" />
</div> </Button>
)} </TableCell>
</div> </TableRow>
</TableCell> ))}
<TableCell> </TableBody>
<div className="overflow-x-auto max-w-[380px]"> </Table>
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block"> </div>
{webhook.url} )}
</code> </TabsContent>
</div> </Tabs>
</TableCell>
<TableCell> <DialogFooter>
<Switch <Button variant="outline" onClick={() => onOpenChange(false)}>
checked={webhook.enabled} {t('common.close')}
onCheckedChange={() => handleToggleWebhook(webhook)} </Button>
/> </DialogFooter>
</TableCell> </DialogContent>
<TableCell> </Dialog>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
{/* Create API Key Dialog */} {/* Create API Key Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}> <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
@@ -61,9 +61,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import SettingsDialog, { import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
SettingsSection,
} from '@/app/home/components/settings-dialog/SettingsDialog';
export default function DynamicFormItemComponent({ export default function DynamicFormItemComponent({
config, config,
@@ -89,8 +87,6 @@ export default function DynamicFormItemComponent({
); );
const { t } = useTranslation(); const { t } = useTranslation();
const [modelsDialogOpen, setModelsDialogOpen] = useState(false); const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
const fetchLlmModels = () => { const fetchLlmModels = () => {
httpClient httpClient
@@ -565,11 +561,9 @@ export default function DynamicFormItemComponent({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{t('models.title')}</TooltipContent> <TooltipContent side="right">{t('models.title')}</TooltipContent>
</Tooltip> </Tooltip>
<SettingsDialog <ModelsDialog
open={modelsDialogOpen} open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange} onOpenChange={handleModelsDialogChange}
section={settingsSection}
onSectionChange={setSettingsSection}
/> />
</div> </div>
); );
@@ -919,11 +913,9 @@ export default function DynamicFormItemComponent({
{t('models.title')} {t('models.title')}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<SettingsDialog <ModelsDialog
open={modelsDialogOpen} open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange} onOpenChange={handleModelsDialogChange}
section={settingsSection}
onSectionChange={setSettingsSection}
/> />
</div> </div>
</div> </div>
@@ -47,6 +47,7 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
export function getDefaultValues( export function getDefaultValues(
itemConfigList: IDynamicFormItemSchema[], itemConfigList: IDynamicFormItemSchema[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Record<string, any> { ): Record<string, any> {
return itemConfigList.reduce( return itemConfigList.reduce(
(acc, item) => { (acc, item) => {
@@ -58,7 +59,7 @@ export function getDefaultValues(
acc[item.name] = item.default; acc[item.name] = item.default;
return acc; return acc;
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{} as Record<string, any>, {} as Record<string, any>,
); );
} }
@@ -57,12 +57,11 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { LanguageSelector } from '@/components/ui/language-selector'; import { LanguageSelector } from '@/components/ui/language-selector';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import AccountSettingsDialog from '@/app/home/components/account-settings-dialog/AccountSettingsDialog';
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog'; import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
import SettingsDialog, { import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
SettingsSection, import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
SETTINGS_ACTION_BY_SECTION,
SETTINGS_SECTION_BY_ACTION,
} from '@/app/home/components/settings-dialog/SettingsDialog';
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient'; import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -1549,10 +1548,17 @@ export default function HomeSidebar({
}, [pathname]); }, [pathname]);
useEffect(() => { useEffect(() => {
const action = searchParams.get('action'); if (searchParams.get('action') === 'showModelSettings') {
if (action && SETTINGS_SECTION_BY_ACTION[action]) { setModelsDialogOpen(true);
setSettingsSection(SETTINGS_SECTION_BY_ACTION[action]); }
setSettingsOpen(true); if (searchParams.get('action') === 'showAccountSettings') {
setAccountSettingsOpen(true);
}
if (searchParams.get('action') === 'showApiIntegrationSettings') {
setApiKeyDialogOpen(true);
}
if (searchParams.get('action') === 'showStorageAnalysis') {
setStorageAnalysisOpen(true);
} }
}, [searchParams]); }, [searchParams]);
@@ -1561,14 +1567,15 @@ export default function HomeSidebar({
useState<Record<string, boolean>>(loadSectionState); useState<Record<string, boolean>>(loadSectionState);
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const [settingsOpen, setSettingsOpen] = useState(false); const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] = const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
useState<SettingsSection>('models');
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>( const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
null, null,
); );
const [hasNewVersion, setHasNewVersion] = useState(false); const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false); const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>(''); const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(null); const [starCount, setStarCount] = useState<number | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
@@ -1593,28 +1600,51 @@ export default function HomeSidebar({
setShowScrollHint(false); setShowScrollHint(false);
}, 250); }, 250);
} }
function openSettings(section: SettingsSection) { function handleModelsDialogChange(open: boolean) {
setSettingsSection(section); setModelsDialogOpen(open);
setSettingsOpen(true); if (open) {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set('action', SETTINGS_ACTION_BY_SECTION[section]); params.set('action', 'showModelSettings');
navigate(`${pathname}?${params.toString()}`, { navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true, preventScrollReset: true,
}); });
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
}
} }
function handleSettingsSectionChange(section: SettingsSection) { function handleAccountSettingsChange(open: boolean) {
setSettingsSection(section); setAccountSettingsOpen(open);
const params = new URLSearchParams(searchParams.toString()); if (open) {
params.set('action', SETTINGS_ACTION_BY_SECTION[section]); const params = new URLSearchParams(searchParams.toString());
navigate(`${pathname}?${params.toString()}`, { params.set('action', 'showAccountSettings');
preventScrollReset: true, navigate(`${pathname}?${params.toString()}`, {
}); preventScrollReset: true,
});
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
}
} }
function handleSettingsOpenChange(open: boolean) { function handleStorageAnalysisChange(open: boolean) {
setSettingsOpen(open); setStorageAnalysisOpen(open);
if (!open) { if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showStorageAnalysis');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
} else {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.delete('action'); params.delete('action');
const newUrl = params.toString() const newUrl = params.toString()
@@ -1883,24 +1913,11 @@ export default function HomeSidebar({
{/* Footer */} {/* Footer */}
<SidebarFooter> <SidebarFooter>
{/* Models entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => openSettings('models')}
tooltip={t('models.title')}
>
<Sparkles className="text-blue-500" />
<span>{t('models.title')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* API Integration entry */} {/* API Integration entry */}
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
onClick={() => openSettings('apiIntegration')} onClick={() => setApiKeyDialogOpen(true)}
tooltip={t('common.apiIntegration')} tooltip={t('common.apiIntegration')}
> >
<KeyRound className="size-4 text-blue-500" /> <KeyRound className="size-4 text-blue-500" />
@@ -1909,6 +1926,19 @@ export default function HomeSidebar({
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
{/* Models entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => handleModelsDialogChange(true)}
tooltip={t('models.title')}
>
<Sparkles className="text-blue-500" />
<span>{t('models.title')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */} {/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
@@ -1988,10 +2018,7 @@ export default function HomeSidebar({
{/* Account actions */} {/* Account actions */}
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => handleAccountSettingsChange(true)}
setUserMenuOpen(false);
openSettings('account');
}}
> >
<Settings /> <Settings />
{t('account.settings')} {t('account.settings')}
@@ -1999,7 +2026,7 @@ export default function HomeSidebar({
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setUserMenuOpen(false); setUserMenuOpen(false);
openSettings('storageAnalysis'); handleStorageAnalysisChange(true);
}} }}
> >
<HardDrive /> <HardDrive />
@@ -2096,17 +2123,27 @@ export default function HomeSidebar({
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
<SettingsDialog <AccountSettingsDialog
open={settingsOpen} open={accountSettingsOpen}
onOpenChange={handleSettingsOpenChange} onOpenChange={handleAccountSettingsChange}
section={settingsSection} />
onSectionChange={handleSettingsSectionChange} <ApiIntegrationDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
/> />
<NewVersionDialog <NewVersionDialog
open={versionDialogOpen} open={versionDialogOpen}
onOpenChange={setVersionDialogOpen} onOpenChange={setVersionDialogOpen}
release={latestRelease} release={latestRelease}
/> />
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
<StorageAnalysisDialog
open={storageAnalysisOpen}
onOpenChange={handleStorageAnalysisChange}
/>
</> </>
); );
} }
@@ -23,13 +23,10 @@ import {
LANGBOT_MODELS_PROVIDER_REQUESTER, LANGBOT_MODELS_PROVIDER_REQUESTER,
} from './types'; } from './types';
import { CustomApiError } from '@/app/infra/entities/common'; import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
interface ModelsPanelProps { interface ModelsDialogProps {
// True when this panel is the active section and the dialog is open. open: boolean;
active: boolean; onOpenChange: (open: boolean) => void;
// Notify parent when a nested modal (provider form) should block outer-close.
onBlockingChange?: (blocking: boolean) => void;
} }
type ExtraArgValue = string | number | boolean | Record<string, unknown>; type ExtraArgValue = string | number | boolean | Record<string, unknown>;
@@ -78,10 +75,10 @@ function parseContextLength(
return value; return value;
} }
export default function ModelsPanel({ export default function ModelsDialog({
active, open,
onBlockingChange, onOpenChange,
}: ModelsPanelProps) { }: ModelsDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [providers, setProviders] = useState<ModelProvider[]>([]); const [providers, setProviders] = useState<ModelProvider[]>([]);
@@ -139,17 +136,12 @@ export default function ModelsPanel({
); );
useEffect(() => { useEffect(() => {
if (active) { if (open) {
loadUserInfo(); loadUserInfo();
loadProviders(); loadProviders();
loadRequesterSupportTypes(); loadRequesterSupportTypes();
} }
}, [active]); }, [open]);
// Notify parent of blocking state so it can guard outer-close.
useEffect(() => {
onBlockingChange?.(providerFormOpen);
}, [providerFormOpen, onBlockingChange]);
// Auto-expand LangBot Models when no external providers exist // Auto-expand LangBot Models when no external providers exist
useEffect(() => { useEffect(() => {
@@ -612,38 +604,57 @@ export default function ModelsPanel({
return ( return (
<> <>
<PanelBody> <Dialog
{/* LangBot Models (Space) provider card is intentionally pinned to the open={open}
top, above the "add custom provider" action row. */} onOpenChange={(newOpen) => {
{langbotProvider && renderProviderCard(langbotProvider, true)} if (!newOpen && providerFormOpen) return;
onOpenChange(newOpen);
}}
>
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
<DialogTitle>{t('models.title')}</DialogTitle>
</DialogHeader>
{/* Add-provider row: stays below the pinned card by design. */} <div className="flex-1 overflow-auto px-6 pb-6 mt-0">
<div className="mb-3 flex items-center justify-between gap-3"> {/* LangBot Models Card */}
<span className="text-sm text-muted-foreground"> {langbotProvider && renderProviderCard(langbotProvider, true)}
{otherProviders.length === 0
? t(
systemInfo.disable_models_service
? 'models.addProviderHintSimple'
: 'models.addProviderHint',
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
{/* Provider List */} {/* Add Provider Button */}
{otherProviders.length === 0 ? ( <div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground"> <span className="text-sm text-muted-foreground">
<Boxes className="h-12 w-12 mb-3 opacity-50" /> {otherProviders.length === 0
<p className="text-sm">{t('models.noProviders')}</p> ? t(
systemInfo.disable_models_service
? 'models.addProviderHintSimple'
: 'models.addProviderHint',
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleCreateProvider}
>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
</div>
{/* Provider List */}
{otherProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Boxes className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{t('models.noProviders')}</p>
</div>
) : (
otherProviders.map((p) => renderProviderCard(p))
)}
</div> </div>
) : ( </DialogContent>
otherProviders.map((p) => renderProviderCard(p)) </Dialog>
)}
</PanelBody>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}> <Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-[600px] p-6"> <DialogContent className="w-[600px] p-6">
@@ -1,229 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from '@/components/ui/sidebar';
import { cn } from '@/lib/utils';
import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/AccountSettingsPanel';
import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel';
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
// The set of settings sections shown in the unified dialog. The string values
// are also reused as the ?action= query param suffix so deep links keep working.
export type SettingsSection =
| 'account'
| 'apiIntegration'
| 'models'
| 'storageAnalysis';
// Map between a section id and its ?action= query value, so existing deep links
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
// showStorageAnalysis) continue to resolve to the right section.
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
account: 'showAccountSettings',
apiIntegration: 'showApiIntegrationSettings',
models: 'showModelSettings',
storageAnalysis: 'showStorageAnalysis',
};
export const SETTINGS_SECTION_BY_ACTION: Record<string, SettingsSection> =
Object.fromEntries(
Object.entries(SETTINGS_ACTION_BY_SECTION).map(([section, action]) => [
action,
section as SettingsSection,
]),
);
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
section: SettingsSection;
onSectionChange: (section: SettingsSection) => void;
}
export default function SettingsDialog({
open,
onOpenChange,
section,
onSectionChange,
}: SettingsDialogProps) {
const { t } = useTranslation();
// A nested modal (e.g. the provider form) can request that we ignore
// outer-close until it is dismissed.
const [blocking, setBlocking] = useState(false);
// Only the Models panel can raise a blocking nested modal. When we navigate
// away from it (or close the dialog) the panel unmounts without resetting,
// so clear the flag here to avoid getting stuck unable to close.
useEffect(() => {
if (section !== 'models' || !open) {
setBlocking(false);
}
}, [section, open]);
const navItems: {
id: SettingsSection;
label: string;
title: string;
description: string;
icon: React.ReactNode;
}[] = [
{
id: 'models',
label: t('settingsDialog.nav.models'),
title: t('models.title'),
description: t('models.description'),
icon: <Sparkles className="size-4" />,
},
{
id: 'apiIntegration',
label: t('settingsDialog.nav.api'),
title: t('common.apiIntegration'),
description: t('common.apiIntegrationDescription'),
icon: <KeyRound className="size-4" />,
},
{
id: 'storageAnalysis',
label: t('settingsDialog.nav.storage'),
title: t('storageAnalysis.title'),
description: t('storageAnalysis.description'),
icon: <HardDrive className="size-4" />,
},
{
id: 'account',
label: t('settingsDialog.nav.account'),
title: t('account.settings'),
description: t('account.settingsDescription'),
icon: <Settings className="size-4" />,
},
];
const activeItem = navItems.find((item) => item.id === section);
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && blocking) return;
onOpenChange(newOpen);
}}
>
<DialogContent
className="h-[80vh] max-h-[800px] overflow-hidden p-0 sm:max-w-[52rem] [&>button:last-child]:z-20"
// Fixed height so switching sections never resizes the dialog; each
// panel scrolls its own content internally.
>
<DialogTitle className="sr-only">
{t('settingsDialog.title')}
</DialogTitle>
<DialogDescription className="sr-only">{activeLabel}</DialogDescription>
{/* Override the SidebarProvider wrapper's default h-svh so the two
columns fill the dialog's fixed height instead of the viewport. */}
<SidebarProvider className="!min-h-0 h-full">
<Sidebar
collapsible="none"
className="hidden h-full w-44 shrink-0 border-r md:flex"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<div className="px-2 py-3 text-sm font-semibold">
{t('settingsDialog.title')}
</div>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={section === item.id}
onClick={() => onSectionChange(item.id)}
>
{item.icon}
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
{/* Mobile section switcher (sidebar is hidden on small screens) */}
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2 md:hidden">
{navItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSectionChange(item.id)}
className={cn(
'flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm',
section === item.id
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground',
)}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</div>
{/* Unified section header (shared across all tabs). The extra
right padding keeps the title clear of the dialog's close X. */}
<div className="flex shrink-0 flex-col gap-0.5 border-b px-6 py-4 pr-12">
<h2 className="flex items-center gap-2 text-base font-semibold">
{activeItem?.icon}
{activeItem?.title}
</h2>
{activeItem?.description && (
<p className="text-sm text-muted-foreground">
{activeItem.description}
</p>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{section === 'models' && (
<ModelsPanel
active={open && section === 'models'}
onBlockingChange={setBlocking}
/>
)}
{section === 'apiIntegration' && (
<ApiIntegrationPanel
active={open && section === 'apiIntegration'}
/>
)}
{section === 'storageAnalysis' && (
<StorageAnalysisPanel
active={open && section === 'storageAnalysis'}
/>
)}
{section === 'account' && (
<AccountSettingsPanel active={open && section === 'account'} />
)}
</div>
</main>
</SidebarProvider>
</DialogContent>
</Dialog>
);
}
@@ -1,45 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Shared layout primitives for the settings-dialog panels.
*
* Every section renders under the dialog's unified header, so the panels
* themselves should share the same vertical rhythm: an optional top toolbar
* (meta on the left, primary action on the right) followed by a scrollable
* body with consistent padding. Keeping these in one place is what makes the
* tabs feel like one cohesive surface instead of four separately-styled views.
*/
export function PanelToolbar({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
'flex shrink-0 items-center justify-between gap-3 border-b px-6 py-3',
className,
)}
>
{children}
</div>
);
}
export function PanelBody({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn('min-h-0 flex-1 overflow-auto px-6 py-5', className)}>
{children}
</div>
);
}
@@ -0,0 +1,410 @@
'use client';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
Archive,
Clock,
Database,
FileWarning,
HardDrive,
RefreshCw,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
interface StorageSection {
key: string;
path: string;
exists: boolean;
size_bytes: number;
file_count: number;
}
interface CleanupCandidate {
key?: string;
name?: string;
size_bytes: number;
modified_at?: string;
date?: string;
}
interface StorageAnalysis {
generated_at: string;
cleanup_policy: {
uploaded_file_retention_days: number;
log_retention_days: number;
};
sections: StorageSection[];
database: {
type: string;
monitoring_counts: Record<string, number>;
binary_storage: {
count: number;
size_bytes: number | null;
};
};
cleanup_candidates: {
uploaded_files: CleanupCandidate[];
log_files: CleanupCandidate[];
};
tasks: Record<string, number | undefined>;
}
interface StorageAnalysisDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) {
return '-';
}
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
export default function StorageAnalysisDialog({
open,
onOpenChange,
}: StorageAnalysisDialogProps) {
const { t } = useTranslation();
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await backendClient.get<StorageAnalysis>(
'/api/v1/system/storage-analysis',
);
setAnalysis(result);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
loadAnalysis();
}
}, [loadAnalysis, open]);
const totalBytes = useMemo(() => {
return (
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
);
}, [analysis]);
const uploadedCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.uploaded_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
const logCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.log_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!flex h-[86vh] max-h-[86vh] max-w-5xl flex-col gap-0 p-0">
<DialogHeader className="shrink-0 px-6 pt-6">
<DialogTitle className="flex items-center gap-2">
<HardDrive className="size-5 text-blue-500" />
{t('storageAnalysis.dialogTitle')}
</DialogTitle>
<DialogDescription>
{t('storageAnalysis.description')}
</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 pb-4">
<div className="text-sm text-muted-foreground">
{analysis
? t('storageAnalysis.generatedAt', {
time: new Date(analysis.generated_at).toLocaleString(),
})
: t('storageAnalysis.loading')}
</div>
<Button
onClick={loadAnalysis}
variant="outline"
size="sm"
disabled={loading}
>
<RefreshCw
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
/>
{t('storageAnalysis.refresh')}
</Button>
</div>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="space-y-5 px-6 py-5">
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
{analysis && (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<SummaryItem
label={t('storageAnalysis.totalSize')}
value={formatBytes(totalBytes)}
icon={<HardDrive className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.binaryStorage')}
value={formatBytes(
analysis.database.binary_storage.size_bytes,
)}
meta={`${analysis.database.binary_storage.count}`}
icon={<Database className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.uploadCleanup')}
value={formatBytes(uploadedCandidateBytes)}
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
icon={<FileWarning className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.logCleanup')}
value={formatBytes(logCandidateBytes)}
meta={`${analysis.cleanup_candidates.log_files.length}`}
icon={<FileWarning className="size-4" />}
/>
</div>
<section className="rounded-md border px-3 py-3">
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Clock className="size-4 text-muted-foreground" />
{t('storageAnalysis.cleanupPolicy')}
</h2>
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
<PolicyItem
label={t('storageAnalysis.uploadRetention')}
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.logRetention')}
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.databaseType')}
value={analysis.database.type}
/>
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-medium">
{t('storageAnalysis.sections')}
</h2>
<div className="overflow-hidden rounded-md border">
{analysis.sections.map((section) => (
<div
key={section.key}
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="font-medium">
{t(`storageAnalysis.sectionNames.${section.key}`)}
</div>
<div className="break-all text-xs text-muted-foreground">
{section.path || '-'}
</div>
</div>
{section.exists ? (
<span />
) : (
<Badge variant="outline" className="self-center">
{t('storageAnalysis.missing')}
</Badge>
)}
<div className="self-center tabular-nums">
{formatBytes(section.size_bytes)}
</div>
<div className="self-center text-muted-foreground tabular-nums">
{section.file_count}
</div>
</div>
))}
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MetricPanel
title={t('storageAnalysis.monitoringTables')}
values={analysis.database.monitoring_counts}
/>
<MetricPanel
title={t('storageAnalysis.runtimeTasks')}
values={analysis.tasks}
/>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CandidatePanel
title={t('storageAnalysis.expiredUploads')}
emptyText={t('storageAnalysis.noExpiredUploads')}
candidates={analysis.cleanup_candidates.uploaded_files}
/>
<CandidatePanel
title={t('storageAnalysis.expiredLogs')}
emptyText={t('storageAnalysis.noExpiredLogs')}
candidates={analysis.cleanup_candidates.log_files}
/>
</section>
</>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
function SummaryItem({
label,
value,
icon,
meta,
}: {
label: string;
value: string;
icon: ReactNode;
meta?: string;
}) {
return (
<div className="rounded-md border px-3 py-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-2 flex items-end justify-between gap-2">
<span className="text-xl font-semibold tabular-nums">{value}</span>
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
</div>
</div>
);
}
function PolicyItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 font-medium">{value}</div>
</div>
);
}
function MetricPanel({
title,
values,
}: {
title: string;
values: Record<string, number | undefined>;
}) {
return (
<div>
<h2 className="mb-2 text-sm font-medium">{title}</h2>
<div className="rounded-md border">
{Object.entries(values).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
>
<span className="text-muted-foreground">{key}</span>
<span className="font-medium tabular-nums">{value ?? '-'}</span>
</div>
))}
</div>
</div>
);
}
function CandidatePanel({
title,
emptyText,
candidates,
}: {
title: string;
emptyText: string;
candidates: CleanupCandidate[];
}) {
return (
<div>
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
<Archive className="size-4 text-muted-foreground" />
{title}
</h2>
<div className="rounded-md border">
{candidates.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
candidates.slice(0, 8).map((candidate, index) => (
<div
key={`${candidate.key ?? candidate.name}-${index}`}
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="truncate font-medium">
{candidate.key ?? candidate.name}
</div>
<div className="text-xs text-muted-foreground">
{candidate.modified_at ?? candidate.date ?? '-'}
</div>
</div>
<div className="self-center tabular-nums">
{formatBytes(candidate.size_bytes)}
</div>
</div>
))
)}
</div>
</div>
);
}
@@ -1,391 +0,0 @@
'use client';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
Archive,
Clock,
Database,
FileWarning,
HardDrive,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
import { PanelToolbar } from '../settings-dialog/panel-layout';
interface StorageSection {
key: string;
path: string;
exists: boolean;
size_bytes: number;
file_count: number;
}
interface CleanupCandidate {
key?: string;
name?: string;
size_bytes: number;
modified_at?: string;
date?: string;
}
interface StorageAnalysis {
generated_at: string;
cleanup_policy: {
uploaded_file_retention_days: number;
log_retention_days: number;
};
sections: StorageSection[];
database: {
type: string;
monitoring_counts: Record<string, number>;
binary_storage: {
count: number;
size_bytes: number | null;
};
};
cleanup_candidates: {
uploaded_files: CleanupCandidate[];
log_files: CleanupCandidate[];
};
tasks: Record<string, number | undefined>;
}
interface StorageAnalysisPanelProps {
// True when this panel is the active section and the dialog is open.
active: boolean;
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) {
return '-';
}
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
export default function StorageAnalysisPanel({
active,
}: StorageAnalysisPanelProps) {
const { t } = useTranslation();
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await backendClient.get<StorageAnalysis>(
'/api/v1/system/storage-analysis',
);
setAnalysis(result);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (active) {
loadAnalysis();
}
}, [loadAnalysis, active]);
const totalBytes = useMemo(() => {
return (
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
);
}, [analysis]);
const uploadedCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.uploaded_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
const logCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.log_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
return (
<div className="flex h-full min-h-0 flex-col">
<PanelToolbar>
<div className="text-sm text-muted-foreground">
{analysis
? t('storageAnalysis.generatedAt', {
time: new Date(analysis.generated_at).toLocaleString(),
})
: t('storageAnalysis.loading')}
</div>
<Button
onClick={loadAnalysis}
variant="outline"
size="sm"
disabled={loading}
>
<RefreshCw
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
/>
{t('storageAnalysis.refresh')}
</Button>
</PanelToolbar>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="space-y-5 px-6 py-5">
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
{analysis && (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<SummaryItem
label={t('storageAnalysis.totalSize')}
value={formatBytes(totalBytes)}
icon={<HardDrive className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.binaryStorage')}
value={formatBytes(
analysis.database.binary_storage.size_bytes,
)}
meta={`${analysis.database.binary_storage.count}`}
icon={<Database className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.uploadCleanup')}
value={formatBytes(uploadedCandidateBytes)}
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
icon={<FileWarning className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.logCleanup')}
value={formatBytes(logCandidateBytes)}
meta={`${analysis.cleanup_candidates.log_files.length}`}
icon={<FileWarning className="size-4" />}
/>
</div>
<section className="rounded-md border px-3 py-3">
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Clock className="size-4 text-muted-foreground" />
{t('storageAnalysis.cleanupPolicy')}
</h2>
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
<PolicyItem
label={t('storageAnalysis.uploadRetention')}
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.logRetention')}
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.databaseType')}
value={analysis.database.type}
/>
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-medium">
{t('storageAnalysis.sections')}
</h2>
<div className="overflow-hidden rounded-md border">
{analysis.sections.map((section) => (
<div
key={section.key}
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="font-medium">
{t(`storageAnalysis.sectionNames.${section.key}`)}
</div>
<div className="break-all text-xs text-muted-foreground">
{section.path || '-'}
</div>
</div>
{section.exists ? (
<span />
) : (
<Badge variant="outline" className="self-center">
{t('storageAnalysis.missing')}
</Badge>
)}
<div className="self-center tabular-nums">
{formatBytes(section.size_bytes)}
</div>
<div className="self-center text-muted-foreground tabular-nums">
{section.file_count}
</div>
</div>
))}
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MetricPanel
title={t('storageAnalysis.monitoringTables')}
values={analysis.database.monitoring_counts}
/>
<MetricPanel
title={t('storageAnalysis.runtimeTasks')}
values={analysis.tasks}
/>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CandidatePanel
title={t('storageAnalysis.expiredUploads')}
emptyText={t('storageAnalysis.noExpiredUploads')}
candidates={analysis.cleanup_candidates.uploaded_files}
/>
<CandidatePanel
title={t('storageAnalysis.expiredLogs')}
emptyText={t('storageAnalysis.noExpiredLogs')}
candidates={analysis.cleanup_candidates.log_files}
/>
</section>
</>
)}
</div>
</ScrollArea>
</div>
);
}
function SummaryItem({
label,
value,
icon,
meta,
}: {
label: string;
value: string;
icon: ReactNode;
meta?: string;
}) {
return (
<div className="rounded-md border px-3 py-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-2 flex items-end justify-between gap-2">
<span className="text-xl font-semibold tabular-nums">{value}</span>
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
</div>
</div>
);
}
function PolicyItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 font-medium">{value}</div>
</div>
);
}
function MetricPanel({
title,
values,
}: {
title: string;
values: Record<string, number | undefined>;
}) {
return (
<div>
<h2 className="mb-2 text-sm font-medium">{title}</h2>
<div className="rounded-md border">
{Object.entries(values).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
>
<span className="text-muted-foreground">{key}</span>
<span className="font-medium tabular-nums">{value ?? '-'}</span>
</div>
))}
</div>
</div>
);
}
function CandidatePanel({
title,
emptyText,
candidates,
}: {
title: string;
emptyText: string;
candidates: CleanupCandidate[];
}) {
return (
<div>
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
<Archive className="size-4 text-muted-foreground" />
{title}
</h2>
<div className="rounded-md border">
{candidates.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
candidates.slice(0, 8).map((candidate, index) => (
<div
key={`${candidate.key ?? candidate.name}-${index}`}
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="truncate font-medium">
{candidate.key ?? candidate.name}
</div>
<div className="text-xs text-muted-foreground">
{candidate.modified_at ?? candidate.date ?? '-'}
</div>
</div>
<div className="self-center tabular-nums">
{formatBytes(candidate.size_bytes)}
</div>
</div>
))
)}
</div>
</div>
);
}
@@ -82,6 +82,7 @@ export default function SystemStatusCard({
fetchStatus(); fetchStatus();
const interval = setInterval(fetchStatus, 30_000); const interval = setInterval(fetchStatus, 30_000);
return () => clearInterval(interval); return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchStatus, refreshKey]); }, [fetchStatus, refreshKey]);
const pluginOk = pluginStatus const pluginOk = pluginStatus
@@ -5,7 +5,6 @@ import {
ModelCall, ModelCall,
LLMCall, LLMCall,
EmbeddingCall, EmbeddingCall,
MonitoringTrace,
} from '../types/monitoring'; } from '../types/monitoring';
import { backendClient } from '@/app/infra/http'; import { backendClient } from '@/app/infra/http';
import { parseUTCTimestamp } from '../utils/dateUtils'; import { parseUTCTimestamp } from '../utils/dateUtils';
@@ -264,48 +263,12 @@ export function useMonitoringData(filterState: FilterState) {
messageId: error.message_id, messageId: error.message_id,
}), }),
), ),
traces: (response.traces || []).map(
(trace: {
trace_id: string;
started_at: string;
ended_at?: string;
duration?: number;
status: string;
name: string;
bot_id?: string;
bot_name?: string;
pipeline_id?: string;
pipeline_name?: string;
session_id?: string;
message_id?: string;
query_id?: string;
attributes?: Record<string, unknown>;
}): MonitoringTrace => ({
traceId: trace.trace_id,
name: trace.name,
startedAt: parseUTCTimestamp(trace.started_at),
endedAt: trace.ended_at
? parseUTCTimestamp(trace.ended_at)
: undefined,
duration: trace.duration,
status: trace.status as 'running' | 'success' | 'error',
botId: trace.bot_id,
botName: trace.bot_name,
pipelineId: trace.pipeline_id,
pipelineName: trace.pipeline_name,
sessionId: trace.session_id,
messageId: trace.message_id,
queryId: trace.query_id,
attributes: trace.attributes || {},
}),
),
totalCount: { totalCount: {
messages: response.totalCount.messages, messages: response.totalCount.messages,
llmCalls: response.totalCount.llmCalls, llmCalls: response.totalCount.llmCalls,
embeddingCalls: response.totalCount.embeddingCalls || 0, embeddingCalls: response.totalCount.embeddingCalls || 0,
sessions: response.totalCount.sessions, sessions: response.totalCount.sessions,
errors: response.totalCount.errors, errors: response.totalCount.errors,
traces: response.totalCount.traces || 0,
}, },
}; };
+1 -297
View File
@@ -10,7 +10,6 @@ import {
MessageSquare, MessageSquare,
Sparkles, Sparkles,
CheckCircle2, CheckCircle2,
GitBranch,
} from 'lucide-react'; } from 'lucide-react';
import OverviewCards from './components/overview-cards/OverviewCards'; import OverviewCards from './components/overview-cards/OverviewCards';
import MonitoringFilters from './components/filters/MonitoringFilters'; import MonitoringFilters from './components/filters/MonitoringFilters';
@@ -23,15 +22,9 @@ import { MessageDetailsCard } from './components/MessageDetailsCard';
import { MessageContentRenderer } from './components/MessageContentRenderer'; import { MessageContentRenderer } from './components/MessageContentRenderer';
import { FeedbackStatsCards } from './components/FeedbackCard'; import { FeedbackStatsCards } from './components/FeedbackCard';
import { FeedbackList } from './components/FeedbackList'; import { FeedbackList } from './components/FeedbackList';
import { import { MessageDetails } from './types/monitoring';
MessageDetails,
TraceDetails,
MonitoringSpan,
} from './types/monitoring';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { backendClient } from '@/app/infra/http';
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner'; import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
import { parseUTCTimestamp } from './utils/dateUtils';
interface RawMessageData { interface RawMessageData {
id: string; id: string;
@@ -79,97 +72,6 @@ interface RawErrorData {
stack_trace: string | null; stack_trace: string | null;
} }
interface RawTraceData {
trace_id: string;
started_at: string;
ended_at?: string;
duration?: number;
status: string;
name: string;
bot_id?: string;
bot_name?: string;
pipeline_id?: string;
pipeline_name?: string;
session_id?: string;
message_id?: string;
query_id?: string;
attributes?: Record<string, unknown>;
}
interface RawSpanData {
span_id: string;
trace_id: string;
parent_span_id?: string;
name: string;
kind: string;
status: string;
started_at: string;
ended_at?: string;
duration?: number;
message_id?: string;
session_id?: string;
bot_id?: string;
pipeline_id?: string;
attributes?: Record<string, unknown>;
error_message?: string;
}
function mapTrace(raw: RawTraceData) {
return {
traceId: raw.trace_id,
name: raw.name,
startedAt: parseUTCTimestamp(raw.started_at),
endedAt: raw.ended_at ? parseUTCTimestamp(raw.ended_at) : undefined,
duration: raw.duration,
status: raw.status as 'running' | 'success' | 'error',
botId: raw.bot_id,
botName: raw.bot_name,
pipelineId: raw.pipeline_id,
pipelineName: raw.pipeline_name,
sessionId: raw.session_id,
messageId: raw.message_id,
queryId: raw.query_id,
attributes: raw.attributes || {},
};
}
function mapSpan(raw: RawSpanData): MonitoringSpan {
return {
spanId: raw.span_id,
traceId: raw.trace_id,
parentSpanId: raw.parent_span_id,
name: raw.name,
kind: raw.kind,
status: raw.status as 'running' | 'success' | 'error',
startedAt: parseUTCTimestamp(raw.started_at),
endedAt: raw.ended_at ? parseUTCTimestamp(raw.ended_at) : undefined,
duration: raw.duration,
messageId: raw.message_id,
sessionId: raw.session_id,
botId: raw.bot_id,
pipelineId: raw.pipeline_id,
attributes: raw.attributes || {},
errorMessage: raw.error_message,
};
}
function spanDepth(
span: MonitoringSpan,
spansById: Map<string, MonitoringSpan>,
) {
let depth = 0;
let current = span.parentSpanId
? spansById.get(span.parentSpanId)
: undefined;
while (current && depth < 8) {
depth += 1;
current = current.parentSpanId
? spansById.get(current.parentSpanId)
: undefined;
}
return depth;
}
function MonitoringPageContent() { function MonitoringPageContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } = const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
@@ -256,13 +158,6 @@ function MonitoringPageContent() {
// State for expanded errors // State for expanded errors
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null); const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [expandedTraceId, setExpandedTraceId] = useState<string | null>(null);
const [traceDetails, setTraceDetails] = useState<
Record<string, TraceDetails>
>({});
const [loadingTraceDetails, setLoadingTraceDetails] = useState<
Record<string, boolean>
>({});
// State for controlled tabs // State for controlled tabs
const [activeTab, setActiveTab] = useState<string>('messages'); const [activeTab, setActiveTab] = useState<string>('messages');
@@ -370,34 +265,6 @@ function MonitoringPageContent() {
} }
}; };
const toggleTraceExpand = async (traceId: string) => {
if (expandedTraceId === traceId) {
setExpandedTraceId(null);
return;
}
setExpandedTraceId(traceId);
if (traceDetails[traceId]) return;
setLoadingTraceDetails((prev) => ({ ...prev, [traceId]: true }));
try {
const result = await backendClient.getMonitoringTraceDetails(traceId);
setTraceDetails((prev) => ({
...prev,
[traceId]: {
traceId: result.trace_id,
found: result.found,
trace: result.trace ? mapTrace(result.trace) : undefined,
spans: (result.spans || []).map(mapSpan),
},
}));
} catch (error) {
console.error('Failed to fetch trace details:', error);
} finally {
setLoadingTraceDetails((prev) => ({ ...prev, [traceId]: false }));
}
};
return ( return (
<div className="w-full h-full overflow-y-auto overflow-x-hidden"> <div className="w-full h-full overflow-y-auto overflow-x-hidden">
{/* Filters and Refresh Button - Sticky */} {/* Filters and Refresh Button - Sticky */}
@@ -456,9 +323,6 @@ function MonitoringPageContent() {
<TabsTrigger value="tokens" className="px-6 py-2"> <TabsTrigger value="tokens" className="px-6 py-2">
{t('monitoring.tabs.tokens')} {t('monitoring.tabs.tokens')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="traces" className="px-6 py-2">
{t('monitoring.tabs.traces')}
</TabsTrigger>
<TabsTrigger value="feedback" className="px-6 py-2"> <TabsTrigger value="feedback" className="px-6 py-2">
{t('monitoring.tabs.feedback')} {t('monitoring.tabs.feedback')}
</TabsTrigger> </TabsTrigger>
@@ -826,166 +690,6 @@ function MonitoringPageContent() {
/> />
</TabsContent> </TabsContent>
<TabsContent value="traces" className="p-6 m-0">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading && data && data.traces && data.traces.length > 0 && (
<div className="space-y-4">
{data.traces.map((trace) => {
const details = traceDetails[trace.traceId];
const spans = details?.spans || [];
const spansById = new Map(
spans.map((span) => [span.spanId, span]),
);
const maxDuration = Math.max(
1,
...spans.map((span) => span.duration || 0),
);
return (
<div
key={trace.traceId}
className="border rounded-xl overflow-hidden transition-all duration-200"
>
<div
className="p-5 cursor-pointer hover:bg-accent transition-colors"
onClick={() => toggleTraceExpand(trace.traceId)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start flex-1 min-w-0">
<div className="mr-3 mt-0.5">
{expandedTraceId === trace.traceId ? (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronRight className="w-5 h-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 mb-2">
<span className="text-xs text-muted-foreground font-mono">
{trace.traceId}
</span>
<span
className={`text-xs px-2 py-1 rounded ${
trace.status === 'error'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: trace.status === 'running'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
}`}
>
{trace.status}
</span>
</div>
<div className="font-medium text-sm text-foreground mb-1">
{trace.name}
</div>
<div className="text-xs text-muted-foreground truncate">
{trace.botName || '-'} {' '}
{trace.pipelineName || '-'}
{trace.sessionId
? ` · ${trace.sessionId}`
: ''}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-1 text-xs text-muted-foreground whitespace-nowrap">
<span>{trace.startedAt.toLocaleString()}</span>
<span>{trace.duration ?? 0}ms</span>
</div>
</div>
</div>
{expandedTraceId === trace.traceId && (
<div className="border-t p-5 bg-muted">
{loadingTraceDetails[trace.traceId] && (
<div className="py-4 flex justify-center">
<LoadingSpinner size="sm" text="" />
</div>
)}
{!loadingTraceDetails[trace.traceId] && (
<div className="space-y-3">
{spans.length === 0 && (
<div className="text-sm text-muted-foreground">
{t('monitoring.traces.noSpans')}
</div>
)}
{spans.map((span) => {
const depth = spanDepth(span, spansById);
const width = Math.max(
6,
Math.min(
100,
((span.duration || 0) / maxDuration) *
100,
),
);
return (
<div
key={span.spanId}
className="grid grid-cols-[minmax(180px,1fr)_minmax(140px,2fr)_80px] gap-3 items-center text-xs"
>
<div
className="min-w-0"
style={{
paddingLeft: `${depth * 16}px`,
}}
>
<div className="font-medium text-foreground truncate">
{span.name}
</div>
<div className="text-muted-foreground truncate">
{span.kind}
</div>
</div>
<div className="h-7 bg-background rounded border overflow-hidden">
<div
className={`h-full ${
span.status === 'error'
? 'bg-red-500/70'
: 'bg-blue-500/70'
}`}
style={{ width: `${width}%` }}
/>
</div>
<div className="text-right text-muted-foreground">
{span.duration ?? 0}ms
</div>
{span.errorMessage && (
<div className="col-span-3 text-red-600 dark:text-red-400 bg-background rounded p-2">
{span.errorMessage}
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{!loading &&
(!data || !data.traces || data.traces.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<GitBranch className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.traces.noTraces')}
</div>
</div>
)}
</div>
</TabsContent>
<TabsContent value="feedback" className="p-6 m-0"> <TabsContent value="feedback" className="p-6 m-0">
<div> <div>
{loading && ( {loading && (
@@ -111,48 +111,6 @@ export interface ErrorLog {
messageId?: string; messageId?: string;
} }
export interface MonitoringTrace {
traceId: string;
name: string;
startedAt: Date;
endedAt?: Date;
duration?: number;
status: 'running' | 'success' | 'error';
botId?: string;
botName?: string;
pipelineId?: string;
pipelineName?: string;
sessionId?: string;
messageId?: string;
queryId?: string;
attributes: Record<string, unknown>;
}
export interface MonitoringSpan {
spanId: string;
traceId: string;
parentSpanId?: string;
name: string;
kind: string;
status: 'success' | 'error' | 'running';
startedAt: Date;
endedAt?: Date;
duration?: number;
messageId?: string;
sessionId?: string;
botId?: string;
pipelineId?: string;
attributes: Record<string, unknown>;
errorMessage?: string;
}
export interface TraceDetails {
traceId: string;
found: boolean;
trace?: MonitoringTrace;
spans: MonitoringSpan[];
}
export interface MessageDetails { export interface MessageDetails {
messageId: string; messageId: string;
found: boolean; found: boolean;
@@ -167,7 +125,6 @@ export interface MessageDetails {
averageDurationMs: number; averageDurationMs: number;
}; };
errors: ErrorLog[]; errors: ErrorLog[];
trace?: MonitoringTrace;
} }
export interface OverviewMetrics { export interface OverviewMetrics {
@@ -246,7 +203,6 @@ export interface MonitoringData {
modelCalls: ModelCall[]; modelCalls: ModelCall[];
sessions: SessionInfo[]; sessions: SessionInfo[];
errors: ErrorLog[]; errors: ErrorLog[];
traces: MonitoringTrace[];
feedback?: FeedbackRecord[]; feedback?: FeedbackRecord[];
feedbackStats?: FeedbackStats; feedbackStats?: FeedbackStats;
totalCount: { totalCount: {
@@ -255,7 +211,6 @@ export interface MonitoringData {
embeddingCalls: number; embeddingCalls: number;
sessions: number; sessions: number;
errors: number; errors: number;
traces: number;
feedback?: number; feedback?: number;
}; };
} }
@@ -323,6 +323,7 @@ export default function PipelineFormComponent({
const isFirstEmission = !initializedStagesRef.current.has(stageKey); const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentValues = const currentValues =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {}; (form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, { form.setValue(formName, {
...currentValues, ...currentValues,
@@ -367,6 +368,7 @@ export default function PipelineFormComponent({
<DynamicFormComponent <DynamicFormComponent
itemConfigList={stage.config} itemConfigList={stage.config}
initialValues={ initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] || (form.watch(formName) as Record<string, any>)?.[stage.name] ||
{} {}
} }
@@ -400,6 +402,7 @@ export default function PipelineFormComponent({
<N8nAuthFormComponent <N8nAuthFormComponent
itemConfigList={stage.config} itemConfigList={stage.config}
initialValues={ initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] || (form.watch(formName) as Record<string, any>)?.[stage.name] ||
{} {}
} }
@@ -442,7 +445,7 @@ export default function PipelineFormComponent({
// make the locked selector display a scope that is NOT the one actually in // make the locked selector display a scope that is NOT the one actually in
// effect. Coerce the displayed/saved value to the forced template so the UI // effect. Coerce the displayed/saved value to the forced template so the UI
// truthfully reflects runtime behavior. // truthfully reflects runtime behavior.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stageInitialValues: Record<string, any> = const stageInitialValues: Record<string, any> =
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}; (form.watch(formName) as Record<string, any>)?.[stage.name] || {};
const effectiveInitialValues = const effectiveInitialValues =
@@ -9,7 +9,7 @@ export interface IPluginCardVO {
enabled: boolean; enabled: boolean;
priority: number; priority: number;
install_source: string; install_source: string;
install_info: Record<string, any>; install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
status: string; status: string;
components: PluginComponent[]; components: PluginComponent[];
debug: boolean; debug: boolean;
@@ -27,7 +27,7 @@ export class PluginCardVO implements IPluginCardVO {
priority: number; priority: number;
debug: boolean; debug: boolean;
install_source: string; install_source: string;
install_info: Record<string, any>; install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
status: string; status: string;
components: PluginComponent[]; components: PluginComponent[];
hasUpdate?: boolean; hasUpdate?: boolean;
+1 -1
View File
@@ -21,7 +21,7 @@ export interface ComponentManifest {
version?: string; version?: string;
author?: string; author?: string;
}; };
spec: Record<string, any>; spec: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
} }
export interface CustomApiError { export interface CustomApiError {
+1 -1
View File
@@ -8,7 +8,7 @@ export const SYSTEM_FIELD_PREFIX = '__system.';
export interface IShowIfCondition { export interface IShowIfCondition {
field: string; field: string;
operator: 'eq' | 'neq' | 'in'; operator: 'eq' | 'neq' | 'in';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any; value: any;
} }
+1 -1
View File
@@ -10,7 +10,7 @@ export interface Plugin {
debug: boolean; debug: boolean;
enabled: boolean; enabled: boolean;
install_source: string; install_source: string;
install_info: Record<string, any>; install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
components: PluginComponent[]; components: PluginComponent[];
} }
-101
View File
@@ -1185,29 +1185,12 @@ export class BackendClient extends BaseHttpClient {
stack_trace?: string; stack_trace?: string;
message_id?: string; message_id?: string;
}>; }>;
traces?: Array<{
trace_id: string;
started_at: string;
ended_at?: string;
duration?: number;
status: string;
name: string;
bot_id?: string;
bot_name?: string;
pipeline_id?: string;
pipeline_name?: string;
session_id?: string;
message_id?: string;
query_id?: string;
attributes?: Record<string, unknown>;
}>;
totalCount: { totalCount: {
messages: number; messages: number;
llmCalls: number; llmCalls: number;
embeddingCalls: number; embeddingCalls: number;
sessions: number; sessions: number;
errors: number; errors: number;
traces?: number;
}; };
}> { }> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
@@ -1230,90 +1213,6 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`); return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`);
} }
public getMonitoringTraces(params: {
botId?: string[];
pipelineId?: string[];
startTime?: string;
endTime?: string;
limit?: number;
}): Promise<{
traces: Array<{
trace_id: string;
started_at: string;
ended_at?: string;
duration?: number;
status: string;
name: string;
bot_id?: string;
bot_name?: string;
pipeline_id?: string;
pipeline_name?: string;
session_id?: string;
message_id?: string;
query_id?: string;
attributes?: Record<string, unknown>;
}>;
total: number;
}> {
const queryParams = new URLSearchParams();
if (params.botId) {
params.botId.forEach((id) => queryParams.append('botId', id));
}
if (params.pipelineId) {
params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));
}
if (params.startTime) {
queryParams.append('startTime', params.startTime);
}
if (params.endTime) {
queryParams.append('endTime', params.endTime);
}
if (params.limit) {
queryParams.append('limit', params.limit.toString());
}
return this.get(`/api/v1/monitoring/traces?${queryParams.toString()}`);
}
public getMonitoringTraceDetails(traceId: string): Promise<{
trace_id: string;
found: boolean;
trace: {
trace_id: string;
started_at: string;
ended_at?: string;
duration?: number;
status: string;
name: string;
bot_id?: string;
bot_name?: string;
pipeline_id?: string;
pipeline_name?: string;
session_id?: string;
message_id?: string;
query_id?: string;
attributes?: Record<string, unknown>;
};
spans: Array<{
span_id: string;
trace_id: string;
parent_span_id?: string;
name: string;
kind: string;
status: string;
started_at: string;
ended_at?: string;
duration?: number;
message_id?: string;
session_id?: string;
bot_id?: string;
pipeline_id?: string;
attributes?: Record<string, unknown>;
error_message?: string;
}>;
}> {
return this.get(`/api/v1/monitoring/traces/${traceId}`);
}
public getMonitoringOverview(params: { public getMonitoringOverview(params: {
botId?: string[]; botId?: string[];
pipelineId?: string[]; pipelineId?: string[];
+1 -1
View File
@@ -86,7 +86,7 @@ export default function WizardPage() {
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null); const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [selectedRunner, setSelectedRunner] = useState<string | null>(null); const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
const [botName, setBotName] = useState(''); const [botName, setBotName] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [botDescription, _setBotDescription] = useState(''); const [botDescription, _setBotDescription] = useState('');
const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>( const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>(
{}, {},
-18
View File
@@ -122,8 +122,6 @@ const enUS = {
changePasswordFailed: changePasswordFailed:
'Failed to change password, please check your current password', 'Failed to change password, please check your current password',
apiIntegration: 'API Integration', apiIntegration: 'API Integration',
apiIntegrationDescription:
'Manage API keys and webhooks for external access',
apiKeys: 'API Keys', apiKeys: 'API Keys',
manageApiIntegration: 'Manage API Integration', manageApiIntegration: 'Manage API Integration',
manageApiKeys: 'Manage API Keys', manageApiKeys: 'Manage API Keys',
@@ -1151,7 +1149,6 @@ const enUS = {
}, },
account: { account: {
settings: 'Account Settings', settings: 'Account Settings',
settingsDescription: 'Manage your password and linked accounts',
setPassword: 'Set Password', setPassword: 'Set Password',
passwordSetSuccess: 'Password set successfully', passwordSetSuccess: 'Password set successfully',
passwordStatus: 'Local Password', passwordStatus: 'Local Password',
@@ -1217,7 +1214,6 @@ const enUS = {
embeddingCalls: 'Embedding Calls', embeddingCalls: 'Embedding Calls',
modelCalls: 'Model Calls', modelCalls: 'Model Calls',
tokens: 'Token Monitoring', tokens: 'Token Monitoring',
traces: 'Traces',
feedback: 'User Feedback', feedback: 'User Feedback',
sessions: 'Session Analysis', sessions: 'Session Analysis',
errors: 'Error Logs', errors: 'Error Logs',
@@ -1322,11 +1318,6 @@ const enUS = {
noErrors: 'No errors found', noErrors: 'No errors found',
stackTrace: 'Stack Trace', stackTrace: 'Stack Trace',
}, },
traces: {
title: 'Traces',
noTraces: 'No traces found',
noSpans: 'No spans recorded for this trace',
},
feedback: { feedback: {
title: 'User Feedback', title: 'User Feedback',
totalFeedback: 'Total Feedback', totalFeedback: 'Total Feedback',
@@ -1395,15 +1386,6 @@ const enUS = {
boxSessionCreated: 'Created', boxSessionCreated: 'Created',
boxSessionLastUsed: 'Last used', boxSessionLastUsed: 'Last used',
}, },
settingsDialog: {
title: 'Settings',
nav: {
models: 'Models',
api: 'API',
storage: 'Storage',
account: 'Account',
},
},
storageAnalysis: { storageAnalysis: {
title: 'Storage Analysis', title: 'Storage Analysis',
description: 'Inspect storage usage and cleanup candidates', description: 'Inspect storage usage and cleanup candidates',
+1 -13
View File
@@ -126,8 +126,6 @@ const esES = {
changePasswordFailed: changePasswordFailed:
'Error al cambiar la contraseña, por favor verifica tu contraseña actual', 'Error al cambiar la contraseña, por favor verifica tu contraseña actual',
apiIntegration: 'Integración API', apiIntegration: 'Integración API',
apiIntegrationDescription:
'Gestiona las claves API y los webhooks para el acceso externo',
apiKeys: 'Claves API', apiKeys: 'Claves API',
manageApiIntegration: 'Gestionar integración API', manageApiIntegration: 'Gestionar integración API',
manageApiKeys: 'Gestionar claves API', manageApiKeys: 'Gestionar claves API',
@@ -846,7 +844,7 @@ const esES = {
'Una vez eliminada, la configuración de este servidor MCP no se podrá recuperar.', 'Una vez eliminada, la configuración de este servidor MCP no se podrá recuperar.',
}, },
pipelines: { pipelines: {
title: 'Flujos', title: 'Pipelines',
description: description:
'Los Pipelines definen el flujo de procesamiento de eventos de mensajes, se usan para vincular a los Bots', 'Los Pipelines definen el flujo de procesamiento de eventos de mensajes, se usan para vincular a los Bots',
createPipeline: 'Crear Pipeline', createPipeline: 'Crear Pipeline',
@@ -1180,7 +1178,6 @@ const esES = {
}, },
account: { account: {
settings: 'Configuración de la cuenta', settings: 'Configuración de la cuenta',
settingsDescription: 'Gestiona tu contraseña y las cuentas vinculadas',
setPassword: 'Establecer contraseña', setPassword: 'Establecer contraseña',
passwordSetSuccess: 'Contraseña establecida correctamente', passwordSetSuccess: 'Contraseña establecida correctamente',
passwordStatus: 'Contraseña local', passwordStatus: 'Contraseña local',
@@ -1422,15 +1419,6 @@ const esES = {
boxSessionCreated: 'Creado', boxSessionCreated: 'Creado',
boxSessionLastUsed: 'Último uso', boxSessionLastUsed: 'Último uso',
}, },
settingsDialog: {
title: 'Configuración',
nav: {
models: 'Modelos',
api: 'API',
storage: 'Almacenamiento',
account: 'Cuenta',
},
},
storageAnalysis: { storageAnalysis: {
title: 'Análisis de almacenamiento', title: 'Análisis de almacenamiento',
description: description:
-12
View File
@@ -124,8 +124,6 @@ const jaJP = {
changePasswordFailed: changePasswordFailed:
'パスワードの変更に失敗しました。現在のパスワードを確認してください', 'パスワードの変更に失敗しました。現在のパスワードを確認してください',
apiIntegration: 'API統合', apiIntegration: 'API統合',
apiIntegrationDescription:
'外部アクセス用の API キーと Webhook を管理します',
apiKeys: 'API キー', apiKeys: 'API キー',
manageApiIntegration: 'API統合の管理', manageApiIntegration: 'API統合の管理',
manageApiKeys: 'API キーの管理', manageApiKeys: 'API キーの管理',
@@ -1155,7 +1153,6 @@ const jaJP = {
}, },
account: { account: {
settings: 'アカウント設定', settings: 'アカウント設定',
settingsDescription: 'パスワードと連携アカウントを管理します',
setPassword: 'パスワードを設定', setPassword: 'パスワードを設定',
passwordSetSuccess: 'パスワードの設定に成功しました', passwordSetSuccess: 'パスワードの設定に成功しました',
passwordStatus: 'ローカルパスワード', passwordStatus: 'ローカルパスワード',
@@ -1395,15 +1392,6 @@ const jaJP = {
boxSessionCreated: '作成日時', boxSessionCreated: '作成日時',
boxSessionLastUsed: '最終使用', boxSessionLastUsed: '最終使用',
}, },
settingsDialog: {
title: '設定',
nav: {
models: 'モデル',
api: 'API',
storage: 'ストレージ',
account: 'アカウント',
},
},
storageAnalysis: { storageAnalysis: {
title: 'ストレージ分析', title: 'ストレージ分析',
description: 'ストレージ使用量とクリーンアップ候補を確認します', description: 'ストレージ使用量とクリーンアップ候補を確認します',
-12
View File
@@ -122,8 +122,6 @@ const ruRU = {
changePasswordFailed: changePasswordFailed:
'Не удалось изменить пароль, проверьте текущий пароль', 'Не удалось изменить пароль, проверьте текущий пароль',
apiIntegration: 'API-интеграция', apiIntegration: 'API-интеграция',
apiIntegrationDescription:
'Управление API-ключами и вебхуками для внешнего доступа',
apiKeys: 'API-ключи', apiKeys: 'API-ключи',
manageApiIntegration: 'Управление API-интеграцией', manageApiIntegration: 'Управление API-интеграцией',
manageApiKeys: 'Управление API-ключами', manageApiKeys: 'Управление API-ключами',
@@ -1158,7 +1156,6 @@ const ruRU = {
}, },
account: { account: {
settings: 'Настройки аккаунта', settings: 'Настройки аккаунта',
settingsDescription: 'Управление паролем и связанными аккаунтами',
setPassword: 'Установить пароль', setPassword: 'Установить пароль',
passwordSetSuccess: 'Пароль успешно установлен', passwordSetSuccess: 'Пароль успешно установлен',
passwordStatus: 'Локальный пароль', passwordStatus: 'Локальный пароль',
@@ -1398,15 +1395,6 @@ const ruRU = {
boxSessionCreated: 'Создано', boxSessionCreated: 'Создано',
boxSessionLastUsed: 'Последнее использование', boxSessionLastUsed: 'Последнее использование',
}, },
settingsDialog: {
title: 'Настройки',
nav: {
models: 'Модели',
api: 'API',
storage: 'Хранилище',
account: 'Аккаунт',
},
},
storageAnalysis: { storageAnalysis: {
title: 'Анализ хранилища', title: 'Анализ хранилища',
description: 'Проверьте использование хранилища и кандидатов на очистку', description: 'Проверьте использование хранилища и кандидатов на очистку',
+2 -14
View File
@@ -120,8 +120,6 @@ const thTH = {
changePasswordSuccess: 'เปลี่ยนรหัสผ่านสำเร็จ', changePasswordSuccess: 'เปลี่ยนรหัสผ่านสำเร็จ',
changePasswordFailed: 'เปลี่ยนรหัสผ่านล้มเหลว กรุณาตรวจสอบรหัสผ่านปัจจุบัน', changePasswordFailed: 'เปลี่ยนรหัสผ่านล้มเหลว กรุณาตรวจสอบรหัสผ่านปัจจุบัน',
apiIntegration: 'การเชื่อมต่อ API', apiIntegration: 'การเชื่อมต่อ API',
apiIntegrationDescription:
'จัดการ API key และ webhook สำหรับการเข้าถึงจากภายนอก',
apiKeys: 'คีย์ API', apiKeys: 'คีย์ API',
manageApiIntegration: 'จัดการการเชื่อมต่อ API', manageApiIntegration: 'จัดการการเชื่อมต่อ API',
manageApiKeys: 'จัดการคีย์ API', manageApiKeys: 'จัดการคีย์ API',
@@ -302,7 +300,7 @@ const thTH = {
}, },
}, },
bots: { bots: {
title: 'บอท', title: 'Bot',
description: description:
'สร้างและจัดการ Bot ซึ่งเป็นจุดเชื่อมต่อของ LangBot กับแพลตฟอร์มต่างๆ', 'สร้างและจัดการ Bot ซึ่งเป็นจุดเชื่อมต่อของ LangBot กับแพลตฟอร์มต่างๆ',
createBot: 'สร้าง Bot', createBot: 'สร้าง Bot',
@@ -821,7 +819,7 @@ const thTH = {
'เมื่อลบแล้ว การกำหนดค่าเซิร์ฟเวอร์ MCP นี้จะไม่สามารถกู้คืนได้', 'เมื่อลบแล้ว การกำหนดค่าเซิร์ฟเวอร์ MCP นี้จะไม่สามารถกู้คืนได้',
}, },
pipelines: { pipelines: {
title: 'ไปป์ไลน์', title: 'Pipeline',
description: description:
'Pipeline กำหนดกระบวนการประมวลผลเหตุการณ์ข้อความ ใช้เพื่อผูกกับ Bot', 'Pipeline กำหนดกระบวนการประมวลผลเหตุการณ์ข้อความ ใช้เพื่อผูกกับ Bot',
createPipeline: 'สร้าง Pipeline', createPipeline: 'สร้าง Pipeline',
@@ -1132,7 +1130,6 @@ const thTH = {
}, },
account: { account: {
settings: 'การตั้งค่าบัญชี', settings: 'การตั้งค่าบัญชี',
settingsDescription: 'จัดการรหัสผ่านและบัญชีที่เชื่อมโยงของคุณ',
setPassword: 'ตั้งรหัสผ่าน', setPassword: 'ตั้งรหัสผ่าน',
passwordSetSuccess: 'ตั้งรหัสผ่านสำเร็จ', passwordSetSuccess: 'ตั้งรหัสผ่านสำเร็จ',
passwordStatus: 'รหัสผ่านท้องถิ่น', passwordStatus: 'รหัสผ่านท้องถิ่น',
@@ -1367,15 +1364,6 @@ const thTH = {
boxSessionCreated: 'สร้างเมื่อ', boxSessionCreated: 'สร้างเมื่อ',
boxSessionLastUsed: 'ใช้ล่าสุด', boxSessionLastUsed: 'ใช้ล่าสุด',
}, },
settingsDialog: {
title: 'การตั้งค่า',
nav: {
models: 'โมเดล',
api: 'API',
storage: 'พื้นที่จัดเก็บ',
account: 'บัญชี',
},
},
storageAnalysis: { storageAnalysis: {
title: 'วิเคราะห์พื้นที่จัดเก็บ', title: 'วิเคราะห์พื้นที่จัดเก็บ',
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้', description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
+1 -13
View File
@@ -123,8 +123,6 @@ const viVN = {
changePasswordFailed: changePasswordFailed:
'Đổi mật khẩu thất bại, vui lòng kiểm tra mật khẩu hiện tại', 'Đổi mật khẩu thất bại, vui lòng kiểm tra mật khẩu hiện tại',
apiIntegration: 'Tích hợp API', apiIntegration: 'Tích hợp API',
apiIntegrationDescription:
'Quản lý API key và webhook cho truy cập từ bên ngoài',
apiKeys: 'Khóa API', apiKeys: 'Khóa API',
manageApiIntegration: 'Quản lý tích hợp API', manageApiIntegration: 'Quản lý tích hợp API',
manageApiKeys: 'Quản lý khóa API', manageApiKeys: 'Quản lý khóa API',
@@ -835,7 +833,7 @@ const viVN = {
deleteMCPHint: 'Sau khi xóa, cấu hình máy chủ MCP này không thể khôi phục.', deleteMCPHint: 'Sau khi xóa, cấu hình máy chủ MCP này không thể khôi phục.',
}, },
pipelines: { pipelines: {
title: 'Quy trình', title: 'Pipeline',
description: description:
'Pipeline xác định luồng xử lý sự kiện tin nhắn, dùng để liên kết với Bot', 'Pipeline xác định luồng xử lý sự kiện tin nhắn, dùng để liên kết với Bot',
createPipeline: 'Tạo Pipeline', createPipeline: 'Tạo Pipeline',
@@ -1152,7 +1150,6 @@ const viVN = {
}, },
account: { account: {
settings: 'Cài đặt tài khoản', settings: 'Cài đặt tài khoản',
settingsDescription: 'Quản lý mật khẩu và các tài khoản liên kết của bạn',
setPassword: 'Đặt mật khẩu', setPassword: 'Đặt mật khẩu',
passwordSetSuccess: 'Đặt mật khẩu thành công', passwordSetSuccess: 'Đặt mật khẩu thành công',
passwordStatus: 'Mật khẩu cục bộ', passwordStatus: 'Mật khẩu cục bộ',
@@ -1391,15 +1388,6 @@ const viVN = {
boxSessionCreated: 'Đã tạo', boxSessionCreated: 'Đã tạo',
boxSessionLastUsed: 'Lần cuối sử dụng', boxSessionLastUsed: 'Lần cuối sử dụng',
}, },
settingsDialog: {
title: 'Cài đặt',
nav: {
models: 'Mô hình',
api: 'API',
storage: 'Lưu trữ',
account: 'Tài khoản',
},
},
storageAnalysis: { storageAnalysis: {
title: 'Phân tích lưu trữ', title: 'Phân tích lưu trữ',
description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp', description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp',
-17
View File
@@ -116,7 +116,6 @@ const zhHans = {
changePasswordSuccess: '密码修改成功', changePasswordSuccess: '密码修改成功',
changePasswordFailed: '密码修改失败,请检查当前密码是否正确', changePasswordFailed: '密码修改失败,请检查当前密码是否正确',
apiIntegration: 'API 集成', apiIntegration: 'API 集成',
apiIntegrationDescription: '管理用于外部访问的 API 密钥和 Webhook',
apiKeys: 'API 密钥', apiKeys: 'API 密钥',
manageApiIntegration: '管理 API 集成', manageApiIntegration: '管理 API 集成',
manageApiKeys: '管理 API 密钥', manageApiKeys: '管理 API 密钥',
@@ -1097,7 +1096,6 @@ const zhHans = {
}, },
account: { account: {
settings: '账户设置', settings: '账户设置',
settingsDescription: '管理你的密码和关联账户',
setPassword: '设置密码', setPassword: '设置密码',
passwordSetSuccess: '密码设置成功', passwordSetSuccess: '密码设置成功',
passwordStatus: '本地密码', passwordStatus: '本地密码',
@@ -1158,7 +1156,6 @@ const zhHans = {
embeddingCalls: 'Embedding调用', embeddingCalls: 'Embedding调用',
modelCalls: '模型调用', modelCalls: '模型调用',
tokens: 'Token 监控', tokens: 'Token 监控',
traces: '链路追踪',
feedback: '用户反馈', feedback: '用户反馈',
sessions: '会话分析', sessions: '会话分析',
errors: '错误日志', errors: '错误日志',
@@ -1263,11 +1260,6 @@ const zhHans = {
noErrors: '未找到错误', noErrors: '未找到错误',
stackTrace: '堆栈追踪', stackTrace: '堆栈追踪',
}, },
traces: {
title: '链路追踪',
noTraces: '未找到链路记录',
noSpans: '此链路暂无 Span 记录',
},
feedback: { feedback: {
title: '用户反馈', title: '用户反馈',
totalFeedback: '总反馈数', totalFeedback: '总反馈数',
@@ -1336,15 +1328,6 @@ const zhHans = {
boxSessionCreated: '创建时间', boxSessionCreated: '创建时间',
boxSessionLastUsed: '最后使用', boxSessionLastUsed: '最后使用',
}, },
settingsDialog: {
title: '设置',
nav: {
models: '模型',
api: 'API',
storage: '存储',
account: '账户',
},
},
storageAnalysis: { storageAnalysis: {
title: '存储分析', title: '存储分析',
description: '查看存储占用和可清理文件', description: '查看存储占用和可清理文件',
-11
View File
@@ -116,7 +116,6 @@ const zhHant = {
changePasswordSuccess: '密碼修改成功', changePasswordSuccess: '密碼修改成功',
changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確', changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確',
apiIntegration: 'API 整合', apiIntegration: 'API 整合',
apiIntegrationDescription: '管理用於外部存取的 API 金鑰和 Webhook',
apiKeys: 'API 金鑰', apiKeys: 'API 金鑰',
manageApiIntegration: '管理 API 整合', manageApiIntegration: '管理 API 整合',
manageApiKeys: '管理 API 金鑰', manageApiKeys: '管理 API 金鑰',
@@ -1096,7 +1095,6 @@ const zhHant = {
}, },
account: { account: {
settings: '帳戶設定', settings: '帳戶設定',
settingsDescription: '管理你的密碼和關聯帳戶',
setPassword: '設定密碼', setPassword: '設定密碼',
passwordSetSuccess: '密碼設定成功', passwordSetSuccess: '密碼設定成功',
passwordStatus: '本地密碼', passwordStatus: '本地密碼',
@@ -1329,15 +1327,6 @@ const zhHant = {
boxSessionCreated: '建立時間', boxSessionCreated: '建立時間',
boxSessionLastUsed: '最後使用', boxSessionLastUsed: '最後使用',
}, },
settingsDialog: {
title: '設定',
nav: {
models: '模型',
api: 'API',
storage: '儲存',
account: '帳戶',
},
},
storageAnalysis: { storageAnalysis: {
title: '儲存分析', title: '儲存分析',
description: '查看儲存占用和可清理檔案', description: '查看儲存占用和可清理檔案',
-455
View File
@@ -1,455 +0,0 @@
import { expect, Page, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
async function save(page: Page) {
const button = page.getByRole('button', { name: /^Save$/ });
await expect(button).toBeEnabled();
await button.click();
}
async function submit(page: Page) {
await page.getByRole('button', { name: /^Submit$/ }).click();
}
async function confirmDelete(page: Page) {
await page
.getByRole('dialog')
.getByRole('button', { name: /^Confirm Delete$/ })
.click();
}
test.describe('frontend CRUD smoke flows', () => {
test('creates, edits, and deletes a bot', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots?id=new');
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('Support Bot');
await page
.locator('input[name="description"]')
.fill('Answers customer support questions.');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue('Support Bot');
await page
.locator('input[name="description"]')
.fill('Answers customer support questions with context.');
await save(page);
await expect(page.locator('input[name="description"]')).toHaveValue(
'Answers customer support questions with context.',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/bots$/);
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
});
test('creates, edits, and deletes a pipeline', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines?id=new');
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
await page.locator('input[name="basic.name"]').fill('Escalation Pipeline');
await page
.locator('input[name="basic.description"]')
.fill('Routes urgent customer issues.');
await submit(page);
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
await page.reload();
await expect(page.locator('input[name="basic.name"]')).toHaveValue(
'Escalation Pipeline',
);
await page
.locator('input[name="basic.description"]')
.fill('Routes urgent customer issues to operators.');
await save(page);
await expect(page.locator('input[name="basic.description"]')).toHaveValue(
'Routes urgent customer issues to operators.',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/pipelines$/);
await expect(
page.getByText('Select a pipeline from the sidebar'),
).toBeVisible();
});
test('creates, edits, and deletes a knowledge base', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/knowledge?id=new');
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('Support Knowledge');
await page
.locator('input[name="description"]')
.fill('Source material for support answers.');
await submit(page);
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue(
'Support Knowledge',
);
await page.waitForTimeout(600);
await page
.locator('input[name="description"]')
.fill('Updated source material for support answers.');
await save(page);
await expect(page.locator('input[name="description"]')).toHaveValue(
'Updated source material for support answers.',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/knowledge$/);
await expect(
page.getByText('Select a knowledge base from the sidebar'),
).toBeVisible();
});
test('creates, edits, and deletes an MCP server', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/mcp?id=new');
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('playwright-mcp');
await page
.locator('input[name="url"]')
.fill('https://mcp.example.test/sse');
await submit(page);
await expect(page).toHaveURL(/\/home\/mcp\?id=playwright-mcp$/);
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue(
'playwright-mcp',
);
await page
.locator('input[name="url"]')
.fill('https://mcp.example.test/updated-sse');
await save(page);
await expect(page.locator('input[name="url"]')).toHaveValue(
'https://mcp.example.test/updated-sse',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/mcp$/);
await expect(
page.getByText('Select an MCP server from the sidebar'),
).toBeVisible();
});
test('updates and deletes a manually-created skill', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/skills?action=create');
await page.locator('#display_name').fill('Release Notes');
await page.locator('#name').fill('release_notes');
await page.locator('#description').fill('Drafts release notes.');
await page
.locator('#instructions')
.fill('Summarize merged changes for the next release.');
await save(page);
await expect(page).toHaveURL(/\/home\/skills\?id=release_notes$/);
await page.reload();
await expect(page.locator('#description')).toHaveValue(
'Drafts release notes.',
);
await page
.locator('#description')
.fill('Drafts concise release notes for maintainers.');
await expect(page.locator('#description')).toHaveValue(
'Drafts concise release notes for maintainers.',
);
await save(page);
await page.reload();
await expect(page.locator('#description')).toHaveValue(
'Drafts concise release notes for maintainers.',
);
await expect(page.locator('#instructions')).toHaveValue(
'Summarize merged changes for the next release.',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/add-extension$/);
});
});
test.describe('bot advanced flows', () => {
test('toggles bot enable/disable state', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a bot first
await page.goto('/home/bots?id=new');
await page.locator('input[name="name"]').fill('Toggle Test Bot');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Wait for the enable switch to load (it's fetched via getBot)
await expect(page.locator('#bot-enable-switch')).toBeVisible({
timeout: 5000,
});
// Verify initial state is enabled
await expect(page.locator('#bot-enable-switch')).toBeChecked();
// Toggle to disabled
await page.locator('#bot-enable-switch').click();
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
// Reload and verify state persisted
await page.reload();
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
});
test('switches between bot detail tabs', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a bot
await page.goto('/home/bots?id=new');
await page.locator('input[name="name"]').fill('Tab Test Bot');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
// Verify we're on the Configuration tab
await expect(
page.getByRole('tab', { name: /Configuration/ }),
).toHaveAttribute('data-state', 'active');
await expect(page.locator('input[name="name"]')).toBeVisible();
// Switch to Logs tab
await page.getByRole('tab', { name: /Logs/ }).click();
await expect(page.getByRole('tab', { name: /Logs/ })).toHaveAttribute(
'data-state',
'active',
);
// Switch to Sessions tab
await page.getByRole('tab', { name: /Sessions/ }).click();
await expect(page.getByRole('tab', { name: /Sessions/ })).toHaveAttribute(
'data-state',
'active',
);
// Switch back to Configuration
await page.getByRole('tab', { name: /Configuration/ }).click();
await expect(page.locator('input[name="name"]')).toBeVisible();
});
test('save button is disabled when form is clean', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a bot
await page.goto('/home/bots?id=new');
await page.locator('input[name="name"]').fill('Clean Form Bot');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
// After creation, save button should be disabled (form is clean)
const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeDisabled();
// Edit the form
await page.locator('input[name="description"]').fill('New description');
await expect(saveButton).toBeEnabled();
// Save
await saveButton.click();
await expect(saveButton).toBeDisabled();
});
test('shows validation error when bot name is empty', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots?id=new');
// Select adapter but leave name empty
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
// Should show validation error for name (zod validation)
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
await expect(page).toHaveURL(/\/home\/bots\?id=new$/);
});
});
test.describe('pipeline advanced flows', () => {
test('switches to monitoring tab from pipeline detail', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline
await page.goto('/home/pipelines?id=new');
await page.locator('input[name="basic.name"]').fill('Tab Test Pipeline');
await submit(page);
// Verify we're on the Configuration tab
await expect(
page.getByRole('tab', { name: /Configuration/ }),
).toHaveAttribute('data-state', 'active');
// Switch to Monitoring tab (labeled "Dashboard" in the pipeline context)
// Skip Debug tab as it requires WebSocket connection
await page.getByRole('tab', { name: /Dashboard/ }).click();
await expect(page.getByRole('tab', { name: /Dashboard/ })).toHaveAttribute(
'data-state',
'active',
);
// Switch back to Configuration
await page.getByRole('tab', { name: /Configuration/ }).click();
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
});
test('save button reflects form dirty state', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline
await page.goto('/home/pipelines?id=new');
await page.locator('input[name="basic.name"]').fill('Dirty Form Pipeline');
await submit(page);
// Wait for the page to fully load and form to reset
await page.waitForTimeout(500);
// Edit the form - use the name field which definitely triggers dirty state
await page
.locator('input[name="basic.name"]')
.fill('Dirty Form Pipeline Updated');
const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeEnabled();
// Save
await saveButton.click();
// Wait for save to complete
await page.waitForTimeout(500);
});
test('shows validation error when pipeline name is empty', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines?id=new');
// Submit without filling name
await submit(page);
// Should show validation error for name (zod validation)
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
await expect(page).toHaveURL(/\/home\/pipelines\?id=new$/);
});
});
test.describe('cross-resource flows', () => {
test('creates a pipeline then binds it to a bot', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline first
await page.goto('/home/pipelines?id=new');
await page.locator('input[name="basic.name"]').fill('Production Pipeline');
await submit(page);
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
// Create a bot
await page.goto('/home/bots?id=new');
await page.locator('input[name="name"]').fill('Bound Bot');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Wait for form to fully load
await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot');
// Find the pipeline select by its label "Bind Pipeline"
const pipelineCard = page.getByText('Bind Pipeline').locator('..');
await expect(pipelineCard).toBeVisible({ timeout: 5000 });
// Click on the select trigger within the pipeline binding card
// The select trigger shows "Select Pipeline" placeholder initially
const pipelineSelectTrigger = page.getByText('Select Pipeline').first();
await pipelineSelectTrigger.click();
// Select the pipeline option
await page.getByRole('option', { name: 'Production Pipeline' }).click();
// Save the bot
await save(page);
// Reload and verify binding persisted
await page.reload();
// The pipeline name should appear in the select trigger (not in sidebar or options)
await expect(
page
.locator('[data-slot="select-trigger"]')
.filter({ hasText: 'Production Pipeline' }),
).toBeVisible();
});
});
test.describe('empty states', () => {
test('shows empty state when no bots exist', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots');
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
});
test('shows empty state when no pipelines exist', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines');
await expect(
page.getByText('Select a pipeline from the sidebar'),
).toBeVisible();
});
test('shows empty state when no knowledge bases exist', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/knowledge');
await expect(
page.getByText('Select a knowledge base from the sidebar'),
).toBeVisible();
});
test('shows empty state when no MCP servers exist', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/mcp');
await expect(
page.getByText('Select an MCP server from the sidebar'),
).toBeVisible();
});
});
+4 -422
View File
@@ -11,68 +11,7 @@ interface SkillMock {
updated_at: string; updated_at: string;
} }
interface PipelineMock {
uuid: string;
name: string;
description: string;
config: JsonRecord;
emoji: string;
is_default: boolean;
updated_at: string;
}
interface KnowledgeBaseMock {
uuid: string;
name: string;
description: string;
emoji: string;
knowledge_engine_plugin_id: string;
creation_settings: JsonRecord;
retrieval_settings: JsonRecord;
knowledge_engine: {
plugin_id: string;
name: {
en_US: string;
zh_Hans: string;
};
capabilities: string[];
};
updated_at: string;
}
interface MCPServerMock {
name: string;
mode: 'sse' | 'stdio' | 'http';
enable: boolean;
extra_args: JsonRecord;
runtime_info: {
status: 'connected';
tool_count: number;
tools: unknown[];
};
readme: string;
updated_at: string;
}
interface BotMock {
uuid: string;
name: string;
description: string;
enable: boolean;
adapter: string;
adapter_config: JsonRecord;
use_pipeline_uuid?: string;
pipeline_routing_rules: unknown[];
adapter_runtime_values: JsonRecord;
updated_at: string;
}
interface LangBotApiMockState { interface LangBotApiMockState {
bots: BotMock[];
counters: Record<string, number>;
knowledgeBases: KnowledgeBaseMock[];
mcpServers: MCPServerMock[];
pipelines: PipelineMock[];
skills: SkillMock[]; skills: SkillMock[];
} }
@@ -97,19 +36,6 @@ function routePath(route: Route) {
return new URL(route.request().url()).pathname; return new URL(route.request().url()).pathname;
} }
function parseJsonBody(route: Route): JsonRecord {
return JSON.parse(route.request().postData() || '{}') as JsonRecord;
}
function now() {
return new Date().toISOString();
}
function nextId(state: LangBotApiMockState, prefix: string) {
state.counters[prefix] = (state.counters[prefix] || 0) + 1;
return `${prefix}-${state.counters[prefix]}`;
}
function emptyMonitoringData() { function emptyMonitoringData() {
return { return {
overview: { overview: {
@@ -167,131 +93,6 @@ function makeSkill(data: JsonRecord): SkillMock {
}; };
} }
function makePipeline(
state: LangBotApiMockState,
data: JsonRecord,
uuid = nextId(state, 'pipeline'),
): PipelineMock {
return {
uuid,
name: String(data.name || ''),
description: String(data.description || ''),
config: (data.config as JsonRecord | undefined) || {
ai: {},
trigger: {},
safety: {},
output: {},
},
emoji: String(data.emoji || '⚙️'),
is_default: false,
updated_at: now(),
};
}
function knowledgeEngine() {
return {
plugin_id: 'builtin/minimal-knowledge',
name: {
en_US: 'Minimal Knowledge Engine',
zh_Hans: '最小知识库引擎',
},
description: {
en_US: 'Minimal mocked engine for frontend smoke tests.',
zh_Hans: '用于前端冒烟测试的最小模拟引擎。',
},
capabilities: ['text_retrieval'],
creation_schema: [],
retrieval_schema: [],
};
}
function makeKnowledgeBase(
state: LangBotApiMockState,
data: JsonRecord,
uuid = nextId(state, 'knowledge'),
): KnowledgeBaseMock {
const engine = knowledgeEngine();
return {
uuid,
name: String(data.name || ''),
description: String(data.description || ''),
emoji: String(data.emoji || '📚'),
knowledge_engine_plugin_id: String(
data.knowledge_engine_plugin_id || engine.plugin_id,
),
creation_settings: (data.creation_settings as JsonRecord | undefined) || {},
retrieval_settings:
(data.retrieval_settings as JsonRecord | undefined) || {},
knowledge_engine: {
plugin_id: engine.plugin_id,
name: engine.name,
capabilities: engine.capabilities,
},
updated_at: now(),
};
}
function makeMCPServer(data: JsonRecord): MCPServerMock {
return {
name: String(data.name || ''),
mode: (data.mode as MCPServerMock['mode']) || 'sse',
enable: data.enable !== false,
extra_args: (data.extra_args as JsonRecord | undefined) || {},
runtime_info: {
status: 'connected',
tool_count: 0,
tools: [],
},
readme: '',
updated_at: now(),
};
}
function makeBot(
state: LangBotApiMockState,
data: JsonRecord,
uuid = nextId(state, 'bot'),
): BotMock {
return {
uuid,
name: String(data.name || ''),
description: String(data.description || ''),
enable: data.enable !== false,
adapter: String(data.adapter || 'playwright-adapter'),
adapter_config: (data.adapter_config as JsonRecord | undefined) || {},
use_pipeline_uuid: data.use_pipeline_uuid
? String(data.use_pipeline_uuid)
: undefined,
pipeline_routing_rules:
(data.pipeline_routing_rules as unknown[] | undefined) || [],
adapter_runtime_values: {
webhook_full_url: `https://playwright.test/bots/${uuid}/webhook`,
extra_webhook_full_url: '',
},
updated_at: now(),
};
}
function mockAdapters() {
return [
{
name: 'playwright-adapter',
label: {
en_US: 'Playwright Adapter',
zh_Hans: 'Playwright 适配器',
},
description: {
en_US: 'Minimal adapter for frontend E2E tests.',
zh_Hans: '用于前端 E2E 测试的最小适配器。',
},
spec: {
categories: ['testing'],
config: [],
},
},
];
}
async function handleBackendApi(route: Route, state: LangBotApiMockState) { async function handleBackendApi(route: Route, state: LangBotApiMockState) {
const request = route.request(); const request = route.request();
const url = new URL(request.url()); const url = new URL(request.url());
@@ -346,160 +147,16 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { credits: null }); return fulfillJson(route, { credits: null });
} }
if (path === '/api/v1/platform/adapters') {
return fulfillJson(route, { adapters: mockAdapters() });
}
if (path === '/api/v1/platform/bots') { if (path === '/api/v1/platform/bots') {
if (method === 'POST') { return fulfillJson(route, { bots: [] });
const bot = makeBot(state, parseJsonBody(route));
state.bots = [
...state.bots.filter((item) => item.uuid !== bot.uuid),
bot,
];
return fulfillJson(route, { uuid: bot.uuid });
}
return fulfillJson(route, { bots: state.bots });
}
const botLogsMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)\/logs$/);
if (botLogsMatch) {
return fulfillJson(route, { logs: [], total: 0 });
}
const botMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)$/);
if (botMatch) {
const botId = decodeURIComponent(botMatch[1]);
if (method === 'PUT') {
const bot = makeBot(state, parseJsonBody(route), botId);
state.bots = [...state.bots.filter((item) => item.uuid !== botId), bot];
return fulfillJson(route, {});
}
if (method === 'DELETE') {
state.bots = state.bots.filter((item) => item.uuid !== botId);
return fulfillJson(route, {});
}
const bot = state.bots.find((item) => item.uuid === botId);
return fulfillJson(route, {
bot: bot || makeBot(state, { name: botId }, botId),
});
}
if (path === '/api/v1/pipelines/_/metadata') {
return fulfillJson(route, { configs: [] });
} }
if (path === '/api/v1/pipelines') { if (path === '/api/v1/pipelines') {
if (method === 'POST') { return fulfillJson(route, { pipelines: [] });
const pipeline = makePipeline(state, parseJsonBody(route));
state.pipelines = [
...state.pipelines.filter((item) => item.uuid !== pipeline.uuid),
pipeline,
];
return fulfillJson(route, { uuid: pipeline.uuid });
}
return fulfillJson(route, { pipelines: state.pipelines });
}
const pipelineMatch = path.match(/^\/api\/v1\/pipelines\/([^/]+)$/);
if (pipelineMatch) {
const pipelineId = decodeURIComponent(pipelineMatch[1]);
if (method === 'PUT') {
const pipeline = makePipeline(state, parseJsonBody(route), pipelineId);
state.pipelines = [
...state.pipelines.filter((item) => item.uuid !== pipelineId),
pipeline,
];
return fulfillJson(route, {});
}
if (method === 'DELETE') {
state.pipelines = state.pipelines.filter(
(item) => item.uuid !== pipelineId,
);
return fulfillJson(route, {});
}
const pipeline = state.pipelines.find((item) => item.uuid === pipelineId);
return fulfillJson(route, {
pipeline:
pipeline || makePipeline(state, { name: pipelineId }, pipelineId),
});
}
const pipelineExtensionsMatch = path.match(
/^\/api\/v1\/pipelines\/([^/]+)\/extensions$/,
);
if (pipelineExtensionsMatch) {
return fulfillJson(route, {
enable_all_plugins: true,
enable_all_mcp_servers: true,
enable_all_skills: true,
bound_plugins: [],
available_plugins: [],
bound_mcp_servers: [],
available_mcp_servers: state.mcpServers,
bound_skills: [],
available_skills: state.skills,
});
} }
if (path === '/api/v1/knowledge/bases') { if (path === '/api/v1/knowledge/bases') {
if (method === 'POST') { return fulfillJson(route, { bases: [] });
const base = makeKnowledgeBase(state, parseJsonBody(route));
state.knowledgeBases = [
...state.knowledgeBases.filter((item) => item.uuid !== base.uuid),
base,
];
return fulfillJson(route, { uuid: base.uuid });
}
return fulfillJson(route, { bases: state.knowledgeBases });
}
const knowledgeBaseFilesMatch = path.match(
/^\/api\/v1\/knowledge\/bases\/([^/]+)\/files$/,
);
if (knowledgeBaseFilesMatch) {
return fulfillJson(route, { files: [] });
}
const knowledgeBaseMatch = path.match(
/^\/api\/v1\/knowledge\/bases\/([^/]+)$/,
);
if (knowledgeBaseMatch) {
const baseId = decodeURIComponent(knowledgeBaseMatch[1]);
if (method === 'PUT') {
const base = makeKnowledgeBase(state, parseJsonBody(route), baseId);
state.knowledgeBases = [
...state.knowledgeBases.filter((item) => item.uuid !== baseId),
base,
];
return fulfillJson(route, { uuid: base.uuid });
}
if (method === 'DELETE') {
state.knowledgeBases = state.knowledgeBases.filter(
(item) => item.uuid !== baseId,
);
return fulfillJson(route, {});
}
const base = state.knowledgeBases.find((item) => item.uuid === baseId);
return fulfillJson(route, {
base: base || makeKnowledgeBase(state, { name: baseId }, baseId),
});
}
if (path === '/api/v1/knowledge/engines') {
return fulfillJson(route, { engines: [knowledgeEngine()] });
} }
if (path === '/api/v1/knowledge/migration/status') { if (path === '/api/v1/knowledge/migration/status') {
@@ -519,60 +176,7 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
} }
if (path === '/api/v1/mcp/servers') { if (path === '/api/v1/mcp/servers') {
if (method === 'POST') { return fulfillJson(route, { servers: [] });
const server = makeMCPServer(parseJsonBody(route));
state.mcpServers = [
...state.mcpServers.filter((item) => item.name !== server.name),
server,
];
return fulfillJson(route, { task_id: nextId(state, 'task') });
}
return fulfillJson(route, { servers: state.mcpServers });
}
const mcpTestMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)\/test$/);
if (mcpTestMatch) {
return fulfillJson(route, {
runtime_info: {
status: 'connected',
tool_count: 0,
tools: [],
},
});
}
const mcpServerMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)$/);
if (mcpServerMatch) {
const serverName = decodeURIComponent(mcpServerMatch[1]);
if (method === 'PUT') {
const existing = state.mcpServers.find(
(item) => item.name === serverName,
);
const server = makeMCPServer({
...(existing || {}),
...parseJsonBody(route),
name: serverName,
});
state.mcpServers = [
...state.mcpServers.filter((item) => item.name !== serverName),
server,
];
return fulfillJson(route, { task_id: nextId(state, 'task') });
}
if (method === 'DELETE') {
state.mcpServers = state.mcpServers.filter(
(item) => item.name !== serverName,
);
return fulfillJson(route, { task_id: nextId(state, 'task') });
}
const server = state.mcpServers.find((item) => item.name === serverName);
return fulfillJson(route, {
server: server || makeMCPServer({ name: serverName }),
});
} }
if (path === '/api/v1/skills') { if (path === '/api/v1/skills') {
@@ -625,23 +229,6 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
const skillMatch = path.match(/^\/api\/v1\/skills\/([^/]+)$/); const skillMatch = path.match(/^\/api\/v1\/skills\/([^/]+)$/);
if (skillMatch) { if (skillMatch) {
const skillName = decodeURIComponent(skillMatch[1]); const skillName = decodeURIComponent(skillMatch[1]);
if (method === 'PUT') {
const skill = makeSkill({
...parseJsonBody(route),
name: skillName,
});
state.skills = [
...state.skills.filter((item) => item.name !== skillName),
skill,
];
return fulfillJson(route, { skill });
}
if (method === 'DELETE') {
state.skills = state.skills.filter((item) => item.name !== skillName);
return fulfillJson(route, {});
}
const skill = state.skills.find((item) => item.name === skillName) || { const skill = state.skills.find((item) => item.name === skillName) || {
name: skillName, name: skillName,
display_name: '', display_name: '',
@@ -802,11 +389,6 @@ export async function installLangBotApiMocks(
) { ) {
const { authenticated = false, storage = {} } = options; const { authenticated = false, storage = {} } = options;
const state: LangBotApiMockState = { const state: LangBotApiMockState = {
bots: [],
counters: {},
knowledgeBases: [],
mcpServers: [],
pipelines: [],
skills: [], skills: [],
}; };