diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index ac580b1a3..e3a13b789 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -18,7 +18,6 @@ class BotsRouterGroup(group.RouterGroup): @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(bot_uuid: str) -> str: if quart.request.method == 'GET': - # 返回运行时信息,包括webhook地址等 bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid) if bot is None: return self.http_status(404, -1, 'bot not found') @@ -37,30 +36,21 @@ class BotsRouterGroup(group.RouterGroup): from_index = json_data.get('from_index', -1) max_count = json_data.get('max_count', 10) logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count) - return self.success( - data={ - 'logs': logs, - 'total_count': total_count, - } - ) + return self.success(data={'logs': logs, 'total_count': total_count}) @self.route('//send_message', methods=['POST'], auth_type=group.AuthType.API_KEY) async def _(bot_uuid: str) -> str: - """Send message to a specific target via bot""" json_data = await quart.request.json target_type = json_data.get('target_type') target_id = json_data.get('target_id') message_chain_data = json_data.get('message_chain') - # Validate required fields if not target_type: return self.http_status(400, -1, 'target_type is required') if not target_id: return self.http_status(400, -1, 'target_id is required') if not message_chain_data: return self.http_status(400, -1, 'message_chain is required') - - # Validate target_type if target_type not in ['person', 'group']: return self.http_status(400, -1, 'target_type must be either "person" or "group"') @@ -72,3 +62,29 @@ class BotsRouterGroup(group.RouterGroup): traceback.print_exc() return self.http_status(500, -1, f'Failed to send message: {str(e)}') + + # ============ Bot Admins ============ + + @self.route('//admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(bot_uuid: str) -> str: + if quart.request.method == 'GET': + admins = await self.ap.bot_service.get_bot_admins(bot_uuid) + return self.success(data={'admins': admins}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + launcher_type = json_data.get('launcher_type', '').strip() + launcher_id = str(json_data.get('launcher_id', '')).strip() + if not launcher_type or not launcher_id: + return self.http_status(400, -1, 'launcher_type and launcher_id are required') + try: + admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id) + return self.success(data={'id': admin_id}) + except Exception as e: + return self.http_status(409, -1, str(e)) + + @self.route( + '//admins/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def _(bot_uuid: str, admin_id: int) -> str: + await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id) + return self.success() diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index b8af08613..995267cf5 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -199,3 +199,35 @@ class BotService: # Send message via adapter await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain) + + # ============ Bot Admins ============ + + async def get_bot_admins(self, bot_uuid: str) -> list[dict]: + from ....entity.persistence import bot as persistence_bot + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid) + ) + return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()] + + async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int: + from ....entity.persistence import bot as persistence_bot + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_bot.BotAdmin).values( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + ) + ) + return result.inserted_primary_key[0] + + async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None: + from ....entity.persistence import bot as persistence_bot + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_bot.BotAdmin).where( + persistence_bot.BotAdmin.bot_uuid == bot_uuid, + persistence_bot.BotAdmin.id == admin_id, + ) + ) diff --git a/src/langbot/pkg/command/cmdmgr.py b/src/langbot/pkg/command/cmdmgr.py index a1d7e009c..ee064c219 100644 --- a/src/langbot/pkg/command/cmdmgr.py +++ b/src/langbot/pkg/command/cmdmgr.py @@ -84,7 +84,17 @@ class CommandManager: privilege = 1 - if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']: + import sqlalchemy as _sa + from ..entity.persistence.bot import BotAdmin as _BotAdmin + + _admins = await self.ap.persistence_mgr.execute_async( + _sa.select(_BotAdmin).where( + _BotAdmin.bot_uuid == (query.bot_uuid or ''), + _BotAdmin.launcher_type == query.launcher_type.value, + _BotAdmin.launcher_id == str(query.launcher_id), + ) + ) + if _admins.first() is not None: privilege = 2 ctx = command_context.ExecuteContext( diff --git a/src/langbot/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py index c3fa295f7..9043b7560 100644 --- a/src/langbot/pkg/entity/persistence/bot.py +++ b/src/langbot/pkg/entity/persistence/bot.py @@ -3,6 +3,20 @@ import sqlalchemy from .base import Base +class BotAdmin(Base): + """Bot admin — a launcher that has admin privilege for a specific bot's commands""" + + __tablename__ = 'bot_admins' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False) + launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + + __table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),) + + class Bot(Base): """Bot""" diff --git a/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py new file mode 100644 index 000000000..a13f2caa6 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py @@ -0,0 +1,84 @@ +"""add bot_admins table and migrate config admins + +Revision ID: 0007_add_bot_admins +Revises: 0006_normalize_mcp_remote_mode +Create Date: 2026-06-26 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0007_add_bot_admins' +down_revision = '0006_normalize_mcp_remote_mode' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + if 'bot_admins' in sa.inspect(conn).get_table_names(): + return + op.create_table( + 'bot_admins', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('bot_uuid', sa.String(255), nullable=False), + sa.Column('launcher_type', sa.String(64), nullable=False), + sa.Column('launcher_id', sa.String(255), nullable=False), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'), + ) + + # Migrate old config-based admins into the first bot (best-effort) + inspector = sa.inspect(conn) + tables = inspector.get_table_names() + + if 'bots' not in tables: + return + + # Read the first bot uuid + row = conn.execute(sa.text('SELECT uuid FROM bots ORDER BY created_at LIMIT 1')).first() + if row is None: + return + first_bot_uuid = row[0] + + # Read instance_config metadata key that holds the admins list + if 'metadata' not in tables: + return + meta_row = conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'instance_config'")).first() + if meta_row is None: + return + + import json + + try: + cfg = json.loads(meta_row[0]) + except Exception: + return + + admins = cfg.get('admins', []) + for entry in admins: + parts = entry.split('_', 1) + if len(parts) != 2: + continue + launcher_type, launcher_id = parts + try: + conn.execute( + sa.text( + 'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id) VALUES (:bu, :lt, :li)' + ), + {'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id}, + ) + except Exception: + pass + + # Remove admins key from stored config + if 'admins' in cfg: + del cfg['admins'] + conn.execute( + sa.text("UPDATE metadata SET value = :v WHERE key = 'instance_config'"), + {'v': json.dumps(cfg)}, + ) + + +def downgrade() -> None: + op.drop_table('bot_admins') diff --git a/src/langbot/pkg/pipeline/monitoring_helper.py b/src/langbot/pkg/pipeline/monitoring_helper.py index 19467cc84..a3a9654bc 100644 --- a/src/langbot/pkg/pipeline/monitoring_helper.py +++ b/src/langbot/pkg/pipeline/monitoring_helper.py @@ -32,7 +32,7 @@ class MonitoringHelper: """Record the start of query processing, returns message_id""" try: # Check if session exists, if not, record session start - session_id = f'{query.launcher_type}_{query.launcher_id}' + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' # Get sender name from message event sender_name = None @@ -137,7 +137,7 @@ class MonitoringHelper: ): """Record bot response message to monitoring""" try: - session_id = f'{query.launcher_type}_{query.launcher_id}' + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' # Get sender name from message event sender_name = None @@ -202,7 +202,7 @@ class MonitoringHelper: ) -> str: """Record query processing error, returns message_id""" try: - session_id = f'{query.launcher_type}_{query.launcher_id}' + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' # Get sender name from message event sender_name = None @@ -268,7 +268,7 @@ class MonitoringHelper: ): """Record LLM call""" try: - session_id = f'{query.launcher_type}_{query.launcher_id}' + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' await ap.monitoring_service.record_llm_call( bot_id=bot_id, diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index 1426fe3de..cdd44df89 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -178,7 +178,7 @@ class RuntimePipeline: bot_name = query.variables.get('_monitoring_bot_name', 'Unknown') pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown') message_id = query.variables.get('_monitoring_message_id', '') - session_id = f'{query.launcher_type}_{query.launcher_id}' + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' # Update message status to error if message_id: diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 2f1c97a7e..85999ae11 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -1,4 +1,3 @@ -admins: [] api: port: 5300 webhook_prefix: 'http://127.0.0.1:5300' diff --git a/tests/unit_tests/utils/test_importutil.py b/tests/unit_tests/utils/test_importutil.py index bf0e4e050..cfd7fc23e 100644 --- a/tests/unit_tests/utils/test_importutil.py +++ b/tests/unit_tests/utils/test_importutil.py @@ -138,7 +138,7 @@ class TestReadResourceFile: from langbot.pkg.utils import importutil content = importutil.read_resource_file('templates/config.yaml') - assert 'admins:' in content + assert 'api:' in content assert 'edition: community' in content def test_raises_for_nonexistent_file(self): @@ -157,7 +157,7 @@ class TestReadResourceFileBytes: from langbot.pkg.utils import importutil content = importutil.read_resource_file_bytes('templates/config.yaml') - assert b'admins:' in content + assert b'api:' in content assert b'edition: community' in content def test_raises_for_nonexistent_file_bytes(self): diff --git a/web/src/app/home/bots/components/bot-admins/BotAdminsDialog.tsx b/web/src/app/home/bots/components/bot-admins/BotAdminsDialog.tsx new file mode 100644 index 000000000..8c187db32 --- /dev/null +++ b/web/src/app/home/bots/components/bot-admins/BotAdminsDialog.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Trash2, Plus, ShieldCheck } from 'lucide-react'; +import { toast } from 'sonner'; + +export interface BotAdmin { + id: number; + launcher_type: string; + launcher_id: string; +} + +interface BotAdminsDialogProps { + botId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + admins: BotAdmin[]; + onAdminsChange: () => void; +} + +export default function BotAdminsDialog({ + botId, + open, + onOpenChange, + admins, + onAdminsChange, +}: BotAdminsDialogProps) { + const { t } = useTranslation(); + const [newType, setNewType] = useState('person'); + const [newId, setNewId] = useState(''); + const [adding, setAdding] = useState(false); + + async function handleAdd() { + if (!newId.trim()) return; + setAdding(true); + try { + await httpClient.addBotAdmin(botId, newType, newId.trim()); + toast.success(t('bots.admins.addSuccess')); + setNewId(''); + onAdminsChange(); + } catch (e: unknown) { + const err = e as { msg?: string; message?: string }; + toast.error(t('bots.admins.addError') + (err?.msg ?? err?.message ?? '')); + } finally { + setAdding(false); + } + } + + async function handleDelete(id: number) { + try { + await httpClient.deleteBotAdmin(botId, id); + toast.success(t('bots.admins.deleteSuccess')); + onAdminsChange(); + } catch (e: unknown) { + const err = e as { msg?: string; message?: string }; + toast.error( + t('bots.admins.deleteError') + (err?.msg ?? err?.message ?? ''), + ); + } + } + + return ( + + + + + + {t('bots.admins.title')} + + {t('bots.admins.description')} + + +
+ {/* Add row */} +
+ + setNewId(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + /> + +
+ + {/* List */} + {admins.length === 0 ? ( +
+ {t('bots.admins.noAdmins')} +
+ ) : ( + +
+ + + + + + + + + {admins.map((admin) => ( + + + + + + ))} + +
+ {t('bots.admins.launcherType')} + + {t('bots.admins.launcherId')} + +
+ + {admin.launcher_type === 'person' + ? t('bots.admins.typePerson') + : t('bots.admins.typeGroup')} + + + {admin.launcher_id} + + +
+
+
+ )} +
+
+
+ ); +} + +// Shared hook so the session monitor and the dialog stay in sync. +export function useBotAdmins(botId: string) { + const [admins, setAdmins] = useState([]); + + const reload = useCallback(async () => { + try { + const res = await httpClient.getBotAdmins(botId); + setAdmins(res.admins ?? []); + } catch (error) { + console.error('Failed to load bot admins:', error); + } + }, [botId]); + + useEffect(() => { + reload(); + }, [reload]); + + return { admins, reload }; +} diff --git a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx index e38da3893..25942394e 100644 --- a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx +++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx @@ -18,7 +18,14 @@ import { Workflow, ThumbsUp, ThumbsDown, + ShieldCheck, + ShieldOff, } from 'lucide-react'; +import { toast } from 'sonner'; +import BotAdminsDialog, { + useBotAdmins, +} from '@/app/home/bots/components/bot-admins/BotAdminsDialog'; +import type { BotAdmin } from '@/app/home/bots/components/bot-admins/BotAdminsDialog'; import { copyToClipboard } from '@/app/utils/clipboard'; import { MessageChainComponent, @@ -94,15 +101,60 @@ const BotSessionMonitor = forwardRef< Record >({}); const messagesContainerRef = useRef(null); + const { admins, reload: reloadAdmins } = useBotAdmins(botId); + const [adminsDialogOpen, setAdminsDialogOpen] = useState(false); + const [togglingAdmin, setTogglingAdmin] = useState(null); const parseSessionType = (sessionId: string): string | null => { - const idx = sessionId.indexOf('_'); - if (idx === -1) return null; - const type = sessionId.slice(0, idx); - if (type === 'person' || type === 'group') return type; + const lower = sessionId.toLowerCase(); + if (lower.includes('person')) return 'person'; + if (lower.includes('group')) return 'group'; return null; }; + const isSessionAdmin = (session: SessionInfo): boolean => { + const type = parseSessionType(session.session_id); + const lid = + session.user_id ?? + session.session_id.replace( + /^.*?[._](?:PERSON|GROUP|person|group)[._]/i, + '', + ); + return admins.some( + (a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid, + ); + }; + + const toggleAdmin = async (session: SessionInfo) => { + const type = parseSessionType(session.session_id); + if (!type) return; + const lid = + session.user_id ?? + session.session_id.replace( + /^.*?[._](?:PERSON|GROUP|person|group)[._]/i, + '', + ); + const key = session.session_id; + setTogglingAdmin(key); + try { + const existing = admins.find( + (a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid, + ); + if (existing) { + await httpClient.deleteBotAdmin(botId, existing.id); + toast.success(t('bots.admins.deleteSuccess')); + } else { + await httpClient.addBotAdmin(botId, type, lid); + toast.success(t('bots.admins.addSuccess')); + } + await reloadAdmins(); + } catch { + toast.error(t('bots.admins.addError')); + } finally { + setTogglingAdmin(null); + } + }; + const abbreviateId = (id: string): string => { if (id.length <= 10) return id; return `${id.slice(0, 4)}..${id.slice(-4)}`; @@ -384,257 +436,307 @@ const BotSessionMonitor = forwardRef< ); return ( -
- {/* Left Panel: Session List */} -
- {/* Session List */} - - {loadingSessions && sessions.length === 0 ? ( -
- {t('bots.sessionMonitor.loading')} -
- ) : sessions.length === 0 ? ( -
- {t('bots.sessionMonitor.noSessions')} + <> +
+ {/* Left Panel: Session List */} +
+ {/* Admin header */} +
+ +
+ {/* Session List */} + + {loadingSessions && sessions.length === 0 ? ( +
+ {t('bots.sessionMonitor.loading')} +
+ ) : sessions.length === 0 ? ( +
+ {t('bots.sessionMonitor.noSessions')} +
+ ) : ( +
+ {sessions.map((session) => { + const isSelected = selectedSessionId === session.session_id; + const sessionType = parseSessionType(session.session_id); + const sessionIsAdmin = isSessionAdmin(session); + return ( +
setSelectedSessionId(session.session_id)} + > +
+ + {session.user_name || + session.user_id || + session.session_id.slice(0, 12)} + + + {formatRelativeTime(session.last_activity)} + +
+
+ {sessionType && ( + + {sessionType} + + )} + + {session.user_id && ( + + {abbreviateId(session.user_id)} + + )} + {session.is_active && ( + + + + )} +
+
+ ); + })} +
+ )} +
+
+ + {/* Right Panel: Messages */} +
+ {!selectedSessionId ? ( +
+ {t('bots.sessionMonitor.selectSession')}
) : ( -
- {sessions.map((session) => { - const isSelected = selectedSessionId === session.session_id; - return ( - + + )} + {selectedSession?.is_active && ( + <> + · + + Active - )} -
- - ); - })} -
- )} - -
- - {/* Right Panel: Messages */} -
- {!selectedSessionId ? ( -
- {t('bots.sessionMonitor.selectSession')} -
- ) : ( - <> - {/* Chat Header */} -
-
-
- {selectedSession?.user_name || - selectedSession?.user_id || - selectedSessionId.slice(0, 20)} -
-
- {parseSessionType(selectedSessionId) && ( - {parseSessionType(selectedSessionId)} - )} - {selectedSession?.platform && ( - <> - {parseSessionType(selectedSessionId) && ·} - {selectedSession.platform} - - )} - {selectedSession?.user_id && ( - <> - · - - {selectedSession.user_id} - - - - )} - {selectedSession?.is_active && ( - <> - · - - - Active - - - )} + + )} + {selectedSession && parseSessionType(selectedSessionId) && ( + <> + · + + + )} +
-
- {/* Messages Area */} - -
- {loadingMessages ? ( -
- {t('bots.sessionMonitor.loading')} -
- ) : messages.length === 0 ? ( -
- {t('bots.sessionMonitor.noMessages')} -
- ) : ( - messages.map((msg, msgIndex) => { - const isUser = isUserMessage(msg); - const isDiscarded = - msg.status === 'discarded' || - msg.pipeline_id === PIPELINE_DISCARD; - // For bot replies, find feedback linked to the preceding user message - let msgFeedback: SessionFeedback | undefined; - if (!isUser) { - for (let i = msgIndex - 1; i >= 0; i--) { - if (isUserMessage(messages[i])) { - msgFeedback = feedbackMap[messages[i].id]; - break; + {/* Messages Area */} + +
+ {loadingMessages ? ( +
+ {t('bots.sessionMonitor.loading')} +
+ ) : messages.length === 0 ? ( +
+ {t('bots.sessionMonitor.noMessages')} +
+ ) : ( + messages.map((msg, msgIndex) => { + const isUser = isUserMessage(msg); + const isDiscarded = + msg.status === 'discarded' || + msg.pipeline_id === PIPELINE_DISCARD; + // For bot replies, find feedback linked to the preceding user message + let msgFeedback: SessionFeedback | undefined; + if (!isUser) { + for (let i = msgIndex - 1; i >= 0; i--) { + if (isUserMessage(messages[i])) { + msgFeedback = feedbackMap[messages[i].id]; + break; + } } } - } - return ( -
+ return (
- {renderMessageContent(msg)} - {/* Role label + pipeline + timestamp */}
- - {isUser - ? t('bots.sessionMonitor.userMessage', { - defaultValue: 'User', - }) - : t('bots.sessionMonitor.botMessage', { - defaultValue: 'Assistant', + {renderMessageContent(msg)} + {/* Role label + pipeline + timestamp */} +
+ + {isUser + ? t('bots.sessionMonitor.userMessage', { + defaultValue: 'User', + }) + : t('bots.sessionMonitor.botMessage', { + defaultValue: 'Assistant', + })} + + + {formatTime(msg.timestamp)} + + {isDiscarded ? ( + + + {t('bots.sessionMonitor.discarded', { + defaultValue: 'Discarded', })} - - - {formatTime(msg.timestamp)} - - {isDiscarded ? ( - - - {t('bots.sessionMonitor.discarded', { - defaultValue: 'Discarded', - })} - - ) : msg.pipeline_name ? ( - - - {msg.pipeline_name} - - ) : null} - {msg.status === 'error' && ( - error - )} - {msg.runner_name && ( - - - {msg.runner_name} - - )} - {/* Feedback indicator — same line, pushed right */} - {!isUser && - msgFeedback && - (msgFeedback.feedback_type === 1 ? ( - - - {t('monitoring.feedback.like')} - {msgFeedback.feedback_content && ( - - {msgFeedback.feedback_content} - - )} - ) : ( - - - {t('monitoring.feedback.dislike')} - {msgFeedback.feedback_content && ( - - {msgFeedback.feedback_content} - - )} + ) : msg.pipeline_name ? ( + + + {msg.pipeline_name} - ))} + ) : null} + {msg.status === 'error' && ( + error + )} + {msg.runner_name && ( + + + {msg.runner_name} + + )} + {/* Feedback indicator — same line, pushed right */} + {!isUser && + msgFeedback && + (msgFeedback.feedback_type === 1 ? ( + + + {t('monitoring.feedback.like')} + {msgFeedback.feedback_content && ( + + {msgFeedback.feedback_content} + + )} + + ) : ( + + + {t('monitoring.feedback.dislike')} + {msgFeedback.feedback_content && ( + + {msgFeedback.feedback_content} + + )} + + ))} +
-
- ); - }) - )} -
-
- - )} + ); + }) + )} +
+
+ + )} +
-
+ + + ); }); diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index bff964ad9..78057aa97 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -394,6 +394,27 @@ export class BackendClient extends BaseHttpClient { return this.delete(`/api/v1/platform/bots/${uuid}`); } + public getBotAdmins(botId: string): Promise<{ + admins: Array<{ id: number; launcher_type: string; launcher_id: string }>; + }> { + return this.get(`/api/v1/platform/bots/${botId}/admins`); + } + + public addBotAdmin( + botId: string, + launcher_type: string, + launcher_id: string, + ): Promise<{ id: number }> { + return this.post(`/api/v1/platform/bots/${botId}/admins`, { + launcher_type, + launcher_id, + }); + } + + public deleteBotAdmin(botId: string, adminId: number): Promise { + return this.delete(`/api/v1/platform/bots/${botId}/admins/${adminId}`); + } + public getBotLogs( botId: string, request: GetBotLogsRequest, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 8881c6bf7..44ad3769b 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -444,6 +444,26 @@ const enUS = { userMessage: 'User', botMessage: 'Assistant', }, + admins: { + title: 'Admins', + description: + "Launchers (person/group IDs) that have admin privilege for this bot's commands", + addAdmin: 'Add Admin', + launcherType: 'Type', + launcherId: 'ID', + typePerson: 'Person', + typeGroup: 'Group', + placeholderId: 'User or group ID', + addSuccess: 'Admin added', + addError: 'Failed to add admin: ', + deleteSuccess: 'Admin removed', + deleteError: 'Failed to remove admin: ', + noAdmins: 'No admins configured', + setAdminTitle: 'Set as admin', + adminBadge: 'Admin', + configureAdmins: 'Manage Admins', + removeAdminTitle: 'Remove admin', + }, }, plugins: { title: 'Extensions', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 4c951ebc8..9a825ad32 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -426,6 +426,25 @@ const zhHans = { userMessage: '用户', botMessage: '助手', }, + admins: { + title: '管理员', + description: '拥有此机器人命令管理员权限的会话(用户/群组 ID)', + addAdmin: '添加管理员', + launcherType: '类型', + launcherId: 'ID', + typePerson: '私聊', + typeGroup: '群聊', + placeholderId: '用户或群组 ID', + addSuccess: '添加成功', + addError: '添加失败:', + deleteSuccess: '已移除', + deleteError: '移除失败:', + noAdmins: '暂无管理员', + setAdminTitle: '设为管理员', + adminBadge: '管理员', + configureAdmins: '配置管理员', + removeAdminTitle: '移除管理员权限', + }, }, plugins: { title: '插件扩展',