Skip to content

Commit fa42323

Browse files
yazelinclaude
andcommitted
feat: /agent command for per-group/per-user AI agent switching (#129)
Add /agent slash command that allows admins to switch the AI agent used in a specific group or personal chat. Each group/user stores their own preference independently via active_agent_id FK on bot_users/bot_groups. Also fix MCP tool permission issue in bypassPermissions mode where ctos_user_id was not injected into tool parameters, causing "permission denied" errors for search_nas_files and read_document. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 73758c4 commit fa42323

18 files changed

Lines changed: 965 additions & 10 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""新增 bot_users 和 bot_groups 的 active_agent_id 欄位
2+
3+
用於 /agent 指令切換對話使用的 AI Agent。
4+
5+
Revision ID: 011
6+
"""
7+
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
revision = "011"
13+
down_revision = "010"
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade() -> None:
19+
# bot_users 新增 active_agent_id
20+
op.add_column(
21+
"bot_users",
22+
sa.Column(
23+
"active_agent_id",
24+
sa.dialects.postgresql.UUID(as_uuid=True),
25+
nullable=True,
26+
),
27+
)
28+
op.create_foreign_key(
29+
"fk_bot_users_active_agent_id",
30+
"bot_users",
31+
"ai_agents",
32+
["active_agent_id"],
33+
["id"],
34+
ondelete="SET NULL",
35+
)
36+
37+
# bot_groups 新增 active_agent_id
38+
op.add_column(
39+
"bot_groups",
40+
sa.Column(
41+
"active_agent_id",
42+
sa.dialects.postgresql.UUID(as_uuid=True),
43+
nullable=True,
44+
),
45+
)
46+
op.create_foreign_key(
47+
"fk_bot_groups_active_agent_id",
48+
"bot_groups",
49+
"ai_agents",
50+
["active_agent_id"],
51+
["id"],
52+
ondelete="SET NULL",
53+
)
54+
55+
56+
def downgrade() -> None:
57+
op.drop_constraint("fk_bot_groups_active_agent_id", "bot_groups", type_="foreignkey")
58+
op.drop_column("bot_groups", "active_agent_id")
59+
op.drop_constraint("fk_bot_users_active_agent_id", "bot_users", type_="foreignkey")
60+
op.drop_column("bot_users", "active_agent_id")

backend/src/ching_tech_os/services/ai_manager.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,24 @@ async def get_agent_by_name(name: str) -> dict | None:
350350
return result
351351

352352

353+
async def get_selectable_agents() -> list[dict]:
354+
"""取得可供 /agent 指令切換的 Agent 清單
355+
356+
查詢 is_active=true 且 settings.user_selectable='true' 的 Agent,按 name 排序。
357+
"""
358+
async with get_connection() as conn:
359+
rows = await conn.fetch(
360+
"""
361+
SELECT id, name, display_name, description, model
362+
FROM ai_agents
363+
WHERE is_active = true
364+
AND settings->>'user_selectable' = 'true'
365+
ORDER BY name
366+
""",
367+
)
368+
return [dict(row) for row in rows]
369+
370+
353371
async def create_agent(data: AiAgentCreate) -> dict:
354372
"""建立 Agent"""
355373
settings_json = json.dumps(data.settings) if data.settings else None

backend/src/ching_tech_os/services/bot/command_handlers.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,95 @@ async def _handle_debug(ctx: CommandContext) -> str | None:
193193
return reply_text or "診斷完成,但未產生回報內容。"
194194

195195

196+
async def _handle_agent(ctx: CommandContext) -> str | None:
197+
"""切換對話使用的 AI Agent
198+
199+
用法:
200+
/agent — 顯示目前使用的 Agent 和可切換清單
201+
/agent <name> — 用名稱切換
202+
/agent <number> — 用編號切換
203+
/agent reset — 恢復預設
204+
"""
205+
from .. import ai_manager
206+
from ..linebot_agents import (
207+
get_group_active_agent_id,
208+
get_user_active_agent_id,
209+
set_group_active_agent,
210+
set_user_active_agent,
211+
)
212+
213+
args = ctx.raw_args.strip()
214+
215+
# 取得可切換的 Agent 清單(按 name 排序)
216+
selectable = await ai_manager.get_selectable_agents()
217+
218+
async def _apply_preference(agent_id: str | None) -> None:
219+
"""將 Agent 偏好寫入群組或個人"""
220+
if ctx.is_group and ctx.group_id:
221+
await set_group_active_agent(ctx.group_id, agent_id)
222+
elif ctx.bot_user_id:
223+
await set_user_active_agent(ctx.bot_user_id, agent_id)
224+
225+
# === /agent reset ===
226+
if args.lower() == "reset":
227+
await _apply_preference(None)
228+
return "已恢復預設 Agent"
229+
230+
# === 查詢目前 Agent ===
231+
current_agent_id = None
232+
if ctx.is_group and ctx.group_id:
233+
current_agent_id = await get_group_active_agent_id(ctx.group_id)
234+
elif ctx.bot_user_id:
235+
current_agent_id = await get_user_active_agent_id(ctx.bot_user_id)
236+
237+
# 取得目前 Agent 的顯示資訊
238+
current_label = "預設"
239+
if current_agent_id:
240+
from uuid import UUID
241+
current_agent = await ai_manager.get_agent(UUID(current_agent_id))
242+
if current_agent:
243+
current_label = f"{current_agent['name']}{current_agent.get('display_name', '')})"
244+
else:
245+
current_label = "預設(偏好 Agent 已不存在)"
246+
247+
# === /agent(無參數)— 顯示狀態和清單 ===
248+
if not args:
249+
lines = [f"目前 Agent:{current_label}"]
250+
if selectable:
251+
lines.append("")
252+
lines.append("可切換的 Agent:")
253+
for i, agent in enumerate(selectable, 1):
254+
display = agent.get("display_name") or agent["name"]
255+
lines.append(f"{i}. {agent['name']}{display}")
256+
lines.append("")
257+
lines.append("用法:/agent <名稱或編號>")
258+
lines.append("恢復預設:/agent reset")
259+
else:
260+
lines.append("目前沒有可切換的 Agent")
261+
lines.append("請在 AI 管理介面將 Agent 的 settings.user_selectable 設為 true")
262+
return "\n".join(lines)
263+
264+
# === /agent <number> — 編號切換 ===
265+
if args.isdigit():
266+
idx = int(args)
267+
if idx < 1 or idx > len(selectable):
268+
return f"編號 {idx} 超出範圍(1-{len(selectable)}),請用 /agent 查看可用清單"
269+
target = selectable[idx - 1]
270+
else:
271+
# === /agent <name> — 名稱切換 ===
272+
target = next((a for a in selectable if a["name"] == args), None)
273+
if not target:
274+
# 檢查 Agent 是否存在但不可選
275+
existing = await ai_manager.get_agent_by_name(args)
276+
if existing:
277+
return f"Agent {args} 不可切換,請用 /agent 查看可用清單"
278+
return f"找不到 Agent: {args},請用 /agent 查看可用清單"
279+
280+
await _apply_preference(str(target["id"]))
281+
display = target.get("display_name") or target["name"]
282+
return f"已切換到 {display}"
283+
284+
196285
_registered = False
197286

198287

@@ -243,6 +332,15 @@ def register_builtin_commands() -> None:
243332
require_admin=True,
244333
private_only=True,
245334
),
335+
SlashCommand(
336+
name="agent",
337+
aliases=["切換助理"],
338+
handler=_handle_agent,
339+
description="切換 AI Agent",
340+
require_bound=True,
341+
require_admin=True,
342+
private_only=False,
343+
),
246344
]
247345

248346
for cmd in all_commands:

backend/src/ching_tech_os/services/bot_telegram/handler.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,11 @@ async def _handle_text_with_ai(
680680
logger.error(f"取得對話歷史失敗: {e}", exc_info=True)
681681

682682
# 1. 取得 Agent 設定
683-
agent = await get_linebot_agent(is_group=is_group)
683+
agent = await get_linebot_agent(
684+
is_group=is_group,
685+
bot_user_id=bot_user_id,
686+
bot_group_id=bot_group_id,
687+
)
684688
if not agent:
685689
logger.error("找不到 Agent 設定")
686690
await adapter.send_text(chat_id, "系統尚未設定 AI Agent,請聯繫管理員。")

backend/src/ching_tech_os/services/linebot_agents.py

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -635,16 +635,96 @@ async def ensure_default_linebot_agents() -> None:
635635
await _ensure_agents(DEFAULT_BOT_MODE_AGENTS, use_dynamic_prompt=False)
636636

637637

638-
async def get_linebot_agent(is_group: bool) -> dict | None:
639-
"""
640-
取得 Line Bot Agent 設定。
638+
async def set_user_active_agent(bot_user_id: str, agent_id: str | None) -> None:
639+
"""設定用戶的個人對話 Agent 偏好"""
640+
from uuid import UUID as _UUID
641+
642+
from ..database import get_connection
643+
644+
value = _UUID(agent_id) if agent_id else None
645+
async with get_connection() as conn:
646+
await conn.execute(
647+
"UPDATE bot_users SET active_agent_id = $1 WHERE id = $2",
648+
value,
649+
bot_user_id,
650+
)
651+
652+
653+
async def set_group_active_agent(bot_group_id: str, agent_id: str | None) -> None:
654+
"""設定群組的 Agent 偏好"""
655+
from uuid import UUID as _UUID
656+
657+
from ..database import get_connection
658+
659+
value = _UUID(agent_id) if agent_id else None
660+
async with get_connection() as conn:
661+
await conn.execute(
662+
"UPDATE bot_groups SET active_agent_id = $1 WHERE id = $2",
663+
value,
664+
bot_group_id,
665+
)
666+
667+
668+
async def get_user_active_agent_id(bot_user_id: str) -> str | None:
669+
"""查詢用戶的 active_agent_id"""
670+
from ..database import get_connection
671+
672+
async with get_connection() as conn:
673+
row = await conn.fetchrow(
674+
"SELECT active_agent_id FROM bot_users WHERE id = $1",
675+
bot_user_id,
676+
)
677+
return str(row["active_agent_id"]) if row and row["active_agent_id"] else None
678+
679+
680+
async def get_group_active_agent_id(bot_group_id: str) -> str | None:
681+
"""查詢群組的 active_agent_id"""
682+
from ..database import get_connection
683+
684+
async with get_connection() as conn:
685+
row = await conn.fetchrow(
686+
"SELECT active_agent_id FROM bot_groups WHERE id = $1",
687+
bot_group_id,
688+
)
689+
return str(row["active_agent_id"]) if row and row["active_agent_id"] else None
690+
691+
692+
async def get_linebot_agent(
693+
is_group: bool,
694+
*,
695+
bot_user_id: str | None = None,
696+
bot_group_id: str | None = None,
697+
) -> dict | None:
698+
"""取得 Line Bot Agent 設定,支援偏好覆蓋。
699+
700+
路由優先級:
701+
1. 群組對話:bot_groups.active_agent_id > 預設 linebot-group
702+
2. 個人對話:bot_users.active_agent_id > 預設 linebot-personal
641703
642704
Args:
643705
is_group: 是否為群組對話
706+
bot_user_id: Bot 用戶 ID(用於查詢個人偏好)
707+
bot_group_id: Bot 群組 ID(用於查詢群組偏好)
644708
645709
Returns:
646710
Agent 設定字典,包含 model 和 system_prompt
647711
如果找不到則回傳 None
648712
"""
713+
from uuid import UUID
714+
715+
# 查詢偏好 Agent
716+
active_agent_id = None
717+
if is_group and bot_group_id:
718+
active_agent_id = await get_group_active_agent_id(bot_group_id)
719+
elif not is_group and bot_user_id:
720+
active_agent_id = await get_user_active_agent_id(bot_user_id)
721+
722+
if active_agent_id:
723+
agent = await ai_manager.get_agent(UUID(active_agent_id))
724+
if agent and agent.get("is_active"):
725+
return agent
726+
# 偏好 Agent 不存在或已停用,fallback 到預設
727+
logger.warning(f"偏好 Agent {active_agent_id} 不可用,使用預設 Agent")
728+
649729
agent_name = AGENT_LINEBOT_GROUP if is_group else AGENT_LINEBOT_PERSONAL
650730
return await ai_manager.get_agent_by_name(agent_name)

backend/src/ching_tech_os/services/linebot_ai.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@ async def process_message_with_ai(
600600
reply_token: str | None,
601601
user_display_name: str | None = None,
602602
quoted_message_id: str | None = None,
603+
bot_user_id: str | None = None,
603604
) -> str | None:
604605
"""
605606
使用 AI 處理訊息
@@ -637,16 +638,25 @@ async def process_message_with_ai(
637638

638639
try:
639640
# 取得 Agent 設定
640-
agent = await get_linebot_agent(is_group)
641-
agent_name = AGENT_LINEBOT_GROUP if is_group else AGENT_LINEBOT_PERSONAL
641+
# 群組 ID 轉換為字串(bot_groups.id 是 UUID)
642+
bot_group_id_str = str(line_group_id) if line_group_id else None
643+
agent = await get_linebot_agent(
644+
is_group,
645+
bot_user_id=bot_user_id,
646+
bot_group_id=bot_group_id_str,
647+
)
642648

643649
if not agent:
644-
error_msg = f"⚠️ AI 設定錯誤:Agent '{agent_name}' 不存在"
650+
fallback_name = AGENT_LINEBOT_GROUP if is_group else AGENT_LINEBOT_PERSONAL
651+
error_msg = f"⚠️ AI 設定錯誤:Agent '{fallback_name}' 不存在"
645652
logger.error(error_msg)
646653
if reply_token:
647654
await reply_text(reply_token, error_msg)
648655
return error_msg
649656

657+
# agent 保證非 None
658+
agent_name = agent.get("name", "")
659+
650660
# 從 Agent 取得 model 和基礎 prompt
651661
model = agent.get("model", "opus").replace("claude-", "") # claude-sonnet -> sonnet
652662
# 安全取得 system_prompt(處理 None 和非 dict 情況)
@@ -1590,4 +1600,5 @@ async def handle_text_message(
15901600
reply_token=reply_token,
15911601
user_display_name=user_display_name,
15921602
quoted_message_id=quoted_message_id,
1603+
bot_user_id=bot_user_id,
15931604
)

backend/src/ching_tech_os/services/mcp/nas_tools.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
async def _get_user_shared_mounts(ctos_user_id: int | None) -> dict[str, str]:
2929
"""取得使用者可存取的 shared 掛載點。"""
3030
from ..path_manager import path_manager
31+
from .server import resolve_ctos_user_id
32+
33+
# bypassPermissions 模式下 AI 可能不傳 ctos_user_id,fallback 環境變數
34+
ctos_user_id = resolve_ctos_user_id(ctos_user_id)
3135

3236
return await get_allowed_shared_mounts_for_user(
3337
path_manager.get_shared_mounts(),

0 commit comments

Comments
 (0)