mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 07:55:18 +08:00
fix(bots): restore sessions across processor types
This commit is contained in:
@@ -1884,6 +1884,16 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
"""Get messages with filters"""
|
||||
limit, offset = self.normalize_page_window(limit, offset)
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
if bot_ids and session_ids and not pipeline_ids and not execution_statuses and mode == 'all':
|
||||
source = self._session_message_source(workspace_uuid, bot_ids)
|
||||
query = sqlalchemy.select(source).where(source.c.session_id.in_(session_ids))
|
||||
if start_time:
|
||||
query = query.where(source.c.timestamp >= start_time)
|
||||
if end_time:
|
||||
query = query.where(source.c.timestamp <= end_time)
|
||||
return await self._session_projection_page(
|
||||
workspace_uuid, query.order_by(source.c.timestamp.desc(), source.c.id.desc()), limit, offset,
|
||||
)
|
||||
conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
|
||||
|
||||
conditions.append(
|
||||
@@ -2141,6 +2151,10 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
"""Get sessions with filters"""
|
||||
limit, offset = self.normalize_page_window(limit, offset)
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
if bot_ids and not pipeline_ids and not execution_statuses and mode == 'all':
|
||||
return await self.get_bot_conversation_sessions(
|
||||
workspace_uuid, bot_ids, start_time, end_time, user_query, is_active, limit, offset,
|
||||
)
|
||||
conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
|
||||
|
||||
conditions.append(
|
||||
|
||||
@@ -83,6 +83,119 @@ def transcript_item(row):
|
||||
|
||||
|
||||
class ExecutionDetailsMixin:
|
||||
def _session_message_source(self, workspace, bot_ids):
|
||||
"""Combine delivered messages with ingress missing from the legacy ledger."""
|
||||
message = models.MonitoringMessage
|
||||
conversation = sa.func.coalesce(
|
||||
EventLog.conversation_id,
|
||||
sa.select(AgentRun.conversation_id).where(
|
||||
AgentRun.workspace_id == workspace,
|
||||
AgentRun.bot_id == EventLog.bot_id,
|
||||
AgentRun.event_id == EventLog.event_id,
|
||||
).order_by(AgentRun.id).limit(1).scalar_subquery(),
|
||||
sa.case(
|
||||
(self._execution_json_text(EventLog.input_json, 'chat_type') == 'group',
|
||||
sa.literal('group_') + self._execution_json_text(EventLog.input_json, 'chat_id')),
|
||||
(self._execution_json_text(EventLog.input_json, 'chat_type') == 'private',
|
||||
sa.literal('person_') + self._execution_json_text(EventLog.input_json, ('sender', 'id'))),
|
||||
),
|
||||
)
|
||||
values = {
|
||||
'id': EventLog.event_id,
|
||||
'workspace_uuid': EventLog.workspace_id,
|
||||
'timestamp': sa.func.coalesce(EventLog.event_time, EventLog.created_at),
|
||||
'bot_id': EventLog.bot_id,
|
||||
'bot_name': sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, 'bot_name'), ''),
|
||||
'pipeline_id': sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'target_uuid')), ''),
|
||||
'pipeline_name': sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'target_type')), ''),
|
||||
'message_content': sa.func.coalesce(EventLog.input_json, EventLog.input_summary, ''),
|
||||
'session_id': conversation,
|
||||
'status': sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, 'status'), 'success'),
|
||||
'level': sa.literal('info'),
|
||||
'platform': self._execution_json_text(EventLog.metadata_json, 'platform'),
|
||||
'user_id': EventLog.actor_id,
|
||||
'user_name': EventLog.actor_name,
|
||||
'role': sa.literal('user'),
|
||||
'event_id': EventLog.event_id,
|
||||
'run_id': EventLog.run_id,
|
||||
}
|
||||
columns = list(message.__table__.columns)
|
||||
stored = sa.select(*columns).where(message.workspace_uuid == workspace, message.bot_id.in_(bot_ids))
|
||||
incoming = sa.select(*[
|
||||
values.get(column.name, sa.literal(None)).label(column.name) for column in columns
|
||||
]).where(
|
||||
EventLog.workspace_id == workspace,
|
||||
EventLog.bot_id.in_(bot_ids),
|
||||
EventLog.source == 'platform',
|
||||
EventLog.event_type == 'message.received',
|
||||
conversation.is_not(None),
|
||||
conversation != '',
|
||||
~sa.exists(sa.select(message.id).where(
|
||||
message.workspace_uuid == workspace,
|
||||
message.bot_id == EventLog.bot_id,
|
||||
sa.func.coalesce(message.role, 'user') != 'assistant',
|
||||
sa.or_(message.event_id == EventLog.event_id, message.id == EventLog.event_id),
|
||||
)),
|
||||
)
|
||||
return sa.union_all(stored, incoming).subquery()
|
||||
|
||||
async def _session_projection_page(self, workspace, statement, limit, offset):
|
||||
count = (await self._execution_query(workspace, sa.select(sa.func.count()).select_from(statement.subquery()))).scalar_one()
|
||||
rows = (await self._execution_query(workspace, statement.limit(limit).offset(offset))).mappings().all()
|
||||
return [
|
||||
{key: value.isoformat() if isinstance(value, datetime.datetime) else value for key, value in row.items()}
|
||||
for row in rows
|
||||
], count
|
||||
|
||||
async def get_bot_conversation_sessions(self, workspace, bot_ids, start_time, end_time, user_query, is_active, limit, offset):
|
||||
"""Read session summaries across processor types without mutating history."""
|
||||
messages = self._session_message_source(workspace, bot_ids)
|
||||
key = [messages.c.workspace_uuid, messages.c.bot_id, messages.c.session_id]
|
||||
ranked = sa.select(
|
||||
*messages.c,
|
||||
sa.func.row_number().over(partition_by=key, order_by=[
|
||||
sa.case((messages.c.role == 'user', 0), else_=1), messages.c.timestamp.desc(), messages.c.id.desc(),
|
||||
]).label('position'),
|
||||
sa.func.min(messages.c.timestamp).over(partition_by=key).label('first_seen'),
|
||||
sa.func.max(messages.c.timestamp).over(partition_by=key).label('last_seen'),
|
||||
sa.func.count().over(partition_by=key).label('count'),
|
||||
).where(messages.c.session_id != '').subquery()
|
||||
session = models.MonitoringSession
|
||||
fields = list(session.__table__.columns)
|
||||
projected = {
|
||||
'start_time': ranked.c.first_seen,
|
||||
'last_activity': ranked.c.last_seen,
|
||||
'message_count': ranked.c['count'],
|
||||
'is_active': sa.literal(True),
|
||||
}
|
||||
derived = sa.select(*[
|
||||
(projected[field.name] if field.name in projected else ranked.c[field.name]).label(field.name)
|
||||
for field in fields
|
||||
]).where(ranked.c.position == 1)
|
||||
existing = sa.select(*fields).where(session.workspace_uuid == workspace, session.bot_id.in_(bot_ids))
|
||||
combined = sa.union_all(existing, derived).subquery()
|
||||
group = [combined.c.workspace_uuid, combined.c.bot_id, combined.c.session_id]
|
||||
merged = sa.select(
|
||||
*[combined.c[field.name] for field in fields if field.name not in {'message_count', 'start_time', 'last_activity'}],
|
||||
sa.func.max(combined.c.message_count).over(partition_by=group).label('message_count'),
|
||||
sa.func.min(combined.c.start_time).over(partition_by=group).label('start_time'),
|
||||
sa.func.max(combined.c.last_activity).over(partition_by=group).label('last_activity'),
|
||||
sa.func.row_number().over(partition_by=group, order_by=combined.c.last_activity.desc()).label('position'),
|
||||
).subquery()
|
||||
query = sa.select(*[merged.c[field.name] for field in fields]).where(merged.c.position == 1)
|
||||
if start_time:
|
||||
query = query.where(merged.c.last_activity >= start_time)
|
||||
if end_time:
|
||||
query = query.where(merged.c.last_activity <= end_time)
|
||||
if user_query and user_query.strip():
|
||||
pattern = f'%{user_query.strip()}%'
|
||||
query = query.where(sa.or_(merged.c.user_id.ilike(pattern), merged.c.user_name.ilike(pattern)))
|
||||
if is_active is not None:
|
||||
query = query.where(merged.c.is_active == is_active)
|
||||
return await self._session_projection_page(
|
||||
workspace, query.order_by(merged.c.last_activity.desc(), merged.c.session_id), limit, offset,
|
||||
)
|
||||
|
||||
def _execution_json_text(self, column, key):
|
||||
# SQLite JSON functions accept TEXT directly; PostgreSQL operators
|
||||
# require an explicit JSON cast for the existing text-backed journals.
|
||||
@@ -121,6 +234,7 @@ class ExecutionDetailsMixin:
|
||||
payload,
|
||||
actor_id=None,
|
||||
actor_name=None,
|
||||
conversation_id=None,
|
||||
status='running',
|
||||
routes=None,
|
||||
):
|
||||
@@ -142,6 +256,7 @@ class ExecutionDetailsMixin:
|
||||
bot_id=bot_id,
|
||||
actor_id=actor_id,
|
||||
actor_name=actor_name,
|
||||
conversation_id=conversation_id,
|
||||
actor_type='user' if actor_id else 'system',
|
||||
input_summary=summary,
|
||||
input_json=content,
|
||||
|
||||
@@ -218,7 +218,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
||||
'now': sqlalchemy.sql.functions.now,
|
||||
'sum': sqlalchemy.sql.functions.sum,
|
||||
}
|
||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'})
|
||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'row_number', 'strftime'})
|
||||
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
||||
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
||||
sqlalchemy.sql.dml.UpdateBase,
|
||||
@@ -488,7 +488,10 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
|
||||
and isinstance(source, sqlalchemy.Column)
|
||||
and type(source.type) is sqlalchemy.Text
|
||||
and getattr(getattr(source, 'table', None), 'name', None) in {'event_log', 'agent_run'}
|
||||
and source.name == 'metadata_json'
|
||||
and (
|
||||
source.name == 'metadata_json'
|
||||
or (source.table.name == 'event_log' and source.name == 'input_json')
|
||||
)
|
||||
)
|
||||
if not (pipeline_json_cas or monitoring_metadata):
|
||||
raise ScopedSessionTransactionError(
|
||||
|
||||
@@ -946,6 +946,7 @@ class RuntimeBot:
|
||||
return
|
||||
try:
|
||||
sender = getattr(event, 'sender', None)
|
||||
target_type, target_id, _ = self._infer_reply_target(event)
|
||||
await service.record_ingress_event(
|
||||
self.execution_context,
|
||||
event_id=execution_id,
|
||||
@@ -960,6 +961,7 @@ class RuntimeBot:
|
||||
},
|
||||
actor_id=self._get_entity_id(sender),
|
||||
actor_name=self._get_entity_name(sender),
|
||||
conversation_id=f'{target_type}_{target_id}' if target_type and target_id else None,
|
||||
status=status,
|
||||
routes=routes,
|
||||
)
|
||||
|
||||
@@ -227,11 +227,13 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<AdapterEventDebugDialog
|
||||
key={id}
|
||||
botId={id}
|
||||
adapterLabel={adapterLabel}
|
||||
/>
|
||||
{activeTab === 'config' && (
|
||||
<AdapterEventDebugDialog
|
||||
key={id}
|
||||
botId={id}
|
||||
adapterLabel={adapterLabel}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="bot-form"
|
||||
|
||||
@@ -410,6 +410,25 @@ const BotSessionMonitor = forwardRef<
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed as MessageChainComponent[];
|
||||
}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const chain = parsed.message_chain ?? parsed.event?.message_chain;
|
||||
if (Array.isArray(chain)) return chain as MessageChainComponent[];
|
||||
if (Array.isArray(chain?.root)) return chain.root as MessageChainComponent[];
|
||||
if (Array.isArray(parsed.contents)) {
|
||||
const components: MessageChainComponent[] = [];
|
||||
for (const part of parsed.contents) {
|
||||
if (part.type === 'text' && typeof part.text === 'string') {
|
||||
components.push({ type: 'Plain', text: part.text } as Plain);
|
||||
} else if (part.type === 'image' && part.image_url) {
|
||||
components.push({ type: 'Image', url: part.image_url } as MessageChainComponent);
|
||||
}
|
||||
}
|
||||
if (components.length) return components;
|
||||
}
|
||||
if (typeof parsed.text === 'string') {
|
||||
return [{ type: 'Plain', text: parsed.text } as Plain];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, return as plain text
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user