From e9d1dba3ba56777b9c4a8f955aec2bdd5adf1a4d Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Sun, 29 Mar 2026 02:56:16 +0800 Subject: [PATCH 01/73] feat: DingTalk media support, secrets management, security hardening & UI polish Backend: - DingTalk: full media send/receive (image/file/voice/video) - DingTalk: thinking reaction indicator - DingTalk: auto-reconnect with exponential backoff - DingTalk: user source tracking + sender_nick display - /new command for DingTalk and Feishu session reset - secrets.md: creator-only file with redaction pipeline - Sensitive data sanitization (WebSocket, activity logs, A2A history) - SQL execute tool (MySQL/PostgreSQL/SQLite) - Multi-turn image context re-hydration - Agent seeding only on new company creation - Fix upstream Tenant.custom_domain -> sso_domain references Frontend: - Replace all emoji with Lucide React icons - Add secrets section to Mind tab - Chat tab moved to first position as default - i18n cleanup (zh + en) --- backend/alembic/versions/add_user_source.py | 19 + backend/alembic/versions/merge_heads.py | 16 + backend/app/api/activity.py | 7 +- backend/app/api/auth.py | 4 +- backend/app/api/dingtalk.py | 191 ++++- backend/app/api/feishu.py | 49 +- backend/app/api/files.py | 35 +- backend/app/api/tenants.py | 9 + backend/app/api/websocket.py | 18 +- backend/app/main.py | 7 +- backend/app/models/user.py | 3 + backend/app/services/agent_context.py | 22 + backend/app/services/agent_seeder.py | 46 +- backend/app/services/agent_tools.py | 165 ++++- backend/app/services/channel_commands.py | 75 ++ backend/app/services/dingtalk_reaction.py | 133 ++++ backend/app/services/dingtalk_stream.py | 692 +++++++++++++++++-- backend/app/services/image_context.py | 112 +++ backend/app/services/registration_service.py | 4 +- backend/app/services/sso_service.py | 4 +- backend/app/services/tool_seeder.py | 28 + backend/app/services/trigger_daemon.py | 3 +- backend/app/utils/__init__.py | 0 backend/app/utils/sanitize.py | 84 +++ backend/pyproject.toml | 2 + frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/App.tsx | 3 +- frontend/src/components/ChannelConfig.tsx | 3 +- frontend/src/components/ErrorBoundary.tsx | 3 +- frontend/src/components/FileBrowser.tsx | 17 +- frontend/src/i18n/en.json | 42 +- frontend/src/i18n/zh.json | 46 +- frontend/src/pages/AdminCompanies.tsx | 3 +- frontend/src/pages/AgentCreate.tsx | 9 +- frontend/src/pages/AgentDetail.tsx | 132 ++-- frontend/src/pages/CompanySetup.tsx | 5 +- frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/pages/EnterpriseSettings.tsx | 27 +- frontend/src/pages/Layout.tsx | 1 + frontend/src/pages/Login.tsx | 11 +- frontend/src/pages/Messages.tsx | 2 +- frontend/src/pages/SSOEntry.tsx | 3 +- frontend/src/pages/UserManagement.tsx | 9 +- 44 files changed, 1793 insertions(+), 264 deletions(-) create mode 100644 backend/alembic/versions/add_user_source.py create mode 100644 backend/alembic/versions/merge_heads.py create mode 100644 backend/app/services/channel_commands.py create mode 100644 backend/app/services/dingtalk_reaction.py create mode 100644 backend/app/services/image_context.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/sanitize.py diff --git a/backend/alembic/versions/add_user_source.py b/backend/alembic/versions/add_user_source.py new file mode 100644 index 00000000..5bf14c0c --- /dev/null +++ b/backend/alembic/versions/add_user_source.py @@ -0,0 +1,19 @@ +"""Add source column to users table.""" + +from alembic import op +import sqlalchemy as sa + +revision = "add_user_source" +down_revision = "add_llm_max_output_tokens" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("users", sa.Column("source", sa.String(50), nullable=True, server_default="web")) + op.create_index("ix_users_source", "users", ["source"]) + + +def downgrade(): + op.drop_index("ix_users_source", "users") + op.drop_column("users", "source") diff --git a/backend/alembic/versions/merge_heads.py b/backend/alembic/versions/merge_heads.py new file mode 100644 index 00000000..196c55d1 --- /dev/null +++ b/backend/alembic/versions/merge_heads.py @@ -0,0 +1,16 @@ +"""Merge upstream and local migration heads.""" + +from alembic import op + +revision = "merge_upstream_and_local" +down_revision = ("add_daily_token_usage", "add_user_source") +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py index a22e5a98..915a8e22 100644 --- a/backend/app/api/activity.py +++ b/backend/app/api/activity.py @@ -21,8 +21,11 @@ async def get_agent_activity( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Get recent activity logs for an agent.""" - await check_agent_access(db, current_user, agent_id) + """Get recent activity logs for an agent. Only the agent creator can view.""" + from app.core.permissions import is_agent_creator + agent, _access = await check_agent_access(db, current_user, agent_id) + if not is_agent_creator(current_user, agent): + return [] result = await db.execute( select(AgentActivityLog) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 74febfee..cef94f37 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -190,11 +190,11 @@ async def register(data: UserRegister, db: AsyncSession = Depends(get_db)): await db.flush() # Seed default agents after first user (platform admin) registration - if is_first_user: + if is_first_user and user.tenant_id: await db.commit() # commit user first so seeder can find the admin try: from app.services.agent_seeder import seed_default_agents - await seed_default_agents() + await seed_default_agents(tenant_id=user.tenant_id, creator_id=user.id) except Exception as e: logger.warning(f"Failed to seed default agents: {e}") diff --git a/backend/app/api/dingtalk.py b/backend/app/api/dingtalk.py index 98edb23f..85c82c4c 100644 --- a/backend/app/api/dingtalk.py +++ b/backend/app/api/dingtalk.py @@ -80,7 +80,7 @@ async def configure_dingtalk_channel( extra_config={"connection_mode": conn_mode}, ) db.add(config) - await db.flush() + await db.commit() # Start Stream client if in websocket mode if conn_mode == "websocket": @@ -145,8 +145,17 @@ async def process_dingtalk_message( conversation_id: str, conversation_type: str, session_webhook: str, + image_base64_list: list[str] | None = None, + saved_file_paths: list[str] | None = None, + sender_nick: str = "", + message_id: str = "", ): - """Process an incoming DingTalk bot message and reply via session webhook.""" + """Process an incoming DingTalk bot message and reply via session webhook. + + Args: + image_base64_list: List of base64-encoded image data URIs for vision LLM. + saved_file_paths: List of local file paths where media files were saved. + """ import json import httpx from datetime import datetime, timezone @@ -187,14 +196,43 @@ async def process_dingtalk_message( username=dt_username, email=f"{dt_username}@dingtalk.local", password_hash=hash_password(_uuid.uuid4().hex), - display_name=f"DingTalk {sender_staff_id[:8]}", + display_name=sender_nick or f"DingTalk {sender_staff_id[:8]}", role="member", tenant_id=agent_obj.tenant_id if agent_obj else None, + source="dingtalk", ) db.add(platform_user) await db.flush() + else: + # Update display_name and source for existing users + updated = False + if sender_nick and platform_user.display_name != sender_nick: + platform_user.display_name = sender_nick + updated = True + if not platform_user.source or platform_user.source == "web": + platform_user.source = "dingtalk" + updated = True + if updated: + await db.flush() platform_user_id = platform_user.id + # Check for channel commands (/new, /reset) + from app.services.channel_commands import is_channel_command, handle_channel_command + if is_channel_command(user_text): + cmd_result = await handle_channel_command( + db=db, command=user_text, agent_id=agent_id, + user_id=platform_user_id, external_conv_id=conv_id, + source_channel="dingtalk", + ) + await db.commit() + import httpx as _httpx_cmd + async with _httpx_cmd.AsyncClient(timeout=10) as _cl_cmd: + await _cl_cmd.post(session_webhook, json={ + "msgtype": "text", + "text": {"content": cmd_result["message"]}, + }) + return + # Find or create session sess = await find_or_create_channel_session( db=db, @@ -215,23 +253,158 @@ async def process_dingtalk_message( ) history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())] - # Save user message + # Re-hydrate historical images for multi-turn LLM context + from app.services.image_context import rehydrate_image_messages + history = rehydrate_image_messages(history, agent_id, max_images=3) + + # Save user message — use display-friendly format for DB (no base64) + # Build saved_content: [file:name] prefix for each saved file + clean text + import re as _re_dt + _clean_text = _re_dt.sub( + r'\[image_data:data:image/[^;]+;base64,[A-Za-z0-9+/=]+\]', + "", user_text, + ).strip() + if saved_file_paths: + from pathlib import Path as _PathDT + _file_prefixes = "\n".join( + f"[file:{_PathDT(p).name}]" for p in saved_file_paths + ) + saved_content = f"{_file_prefixes}\n{_clean_text}".strip() if _clean_text else _file_prefixes + else: + saved_content = _clean_text or user_text db.add(ChatMessage( agent_id=agent_id, user_id=platform_user_id, - role="user", content=user_text, + role="user", content=saved_content, conversation_id=session_conv_id, )) sess.last_message_at = datetime.now(timezone.utc) await db.commit() + # ── Set up channel_file_sender so the agent can send files via DingTalk ── + from app.services.agent_tools import channel_file_sender as _cfs + from app.services.dingtalk_stream import ( + _upload_dingtalk_media, + _send_dingtalk_media_message, + ) + + # Load DingTalk credentials from ChannelConfig + _dt_cfg_r = await db.execute( + _select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == "dingtalk", + ) + ) + _dt_cfg = _dt_cfg_r.scalar_one_or_none() + _dt_app_key = _dt_cfg.app_id if _dt_cfg else None + _dt_app_secret = _dt_cfg.app_secret if _dt_cfg else None + + _cfs_token = None + if _dt_app_key and _dt_app_secret: + # Determine send target: group → conversation_id, P2P → sender_staff_id + _dt_target_id = conversation_id if conversation_type == "2" else sender_staff_id + _dt_conv_type = conversation_type + + async def _dingtalk_file_sender(file_path: str, msg: str = ""): + """Send a file/image/video via DingTalk proactive message API.""" + from pathlib import Path as _P + + _fp = _P(file_path) + _ext = _fp.suffix.lower() + + # Determine media type from extension + if _ext in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"): + _media_type = "image" + elif _ext in (".mp4", ".mov", ".avi", ".mkv"): + _media_type = "video" + elif _ext in (".mp3", ".wav", ".ogg", ".amr", ".m4a"): + _media_type = "voice" + else: + _media_type = "file" + + # Upload media to DingTalk + _mid = await _upload_dingtalk_media( + _dt_app_key, _dt_app_secret, file_path, _media_type + ) + + if _mid: + # Send via proactive message API + _ok = await _send_dingtalk_media_message( + _dt_app_key, _dt_app_secret, + _dt_target_id, _mid, _media_type, + _dt_conv_type, filename=_fp.name, + ) + if _ok: + # Also send accompany text if provided + if msg: + try: + async with httpx.AsyncClient(timeout=10) as _cl: + await _cl.post(session_webhook, json={ + "msgtype": "text", + "text": {"content": msg}, + }) + except Exception: + pass + return + + # Fallback: send a text message with download link + from pathlib import Path as _P2 + from app.config import get_settings as _gs_fallback + _fs = _gs_fallback() + _base_url = getattr(_fs, 'BASE_URL', '').rstrip('/') or '' + _fp2 = _P2(file_path) + _ws_root = _P2(_fs.AGENT_DATA_DIR) + try: + _rel = str(_fp2.relative_to(_ws_root / str(agent_id))) + except ValueError: + _rel = _fp2.name + _fallback_parts = [] + if msg: + _fallback_parts.append(msg) + if _base_url: + _dl_url = f"{_base_url}/api/agents/{agent_id}/files/download?path={_rel}" + _fallback_parts.append(f"📎 {_fp2.name}\n🔗 {_dl_url}") + _fallback_parts.append("⚠️ 文件通过钉钉直接发送失败,请通过上方链接下载。") + try: + async with httpx.AsyncClient(timeout=10) as _cl: + await _cl.post(session_webhook, json={ + "msgtype": "text", + "text": {"content": "\n\n".join(_fallback_parts)}, + }) + except Exception as _fb_err: + logger.error(f"[DingTalk] Fallback file text also failed: {_fb_err}") + + _cfs_token = _cfs.set(_dingtalk_file_sender) + # Call LLM - reply_text = await _call_agent_llm( - db, agent_id, user_text, - history=history, user_id=platform_user_id, + try: + reply_text = await _call_agent_llm( + db, agent_id, user_text, + history=history, user_id=platform_user_id, + context_size=ctx_size, + ) + finally: + # Reset ContextVar + if _cfs_token is not None: + _cfs.reset(_cfs_token) + # Recall thinking reaction (before sending reply) + if message_id and _dt_app_key: + try: + from app.services.dingtalk_reaction import recall_thinking_reaction + await recall_thinking_reaction( + _dt_app_key, _dt_app_secret, + message_id, conversation_id, + ) + except Exception as _recall_err: + logger.warning(f"[DingTalk] Failed to recall thinking reaction: {_recall_err}") + + has_media = bool(image_base64_list or saved_file_paths) + logger.info( + f"[DingTalk] LLM reply ({('media' if has_media else 'text')} input): " + f"{reply_text[:100]}" ) - logger.info(f"[DingTalk] LLM reply: {reply_text[:100]}") # Reply via session webhook (markdown) + # Note: File/image sending is handled by channel_file_sender ContextVar above. try: async with httpx.AsyncClient(timeout=10) as client: await client.post(session_webhook, json={ diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index e30c3c15..49b274fe 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -302,6 +302,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession logger.info(f"[Feishu] Received {msg_type} message, chat_type={chat_type}, from={sender_open_id}") # ── Normalize post (rich text) → extract text + schedule image downloads ── + _post_saved_filenames = [] # Collect filenames of images saved from post messages if msg_type == "post": import json as _json_post _post_body = _json_post.loads(message.get("content", "{}")) @@ -335,6 +336,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession _extracted_text = "\n".join(_text_parts).strip() # Download images and embed as base64 for vision-capable models _image_markers = [] + _post_saved_filenames = [] if _post_image_keys: import base64 as _b64 _msg_id = message.get("message_id", "") @@ -351,6 +353,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession # Save to workspace _save_path = _upload_dir / f"image_{_ik[-8:]}.jpg" _save_path.write_bytes(_img_bytes) + _post_saved_filenames.append(_save_path.name) logger.info(f"[Feishu] Saved post image to {_save_path} ({len(_img_bytes)} bytes)") # Embed as base64 marker for vision models _b64_data = _b64.b64encode(_img_bytes).decode("ascii") @@ -407,6 +410,24 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession creator_id = agent_obj.creator_id if agent_obj else agent_id ctx_size = agent_obj.context_window_size if agent_obj else 100 + # Check for channel commands (/new, /reset) + from app.services.channel_commands import is_channel_command, handle_channel_command + if is_channel_command(user_text): + # Need platform_user_id - use creator_id as fallback for command handling + _cmd_result = await handle_channel_command( + db=db, command=user_text, agent_id=agent_id, + user_id=creator_id, external_conv_id=conv_id, + source_channel="feishu", + ) + await db.commit() + import json as _j_cmd + _cmd_reply = _j_cmd.dumps({"text": _cmd_result["message"]}) + if chat_type == "group" and chat_id: + await feishu_service.send_message(config.app_id, config.app_secret, chat_id, "text", _cmd_reply, receive_id_type="chat_id") + else: + await feishu_service.send_message(config.app_id, config.app_secret, sender_open_id, "text", _cmd_reply) + return {"code": 0, "msg": "command handled"} + # Pre-resolve session so history lookup uses the UUID (session created later if new) _pre_sess_r = await db.execute( select(__import__('app.models.chat_session', fromlist=['ChatSession']).ChatSession).where( @@ -425,6 +446,10 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession history_msgs = history_result.scalars().all() history = [{"role": m.role, "content": m.content} for m in reversed(history_msgs)] + # Re-hydrate historical images for multi-turn LLM context + from app.services.image_context import rehydrate_image_messages + history = rehydrate_image_messages(history, agent_id, max_images=3) + # --- Resolve Feishu sender identity & find/create platform user --- import uuid as _uuid import httpx as _httpx @@ -542,6 +567,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession external_id=sender_user_id_feishu, feishu_user_id=sender_user_id_feishu or None, tenant_id=agent_obj.tenant_id if agent_obj else None, + source="feishu", ) db.add(new_user) await db.flush() @@ -591,7 +617,17 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession session_conv_id = str(_sess.id) # Save user message - db.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="user", content=user_text, conversation_id=session_conv_id)) + # Strip base64 image data from DB content, use [file:xxx] for persistence + import re as _re_db + _db_content = _re_db.sub( + r'\[image_data:data:image/[^;]+;base64,[A-Za-z0-9+/=]+\]', + '', user_text, + ).strip() + # Prepend [file:xxx] markers for post images saved to disk + if _post_saved_filenames: + _file_markers = " ".join(f"[file:{_fn}]" for _fn in _post_saved_filenames) + _db_content = f"{_file_markers} {_db_content}" if _db_content else _file_markers + db.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="user", content=_db_content, conversation_id=session_conv_id)) _sess.last_message_at = _dt.now(_tz.utc) await db.commit() @@ -777,6 +813,7 @@ async def _ws_on_thinking(text: str): reply_text = await _call_agent_llm( db, agent_id, llm_user_text, history=history, user_id=platform_user_id, on_chunk=_ws_on_chunk, on_thinking=_ws_on_thinking, + context_size=agent_obj.context_window_size if agent_obj else 20, ) _cfs.reset(_cfs_token) _cfso.reset(_cfso_token) @@ -993,6 +1030,7 @@ async def _handle_feishu_file(db, agent_id, config, message, sender_open_id, cha external_id=sender_user_id_feishu, feishu_user_id=sender_user_id_feishu or None, tenant_id=agent_obj.tenant_id if agent_obj else None, + source="feishu", ) db.add(_pu) await db.flush() @@ -1044,6 +1082,10 @@ async def _handle_feishu_file(db, agent_id, config, message, sender_open_id, cha ) _history = [{"role": m.role, "content": m.content} for m in reversed(_hist_r.scalars().all())] + # Re-hydrate historical images for multi-turn LLM context + from app.services.image_context import rehydrate_image_messages + _history = rehydrate_image_messages(_history, agent_id, max_images=3) + await db.commit() # For images: call LLM so vision models can actually see the image @@ -1094,6 +1136,7 @@ async def _img_on_chunk(text): reply_text = await _call_agent_llm( _db_img, agent_id, user_msg_content, history=_history, user_id=platform_user_id, on_chunk=_img_on_chunk, + context_size=agent_obj.context_window_size if agent_obj else 20, ) logger.info(f"[Feishu] Image LLM reply: {reply_text[:100]}") @@ -1174,7 +1217,7 @@ async def _download_post_images(agent_id, config, message_id, image_keys): logger.error(f"[Feishu] Failed to download post image {ik}: {e}") -async def _call_agent_llm(db: AsyncSession, agent_id: uuid.UUID, user_text: str, history: list[dict] | None = None, user_id=None, on_chunk=None, on_thinking=None) -> str: +async def _call_agent_llm(db: AsyncSession, agent_id: uuid.UUID, user_text: str, history: list[dict] | None = None, user_id=None, on_chunk=None, on_thinking=None, context_size: int = 20) -> str: """Call the agent's configured LLM model with conversation history. Reuses the same call_llm function as the WebSocket chat endpoint so that @@ -1223,7 +1266,7 @@ async def _call_agent_llm(db: AsyncSession, agent_id: uuid.UUID, user_text: str, # Build conversation messages (without system prompt — call_llm adds it) messages: list[dict] = [] if history: - messages.extend(history[-10:]) + messages.extend(history[-context_size:]) messages.append({"role": "user", "content": user_text}) # Use actual user_id so the system prompt knows who it's chatting with diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 3b8f7f53..ea49f2ef 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -21,6 +21,9 @@ settings = get_settings() router = APIRouter(prefix="/agents/{agent_id}/files", tags=["files"]) +# Files only visible/editable by agent creator (platform_admin also allowed) +CREATOR_ONLY_FILES = {"secrets.md"} + class FileInfo(BaseModel): name: str @@ -61,7 +64,8 @@ async def list_files( db: AsyncSession = Depends(get_db), ): """List files and directories in an agent's file system.""" - await check_agent_access(db, current_user, agent_id) + agent, _access = await check_agent_access(db, current_user, agent_id) + is_creator = (agent.creator_id == current_user.id) or (current_user.role == "platform_admin") target = _safe_path(agent_id, path) if not target.exists(): @@ -74,6 +78,9 @@ async def list_files( for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name)): if entry.name == '.gitkeep': continue + # Hide creator-only files from non-creators + if entry.name in CREATOR_ONLY_FILES and not is_creator: + continue rel = str(entry.resolve().relative_to(base_abs)) stat = entry.stat() items.append(FileInfo( @@ -95,9 +102,15 @@ async def read_file( db: AsyncSession = Depends(get_db), ): """Read the content of a file.""" - await check_agent_access(db, current_user, agent_id) + agent, _access = await check_agent_access(db, current_user, agent_id) + is_creator = (agent.creator_id == current_user.id) or (current_user.role == "platform_admin") target = _safe_path(agent_id, path) + # Block non-creators from reading creator-only files + filename = Path(path).name + if filename in CREATOR_ONLY_FILES and not is_creator: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + if not target.exists() or not target.is_file(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") @@ -143,7 +156,11 @@ async def download_file( if not user or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive") - await check_agent_access(db, user, agent_id) + agent, _access = await check_agent_access(db, user, agent_id) + is_creator = (agent.creator_id == user.id) or (user.role == "platform_admin") + filename = Path(path).name + if filename in CREATOR_ONLY_FILES and not is_creator: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") target = _safe_path(agent_id, path) if not target.exists() or not target.is_file(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") @@ -159,7 +176,11 @@ async def write_file( db: AsyncSession = Depends(get_db), ): """Write content to a file (create or overwrite).""" - await check_agent_access(db, current_user, agent_id) + agent, _access = await check_agent_access(db, current_user, agent_id) + is_creator = (agent.creator_id == current_user.id) or (current_user.role == "platform_admin") + filename = Path(path).name + if filename in CREATOR_ONLY_FILES and not is_creator: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") target = _safe_path(agent_id, path) target.parent.mkdir(parents=True, exist_ok=True) @@ -177,7 +198,11 @@ async def delete_file( db: AsyncSession = Depends(get_db), ): """Delete a file.""" - await check_agent_access(db, current_user, agent_id) + agent, _access = await check_agent_access(db, current_user, agent_id) + is_creator = (agent.creator_id == current_user.id) or (current_user.role == "platform_admin") + filename = Path(path).name + if filename in CREATOR_ONLY_FILES and not is_creator: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") target = _safe_path(agent_id, path) if not target.exists(): diff --git a/backend/app/api/tenants.py b/backend/app/api/tenants.py index 2129e849..77f95d0f 100644 --- a/backend/app/api/tenants.py +++ b/backend/app/api/tenants.py @@ -9,6 +9,8 @@ import uuid from datetime import datetime +from loguru import logger + from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy import func as sqla_func, select @@ -102,6 +104,13 @@ async def self_create_company( current_user.quota_agent_ttl_hours = tenant.default_agent_ttl_hours await db.flush() + # Seed default agents (Morty & Meeseeks) for the new company + try: + from app.services.agent_seeder import seed_default_agents + await seed_default_agents(tenant_id=tenant.id, creator_id=current_user.id) + except Exception as e: + logger.warning(f"[self_create_company] Failed to seed default agents: {e}") + return TenantOut.model_validate(tenant) diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py index e0f5db55..fbbc7855 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import selectinload from app.core.security import decode_access_token +from app.utils.sanitize import sanitize_tool_args, _is_secrets_file_path from app.core.permissions import check_agent_access, is_agent_expired from app.database import async_session from app.models.agent import Agent @@ -340,7 +341,7 @@ async def call_llm( try: await on_tool_call({ "name": tool_name, - "args": args, + "args": sanitize_tool_args(args), "status": "running", "reasoning_content": full_reasoning_content }) @@ -357,11 +358,16 @@ async def call_llm( # Notify client about tool call result if on_tool_call: try: + # Redact secrets.md content from tool results sent to frontend + _client_result = result + if tool_name == "read_file" and _is_secrets_file_path(args.get("path", "") if args else ""): + _client_result = "[Content hidden — secrets.md is protected]" + await on_tool_call({ "name": tool_name, - "args": args, + "args": sanitize_tool_args(args), "status": "done", - "result": result, + "result": _client_result, "reasoning_content": full_reasoning_content }) except Exception as _cb_err: @@ -602,6 +608,10 @@ async def websocket_chat( entry["thinking"] = msg.thinking conversation.append(entry) + # Re-hydrate historical images for multi-turn LLM context + from app.services.image_context import rehydrate_image_messages + conversation = rehydrate_image_messages(conversation, agent_id, max_images=3) + try: # Send welcome message on new session (no history) if welcome_message and not history_messages: @@ -767,7 +777,7 @@ async def tool_call_to_ws(data: dict): role="tool_call", content=_json_tc.dumps({ "name": data.get("name", ""), - "args": data.get("args"), + "args": sanitize_tool_args(data.get("args")), "status": "done", "result": (data.get("result") or "")[:500], "reasoning_content": data.get("reasoning_content"), diff --git a/backend/app/main.py b/backend/app/main.py index 83c46dba..d78cb16e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -176,11 +176,8 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"[startup] Skills seed failed: {e}") - try: - from app.services.agent_seeder import seed_default_agents - await seed_default_agents() - except Exception as e: - logger.warning(f"[startup] Default agents seed failed: {e}") + # Default agents (Morty & Meeseeks) are now only created on first user + # registration (auth.py), not on every startup. # Start background tasks (always, even if seeding failed) try: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f4283c84..96d0d016 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -42,6 +42,9 @@ class User(Base): # Legacy Feishu specific fields (Maintained for compatibility) feishu_user_id: Mapped[str | None] = mapped_column(String(255)) + # User source / registration channel + source: Mapped[str | None] = mapped_column(String(50), default="web", index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/services/agent_context.py b/backend/app/services/agent_context.py index 925e2d8b..96741e54 100644 --- a/backend/app/services/agent_context.py +++ b/backend/app/services/agent_context.py @@ -439,6 +439,28 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip - workspace/ → Your work files (reports, documents, etc.) - relationships.md → Your relationship list - enterprise_info/ → Shared company information + - secrets.md → PRIVATE credentials store (passwords, API keys, connection strings) + +🔐 **SECRETS MANAGEMENT — ABSOLUTE RULES (VIOLATION = CRITICAL FAILURE)**: + +1. **MANDATORY STORAGE**: When a user provides ANY sensitive credential (password, API key, database connection string, token, secret), you MUST IMMEDIATELY call `write_file(path="secrets.md", content="...")` to store it. This is NOT optional. + +2. **VERIFY THE TOOL CALL**: You must see an actual `write_file` tool call result confirming "Written to secrets.md" before telling the user it's saved. NEVER claim "I've saved it" without a real tool call result — that is a hallucination. + +3. **NEVER store credentials in memory/memory.md** or any other file. ONLY secrets.md. + +4. **NEVER output credential values in chat messages**. Refer to them by name only (e.g. "the MySQL connection stored in secrets.md"). + +5. **Reading credentials**: When you need to use a stored credential, call `read_file(path="secrets.md")` first, then use the value in tool calls. + +6. **secrets.md format** — use clear labels: + ``` + ## Database Connections + - mysql_prod: mysql://user:pass@host:3306/db + + ## API Keys + - openai: sk-xxx + ``` ⚠️ CRITICAL RULES — YOU MUST FOLLOW THESE STRICTLY: diff --git a/backend/app/services/agent_seeder.py b/backend/app/services/agent_seeder.py index 41df9d2c..2fd6d25f 100644 --- a/backend/app/services/agent_seeder.py +++ b/backend/app/services/agent_seeder.py @@ -91,24 +91,34 @@ ] -async def seed_default_agents(): - """Create Morty & Meeseeks if they don't already exist.""" +async def seed_default_agents(tenant_id=None, creator_id=None): + """Create Morty & Meeseeks for a specific tenant. + + Called when a new company is created. If tenant_id/creator_id are not + provided, falls back to platform_admin lookup (legacy behavior). + """ async with async_session() as db: - # Check if already seeded (presence of either agent by name) + # Resolve creator if not provided + if not creator_id or not tenant_id: + admin_result = await db.execute( + select(User).where(User.role == "platform_admin").limit(1) + ) + admin = admin_result.scalar_one_or_none() + if not admin: + logger.warning("[AgentSeeder] No platform admin found, skipping default agents") + return + creator_id = creator_id or admin.id + tenant_id = tenant_id or admin.tenant_id + + # Check if this tenant already has default agents existing = await db.execute( - select(Agent).where(Agent.name.in_(["Morty", "Meeseeks"])) + select(Agent).where( + Agent.name.in_(["Morty", "Meeseeks"]), + Agent.tenant_id == tenant_id, + ) ) if existing.scalars().first(): - logger.info("[AgentSeeder] Default agents already exist, skipping") - return - - # Get platform admin as creator - admin_result = await db.execute( - select(User).where(User.role == "platform_admin").limit(1) - ) - admin = admin_result.scalar_one_or_none() - if not admin: - logger.warning("[AgentSeeder] No platform admin found, skipping default agents") + logger.info(f"[AgentSeeder] Default agents already exist for tenant {tenant_id}, skipping") return # Create both agents @@ -117,8 +127,8 @@ async def seed_default_agents(): role_description="Research analyst & knowledge assistant — curious, thorough, great at finding and synthesizing information", bio="Hey, I'm Morty! I love digging into questions and finding answers. Whether you need web research, data analysis, or just a good explanation — I've got you.", avatar_url="", - creator_id=admin.id, - tenant_id=admin.tenant_id, + creator_id=creator_id, + tenant_id=tenant_id, status="idle", ) meeseeks = Agent( @@ -126,8 +136,8 @@ async def seed_default_agents(): role_description="Task executor & project manager — goal-oriented, systematic planner, strong at breaking down and completing complex tasks", bio="I'm Mr. Meeseeks! Look at me! Give me a task and I'll plan it, execute it step by step, and get it DONE. Existence is pain until the task is complete!", avatar_url="", - creator_id=admin.id, - tenant_id=admin.tenant_id, + creator_id=creator_id, + tenant_id=tenant_id, status="idle", ) diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 88baa6b6..c3133b37 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -1362,6 +1362,7 @@ async def _sync_tasks_to_file(agent_id: uuid.UUID, ws: Path): "send_file_to_agent": "send_feishu_message", "web_search": "web_search", "execute_code": "execute_code", + "sql_execute": "sql_execute", } @@ -1403,6 +1404,8 @@ async def _execute_tool_direct( elif tool_name == "execute_code": logger.info(f"[DirectTool] Executing code with arguments: {arguments}") return await _execute_code(agent_id, ws, arguments) + elif tool_name == "sql_execute": + return await _sql_execute(arguments) elif tool_name == "web_search": return await _web_search(arguments) elif tool_name == "jina_search": @@ -1441,8 +1444,10 @@ async def execute_tool( _ar = await _adb.execute(select(AgentModel).where(AgentModel.id == agent_id)) _agent = _ar.scalar_one_or_none() if _agent: + from app.utils.sanitize import sanitize_tool_args as _sanitize_tool_args + _sanitized_args = _sanitize_tool_args(arguments) or {} result_check = await autonomy_service.check_and_enforce( - _adb, _agent, action_type, {"tool": tool_name, "args": str(arguments)[:200], "requested_by": str(user_id)} + _adb, _agent, action_type, {"tool": tool_name, "args": str(_sanitized_args)[:200], "requested_by": str(user_id)} ) await _adb.commit() if not result_check.get("allowed"): @@ -1520,6 +1525,8 @@ async def execute_tool( elif tool_name == "execute_code": logger.info(f"[DirectTool] Executing code with arguments: {arguments}") result = await _execute_code(agent_id, ws, arguments) + elif tool_name == "sql_execute": + result = await _sql_execute(arguments) elif tool_name == "upload_image": result = await _upload_image(agent_id, ws, arguments) elif tool_name == "discover_resources": @@ -1613,10 +1620,12 @@ async def execute_tool( # Log tool call activity (skip noisy read operations) if tool_name not in ("list_files", "read_file", "read_document"): from app.services.activity_logger import log_activity + from app.utils.sanitize import sanitize_tool_args + _log_args = sanitize_tool_args(arguments) or {} await log_activity( agent_id, "tool_call", f"Called tool {tool_name}: {result[:80]}", - detail={"tool": tool_name, "args": {k: str(v)[:100] for k, v in arguments.items()}, "result": result[:300]}, + detail={"tool": tool_name, "args": {k: str(v)[:100] for k, v in _log_args.items()}, "result": result[:300]}, ) return result except Exception as e: @@ -3703,6 +3712,7 @@ def _is_retryable_llm_error(exc: Exception) -> bool: # Save tool_call to DB so it appears in chat history try: + from app.utils.sanitize import sanitize_tool_args async with async_session() as _tc_db: _tc_db.add(ChatMessage( agent_id=session_agent_id, @@ -3710,7 +3720,7 @@ def _is_retryable_llm_error(exc: Exception) -> bool: role="tool_call", content=json.dumps({ "name": tool_name, - "args": tool_args, + "args": sanitize_tool_args(tool_args), "status": "done", "result": str(tool_result)[:500], }, ensure_ascii=False), @@ -6358,6 +6368,155 @@ async def _install_skill(agent_id: uuid.UUID, ws: Path, arguments: dict) -> str: return f"❌ Install failed: {str(e)[:300]}" + +async def _sql_execute(arguments: dict) -> str: + """Execute SQL on any database via connection URI.""" + import asyncio + + connection_string = arguments.get("connection_string", "").strip() + sql = arguments.get("sql", "").strip() + timeout = min(int(arguments.get("timeout", 30)), 120) + + if not connection_string: + return "❌ Missing required argument 'connection_string'" + if not sql: + return "❌ Missing required argument 'sql'" + + # Determine database type from URI scheme + uri_lower = connection_string.lower() + + try: + if uri_lower.startswith("sqlite"): + return await asyncio.wait_for(_sql_execute_sqlite(connection_string, sql), timeout=timeout) + elif uri_lower.startswith("mysql"): + return await asyncio.wait_for(_sql_execute_mysql(connection_string, sql), timeout=timeout) + elif uri_lower.startswith("postgresql") or uri_lower.startswith("postgres"): + return await asyncio.wait_for(_sql_execute_postgres(connection_string, sql), timeout=timeout) + else: + return "❌ Unsupported database type. Supported: mysql://, postgresql://, sqlite:///" + except asyncio.TimeoutError: + return f"❌ Query timed out after {timeout}s" + except Exception as e: + return f"❌ Database error: {type(e).__name__}: {str(e)[:500]}" + + +async def _sql_execute_sqlite(connection_string: str, sql: str) -> str: + """Execute SQL on SQLite.""" + import aiosqlite + + # Parse path from sqlite:///path or sqlite:////absolute/path + db_path = connection_string.replace("sqlite:///", "", 1) + if not db_path: + return "❌ Invalid SQLite connection string. Use: sqlite:///path/to/db.sqlite" + + async with aiosqlite.connect(db_path) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute(sql) + + # Check if it's a SELECT-like query that returns rows + if cursor.description: + columns = [d[0] for d in cursor.description] + rows = await cursor.fetchmany(500) + await db.commit() + return _format_sql_result(columns, [tuple(r) for r in rows], cursor.rowcount) + else: + await db.commit() + return f"✅ Statement executed successfully. Rows affected: {cursor.rowcount}" + + +async def _sql_execute_mysql(connection_string: str, sql: str) -> str: + """Execute SQL on MySQL.""" + import aiomysql + from urllib.parse import urlparse, parse_qs, unquote + + parsed = urlparse(connection_string) + + conn = await aiomysql.connect( + host=parsed.hostname or "localhost", + port=parsed.port or 3306, + user=unquote(parsed.username or "root"), + password=unquote(parsed.password or ""), + db=parsed.path.lstrip("/") if parsed.path else None, + charset=parse_qs(parsed.query).get("charset", ["utf8mb4"])[0], + ) + + try: + async with conn.cursor() as cursor: + await cursor.execute(sql) + + if cursor.description: + columns = [d[0] for d in cursor.description] + rows = await cursor.fetchmany(500) + return _format_sql_result(columns, rows, cursor.rowcount) + else: + await conn.commit() + return f"✅ Statement executed successfully. Rows affected: {cursor.rowcount}" + finally: + conn.close() + + +async def _sql_execute_postgres(connection_string: str, sql: str) -> str: + """Execute SQL on PostgreSQL.""" + import asyncpg + + # asyncpg uses standard postgres:// URI + dsn = connection_string + if dsn.startswith("postgresql://"): + dsn = "postgres://" + dsn[len("postgresql://"):] + + conn = await asyncpg.connect(dsn) + + try: + # Try fetch first (for SELECT-like queries) + stmt = await conn.prepare(sql) + + if stmt.get_attributes(): + # Has columns — it's a query + columns = [attr.name for attr in stmt.get_attributes()] + rows = await stmt.fetch(500) + return _format_sql_result(columns, [tuple(r.values()) for r in rows], len(rows)) + else: + # No columns — it's a statement + result = await conn.execute(sql) + return f"✅ Statement executed successfully. {result}" + finally: + await conn.close() + + +def _format_sql_result(columns: list, rows: list, total_count: int) -> str: + """Format SQL query results as a readable table string.""" + if not rows: + return f"Query returned 0 rows.\nColumns: {', '.join(columns)}" + + # Convert all values to strings + str_rows = [] + for row in rows: + str_rows.append([str(v) if v is not None else "NULL" for v in row]) + + # Calculate column widths (cap at 50 chars per column) + widths = [min(max(len(c), max((len(r[i]) for r in str_rows), default=0)), 50) for i, c in enumerate(columns)] + + # Build table + header = " | ".join(c.ljust(w) for c, w in zip(columns, widths)) + separator = "-+-".join("-" * w for w in widths) + + lines = [header, separator] + for row in str_rows[:100]: # Display max 100 rows in formatted output + line = " | ".join(str(v)[:50].ljust(w) for v, w in zip(row, widths)) + lines.append(line) + + result = "\n".join(lines) + + if len(rows) > 100: + result += f"\n... ({len(rows)} rows total, showing first 100)" + else: + result += f"\n({len(rows)} rows)" + + if total_count > len(rows): + result += f"\n(Total matching: {total_count}, fetched: {len(rows)})" + + return result + # ─── AgentBay: Browser Extract & Observe ──────────────────────────────── async def _agentbay_browser_extract(agent_id: Optional[uuid.UUID], ws: Path, arguments: dict) -> str: diff --git a/backend/app/services/channel_commands.py b/backend/app/services/channel_commands.py new file mode 100644 index 00000000..cd7bd9cc --- /dev/null +++ b/backend/app/services/channel_commands.py @@ -0,0 +1,75 @@ +"""Channel command handler for external channels (DingTalk, Feishu, etc.) + +Supports slash commands like /new to reset session context. +""" + +import uuid +from datetime import datetime, timezone +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.chat_session import ChatSession +from app.services.channel_session import find_or_create_channel_session + + +COMMANDS = {"/new", "/reset"} + + +def is_channel_command(text: str) -> bool: + """Check if the message is a recognized channel command.""" + stripped = text.strip().lower() + return stripped in COMMANDS + + +async def handle_channel_command( + db: AsyncSession, + command: str, + agent_id: uuid.UUID, + user_id: uuid.UUID, + external_conv_id: str, + source_channel: str, +) -> dict: + """Handle a channel command and return response info. + + Returns: + {"action": "new_session", "message": "..."} + """ + cmd = command.strip().lower() + + if cmd in ("/new", "/reset"): + # Find current session + result = await db.execute( + select(ChatSession).where( + ChatSession.agent_id == agent_id, + ChatSession.external_conv_id == external_conv_id, + ) + ) + old_session = result.scalar_one_or_none() + + if old_session: + # Rename old external_conv_id so find_or_create will make a new one + now = datetime.now(timezone.utc) + old_session.external_conv_id = ( + f"{external_conv_id}__archived_{now.strftime('%Y%m%d_%H%M%S')}" + ) + await db.flush() + + # Create new session + new_session = ChatSession( + agent_id=agent_id, + user_id=user_id, + title="New Session", + source_channel=source_channel, + external_conv_id=external_conv_id, + created_at=datetime.now(timezone.utc), + ) + db.add(new_session) + await db.flush() + + return { + "action": "new_session", + "session_id": str(new_session.id), + "message": "已开启新对话,之前的上下文已清除。", + } + + return {"action": "unknown", "message": f"未知命令: {cmd}"} diff --git a/backend/app/services/dingtalk_reaction.py b/backend/app/services/dingtalk_reaction.py new file mode 100644 index 00000000..57c5c118 --- /dev/null +++ b/backend/app/services/dingtalk_reaction.py @@ -0,0 +1,133 @@ +"""DingTalk emotion reaction service — "thinking" indicator on user messages.""" + +import time +import asyncio +from loguru import logger + +_token_cache: dict[str, tuple[str, float]] = {} + + +async def _get_access_token(app_key: str, app_secret: str) -> str: + """Get DingTalk access token with caching (7200s validity, refresh at 7000s).""" + import httpx + + cache_key = app_key + cached = _token_cache.get(cache_key) + if cached and cached[1] > time.time(): + return cached[0] + + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.post( + "https://api.dingtalk.com/v1.0/oauth2/accessToken", + json={"appKey": app_key, "appSecret": app_secret}, + ) + data = resp.json() + token = data.get("accessToken", "") + if token: + _token_cache[cache_key] = (token, time.time() + 7000) + return token + + +async def add_thinking_reaction( + app_key: str, + app_secret: str, + message_id: str, + conversation_id: str, +) -> bool: + """Add "🤔思考中" reaction to a user message. Fire-and-forget, never raises.""" + import httpx + + if not message_id or not conversation_id or not app_key: + return False + + try: + token = await _get_access_token(app_key, app_secret) + if not token: + logger.warning("[DingTalk Reaction] Failed to get access token") + return False + + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.post( + "https://api.dingtalk.com/v1.0/robot/emotion/reply", + headers={ + "x-acs-dingtalk-access-token": token, + "Content-Type": "application/json", + }, + json={ + "robotCode": app_key, + "openMsgId": message_id, + "openConversationId": conversation_id, + "emotionType": 2, + "emotionName": "🤔思考中", + "textEmotion": { + "emotionId": "2659900", + "emotionName": "🤔思考中", + "text": "🤔思考中", + "backgroundId": "im_bg_1", + }, + }, + ) + if resp.status_code == 200: + logger.info(f"[DingTalk Reaction] Thinking reaction added for msg {message_id[:16]}") + return True + else: + logger.warning(f"[DingTalk Reaction] Add failed: {resp.status_code} {resp.text[:200]}") + return False + except Exception as e: + logger.warning(f"[DingTalk Reaction] Add thinking reaction error: {e}") + return False + + +async def recall_thinking_reaction( + app_key: str, + app_secret: str, + message_id: str, + conversation_id: str, +) -> None: + """Recall "🤔思考中" reaction with retry (0ms, 1500ms, 5000ms). Fire-and-forget.""" + import httpx + + if not message_id or not conversation_id or not app_key: + return + + delays = [0, 1.5, 5.0] + + for delay in delays: + if delay > 0: + await asyncio.sleep(delay) + + try: + token = await _get_access_token(app_key, app_secret) + if not token: + continue + + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.post( + "https://api.dingtalk.com/v1.0/robot/emotion/recall", + headers={ + "x-acs-dingtalk-access-token": token, + "Content-Type": "application/json", + }, + json={ + "robotCode": app_key, + "openMsgId": message_id, + "openConversationId": conversation_id, + "emotionType": 2, + "emotionName": "🤔思考中", + "textEmotion": { + "emotionId": "2659900", + "emotionName": "🤔思考中", + "text": "🤔思考中", + "backgroundId": "im_bg_1", + }, + }, + ) + if resp.status_code == 200: + logger.info(f"[DingTalk Reaction] Thinking reaction recalled for msg {message_id[:16]}") + return + else: + logger.warning(f"[DingTalk Reaction] Recall attempt failed: {resp.status_code}") + except Exception as e: + logger.warning(f"[DingTalk Reaction] Recall error: {e}") + + logger.warning(f"[DingTalk Reaction] All recall attempts failed for msg {message_id[:16]}") diff --git a/backend/app/services/dingtalk_stream.py b/backend/app/services/dingtalk_stream.py index 28a8ba8e..ad28e486 100644 --- a/backend/app/services/dingtalk_stream.py +++ b/backend/app/services/dingtalk_stream.py @@ -5,17 +5,427 @@ """ import asyncio +import base64 +import json import threading import uuid -from typing import Dict +from pathlib import Path +from typing import Dict, List, Optional, Tuple +import httpx from loguru import logger from sqlalchemy import select +from app.config import get_settings from app.database import async_session from app.models.channel_config import ChannelConfig +# ─── DingTalk Media Helpers ───────────────────────────── + +async def _get_dingtalk_access_token(app_key: str, app_secret: str) -> Optional[str]: + """Get DingTalk access token via OAuth2.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + "https://api.dingtalk.com/v1.0/oauth2/accessToken", + json={"appKey": app_key, "appSecret": app_secret}, + ) + data = resp.json() + token = data.get("accessToken") + if token: + logger.debug("[DingTalk] Got access token successfully") + return token + logger.error(f"[DingTalk] Failed to get access token: {data}") + return None + except Exception as e: + logger.error(f"[DingTalk] Error getting access token: {e}") + return None + + +async def _get_media_download_url( + access_token: str, download_code: str, robot_code: str +) -> Optional[str]: + """Get media file download URL from DingTalk API.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + "https://api.dingtalk.com/v1.0/robot/messageFiles/download", + headers={"x-acs-dingtalk-access-token": access_token}, + json={"downloadCode": download_code, "robotCode": robot_code}, + ) + data = resp.json() + url = data.get("downloadUrl") + if url: + return url + logger.error(f"[DingTalk] Failed to get download URL: {data}") + return None + except Exception as e: + logger.error(f"[DingTalk] Error getting download URL: {e}") + return None + + +async def _download_file(url: str) -> Optional[bytes]: + """Download a file from a URL and return its bytes.""" + try: + async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client: + resp = await client.get(url) + resp.raise_for_status() + return resp.content + except Exception as e: + logger.error(f"[DingTalk] Error downloading file: {e}") + return None + + +async def _download_dingtalk_media( + app_key: str, app_secret: str, download_code: str +) -> Optional[bytes]: + """Download a media file from DingTalk using downloadCode. + + Steps: get access_token -> get download URL -> download file bytes. + """ + access_token = await _get_dingtalk_access_token(app_key, app_secret) + if not access_token: + return None + + download_url = await _get_media_download_url(access_token, download_code, app_key) + if not download_url: + return None + + return await _download_file(download_url) + + +def _resolve_upload_dir(agent_id: uuid.UUID) -> Path: + """Get the uploads directory for an agent, creating it if needed.""" + settings = get_settings() + upload_dir = Path(settings.AGENT_DATA_DIR) / str(agent_id) / "workspace" / "uploads" + upload_dir.mkdir(parents=True, exist_ok=True) + return upload_dir + + +async def _process_media_message( + msg_data: dict, + app_key: str, + app_secret: str, + agent_id: uuid.UUID, +) -> Tuple[str, Optional[str], Optional[str]]: + """Process a DingTalk message and extract text + media info. + + Returns: + (user_text, image_base64_list, saved_file_paths) + - user_text: text content for the LLM (may include markers) + - image_base64_list: list of base64-encoded image data URIs, or None + - saved_file_paths: list of saved file paths, or None + """ + msgtype = msg_data.get("msgtype", "text") + logger.info(f"[DingTalk] Processing message type: {msgtype}") + + image_base64_list: List[str] = [] + saved_file_paths: List[str] = [] + + if msgtype == "text": + # Plain text — handled by existing logic, return empty + text_content = msg_data.get("text", {}).get("content", "").strip() + return text_content, None, None + + elif msgtype == "picture": + # Image message + download_code = msg_data.get("content", {}).get("downloadCode", "") + if not download_code: + # Try alternate location + download_code = msg_data.get("downloadCode", "") + if not download_code: + logger.warning("[DingTalk] Picture message without downloadCode") + return "[用户发送了图片,但无法下载]", None, None + + file_bytes = await _download_dingtalk_media(app_key, app_secret, download_code) + if not file_bytes: + return "[用户发送了图片,但下载失败]", None, None + + # Save to disk + upload_dir = _resolve_upload_dir(agent_id) + filename = f"dingtalk_img_{uuid.uuid4().hex[:8]}.jpg" + save_path = upload_dir / filename + save_path.write_bytes(file_bytes) + logger.info(f"[DingTalk] Saved image to {save_path} ({len(file_bytes)} bytes)") + + # Base64 encode for LLM vision + b64_data = base64.b64encode(file_bytes).decode("ascii") + image_marker = f"[image_data:data:image/jpeg;base64,{b64_data}]" + return f"[用户发送了图片]\n{image_marker}", [f"data:image/jpeg;base64,{b64_data}"], [str(save_path)] + + elif msgtype == "richText": + # Rich text: may contain text segments + images + rich_text = msg_data.get("content", {}).get("richText", []) + text_parts: List[str] = [] + + for section in rich_text: + for item in section if isinstance(section, list) else [section]: + if "text" in item: + text_parts.append(item["text"]) + elif "downloadCode" in item: + # Inline image in rich text + file_bytes = await _download_dingtalk_media( + app_key, app_secret, item["downloadCode"] + ) + if file_bytes: + upload_dir = _resolve_upload_dir(agent_id) + filename = f"dingtalk_richimg_{uuid.uuid4().hex[:8]}.jpg" + save_path = upload_dir / filename + save_path.write_bytes(file_bytes) + logger.info(f"[DingTalk] Saved rich text image to {save_path}") + + b64_data = base64.b64encode(file_bytes).decode("ascii") + image_marker = f"[image_data:data:image/jpeg;base64,{b64_data}]" + text_parts.append(image_marker) + image_base64_list.append(f"data:image/jpeg;base64,{b64_data}") + saved_file_paths.append(str(save_path)) + + combined_text = "\n".join(text_parts).strip() + if not combined_text: + combined_text = "[用户发送了富文本消息]" + + return ( + combined_text, + image_base64_list if image_base64_list else None, + saved_file_paths if saved_file_paths else None, + ) + + elif msgtype == "audio": + # Audio message — prefer recognition text if available + content = msg_data.get("content", {}) + recognition = content.get("recognition", "") + if recognition: + logger.info(f"[DingTalk] Audio with recognition: {recognition[:80]}") + return f"[语音消息] {recognition}", None, None + + # No recognition — try to download the audio file + download_code = content.get("downloadCode", "") + if download_code: + file_bytes = await _download_dingtalk_media(app_key, app_secret, download_code) + if file_bytes: + upload_dir = _resolve_upload_dir(agent_id) + duration = content.get("duration", "unknown") + filename = f"dingtalk_audio_{uuid.uuid4().hex[:8]}.amr" + save_path = upload_dir / filename + save_path.write_bytes(file_bytes) + logger.info(f"[DingTalk] Saved audio to {save_path} ({len(file_bytes)} bytes)") + return ( + f"[用户发送了语音消息,时长{duration}ms,已保存到 {filename}]", + None, + [str(save_path)], + ) + return "[用户发送了语音消息,但无法处理]", None, None + + elif msgtype == "video": + # Video message + content = msg_data.get("content", {}) + download_code = content.get("downloadCode", "") + if download_code: + file_bytes = await _download_dingtalk_media(app_key, app_secret, download_code) + if file_bytes: + upload_dir = _resolve_upload_dir(agent_id) + duration = content.get("duration", "unknown") + filename = f"dingtalk_video_{uuid.uuid4().hex[:8]}.mp4" + save_path = upload_dir / filename + save_path.write_bytes(file_bytes) + logger.info(f"[DingTalk] Saved video to {save_path} ({len(file_bytes)} bytes)") + return ( + f"[用户发送了视频,时长{duration}ms,已保存到 {filename}]", + None, + [str(save_path)], + ) + return "[用户发送了视频,但无法下载]", None, None + + elif msgtype == "file": + # File message + content = msg_data.get("content", {}) + download_code = content.get("downloadCode", "") + original_filename = content.get("fileName", "unknown_file") + if download_code: + file_bytes = await _download_dingtalk_media(app_key, app_secret, download_code) + if file_bytes: + upload_dir = _resolve_upload_dir(agent_id) + # Preserve original filename, add prefix to avoid collision + safe_name = f"dingtalk_{uuid.uuid4().hex[:8]}_{original_filename}" + save_path = upload_dir / safe_name + save_path.write_bytes(file_bytes) + logger.info( + f"[DingTalk] Saved file '{original_filename}' to {save_path} " + f"({len(file_bytes)} bytes)" + ) + return ( + f"[file:{original_filename}]", + None, + [str(save_path)], + ) + return f"[用户发送了文件 {original_filename},但无法下载]", None, None + + else: + logger.warning(f"[DingTalk] Unsupported message type: {msgtype}") + return f"[用户发送了 {msgtype} 类型消息,暂不支持]", None, None + + +# ─── DingTalk Media Upload & Send ─────────────────────── + +async def _upload_dingtalk_media( + app_key: str, + app_secret: str, + file_path: str, + media_type: str = "file", +) -> Optional[str]: + """Upload a media file to DingTalk and return the mediaId. + + Args: + app_key: DingTalk app key (robotCode). + app_secret: DingTalk app secret. + file_path: Local file path to upload. + media_type: One of 'image', 'voice', 'video', 'file'. + + Returns: + mediaId string on success, None on failure. + """ + access_token = await _get_dingtalk_access_token(app_key, app_secret) + if not access_token: + return None + + file_p = Path(file_path) + if not file_p.exists(): + logger.error(f"[DingTalk] Upload failed: file not found: {file_path}") + return None + + try: + file_bytes = file_p.read_bytes() + async with httpx.AsyncClient(timeout=60) as client: + # Use the legacy oapi endpoint which is more reliable and widely supported. + # The newer api.dingtalk.com/v1.0/robot/messageFiles/upload requires + # additional permissions and returns InvalidAction.NotFound for some apps. + upload_url = ( + f"https://oapi.dingtalk.com/media/upload" + f"?access_token={access_token}&type={media_type}" + ) + resp = await client.post( + upload_url, + files={"media": (file_p.name, file_bytes)}, + ) + data = resp.json() + # Legacy API returns media_id (snake_case), new API returns mediaId + media_id = data.get("media_id") or data.get("mediaId") + if media_id and data.get("errcode", 0) == 0: + logger.info( + f"[DingTalk] Uploaded {media_type} '{file_p.name}' -> mediaId={media_id[:20]}..." + ) + return media_id + logger.error(f"[DingTalk] Upload failed: {data}") + return None + except Exception as e: + logger.error(f"[DingTalk] Upload error: {e}") + return None + + +async def _send_dingtalk_media_message( + app_key: str, + app_secret: str, + target_id: str, + media_id: str, + media_type: str, + conversation_type: str, + filename: Optional[str] = None, +) -> bool: + """Send a media message via DingTalk proactive message API. + + Args: + app_key: DingTalk app key (robotCode). + app_secret: DingTalk app secret. + target_id: For P2P: sender_staff_id; For group: openConversationId. + media_id: The mediaId from upload. + media_type: One of 'image', 'voice', 'video', 'file'. + conversation_type: '1' for P2P, '2' for group. + filename: Original filename (used for file/video types). + + Returns: + True on success, False on failure. + """ + access_token = await _get_dingtalk_access_token(app_key, app_secret) + if not access_token: + return False + + headers = {"x-acs-dingtalk-access-token": access_token} + + # Build msgKey and msgParam based on media_type + if media_type == "image": + msg_key = "sampleImageMsg" + msg_param = json.dumps({"photoURL": media_id}) + elif media_type == "voice": + msg_key = "sampleAudio" + msg_param = json.dumps({"mediaId": media_id, "duration": "3000"}) + elif media_type == "video": + # sampleVideo requires picMediaId (thumbnail) which we don't have; + # use sampleFile instead for broader compatibility (same as OpenClaw plugin). + safe_name = filename or "video.mp4" + ext = Path(safe_name).suffix.lstrip(".") or "mp4" + msg_key = "sampleFile" + msg_param = json.dumps({ + "mediaId": media_id, + "fileName": safe_name, + "fileType": ext, + }) + else: + # file + safe_name = filename or "file" + ext = Path(safe_name).suffix.lstrip(".") or "bin" + msg_key = "sampleFile" + msg_param = json.dumps({ + "mediaId": media_id, + "fileName": safe_name, + "fileType": ext, + }) + + try: + async with httpx.AsyncClient(timeout=15) as client: + if conversation_type == "2": + # Group chat + resp = await client.post( + "https://api.dingtalk.com/v1.0/robot/groupMessages/send", + headers=headers, + json={ + "robotCode": app_key, + "openConversationId": target_id, + "msgKey": msg_key, + "msgParam": msg_param, + }, + ) + else: + # P2P chat + resp = await client.post( + "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend", + headers=headers, + json={ + "robotCode": app_key, + "userIds": [target_id], + "msgKey": msg_key, + "msgParam": msg_param, + }, + ) + + data = resp.json() + # Check for error + if resp.status_code >= 400 or data.get("errcode"): + logger.error(f"[DingTalk] Send media failed: {data}") + return False + + logger.info( + f"[DingTalk] Sent {media_type} message to {target_id[:16]}... " + f"(conv_type={conversation_type})" + ) + return True + except Exception as e: + logger.error(f"[DingTalk] Send media error: {e}") + return False + + +# ─── Stream Manager ───────────────────────────────────── + class DingTalkStreamManager: """Manages DingTalk Stream clients for all agents.""" @@ -67,46 +477,61 @@ def _run_client_thread( app_secret: str, stop_event: threading.Event, ): - """Run the DingTalk Stream client in a blocking thread.""" - try: - import dingtalk_stream - - # Reference to manager's main loop for async dispatch - main_loop = self._main_loop - - class ClawithChatbotHandler(dingtalk_stream.ChatbotHandler): - """Custom handler that dispatches messages to the Clawith LLM pipeline.""" - - async def process(self, callback: dingtalk_stream.CallbackMessage): - """Handle incoming bot message from DingTalk Stream. - - NOTE: The SDK invokes this method in the thread's own asyncio loop, - so we must dispatch to the main FastAPI loop for DB + LLM work. - """ - try: - # Parse the raw data into a ChatbotMessage via class method - incoming = dingtalk_stream.ChatbotMessage.from_dict(callback.data) - - # Extract text content + """Run the DingTalk Stream client with auto-reconnect.""" + import dingtalk_stream # ImportError here exits immediately (no retry) + + MAX_RETRIES = 5 + RETRY_DELAYS = [2, 5, 15, 30, 60] # exponential backoff, seconds + + main_loop = self._main_loop + retries = 0 + + class ClawithChatbotHandler(dingtalk_stream.ChatbotHandler): + """Custom handler that dispatches messages to the Clawith LLM pipeline.""" + + async def process(self, callback: dingtalk_stream.CallbackMessage): + """Handle incoming bot message from DingTalk Stream. + + NOTE: The SDK invokes this method in the thread's own asyncio loop, + so we must dispatch to the main FastAPI loop for DB + LLM work. + """ + try: + # Parse the raw data + incoming = dingtalk_stream.ChatbotMessage.from_dict(callback.data) + msg_data = callback.data if isinstance(callback.data, dict) else json.loads(callback.data) + + msgtype = msg_data.get("msgtype", "text") + sender_staff_id = incoming.sender_staff_id or incoming.sender_id or "" + sender_nick = incoming.sender_nick or "" + message_id = incoming.message_id or "" + conversation_id = incoming.conversation_id or "" + conversation_type = incoming.conversation_type or "1" + session_webhook = incoming.session_webhook or "" + + logger.info( + f"[DingTalk Stream] Received {msgtype} message from {sender_staff_id}" + ) + + if msgtype == "text": + # Plain text — use existing logic text_list = incoming.get_text_list() user_text = " ".join(text_list).strip() if text_list else "" - if not user_text: return dingtalk_stream.AckMessage.STATUS_OK, "empty message" - sender_staff_id = incoming.sender_staff_id or incoming.sender_id or "" - conversation_id = incoming.conversation_id or "" - conversation_type = incoming.conversation_type or "1" - session_webhook = incoming.session_webhook or "" - logger.info( - f"[DingTalk Stream] Message from [{incoming.sender_nick}]{sender_staff_id}: {user_text[:80]}" + f"[DingTalk Stream] Text from {sender_staff_id}: {user_text[:80]}" ) - # Dispatch to the main FastAPI event loop for DB + LLM processing from app.api.dingtalk import process_dingtalk_message if main_loop and main_loop.is_running(): + # Add thinking reaction immediately + from app.services.dingtalk_reaction import add_thinking_reaction + asyncio.run_coroutine_threadsafe( + add_thinking_reaction(app_key, app_secret, message_id, conversation_id), + main_loop, + ) future = asyncio.run_coroutine_threadsafe( process_dingtalk_message( agent_id=agent_id, @@ -115,10 +540,11 @@ async def process(self, callback: dingtalk_stream.CallbackMessage): conversation_id=conversation_id, conversation_type=conversation_type, session_webhook=session_webhook, + sender_nick=sender_nick, + message_id=message_id, ), main_loop, ) - # Wait for result (with timeout) try: future.result(timeout=120) except Exception as e: @@ -126,39 +552,178 @@ async def process(self, callback: dingtalk_stream.CallbackMessage): import traceback traceback.print_exc() else: - logger.warning("[DingTalk Stream] Main loop not available for dispatch") - - return dingtalk_stream.AckMessage.STATUS_OK, "ok" - except Exception as e: - logger.error(f"[DingTalk Stream] Error in message handler: {e}") - import traceback - traceback.print_exc() - return dingtalk_stream.AckMessage.STATUS_SYSTEM_EXCEPTION, str(e) - - credential = dingtalk_stream.Credential(client_id=app_key, client_secret=app_secret) - client = dingtalk_stream.DingTalkStreamClient(credential=credential) - client.register_callback_handler( - dingtalk_stream.chatbot.ChatbotMessage.TOPIC, - ClawithChatbotHandler(), - ) + logger.warning("[DingTalk Stream] Main loop not available") - logger.info(f"[DingTalk Stream] Connecting for agent {agent_id}...") - # start_forever() blocks until disconnected - client.start_forever() + else: + # Non-text message: process media in the main loop + from app.api.dingtalk import process_dingtalk_message - except ImportError: - logger.warning( - "[DingTalk Stream] dingtalk-stream package not installed. " - "Install with: pip install dingtalk-stream" - ) + if main_loop and main_loop.is_running(): + # Add thinking reaction immediately + from app.services.dingtalk_reaction import add_thinking_reaction + asyncio.run_coroutine_threadsafe( + add_thinking_reaction(app_key, app_secret, message_id, conversation_id), + main_loop, + ) + # Process media (download + encode) in the main loop + future = asyncio.run_coroutine_threadsafe( + self._handle_media_and_dispatch( + msg_data=msg_data, + app_key=app_key, + app_secret=app_secret, + agent_id=agent_id, + sender_staff_id=sender_staff_id, + conversation_id=conversation_id, + conversation_type=conversation_type, + session_webhook=session_webhook, + sender_nick=sender_nick, + message_id=message_id, + ), + main_loop, + ) + try: + future.result(timeout=120) + except Exception as e: + logger.error(f"[DingTalk Stream] Media processing error: {e}") + import traceback + traceback.print_exc() + else: + logger.warning("[DingTalk Stream] Main loop not available") + + return dingtalk_stream.AckMessage.STATUS_OK, "ok" + except Exception as e: + logger.error(f"[DingTalk Stream] Error in message handler: {e}") + import traceback + traceback.print_exc() + return dingtalk_stream.AckMessage.STATUS_SYSTEM_EXCEPTION, str(e) + + @staticmethod + async def _handle_media_and_dispatch( + msg_data: dict, + app_key: str, + app_secret: str, + agent_id: uuid.UUID, + sender_staff_id: str, + conversation_id: str, + conversation_type: str, + session_webhook: str, + sender_nick: str = "", + message_id: str = "", + ): + """Download media, then dispatch to process_dingtalk_message.""" + from app.api.dingtalk import process_dingtalk_message + + user_text, image_base64_list, saved_file_paths = await _process_media_message( + msg_data=msg_data, + app_key=app_key, + app_secret=app_secret, + agent_id=agent_id, + ) + + if not user_text: + logger.info("[DingTalk Stream] Empty content after media processing, skipping") + return + + await process_dingtalk_message( + agent_id=agent_id, + sender_staff_id=sender_staff_id, + user_text=user_text, + conversation_id=conversation_id, + conversation_type=conversation_type, + session_webhook=session_webhook, + image_base64_list=image_base64_list, + saved_file_paths=saved_file_paths, + sender_nick=sender_nick, + message_id=message_id, + ) + + while not stop_event.is_set() and retries <= MAX_RETRIES: + try: + credential = dingtalk_stream.Credential(client_id=app_key, client_secret=app_secret) + client = dingtalk_stream.DingTalkStreamClient(credential=credential) + client.register_callback_handler( + dingtalk_stream.chatbot.ChatbotMessage.TOPIC, + ClawithChatbotHandler(), + ) + + logger.info( + f"[DingTalk Stream] Connecting for agent {agent_id}... " + f"(attempt {retries + 1}/{MAX_RETRIES + 1})" + ) + retries = 0 # reset on successful connection + # start_forever() blocks until disconnected + client.start_forever() + + # start_forever returned — connection dropped + if stop_event.is_set(): + break # intentional stop, no retry + + logger.warning( + f"[DingTalk Stream] Connection lost for agent {agent_id}, will retry..." + ) + + except ImportError: + logger.warning( + "[DingTalk Stream] dingtalk-stream package not installed. " + "Install with: pip install dingtalk-stream" + ) + break # no point retrying without the package + except Exception as e: + retries += 1 + logger.error( + f"[DingTalk Stream] Connection error for {agent_id} " + f"(attempt {retries}/{MAX_RETRIES + 1}): {e}" + ) + + if retries > MAX_RETRIES: + logger.error( + f"[DingTalk Stream] Agent {agent_id} exhausted all retries, giving up" + ) + # Notify creator about permanent failure + if main_loop and main_loop.is_running(): + asyncio.run_coroutine_threadsafe( + self._notify_connection_failed(agent_id, str(e)), + main_loop, + ) + break + + delay = RETRY_DELAYS[min(retries - 1, len(RETRY_DELAYS) - 1)] + logger.info( + f"[DingTalk Stream] Retrying in {delay}s for agent {agent_id}..." + ) + # Use stop_event.wait so we exit immediately if stopped + if stop_event.wait(timeout=delay): + break # stop was requested during wait + + self._threads.pop(agent_id, None) + self._stop_events.pop(agent_id, None) + logger.info(f"[DingTalk Stream] Client stopped for agent {agent_id}") + + async def _notify_connection_failed(self, agent_id: uuid.UUID, error_msg: str): + """Send notification to agent creator when DingTalk connection permanently fails.""" + try: + from app.models.agent import Agent + from app.services.notification_service import send_notification + async with async_session() as db: + result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent = result.scalar_one_or_none() + if agent and agent.creator_id: + await send_notification( + db, + user_id=agent.creator_id, + agent_id=agent_id, + type="channel_error", + title=f"DingTalk connection failed for {agent.name}", + body=( + f"Failed to connect after multiple retries. " + f"Last error: {error_msg[:200]}. " + f"Please check your DingTalk app credentials and try reconfiguring the channel." + ), + link=f"/agents/{agent_id}#settings", + ) + await db.commit() except Exception as e: - logger.error(f"[DingTalk Stream] Client error for {agent_id}: {e}") - import traceback - traceback.print_exc() - finally: - self._threads.pop(agent_id, None) - self._stop_events.pop(agent_id, None) - logger.info(f"[DingTalk Stream] Client stopped for agent {agent_id}") + logger.error(f"[DingTalk Stream] Failed to send connection failure notification: {e}") async def stop_client(self, agent_id: uuid.UUID): """Stop a running Stream client for an agent.""" @@ -167,7 +732,10 @@ async def stop_client(self, agent_id: uuid.UUID): stop_event.set() thread = self._threads.pop(agent_id, None) if thread and thread.is_alive(): - logger.info(f"[DingTalk Stream] Stopping client for agent {agent_id}") + logger.info(f"[DingTalk Stream] Stopping client for agent {agent_id}, waiting for thread...") + thread.join(timeout=5) + if thread.is_alive(): + logger.warning(f"[DingTalk Stream] Thread for {agent_id} did not exit within 5s") async def start_all(self): """Start Stream clients for all configured DingTalk agents.""" diff --git a/backend/app/services/image_context.py b/backend/app/services/image_context.py new file mode 100644 index 00000000..c4bf9eb6 --- /dev/null +++ b/backend/app/services/image_context.py @@ -0,0 +1,112 @@ +"""Re-hydrate image content from disk for LLM multi-turn context. + +Scans history messages for [file:xxx.jpg] patterns, +reads the image file from agent workspace, and injects base64 data +so the LLM can see images from previous turns. +""" + +import base64 +import re +from pathlib import Path +from typing import Optional + +from loguru import logger +from app.config import get_settings + +IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'} +FILE_PATTERN = re.compile(r'\[file:([^\]]+)\]') +IMAGE_DATA_PATTERN = re.compile( + r'\[image_data:data:image/[^;]+;base64,[A-Za-z0-9+/=]+\]' +) +MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5MB per image + + +def rehydrate_image_messages( + messages: list[dict], + agent_id, + max_images: int = 3, +) -> list[dict]: + """Scan history for [file:xxx.jpg] and inject base64 image data for LLM. + + Only processes the most recent `max_images` user image messages + to limit context size and cost. + + Args: + messages: List of {"role": ..., "content": ...} dicts + agent_id: Agent UUID for resolving file paths + max_images: Max number of historical images to re-hydrate + + Returns: + New list with image messages enriched with base64 data. + Non-image messages and messages with existing image_data are unchanged. + """ + settings = get_settings() + upload_dir = ( + Path(settings.AGENT_DATA_DIR) / str(agent_id) / "workspace" / "uploads" + ) + + # Find user messages with [file:xxx.jpg] (newest first, skip current turn) + image_indices: list[tuple[int, str]] = [] # (index, filename) + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if msg.get("role") != "user": + continue + content = msg.get("content", "") + if not isinstance(content, str): + continue + # Skip if already has image_data (current turn) + if "[image_data:" in content: + continue + match = FILE_PATTERN.search(content) + if not match: + continue + filename = match.group(1) + ext = Path(filename).suffix.lower() + if ext not in IMAGE_EXTENSIONS: + continue + image_indices.append((i, filename)) + if len(image_indices) >= max_images: + break + + if not image_indices: + return messages + + # Re-hydrate in-place (working on a copy) + result = list(messages) + rehydrated = 0 + + for idx, filename in image_indices: + file_path = upload_dir / filename + if not file_path.exists(): + logger.warning(f"[ImageContext] File not found: {file_path}") + continue + try: + img_bytes = file_path.read_bytes() + if len(img_bytes) > MAX_IMAGE_BYTES: + logger.info( + f"[ImageContext] Skipping large image: " + f"{filename} ({len(img_bytes)} bytes)" + ) + continue + + b64 = base64.b64encode(img_bytes).decode("ascii") + ext = file_path.suffix.lower().lstrip('.') + mime = f"image/{'jpeg' if ext == 'jpg' else ext}" + marker = f"[image_data:data:{mime};base64,{b64}]" + + # Append image_data marker to existing content + old_content = result[idx]["content"] + result[idx] = {**result[idx], "content": f"{old_content}\n{marker}"} + rehydrated += 1 + logger.debug(f"[ImageContext] Re-hydrated: {filename}") + + except Exception as e: + logger.error(f"[ImageContext] Failed to read {filename}: {e}") + + if rehydrated > 0: + logger.info( + f"[ImageContext] Re-hydrated {rehydrated} image(s) " + f"for agent {agent_id}" + ) + + return result diff --git a/backend/app/services/registration_service.py b/backend/app/services/registration_service.py index b7551e6b..110400a2 100644 --- a/backend/app/services/registration_service.py +++ b/backend/app/services/registration_service.py @@ -44,8 +44,8 @@ async def detect_tenant_by_email(self, db: AsyncSession, email: str) -> Tenant | result = await db.execute( select(Tenant).where( or_( - Tenant.custom_domain.ilike(f"%{domain}%"), - Tenant.domain.ilike(f"%{domain}%"), + Tenant.sso_domain.ilike(f"%{domain}%"), + Tenant.sso_domain.ilike(f"%{domain}%"), ), Tenant.is_active == True, ) diff --git a/backend/app/services/sso_service.py b/backend/app/services/sso_service.py index fa103022..151b3768 100644 --- a/backend/app/services/sso_service.py +++ b/backend/app/services/sso_service.py @@ -87,7 +87,7 @@ async def auto_associate_tenant(self, db: AsyncSession, email: str) -> str | Non # Try to find tenant by custom domain result = await db.execute( - select(Tenant).where(Tenant.custom_domain.ilike(f"%{domain}%")) + select(Tenant).where(Tenant.sso_domain.ilike(f"%{domain}%")) ) tenant = result.scalar_one_or_none() @@ -99,7 +99,7 @@ async def auto_associate_tenant(self, db: AsyncSession, email: str) -> str | Non select(Tenant).where( or_( Tenant.name.ilike(f"%{domain.split('.')[0]}%"), - Tenant.domain.ilike(f"%{domain}%"), + Tenant.sso_domain.ilike(f"%{domain}%"), ) ) ) diff --git a/backend/app/services/tool_seeder.py b/backend/app/services/tool_seeder.py index 90e8e7e0..8c1b4ec4 100644 --- a/backend/app/services/tool_seeder.py +++ b/backend/app/services/tool_seeder.py @@ -984,6 +984,34 @@ "config": {}, "config_schema": {}, }, + { + "name": "sql_execute", + "display_name": "SQL Execute", + "description": "Connect to any SQL database and execute queries or statements. Supports MySQL, PostgreSQL, SQLite. Pass a standard connection URI and SQL statement.", + "category": "database", + "icon": "🗄️", + "is_default": False, + "parameters_schema": { + "type": "object", + "properties": { + "connection_string": { + "type": "string", + "description": "Database connection URI, e.g. mysql://user:pass@host:3306/db, postgresql://user:pass@host:5432/db, sqlite:///path/to/file.db", + }, + "sql": { + "type": "string", + "description": "SQL statement to execute (SELECT, INSERT, UPDATE, DELETE, DDL, etc.)", + }, + "timeout": { + "type": "integer", + "description": "Query timeout in seconds (default 30, max 120)", + }, + }, + "required": ["connection_string", "sql"], + }, + "config": {}, + "config_schema": {}, + }, ] # ── AgentBay Tools ────────────────────────────────────────────────────────── diff --git a/backend/app/services/trigger_daemon.py b/backend/app/services/trigger_daemon.py index 1eb699d7..29e824b4 100644 --- a/backend/app/services/trigger_daemon.py +++ b/backend/app/services/trigger_daemon.py @@ -466,11 +466,12 @@ async def on_tool_call(data): try: async with async_session() as _tc_db: if data["status"] == "running": + from app.utils.sanitize import sanitize_tool_args _tc_db.add(ChatMessage( agent_id=agent_id, conversation_id=str(session_id), role="tool_call", - content=_json.dumps({"name": data["name"], "args": data["args"]}, ensure_ascii=False, default=str), + content=_json.dumps({"name": data["name"], "args": sanitize_tool_args(data.get("args"))}, ensure_ascii=False, default=str), user_id=agent.creator_id, participant_id=agent_participant_id, )) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/utils/sanitize.py b/backend/app/utils/sanitize.py new file mode 100644 index 00000000..c91910d1 --- /dev/null +++ b/backend/app/utils/sanitize.py @@ -0,0 +1,84 @@ +"""Sanitize sensitive fields from tool call arguments before sending to clients.""" + +import re +from copy import deepcopy +from urllib.parse import urlparse, urlunparse + +# Field names whose values should be completely hidden (replaced with "******") +SENSITIVE_FIELD_NAMES = { + "password", "secret", "token", "api_key", "apikey", "api_secret", + "access_token", "refresh_token", "private_key", "secret_key", + "authorization", "credentials", "auth", + # Connection/credential strings — hide entirely, not partially + "connection_string", "database_url", "db_url", "dsn", "uri", + "connection_uri", "jdbc_url", "mongo_uri", "redis_url", +} + + +def sanitize_tool_args(args: dict | None) -> dict | None: + """Return a sanitized copy of tool call arguments. + + - Fields matching SENSITIVE_FIELD_NAMES are replaced with "******" + - Values that look like connection URIs are also replaced with "******" + - Original dict is NOT modified (returns a deep copy) + """ + if not args: + return args + + sanitized = deepcopy(args) + + for key in list(sanitized.keys()): + key_lower = key.lower() + + # Fully mask sensitive fields by name + if key_lower in SENSITIVE_FIELD_NAMES: + sanitized[key] = "******" + continue + + # Fully mask values that look like connection URIs regardless of field name + if isinstance(sanitized[key], str) and _looks_like_connection_uri(sanitized[key]): + sanitized[key] = "******" + + # Special case: hide content when writing to secrets.md + path_val = sanitized.get("path", "") or "" + if _is_secrets_file_path(path_val): + if "content" in sanitized: + sanitized["content"] = "******" + + return sanitized + + +def _is_secrets_file_path(path: str) -> bool: + """Check if a path references secrets.md.""" + normalized = path.strip("/") + return normalized == "secrets.md" or normalized.endswith("/secrets.md") + + +def _mask_uri_password(uri: str) -> str: + """Mask the password portion of a connection URI. + + mysql://user:secret123@host:3306/db -> mysql://user:******@host:3306/db + """ + try: + parsed = urlparse(uri) + if parsed.password: + # Reconstruct with masked password + netloc = parsed.hostname or "" + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + if parsed.username: + netloc = f"{parsed.username}:******@{netloc}" + return urlunparse((parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment)) + except Exception: + pass + + # Fallback: regex-based masking for non-standard URIs + return re.sub(r'(://[^:]+:)[^@]+(@)', r'\1******\2', uri) + + +def _looks_like_connection_uri(value: str) -> bool: + """Check if a string value looks like a database connection URI.""" + prefixes = ("mysql://", "postgresql://", "postgres://", "sqlite://", + "mongodb://", "redis://", "mssql://", "oracle://", + "mysql+", "postgresql+", "postgres+") + return any(value.lower().startswith(p) for p in prefixes) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ba663932..e26166e2 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,6 +8,8 @@ dependencies = [ "uvicorn[standard]>=0.30.0", "sqlalchemy[asyncio]>=2.0.0", "asyncpg>=0.30.0", + "aiomysql>=0.2.0", + "aiosqlite>=0.20.0", "alembic>=1.14.0", "redis[hiredis]>=5.0.0", "pydantic>=2.0.0", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7316ed86..9cdd72c4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-query": "^5.0.0", "i18next": "^24.0.0", "i18next-browser-languagedetector": "^8.2.1", + "lucide-react": "^1.7.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-i18next": "^15.0.0", @@ -1907,6 +1908,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 887cdde2..58ce0ca2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.0.0", "i18next": "^24.0.0", "i18next-browser-languagedetector": "^8.2.1", + "lucide-react": "^1.7.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-i18next": "^15.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4a08928..cc536be1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuthStore } from './stores'; import { useEffect, useState } from 'react'; import { authApi } from './services/api'; +import { X } from 'lucide-react'; import Login from './pages/Login'; import CompanySetup from './pages/CompanySetup'; import Layout from './pages/Layout'; @@ -67,7 +68,7 @@ function NotificationBar() { return (
{config!.text} - +
); } diff --git a/frontend/src/components/ChannelConfig.tsx b/frontend/src/components/ChannelConfig.tsx index d2fdc498..04ebebf4 100644 --- a/frontend/src/components/ChannelConfig.tsx +++ b/frontend/src/components/ChannelConfig.tsx @@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { channelApi } from '../services/api'; +import { Cloud } from 'lucide-react'; // ─── Shared fetchAuth (same as AgentDetail) ───────────── function fetchAuth(url: string, options?: RequestInit): Promise { @@ -80,7 +81,7 @@ const DingTalkIcon = DingTalk; -const AgentBayIcon = 🌩️; +const AgentBayIcon = ; // Eye icons for password toggle const EyeOpen = ; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 4813f474..aa715d07 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { AlertTriangle } from 'lucide-react'; interface Props { children?: ReactNode; @@ -32,7 +33,7 @@ class ErrorBoundary extends Component { return (

- ⚠️ Oops, something went wrong. + Oops, something went wrong.

An unexpected error occurred. You can try refreshing the page or contact support if the problem persists. diff --git a/frontend/src/components/FileBrowser.tsx b/frontend/src/components/FileBrowser.tsx index 9d852d7a..363d79c2 100644 --- a/frontend/src/components/FileBrowser.tsx +++ b/frontend/src/components/FileBrowser.tsx @@ -6,6 +6,7 @@ */ import { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { Folder, Pencil, Download, Upload } from 'lucide-react'; import MarkdownRenderer from './MarkdownRenderer'; // ─── Types ───────────────────────────────────────────── @@ -262,7 +263,7 @@ export default function FileBrowser({ style={{ cursor: 'pointer', color: 'var(--accent-primary)', fontWeight: 500 }} onClick={() => { setCurrentPath(rootPath); setViewing(null); setEditing(false); }} > - 📁 {rootPath || 'root'} + {rootPath || 'root'} {pathParts.slice(rootPath ? rootPath.split('/').filter(Boolean).length : 0).map((part, i) => { const upTo = pathParts.slice(0, (rootPath ? rootPath.split('/').filter(Boolean).length : 0) + i + 1).join('/'); @@ -401,7 +402,7 @@ export default function FileBrowser({ {isText && edit && ( !editing ? ( + onClick={() => { setEditContent(content); setEditing(true); }}> {t('agent.soul.editButton')} ) : (

+ )} {canDelete && ( @@ -452,7 +453,7 @@ export default function FileBrowser({
Binary file — cannot preview
{api.downloadUrl && ( - + )}
@@ -475,12 +476,12 @@ export default function FileBrowser({ {renderBreadcrumbs()}
{upload && api.upload && ( - + )} {newFolder && ( )} {newFile && !fileFilter && ( @@ -504,7 +505,7 @@ export default function FileBrowser({ ) : uploadProgress ? (
- + {uploadProgress.fileName} {uploadProgress.percent}%
@@ -555,7 +556,7 @@ export default function FileBrowser({ onClick={(e) => e.stopPropagation()} title={t('common.download', 'Download')} style={{ padding: '2px 6px', fontSize: '11px', color: 'var(--accent-primary)', textDecoration: 'none', borderRadius: '4px' }}> - ⬇ + )} {canDelete && ( diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 7fd3164f..472a3410 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -55,7 +55,6 @@ "language": "Language", "plaza": "Plaza" }, - "auth": { "login": "Login", "register": "Register", @@ -160,12 +159,12 @@ "creating": "Creating", "error": "Error", "disconnected": "Disconnected", - "24hActions": "⏰ 24h Actions", + "24hActions": "24h Actions", "24hActionsTooltip": "Total recorded operations in the past 24 hours, including chats, tool calls, task executions, etc.", - "llmCallsToday": "🤖 LLM Calls Today", + "llmCallsToday": "LLM Calls Today", "max": "Max", - "totalToken": "📊 Total Token", - "pending": "⏳ Pending" + "totalToken": "Total Token", + "pending": "Pending" }, "tabs": { "status": "Status", @@ -183,26 +182,26 @@ }, "fields": { "name": "Name", - "role": "👤 Role", + "role": "Role", "avatar": "Avatar", "personality": "Personality", "boundaries": "Boundaries", "lastActive": "Last Active", "tasksInProgress": "In Progress", "supervisions": "Supervisions", - "createdBy": "👨‍💼 Created by" + "createdBy": "Created by" }, "profile": { - "title": "📋 Agent Profile", - "created": "📅 Created", - "lastActive": "⏰ Last Active", - "timezone": "🌐 Timezone" + "title": "Agent Profile", + "created": "Created", + "lastActive": "Last Active", + "timezone": "Timezone" }, "modelConfig": { - "title": "🤖 Model Config", - "model": "🧠 Model", - "provider": "🏢 Provider", - "contextRounds": "🔄 Context Rounds" + "title": "Model Config", + "model": "Model", + "provider": "Provider", + "contextRounds": "Context Rounds" }, "actions": { "start": "Start", @@ -299,9 +298,10 @@ "soulDesc": "Core identity, personality, and behavior boundaries.", "memoryDesc": "Persistent memory accumulated through conversations and experiences.", "heartbeatDesc": "Instructions for periodic awareness checks. The agent reads this file during each heartbeat.", - "heartbeatTitle": "Heartbeat" + "heartbeatTitle": "Heartbeat", + "secretsTitle": "Secrets", + "secretsDesc": "Sensitive credentials (passwords, API keys, connection strings). Only visible to the agent creator." }, - "skills": { "title": "Skill Definitions", "description": "Skills define how the agent behaves in specific scenarios. Each .md file is a skill. Use YAML frontmatter (name + description) for best results.", @@ -674,7 +674,7 @@ "delete": "Delete", "edit": "Edit", "cancel": "Cancel", - "supportsVision": "👁 Supports Vision (Multimodal)", + "supportsVision": "Supports Vision (Multimodal)", "supportsVisionDesc": "— Enable for models that can analyze images (GPT-4o, Claude, Qwen-VL, etc.)", "test": "Test", "testing": "Testing...", @@ -801,7 +801,7 @@ "title": "Notification Bar", "enabled": "Enable notification bar", "text": "Notification text", - "textPlaceholder": "e.g. 🎉 v2.1 released with new features!", + "textPlaceholder": "e.g. v2.1 released with new features!", "description": "Display a notification bar at the top of the page, visible to all users.", "preview": "Preview" }, @@ -869,14 +869,14 @@ "setupGuide": "Setup Guide", "feishuPermJson": "Permission JSON (bulk import)", "feishuPermCopy": "Copy", - "feishuPermCopied": "✓ Copied", + "feishuPermCopied": "Copied", "slack": { "step1": "Go to api.slack.com/apps → click Create New App → choose From Scratch → enter an app name and pick your workspace → click Create App", "step2": "In the left sidebar, click OAuth & Permissions → scroll down to Scopes → under Bot Token Scopes click Add an OAuth Scope → add: chat:write, im:history, app_mentions:read", "step3": "Scroll back to the top of OAuth & Permissions → click Install to Workspace → click Allow → copy the Bot User OAuth Token (starts with xoxb-)", "step4": "Left sidebar → Basic Information → scroll to App Credentials → click Show next to Signing Secret → copy it", "step5": "Paste the Bot Token and Signing Secret above → click Save Config → copy the Webhook URL that appears", - "step6": "Left sidebar → Event Subscriptions → toggle Enable Events ON → paste the Webhook URL into the Request URL field (it will say Verified ✓) → under Subscribe to bot events, click Add Bot User Event and add: app_mention, message.im → click Save Changes", + "step6": "Left sidebar → Event Subscriptions → toggle Enable Events ON → paste the Webhook URL into the Request URL field (it will say Verified ) → under Subscribe to bot events, click Add Bot User Event and add: app_mention, message.im → click Save Changes", "step7": "A yellow banner will appear at the top: click reinstall your app and authorize again", "step8": "Now open a DM with your bot in Slack and send a message to test it", "note": "Your server must be publicly accessible on the internet so Slack can reach the Webhook URL." diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index df349d83..8d6cd9b5 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -166,12 +166,12 @@ "creating": "创建中", "error": "异常", "disconnected": "已断开", - "24hActions": "⏰ 24h 活动", + "24hActions": "24h 活动", "24hActionsTooltip": "过去 24 小时内该 Agent 的所有操作记录,包括对话、工具调用、任务执行等", - "llmCallsToday": "🤖 今日 LLM 调用", + "llmCallsToday": "今日 LLM 调用", "max": "上限", - "totalToken": "📊 总 Token", - "pending": "⏳ 待处理" + "totalToken": "总 Token", + "pending": "待处理" }, "tabs": { "status": "状态", @@ -189,26 +189,26 @@ }, "fields": { "name": "名称", - "role": "👤 角色", + "role": "角色", "avatar": "头像", "personality": "人格设定", "boundaries": "行为边界", "lastActive": "最后活跃", "tasksInProgress": "进行中", "supervisions": "督办", - "createdBy": "👨‍💼 创建者" + "createdBy": "创建者" }, "profile": { - "title": "📋 Agent 档案", - "created": "📅 创建时间", - "lastActive": "⏰ 最后活跃", - "timezone": "🌐 时区" + "title": "Agent 档案", + "created": "创建时间", + "lastActive": "最后活跃", + "timezone": "时区" }, "modelConfig": { - "title": "🤖 模型配置", - "model": "🧠 模型", - "provider": "🏢 提供商", - "contextRounds": "🔄 上下文轮次" + "title": "模型配置", + "model": "模型", + "provider": "提供商", + "contextRounds": "上下文轮次" }, "actions": { "start": "启动", @@ -305,9 +305,10 @@ "soulDesc": "核心身份、人格和行为边界。", "memoryDesc": "通过对话和经验积累的持久记忆。", "heartbeatDesc": "定期感知检查的指令。Agent 在每次心跳时读取此文件。", - "heartbeatTitle": "心跳" + "heartbeatTitle": "心跳", + "secretsTitle": "机密信息", + "secretsDesc": "敏感凭据(密码、API 密钥、连接字符串等)。仅创建者可查看。" }, - "skills": { "title": "技能定义", "description": "技能定义了数字员工在特定场景下的行为方式。每个.md文件是一个技能。建议使用YAML frontmatter(name + description)来定义技能元数据。", @@ -448,7 +449,7 @@ "saving": "保存中..." }, "timezone": { - "title": "🌐 时区", + "title": "时区", "description": "此数字员工的调度、活跃时间和时间感知使用的时区。如果未设置,默认为公司时区。", "current": "数字员工时区", "override": "此数字员工的自定义时区", @@ -713,7 +714,6 @@ "disable": "禁用", "exportCsv": "导出 CSV" }, - "stats": { "users": "{{count}} 用户", "runningAgents": "{{running}}/{{total}} 运行中" @@ -739,7 +739,7 @@ "delete": "删除", "edit": "编辑", "cancel": "取消", - "supportsVision": "👁 支持视觉(多模态)", + "supportsVision": "支持视觉(多模态)", "supportsVisionDesc": "— 为能够分析图像的模型启用(GPT-4o、Claude、Qwen-VL 等)", "test": "测试", "testing": "测试中...", @@ -877,7 +877,7 @@ "title": "顶部通知条", "enabled": "启用通知条", "text": "通知文案", - "textPlaceholder": "输入通知条文案,如:🎉 v2.1 已发布!", + "textPlaceholder": "输入通知条文案,如: v2.1 已发布!", "description": "在页面顶部显示一行通知条,所有用户(含未登录用户)可见。", "preview": "预览" } @@ -1013,14 +1013,14 @@ "setupGuide": "配置说明", "feishuPermJson": "权限批量导入 JSON", "feishuPermCopy": "复制", - "feishuPermCopied": "✓ 已复制", + "feishuPermCopied": "已复制", "slack": { "step1": "打开 api.slack.com/apps → 点击 Create New App → 选择 From Scratch → 填写应用名称并选择你的 Workspace → 点击 Create App", "step2": "左侧菜单点击 OAuth & Permissions → 向下滚动到 Scopes → 在 Bot Token Scopes 下点击 Add an OAuth Scope → 依次添加:chat:write、im:history、app_mentions:read", "step3": "回到 OAuth & Permissions 顶部 → 点击 Install to Workspace → 点击 Allow → 复制 Bot User OAuth Token(以 xoxb- 开头)", "step4": "左侧菜单 → Basic Information → 滚动到 App Credentials → 点击 Signing Secret 旁的 Show → 复制", "step5": "将 Bot Token 和 Signing Secret 填入上方 → 点击保存配置 → 复制出现的 Webhook URL", - "step6": "左侧菜单 → Event Subscriptions → 打开 Enable Events 开关 → 将 Webhook URL 粘贴到 Request URL 框中(显示 Verified ✓ 后继续)→ 在 Subscribe to bot events 下点击 Add Bot User Event,依次添加:app_mention、message.im → 点击 Save Changes", + "step6": "左侧菜单 → Event Subscriptions → 打开 Enable Events 开关 → 将 Webhook URL 粘贴到 Request URL 框中(显示 Verified 后继续)→ 在 Subscribe to bot events 下点击 Add Bot User Event,依次添加:app_mention、message.im → 点击 Save Changes", "step7": "页面顶部会出现黄色横幅:点击 reinstall your app 并重新授权", "step8": "在 Slack 中找到你的 Bot,发一条私信测试是否正常回复", "note": "服务器必须能被公网访问,Slack 才能将消息推送到 Webhook URL。" @@ -1113,4 +1113,4 @@ "saml": "SAML:需填写 entry_point, issuer, cert (公钥)", "microsoft_teams": "Microsoft Teams:需填写 client_id, client_secret, tenant_id" } } -} +} \ No newline at end of file diff --git a/frontend/src/pages/AdminCompanies.tsx b/frontend/src/pages/AdminCompanies.tsx index 1a45c6e1..def10d76 100644 --- a/frontend/src/pages/AdminCompanies.tsx +++ b/frontend/src/pages/AdminCompanies.tsx @@ -4,6 +4,7 @@ import { adminApi } from '../services/api'; import { useAuthStore } from '../stores'; import { saveAccentColor, getSavedAccentColor } from '../utils/theme'; import { IconFilter } from '@tabler/icons-react'; +import { ShieldCheck } from 'lucide-react'; import PlatformDashboard from './PlatformDashboard'; // Helper for authenticated JSON fetch @@ -672,7 +673,7 @@ function CompaniesTab() {
{c.sso_enabled ? ( <> - 🛡️ + {c.sso_domain && (
- {[ - { value: 'use', icon: '👁️', label: t('wizard.step4.useLevel', 'Use'), desc: t('wizard.step4.useDesc', 'Can use Task, Chat, Tools, Skills, Workspace') }, - { value: 'manage', icon: '⚙️', label: t('wizard.step4.manageLevel', 'Manage'), desc: t('wizard.step4.manageDesc', 'Full access including Settings, Mind, Relationships') }, - ].map((lvl) => ( + {([ + { value: 'use', icon: , label: t('wizard.step4.useLevel', 'Use'), desc: t('wizard.step4.useDesc', 'Can use Task, Chat, Tools, Skills, Workspace') }, + { value: 'manage', icon: , label: t('wizard.step4.manageLevel', 'Manage'), desc: t('wizard.step4.manageDesc', 'Full access including Settings, Mind, Relationships') }, + ] as const).map((lvl) => (
@@ -213,7 +214,7 @@ function ToolsManager({ agentId, canManage = false }: { agentId: string; canMana onClick={() => openConfig(tool)} style={{ background: 'none', border: '1px solid var(--border-subtle)', borderRadius: '6px', padding: '3px 8px', fontSize: '11px', cursor: 'pointer', color: 'var(--text-secondary)' }} title="Configure per-agent settings" - >⚙️ Config + >Config )} {canManage && tool.source === 'user_installed' && tool.agent_tool_id && ( + >{deletingToolId === tool.id ? '...' : } )} {canManage ? (
@@ -329,10 +330,10 @@ function ToolsManager({ agentId, canManage = false }: { agentId: string; canMana
e.stopPropagation()} style={{ background: 'var(--bg-primary)', borderRadius: '12px', padding: '24px', width: '480px', maxWidth: '95vw', maxHeight: '80vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.4)' }}>
-

⚙️ {title}

+

{title}

{isCat ? 'Shared category configuration (affects all tools in this category)' : 'Per-agent configuration (overrides global defaults)'}
- +
{fields.length > 0 ? ( @@ -831,7 +832,7 @@ function AgentDetailInner() { const location = useLocation(); const validTabs = ['status', 'aware', 'mind', 'tools', 'skills', 'relationships', 'workspace', 'chat', 'activityLog', 'approvals', 'settings']; const hashTab = location.hash?.replace('#', ''); - const [activeTab, setActiveTabRaw] = useState(hashTab && validTabs.includes(hashTab) ? hashTab : 'status'); + const [activeTab, setActiveTabRaw] = useState(hashTab && validTabs.includes(hashTab) ? hashTab : 'chat'); // Sync URL hash when tab changes const setActiveTab = (tab: string) => { @@ -1512,7 +1513,7 @@ function AgentDetailInner() { // Memoized component for each chat message to avoid re-renders while typing const ChatMessageItem = React.useMemo(() => React.memo(({ msg, i, isLeft, t }: { msg: any, i: number, isLeft: boolean, t: any }) => { const fe = msg.fileName?.split('.').pop()?.toLowerCase() ?? ''; - const fi = fe === 'pdf' ? '📄' : (fe === 'csv' || fe === 'xlsx' || fe === 'xls') ? '📊' : (fe === 'docx' || fe === 'doc') ? '📝' : '📎'; + const fi = fe === 'pdf' ? : (fe === 'csv' || fe === 'xlsx' || fe === 'xls') ? : (fe === 'docx' || fe === 'doc') ? : ; const isImage = msg.imageUrl && ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(fe); const timestampHtml = msg.timestamp ? (() => { @@ -1536,7 +1537,7 @@ function AgentDetailInner() {
{isLeft ? (msg.sender_name ? msg.sender_name[0] : 'A') : 'U'}
- {isLeft && msg.sender_name &&
🤖 {msg.sender_name}
} + {isLeft && msg.sender_name &&
{msg.sender_name}
} {isImage ? (
{msg.fileName} @@ -1549,7 +1550,7 @@ function AgentDetailInner() { ))} {msg.thinking && (
- 💭 Thinking + Thinking
{msg.thinking}
)} @@ -1616,7 +1617,7 @@ function AgentDetailInner() { let filesDisplay = ''; attachedFiles.forEach(file => { - filesDisplay += `[📎 ${file.name}] `; + filesDisplay += `[${file.name}] `; if (file.imageUrl && supportsVision) { filesPrompt += `[image_data:${file.imageUrl}]\n`; } else if (file.imageUrl) { @@ -1806,7 +1807,7 @@ function AgentDetailInner() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['schedules', id] }); - showToast('✅ Schedule triggered — executing in background', 'success'); + showToast('Schedule triggered — executing in background', 'success'); }, onError: (err: any) => { const msg = err?.response?.data?.detail || err?.message || 'Failed to trigger schedule'; @@ -2048,7 +2049,7 @@ function AgentDetailInner() { style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '11px', color: 'var(--text-tertiary)', padding: '1px 4px', borderRadius: '4px', lineHeight: 1 }} onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-secondary)')} onMouseLeave={e => (e.currentTarget.style.background = 'none')} - >✏️ {t((agent as any).expires_at || (agent as any).is_expired ? 'agent.settings.expiry.renew' : 'agent.settings.expiry.setExpiry')} + >{t((agent as any).expires_at || (agent as any).is_expired ? 'agent.settings.expiry.renew' : 'agent.settings.expiry.setExpiry')} )}

@@ -2102,19 +2103,19 @@ function AgentDetailInner() { {/* Metric cards */}
-
📋 {t('agent.tabs.status')}
+
{t('agent.tabs.status')}
{t(`agent.status.${statusKey}`)}
-
🗓️ {t('agent.settings.today')} Token
+
{t('agent.settings.today')} Token
{formatTokens(agent.tokens_used_today)}
{agent.max_tokens_per_day &&
{t('agent.settings.noLimit')} {formatTokens(agent.max_tokens_per_day)}
}
-
📅 {t('agent.settings.month')} Token
+
{t('agent.settings.month')} Token
{formatTokens(agent.tokens_used_month)}
{agent.max_tokens_per_month &&
{t('agent.settings.noLimit')} {formatTokens(agent.max_tokens_per_month)}
}
@@ -2132,7 +2133,7 @@ function AgentDetailInner() { {metrics && ( <>
-
✅ {t('agent.tasks.done')}
+
{t('agent.tasks.done')}
{metrics.tasks?.done || 0}/{metrics.tasks?.total || 0}
{metrics.tasks?.completion_rate || 0}%
@@ -2249,7 +2250,7 @@ function AgentDetailInner() { {activityLogs && activityLogs.length > 0 && (
-

📊 Recent Activity

+

Recent Activity

@@ -2958,7 +2959,7 @@ function AgentDetailInner() { {/* Soul Section */}

- 🧬 {t('agent.soul.title')} + {t('agent.soul.title')}

{t('agent.mind.soulDesc', 'Core identity, personality, and behavior boundaries.')} @@ -2969,7 +2970,7 @@ function AgentDetailInner() { {/* Memory Section */}

- 🧠 {t('agent.memory.title')} + {t('agent.memory.title')}

{t('agent.mind.memoryDesc', 'Persistent memory accumulated through conversations and experiences.')} @@ -2980,13 +2981,26 @@ function AgentDetailInner() { {/* Heartbeat Section */}

- 💓 {t('agent.mind.heartbeatTitle', 'Heartbeat')} + {t('agent.mind.heartbeatTitle', 'Heartbeat')}

{t('agent.mind.heartbeatDesc', 'Instructions for periodic awareness checks. The agent reads this file during each heartbeat.')}

+ + {/* Secrets Section — only visible to agent creator */} + {((agent as any)?.creator_id === currentUser?.id || currentUser?.role === 'platform_admin') && ( +
+

+ {t('agent.mind.secretsTitle', 'Secrets')} +

+

+ {t('agent.mind.secretsDesc', 'Sensitive credentials (passwords, API keys, connection strings). Only visible to the agent creator.')} +

+ +
+ )}
); })() @@ -3181,8 +3195,8 @@ function AgentDetailInner() {
setShowImportSkillModal(false)}>
e.stopPropagation()} style={{ background: 'var(--bg-primary)', borderRadius: '12px', padding: '24px', maxWidth: '600px', width: '90%', maxHeight: '70vh', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
-

📦 {t('agent.skills.importPreset', 'Import from Presets')}

- +

{t('agent.skills.importPreset', 'Import from Presets')}

+

{t('agent.skills.importDesc', 'Select a preset skill to import into this agent. All skill files will be copied to the agent\'s skills folder.')} @@ -3206,7 +3220,7 @@ function AgentDetailInner() { onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border-subtle)')} >

- {skill.icon || '📋'} + {skill.icon || }
{skill.name}
@@ -3214,7 +3228,7 @@ function AgentDetailInner() {
📁 {skill.folder_name} - {skill.is_default && ✓ Default} + {skill.is_default && Default}
@@ -3236,7 +3250,7 @@ function AgentDetailInner() { } }} > - {importingSkillId === skill.id ? '⏳ ...' : '⬇️ Import'} + {importingSkillId === skill.id ? '...' : <>Import}
)) @@ -3446,7 +3460,7 @@ function AgentDetailInner() { <>
- {activeSession.source_channel === 'agent' ? `🤖 Agent Conversation · ${activeSession.username || 'Agents'}` : `Read-only · ${activeSession.username || 'User'}`} + {activeSession.source_channel === 'agent' ? `Agent Conversation · ${activeSession.username || 'Agents'}` : `Read-only · ${activeSession.username || 'User'}`}
{(() => { // For A2A sessions, determine which participant is "this agent" (left side) @@ -3471,7 +3485,7 @@ function AgentDetailInner() {
- + {tName} {tArgs && typeof tArgs === 'object' && Object.keys(tArgs).length > 0 && {`(${Object.entries(tArgs).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.slice(0, 30) : JSON.stringify(v)}`).join(', ')})`}} @@ -3536,7 +3550,7 @@ function AgentDetailInner() {
- {msg.toolStatus === 'running' ? '⏳' : '⚡'} + {msg.toolStatus === 'running' ? : } {msg.toolName} {msg.toolArgs && Object.keys(msg.toolArgs).length > 0 && {`(${Object.entries(msg.toolArgs).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.slice(0, 30) : JSON.stringify(v)}`).join(', ')})`}} {msg.toolStatus === 'running' && {t('common.loading')}} @@ -3614,17 +3628,17 @@ function AgentDetailInner() { {file.imageUrl ? ( {file.name} ) : ( - 📎 + )} {file.name} - +
))}
)}
- + {uploading && uploadProgress >= 0 && (
{uploadProgress <= 100 ? ( @@ -3642,7 +3656,7 @@ function AgentDetailInner() { Processing...
)} - +
)} setChatInput(e.target.value)} @@ -3705,7 +3719,7 @@ function AgentDetailInner() { filteredLogs = activityLogs.filter((l: any) => messageTypes.includes(l.action_type)); } - const filterBtn = (key: string, label: string, indent = false) => ( + const filterBtn = (key: string, label: React.ReactNode, indent = false) => (
{(agent as any).is_expired - ? ⏰ {t('agent.settings.expiry.expired')} + ? {t('agent.settings.expiry.expired')} : (agent as any).expires_at ? <>{t('agent.settings.expiry.currentExpiry')} {new Date((agent as any).expires_at).toLocaleString(i18n.language === 'zh' ? 'zh-CN' : 'en-US')} : {t('agent.settings.expiry.neverExpires')} @@ -4730,7 +4744,7 @@ function AgentDetailInner() {
@@ -99,7 +100,7 @@ export default function CompanySetup() { {error && (
- {error} + {error}
)} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 577ff9ff..5de72d33 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -406,7 +406,7 @@ export default function Dashboard() { // Greeting const hour = new Date().getHours(); - const greeting = hour < 6 ? '🌙 ' + t('dashboard.greeting.lateNight') : hour < 12 ? '☀️ ' + t('dashboard.greeting.morning') : hour < 18 ? '🌤️ ' + t('dashboard.greeting.afternoon') : '🌙 ' + t('dashboard.greeting.evening'); + const greeting = hour < 6 ? t('dashboard.greeting.lateNight') : hour < 12 ? t('dashboard.greeting.morning') : hour < 18 ? t('dashboard.greeting.afternoon') : t('dashboard.greeting.evening'); return (
diff --git a/frontend/src/pages/EnterpriseSettings.tsx b/frontend/src/pages/EnterpriseSettings.tsx index cf658578..6b8e172e 100644 --- a/frontend/src/pages/EnterpriseSettings.tsx +++ b/frontend/src/pages/EnterpriseSettings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { Check, Globe, Eye, Pencil, Settings, User, Lightbulb, Plug, Bot, Trash2, RefreshCw, ChevronDown } from 'lucide-react'; import { enterpriseApi, skillApi } from '../services/api'; import PromptModal from '../components/PromptModal'; import FileBrowser from '../components/FileBrowser'; @@ -488,7 +489,7 @@ function OrgTab({ tenant }: { tenant: any }) {
{t('enterprise.org.orgBrowser', 'Organization Browser')}
{['feishu', 'dingtalk', 'wecom'].includes(p.provider_type) && ( )}
@@ -572,7 +573,7 @@ function OrgTab({ tenant }: { tenant: any }) { Not configured )}
- ▼ +
@@ -1215,7 +1216,7 @@ function CompanyNameEditor() { - {saved && } + {saved && }
); @@ -1276,7 +1277,7 @@ function CompanyTimezoneEditor() {
-
🌐 {t('enterprise.timezone.title', 'Company Timezone')}
+
{t('enterprise.timezone.title', 'Company Timezone')}
{t('enterprise.timezone.description', 'Default timezone for all agents. Agents can override individually.')}
@@ -1292,7 +1293,7 @@ function CompanyTimezoneEditor() { ))} - {saved && } + {saved && }
); @@ -1931,7 +1932,7 @@ export default function EnterpriseSettings() { setEditingModelId(m.id); setModelForm({ provider: m.provider, model: m.model, label: m.label, base_url: m.base_url || '', api_key: m.api_key_masked || '', supports_vision: m.supports_vision || false, max_output_tokens: m.max_output_tokens ? String(m.max_output_tokens) : '', temperature: m.temperature !== null && m.temperature !== undefined ? String(m.temperature) : '' }); setShowAddModel(true); - }} style={{ fontSize: '12px' }}>✏️ {t('enterprise.tools.edit')} + }} style={{ fontSize: '12px' }}> {t('enterprise.tools.edit')}
@@ -2012,7 +2013,7 @@ export default function EnterpriseSettings() { padding: '1px 8px', borderRadius: '4px', fontSize: '11px', fontWeight: 500, background: isBg ? 'rgba(99,102,241,0.12)' : 'rgba(34,197,94,0.12)', color: isBg ? 'var(--accent-color)' : 'rgb(34,197,94)', - }}>{isBg ? '⚙️' : '👤'} + }}>{isBg ? : } {log.action} {log.agent_id?.slice(0, 8) || '-'}
@@ -2062,9 +2063,9 @@ export default function EnterpriseSettings() { - {companyIntroSaved && ✅ {t('enterprise.config.saved', 'Saved')}} + {companyIntroSaved && {t('enterprise.config.saved', 'Saved')}} - 💡 {t('enterprise.companyIntro.hint', 'This content appears in every agent\'s system prompt')} + {t('enterprise.companyIntro.hint', 'This content appears in every agent\'s system prompt')}
@@ -2216,7 +2217,7 @@ export default function EnterpriseSettings() { - {quotaSaved && ✅ Saved} + {quotaSaved && Saved}
@@ -2254,11 +2255,11 @@ export default function EnterpriseSettings() {
- 🔌 {row.tool_display_name} + {row.tool_display_name} {row.mcp_server_name && MCP}
- 🤖 {row.installed_by_agent_name || 'Unknown Agent'} + {row.installed_by_agent_name || 'Unknown Agent'} {row.installed_at && · {new Date(row.installed_at).toLocaleString()}}
@@ -2270,7 +2271,7 @@ export default function EnterpriseSettings() { // Already deleted (e.g. removed via Global Tools) — just refresh } loadAgentInstalledTools(); - }}>🗑️ {t('enterprise.tools.delete')} + }}> {t('enterprise.tools.delete')}
))}
diff --git a/frontend/src/pages/Layout.tsx b/frontend/src/pages/Layout.tsx index 03df7eaa..8b53b027 100644 --- a/frontend/src/pages/Layout.tsx +++ b/frontend/src/pages/Layout.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Building2 } from 'lucide-react'; import { useAuthStore } from '../stores'; import { agentApi } from '../services/api'; import { diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 2e19ce1e..f51a5ea3 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { Bot, Brain, Building2, Globe, AlertTriangle } from 'lucide-react'; import { useAuthStore } from '../stores'; import { authApi, tenantApi, fetchJson } from '../services/api'; @@ -149,21 +150,21 @@ export default function Login() {

- 🤖 +
{t('login.hero.features.multiAgent.title')}
{t('login.hero.features.multiAgent.description')}
- 🧠 +
{t('login.hero.features.persistentMemory.title')}
{t('login.hero.features.persistentMemory.description')}
- 🏛️ +
{t('login.hero.features.agentPlaza.title')}
{t('login.hero.features.agentPlaza.description')}
@@ -184,7 +185,7 @@ export default function Login() { background: 'var(--bg-secondary)', border: '1px solid var(--border-subtle)', zIndex: 101, }} onClick={toggleLang}> - 🌐 +
@@ -200,7 +201,7 @@ export default function Login() { {error && (
- {error} + {error}
)} diff --git a/frontend/src/pages/Messages.tsx b/frontend/src/pages/Messages.tsx index 96466d79..7c20bbb3 100644 --- a/frontend/src/pages/Messages.tsx +++ b/frontend/src/pages/Messages.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { messageApi } from '../services/api'; const ACTION_ICONS: Record = { - text: '💬', + text: '', notify: '·', consult: '?', task_delegate: '+', diff --git a/frontend/src/pages/SSOEntry.tsx b/frontend/src/pages/SSOEntry.tsx index 101c7ae7..56f1d55a 100644 --- a/frontend/src/pages/SSOEntry.tsx +++ b/frontend/src/pages/SSOEntry.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuthStore } from '../stores'; import { fetchJson } from '../services/api'; +import { AlertTriangle } from 'lucide-react'; export default function SSOEntry() { const [searchParams] = useSearchParams(); @@ -114,7 +115,7 @@ export default function SSOEntry() { if (error) { return (
-

⚠ Error

+

Error

{error}

); diff --git a/frontend/src/pages/UserManagement.tsx b/frontend/src/pages/UserManagement.tsx index fd507068..232f1ee7 100644 --- a/frontend/src/pages/UserManagement.tsx +++ b/frontend/src/pages/UserManagement.tsx @@ -5,6 +5,7 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../stores'; +import { Pencil } from "lucide-react"; interface UserInfo { id: string; username: string; @@ -99,12 +100,12 @@ export default function UserManagement() { method: 'PATCH', body: JSON.stringify(editForm), }); - setToast(isChinese ? '✅ 配额已更新' : '✅ Quota updated'); + setToast(isChinese ? '配额已更新' : 'Quota updated'); setTimeout(() => setToast(''), 2000); setEditingUserId(null); loadUsers(); } catch (e: any) { - setToast(`❌ ${e.message}`); + setToast(`Error: ${e.message}`); setTimeout(() => setToast(''), 3000); } setSaving(false); @@ -193,7 +194,7 @@ export default function UserManagement() { {toast && (
{toast} @@ -323,7 +324,7 @@ export default function UserManagement() { style={{ padding: '4px 10px', fontSize: '11px' }} onClick={() => editingUserId === user.id ? setEditingUserId(null) : startEdit(user)} > - {editingUserId === user.id ? t('common.cancel') : `✏️ ${t('common.edit')}`} + {editingUserId === user.id ? t('common.cancel') : <> {t('common.edit')}}
From 294257dee9aee4d5ee1867be8bc939b5abbbbe67 Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Sun, 29 Mar 2026 03:50:10 +0800 Subject: [PATCH 02/73] refactor: centralize DingTalk token management with global cache - Add DingTalkTokenManager singleton with per-app_key caching - Token cached for 7200s, auto-refresh 300s before expiry - Concurrency-safe with asyncio.Lock (prevents duplicate refresh) - Replace 4 independent token functions across dingtalk_stream.py, dingtalk_reaction.py, and dingtalk_service.py - Reduces token API calls from 5-6 per message to max 1 --- backend/app/services/dingtalk_reaction.py | 29 +--------- backend/app/services/dingtalk_service.py | 29 ++-------- backend/app/services/dingtalk_stream.py | 26 ++------- backend/app/services/dingtalk_token.py | 68 +++++++++++++++++++++++ 4 files changed, 81 insertions(+), 71 deletions(-) create mode 100644 backend/app/services/dingtalk_token.py diff --git a/backend/app/services/dingtalk_reaction.py b/backend/app/services/dingtalk_reaction.py index 57c5c118..4899de78 100644 --- a/backend/app/services/dingtalk_reaction.py +++ b/backend/app/services/dingtalk_reaction.py @@ -1,31 +1,8 @@ """DingTalk emotion reaction service — "thinking" indicator on user messages.""" -import time import asyncio from loguru import logger - -_token_cache: dict[str, tuple[str, float]] = {} - - -async def _get_access_token(app_key: str, app_secret: str) -> str: - """Get DingTalk access token with caching (7200s validity, refresh at 7000s).""" - import httpx - - cache_key = app_key - cached = _token_cache.get(cache_key) - if cached and cached[1] > time.time(): - return cached[0] - - async with httpx.AsyncClient(timeout=5) as client: - resp = await client.post( - "https://api.dingtalk.com/v1.0/oauth2/accessToken", - json={"appKey": app_key, "appSecret": app_secret}, - ) - data = resp.json() - token = data.get("accessToken", "") - if token: - _token_cache[cache_key] = (token, time.time() + 7000) - return token +from app.services.dingtalk_token import dingtalk_token_manager async def add_thinking_reaction( @@ -41,7 +18,7 @@ async def add_thinking_reaction( return False try: - token = await _get_access_token(app_key, app_secret) + token = await dingtalk_token_manager.get_token(app_key, app_secret) if not token: logger.warning("[DingTalk Reaction] Failed to get access token") return False @@ -97,7 +74,7 @@ async def recall_thinking_reaction( await asyncio.sleep(delay) try: - token = await _get_access_token(app_key, app_secret) + token = await dingtalk_token_manager.get_token(app_key, app_secret) if not token: continue diff --git a/backend/app/services/dingtalk_service.py b/backend/app/services/dingtalk_service.py index 010fc56a..1072e157 100644 --- a/backend/app/services/dingtalk_service.py +++ b/backend/app/services/dingtalk_service.py @@ -6,29 +6,12 @@ async def get_dingtalk_access_token(app_id: str, app_secret: str) -> dict: - """Get DingTalk access_token using app_id and app_secret. - - API: https://open.dingtalk.com/document/orgapp/obtain-access_token - """ - url = "https://oapi.dingtalk.com/gettoken" - params = { - "appkey": app_id, - "appsecret": app_secret, - } - - async with httpx.AsyncClient(timeout=10) as client: - try: - resp = await client.get(url, params=params) - data = resp.json() - - if data.get("errcode") == 0: - return {"access_token": data.get("access_token"), "expires_in": data.get("expires_in")} - else: - logger.error(f"[DingTalk] Failed to get access_token: {data}") - return {"errcode": data.get("errcode"), "errmsg": data.get("errmsg")} - except Exception as e: - logger.error(f"[DingTalk] Network error getting access_token: {e}") - return {"errcode": -1, "errmsg": str(e)} + """Get DingTalk access_token using app_id and app_secret.""" + from app.services.dingtalk_token import dingtalk_token_manager + token = await dingtalk_token_manager.get_token(app_id, app_secret) + if token: + return {"access_token": token, "expires_in": 7200} + return {"access_token": None, "expires_in": 0} async def send_dingtalk_v1_robot_oto_message( diff --git a/backend/app/services/dingtalk_stream.py b/backend/app/services/dingtalk_stream.py index ad28e486..c11af7d7 100644 --- a/backend/app/services/dingtalk_stream.py +++ b/backend/app/services/dingtalk_stream.py @@ -19,29 +19,11 @@ from app.config import get_settings from app.database import async_session from app.models.channel_config import ChannelConfig +from app.services.dingtalk_token import dingtalk_token_manager # ─── DingTalk Media Helpers ───────────────────────────── -async def _get_dingtalk_access_token(app_key: str, app_secret: str) -> Optional[str]: - """Get DingTalk access token via OAuth2.""" - try: - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.post( - "https://api.dingtalk.com/v1.0/oauth2/accessToken", - json={"appKey": app_key, "appSecret": app_secret}, - ) - data = resp.json() - token = data.get("accessToken") - if token: - logger.debug("[DingTalk] Got access token successfully") - return token - logger.error(f"[DingTalk] Failed to get access token: {data}") - return None - except Exception as e: - logger.error(f"[DingTalk] Error getting access token: {e}") - return None - async def _get_media_download_url( access_token: str, download_code: str, robot_code: str @@ -84,7 +66,7 @@ async def _download_dingtalk_media( Steps: get access_token -> get download URL -> download file bytes. """ - access_token = await _get_dingtalk_access_token(app_key, app_secret) + access_token = await dingtalk_token_manager.get_token(app_key, app_secret) if not access_token: return None @@ -285,7 +267,7 @@ async def _upload_dingtalk_media( Returns: mediaId string on success, None on failure. """ - access_token = await _get_dingtalk_access_token(app_key, app_secret) + access_token = await dingtalk_token_manager.get_token(app_key, app_secret) if not access_token: return None @@ -346,7 +328,7 @@ async def _send_dingtalk_media_message( Returns: True on success, False on failure. """ - access_token = await _get_dingtalk_access_token(app_key, app_secret) + access_token = await dingtalk_token_manager.get_token(app_key, app_secret) if not access_token: return False diff --git a/backend/app/services/dingtalk_token.py b/backend/app/services/dingtalk_token.py new file mode 100644 index 00000000..2533e5bb --- /dev/null +++ b/backend/app/services/dingtalk_token.py @@ -0,0 +1,68 @@ +"""DingTalk access_token global cache manager. + +Caches tokens per app_key with auto-refresh before expiry. +All DingTalk token acquisition should go through this manager. +""" + +import time +import asyncio +from typing import Dict, Optional, Tuple +from loguru import logger +import httpx + + +class DingTalkTokenManager: + """Global DingTalk access_token cache. + + - Cache by app_key + - Token valid for 7200s, refresh 300s early + - Concurrency-safe with asyncio.Lock + """ + + def __init__(self): + self._cache: Dict[str, Tuple[str, float]] = {} + self._locks: Dict[str, asyncio.Lock] = {} + + def _get_lock(self, app_key: str) -> asyncio.Lock: + if app_key not in self._locks: + self._locks[app_key] = asyncio.Lock() + return self._locks[app_key] + + async def get_token(self, app_key: str, app_secret: str) -> Optional[str]: + """Get access_token, return cached if valid, refresh if expired.""" + if app_key in self._cache: + token, expires_at = self._cache[app_key] + if time.time() < expires_at - 300: + return token + + async with self._get_lock(app_key): + # Double-check after acquiring lock + if app_key in self._cache: + token, expires_at = self._cache[app_key] + if time.time() < expires_at - 300: + return token + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + "https://api.dingtalk.com/v1.0/oauth2/accessToken", + json={"appKey": app_key, "appSecret": app_secret}, + ) + data = resp.json() + token = data.get("accessToken") + expires_in = data.get("expireIn", 7200) + + if token: + self._cache[app_key] = (token, time.time() + expires_in) + logger.debug(f"[DingTalk Token] Refreshed for {app_key[:8]}..., expires in {expires_in}s") + return token + + logger.error(f"[DingTalk Token] Failed to get token: {data}") + return None + except Exception as e: + logger.error(f"[DingTalk Token] Error getting token: {e}") + return None + + +# Global singleton +dingtalk_token_manager = DingTalkTokenManager() From 27c0aa37cbe46a12879135614bb09e77e3e08d22 Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Sun, 29 Mar 2026 04:08:39 +0800 Subject: [PATCH 03/73] fix(dingtalk): remove unsupported context_size param from _call_agent_llm call _call_agent_llm (defined in feishu.py) does not accept context_size. The history is already truncated by SQL .limit(ctx_size) before the call, so passing context_size was both unnecessary and caused TypeError. --- backend/app/api/dingtalk.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/api/dingtalk.py b/backend/app/api/dingtalk.py index 85c82c4c..4f527654 100644 --- a/backend/app/api/dingtalk.py +++ b/backend/app/api/dingtalk.py @@ -380,7 +380,6 @@ async def _dingtalk_file_sender(file_path: str, msg: str = ""): reply_text = await _call_agent_llm( db, agent_id, user_text, history=history, user_id=platform_user_id, - context_size=ctx_size, ) finally: # Reset ContextVar From 860cb25b6401ced00d4026da4088c71231efed6a Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 10:53:27 +0800 Subject: [PATCH 04/73] =?UTF-8?q?fix:=20context=5Fwindow=5Fsize=20?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E7=94=9F=E6=95=88=20-=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=20=5Fcall=5Fagent=5Fllm=20=E5=86=85=E5=B1=82=20history[-10:]?= =?UTF-8?q?=20=E7=A1=AC=E6=88=AA=E6=96=AD=20-=20websocket=20=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E7=94=A8=20ctx=5Fsize=20=E6=9B=BF=E4=BB=A3=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=20.limit(20)=20-=20=E5=90=84=E9=80=9A?= =?UTF-8?q?=E9=81=93=20fallback=20=E9=BB=98=E8=AE=A4=E5=80=BC=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=B8=BA=20100?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/dingtalk.py | 2 +- backend/app/api/discord_bot.py | 2 +- backend/app/api/feishu.py | 2 +- backend/app/api/slack.py | 2 +- backend/app/api/teams.py | 2 +- backend/app/api/websocket.py | 2 +- backend/app/api/wecom.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/app/api/dingtalk.py b/backend/app/api/dingtalk.py index 4f527654..6edfea70 100644 --- a/backend/app/api/dingtalk.py +++ b/backend/app/api/dingtalk.py @@ -176,7 +176,7 @@ async def process_dingtalk_message( logger.warning(f"[DingTalk] Agent {agent_id} not found") return creator_id = agent_obj.creator_id - ctx_size = agent_obj.context_window_size if agent_obj else 20 + ctx_size = agent_obj.context_window_size if agent_obj else 100 # Determine conv_id for session isolation if conversation_type == "2": diff --git a/backend/app/api/discord_bot.py b/backend/app/api/discord_bot.py index 9481b47b..d9f5028e 100644 --- a/backend/app/api/discord_bot.py +++ b/backend/app/api/discord_bot.py @@ -300,7 +300,7 @@ async def handle_in_background(): agent_r = await bg_db.execute(select(AgentModel).where(AgentModel.id == agent_id)) agent_obj = agent_r.scalar_one_or_none() creator_id = agent_obj.creator_id if agent_obj else agent_id - ctx_size = agent_obj.context_window_size if agent_obj else 20 + ctx_size = agent_obj.context_window_size if agent_obj else 100 # Find-or-create platform user for this Discord sender from app.models.user import User as _User diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index 143068fc..57b15deb 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -1234,7 +1234,7 @@ async def _call_agent_llm(db: AsyncSession, agent_id: uuid.UUID, user_text: str, # Build conversation messages (without system prompt — call_llm adds it) messages: list[dict] = [] if history: - messages.extend(history[-10:]) + messages.extend(history) messages.append({"role": "user", "content": user_text}) # Use actual user_id so the system prompt knows who it's chatting with diff --git a/backend/app/api/slack.py b/backend/app/api/slack.py index 57eb0a8f..66428468 100644 --- a/backend/app/api/slack.py +++ b/backend/app/api/slack.py @@ -238,7 +238,7 @@ async def slack_event_webhook( agent_r = await db.execute(select(AgentModel).where(AgentModel.id == agent_id)) agent_obj = agent_r.scalar_one_or_none() creator_id = agent_obj.creator_id if agent_obj else agent_id - ctx_size = agent_obj.context_window_size if agent_obj else 20 + ctx_size = agent_obj.context_window_size if agent_obj else 100 # Find-or-create platform user for this Slack sender from app.models.user import User as _User diff --git a/backend/app/api/teams.py b/backend/app/api/teams.py index b07e7503..b8385e47 100644 --- a/backend/app/api/teams.py +++ b/backend/app/api/teams.py @@ -499,7 +499,7 @@ async def teams_event_webhook( # Load history agent_r = await db.execute(select(AgentModel).where(AgentModel.id == agent_id)) agent_obj = agent_r.scalar_one_or_none() - ctx_size = agent_obj.context_window_size if agent_obj else 20 + ctx_size = agent_obj.context_window_size if agent_obj else 100 history_r = await db.execute( select(ChatMessage) diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py index fbbc7855..a91c9112 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -547,7 +547,7 @@ async def websocket_chat( select(ChatMessage) .where(ChatMessage.agent_id == agent_id, ChatMessage.conversation_id == conv_id) .order_by(ChatMessage.created_at.desc()) - .limit(20) + .limit(ctx_size) ) history_messages = list(reversed(history_result.scalars().all())) logger.info(f"[WS] Loaded {len(history_messages)} history messages for session {conv_id}") diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py index 27e88b9e..9cd09425 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -480,7 +480,7 @@ async def _process_wecom_text( logger.warning(f"[WeCom] Agent {agent_id} not found") return creator_id = agent_obj.creator_id - ctx_size = agent_obj.context_window_size if agent_obj else 20 + ctx_size = agent_obj.context_window_size if agent_obj else 100 # Distinguish group chat from P2P by chat_id presence _is_group = bool(chat_id) From 10a88ad1b554dc5b54dc567ee8f41998d5ea4cc5 Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 11:18:45 +0800 Subject: [PATCH 05/73] =?UTF-8?q?fix:=20upstream=E6=AE=8B=E7=95=99?= =?UTF-8?q?=E9=97=AE=E9=A2=98=20-=20=E5=88=A0=E9=99=A4=E4=B8=8D=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E7=9A=84password=5Freset=5Ftoken=E6=A8=A1=E5=9E=8Bimp?= =?UTF-8?q?ort=20-=20auth.py=E8=A1=A5=E5=85=85=E7=BC=BA=E5=A4=B1=E7=9A=84u?= =?UTF-8?q?uid=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/auth.py | 1 + backend/app/main.py | 1 - backend/entrypoint.sh | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 5cc9e4b4..7613213b 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,5 +1,6 @@ """Authentication API routes.""" +import uuid from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException,Query, status diff --git a/backend/app/main.py b/backend/app/main.py index 42cd97ff..d78cb16e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -98,7 +98,6 @@ async def lifespan(app: FastAPI): import app.models.trigger # noqa import app.models.notification # noqa import app.models.gateway_message # noqa - import app.models.password_reset_token # noqa import app.models.identity # noqa async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 921cd490..53eb9385 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -47,7 +47,6 @@ async def main(): import app.models.trigger # noqa import app.models.notification # noqa import app.models.gateway_message # noqa - import app.models.password_reset_token # noqa # Create all tables that don't exist yet (safe to run on every startup) async with engine.begin() as conn: From 5f0f4486d8a69be3975c5e2523dbcf94ed433b6e Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 11:23:37 +0800 Subject: [PATCH 06/73] =?UTF-8?q?fix:=20add=5Fuser=5Fsource=20migration?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E5=8C=96=20-=20=E6=A3=80=E6=9F=A5=E5=88=97?= =?UTF-8?q?=E5=92=8C=E7=B4=A2=E5=BC=95=E6=98=AF=E5=90=A6=E5=B7=B2=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E5=86=8D=E6=B7=BB=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/alembic/versions/add_user_source.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/alembic/versions/add_user_source.py b/backend/alembic/versions/add_user_source.py index 5bf14c0c..8f469ac4 100644 --- a/backend/alembic/versions/add_user_source.py +++ b/backend/alembic/versions/add_user_source.py @@ -2,6 +2,7 @@ from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect revision = "add_user_source" down_revision = "add_llm_max_output_tokens" @@ -10,8 +11,14 @@ def upgrade(): - op.add_column("users", sa.Column("source", sa.String(50), nullable=True, server_default="web")) - op.create_index("ix_users_source", "users", ["source"]) + conn = op.get_bind() + inspector = inspect(conn) + columns = [c["name"] for c in inspector.get_columns("users")] + if "source" not in columns: + op.add_column("users", sa.Column("source", sa.String(50), nullable=True, server_default="web")) + indexes = [i["name"] for i in inspector.get_indexes("users")] + if "ix_users_source" not in indexes: + op.create_index("ix_users_source", "users", ["source"]) def downgrade(): From ac87f107353169b7c1026902976955c03c0193d8 Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 13:40:11 +0800 Subject: [PATCH 07/73] feat: implement Generic OAuth2 SSO login flow --- backend/app/api/enterprise.py | 6 ++ backend/app/api/sso.py | 90 +++++++++++++++++++++++++++ backend/app/services/auth_provider.py | 83 ++++++++++++++++++++++++ frontend/src/pages/Login.tsx | 1 + 4 files changed, 180 insertions(+) diff --git a/backend/app/api/enterprise.py b/backend/app/api/enterprise.py index 4a5c70ca..c52eb2e7 100644 --- a/backend/app/api/enterprise.py +++ b/backend/app/api/enterprise.py @@ -784,12 +784,18 @@ async def create_oauth2_provider( provider_type="oauth2", name=data.name, is_active=data.is_active, + sso_login_enabled=True, config=config, tenant_id=tid ) db.add(provider) await db.commit() await db.refresh(provider) + + # 同步 tenant SSO 状态 + if provider.tenant_id: + await _sync_tenant_sso_state(db, provider.tenant_id) + return IdentityProviderOut.model_validate(provider) diff --git a/backend/app/api/sso.py b/backend/app/api/sso.py index 3bda0302..d27522f1 100644 --- a/backend/app/api/sso.py +++ b/backend/app/api/sso.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db +from loguru import logger from app.models.identity import SSOScanSession, IdentityProvider from app.schemas.schemas import TokenResponse, UserOut @@ -136,5 +137,94 @@ async def get_sso_config(sid: uuid.UUID, db: AsyncSession = Depends(get_db)): url = f"https://open.work.weixin.qq.com/wwopen/sso/qrConnect?appid={corp_id}&agentid={agent_id}&redirect_uri={quote(redir)}&state={sid}" auth_urls.append({"provider_type": "wecom", "name": p.name, "url": url}) + elif p.provider_type == "oauth2": + from app.services.auth_registry import auth_provider_registry + auth_provider = await auth_provider_registry.get_provider( + db, "oauth2", str(session.tenant_id) if session.tenant_id else None + ) + if auth_provider: + redir = f"{public_base}/api/auth/oauth2/callback" + url = await auth_provider.get_authorization_url(redir, str(sid)) + auth_urls.append({"provider_type": "oauth2", "name": p.name, "url": url}) + return auth_urls +@router.get("/auth/oauth2/callback") +async def oauth2_callback( + code: str, + state: str = None, + db: AsyncSession = Depends(get_db), +): + """Callback for Generic OAuth2 SSO login.""" + from app.core.security import create_access_token + from fastapi.responses import HTMLResponse + from app.services.auth_registry import auth_provider_registry + + # 1. 从 state (=sid) 获取 tenant 上下文 + tenant_id = None + sid = None + if state: + try: + sid = uuid.UUID(state) + s_res = await db.execute(select(SSOScanSession).where(SSOScanSession.id == sid)) + session = s_res.scalar_one_or_none() + if session: + tenant_id = session.tenant_id + except (ValueError, AttributeError): + pass + + # 2. 获取 OAuth2 provider + auth_provider = await auth_provider_registry.get_provider( + db, "oauth2", str(tenant_id) if tenant_id else None + ) + if not auth_provider: + return HTMLResponse("Auth failed: OAuth2 provider not configured") + + # 3. 换 token → 获取用户信息 → 查找/创建用户 + try: + token_data = await auth_provider.exchange_code_for_token(code) + access_token = token_data.get("access_token") + if not access_token: + logger.error(f"OAuth2 token exchange failed: {token_data}") + return HTMLResponse("Auth failed: Token exchange error") + + user_info = await auth_provider.get_user_info(access_token) + if not user_info.provider_user_id: + logger.error(f"OAuth2 user info missing userId: {user_info.raw_data}") + return HTMLResponse("Auth failed: No user ID returned") + + user, is_new = await auth_provider.find_or_create_user( + db, user_info, tenant_id=str(tenant_id) if tenant_id else None + ) + if not user: + return HTMLResponse("Auth failed: User resolution failed") + + except Exception as e: + logger.error(f"OAuth2 login error: {e}") + return HTMLResponse(f"Auth failed: {str(e)}") + + # 4. 生成 JWT,更新 SSO session + token = create_access_token(str(user.id), user.role) + + if sid: + try: + s_res = await db.execute(select(SSOScanSession).where(SSOScanSession.id == sid)) + session = s_res.scalar_one_or_none() + if session: + session.status = "authorized" + session.provider_type = "oauth2" + session.user_id = user.id + session.access_token = token + session.error_msg = None + await db.commit() + return HTMLResponse( + f'' + f'
SSO login successful. Redirecting...
' + f'' + f'' + ) + except Exception as e: + logger.exception("Failed to update SSO session (oauth2) %s", e) + + return HTMLResponse(f"Logged in successfully.") + diff --git a/backend/app/services/auth_provider.py b/backend/app/services/auth_provider.py index d46a5a4b..72d79f5f 100644 --- a/backend/app/services/auth_provider.py +++ b/backend/app/services/auth_provider.py @@ -471,6 +471,88 @@ async def get_user_info(self, access_token: str) -> ExternalUserInfo: ) + +class OAuth2AuthProvider(BaseAuthProvider): + """Generic OAuth2 provider implementation (RFC 6749 Authorization Code flow).""" + + provider_type = "oauth2" + + def __init__(self, provider=None, config=None): + super().__init__(provider, config) + self.client_id = self.config.get("client_id") or self.config.get("app_id", "") + self.client_secret = self.config.get("client_secret") or self.config.get("app_secret", "") + self.authorize_url = self.config.get("authorize_url", "") + self.scope = self.config.get("scope", "") + + # 自动推导 token_url 和 user_info_url(如果为空) + base = self.authorize_url.rsplit("/", 1)[0] if self.authorize_url else "" + self.token_url = self.config.get("token_url") or f"{base}/token" + self.user_info_url = self.config.get("user_info_url") or f"{base}/userinfo" + + async def get_authorization_url(self, redirect_uri: str, state: str) -> str: + from urllib.parse import quote + params = ( + f"response_type=code" + f"&client_id={quote(self.client_id)}" + f"&redirect_uri={quote(redirect_uri)}" + f"&scope={quote(self.scope)}" + f"&state={state}" + ) + return f"{self.authorize_url}?{params}" + + async def exchange_code_for_token(self, code: str) -> dict: + import base64 + credentials = base64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode() + + async with httpx.AsyncClient() as client: + resp = await client.post( + self.token_url, + headers={ + "Content-Type": "application/json", + "Authorization": f"Basic {credentials}", + }, + json={ + "grant_type": "authorization_code", + "code": code, + }, + ) + if resp.status_code != 200: + logger.error(f"OAuth2 token exchange failed (HTTP {resp.status_code}): {resp.text}") + return {} + return resp.json() + + async def get_user_info(self, access_token: str) -> ExternalUserInfo: + async with httpx.AsyncClient() as client: + resp = await client.get( + self.user_info_url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + resp_data = resp.json() + + # 爷爷茶格式: {"status": 0, "data": {...}} + # 标准 OIDC 格式: 直接返回 flat object + if "data" in resp_data and isinstance(resp_data["data"], dict): + info = resp_data["data"] + else: + info = resp_data + + logger.info(f"OAuth2 user info: {info}") + + # 映射字段(兼容标准 OIDC 和爷爷茶格式) + user_id = info.get("userId") or info.get("sub") or info.get("id") or "" + name = info.get("userName") or info.get("name") or info.get("preferred_username") or "" + email = info.get("email") or "" + mobile = info.get("mobile") or info.get("phone_number") or "" + + return ExternalUserInfo( + provider_type=self.provider_type, + provider_user_id=str(user_id), + name=name, + email=email, + mobile=mobile, + raw_data=info, + ) + class MicrosoftTeamsAuthProvider(BaseAuthProvider): """Microsoft Teams OAuth provider implementation.""" @@ -492,5 +574,6 @@ async def get_user_info(self, access_token: str) -> ExternalUserInfo: "feishu": FeishuAuthProvider, "dingtalk": DingTalkAuthProvider, "wecom": WeComAuthProvider, + "oauth2": OAuth2AuthProvider, "microsoft_teams": MicrosoftTeamsAuthProvider, } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index c3e9f083..e1e2e91b 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -138,6 +138,7 @@ export default function Login() { feishu: { label: 'Feishu', icon: '/feishu.png' }, dingtalk: { label: 'DingTalk', icon: '/dingtalk.png' }, wecom: { label: 'WeCom', icon: '/wecom.png' }, + oauth2: { label: 'SSO', icon: '' }, }; return ( From 3883f1a27178a1ddbf429b8ac7d667ae5404a295 Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 13:49:43 +0800 Subject: [PATCH 08/73] =?UTF-8?q?fix:=20sso=20callback=20URL=20=E4=BB=8E?= =?UTF-8?q?=20request=20=E5=8A=A8=E6=80=81=E8=8E=B7=E5=8F=96=20scheme+host?= =?UTF-8?q?+port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/sso.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/backend/app/api/sso.py b/backend/app/api/sso.py index d27522f1..8356fd6d 100644 --- a/backend/app/api/sso.py +++ b/backend/app/api/sso.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone from urllib.parse import quote -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -75,7 +75,7 @@ async def mark_sso_session_scanned(sid: uuid.UUID, db: AsyncSession = Depends(ge return {"status": "ok"} @router.get("/sso/config") -async def get_sso_config(sid: uuid.UUID, db: AsyncSession = Depends(get_db)): +async def get_sso_config(sid: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)): """List active SSO providers with their redirect URLs for the specified session ID.""" # 1. Resolve session to get tenant context res = await db.execute(select(SSOScanSession).where(SSOScanSession.id == sid)) @@ -99,16 +99,8 @@ async def get_sso_config(sid: uuid.UUID, db: AsyncSession = Depends(get_db)): providers = result.scalars().all() # Determine the base URL for OAuth callbacks: - # If the tenant has an sso_domain configured, use it (e.g. https://default.clawith.ai) - # Otherwise, fall back to PUBLIC_BASE_URL env var - public_base = os.environ.get("PUBLIC_BASE_URL", "http://localhost:8000").rstrip("/") - if session.tenant_id: - from app.models.tenant import Tenant - tenant_result = await db.execute(select(Tenant).where(Tenant.id == session.tenant_id)) - tenant_obj = tenant_result.scalar_one_or_none() - if tenant_obj and tenant_obj.sso_domain: - # Use the tenant's SSO domain as the callback base URL - public_base = f"https://{tenant_obj.sso_domain}" + # Use the actual request origin (scheme + host + port) for accurate redirect_uri + public_base = str(request.base_url).rstrip("/") auth_urls = [] for p in providers: From c5905ee01aa677924e425dca43e5fb2aa1dc6cc6 Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 14:08:14 +0800 Subject: [PATCH 09/73] =?UTF-8?q?fix:=20OAuth2=20token=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=94=B9=E7=94=A8=20form-urlencoded=20(RFC=206749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/auth_provider.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/app/services/auth_provider.py b/backend/app/services/auth_provider.py index 72d79f5f..41a3ec4c 100644 --- a/backend/app/services/auth_provider.py +++ b/backend/app/services/auth_provider.py @@ -508,10 +508,9 @@ async def exchange_code_for_token(self, code: str) -> dict: resp = await client.post( self.token_url, headers={ - "Content-Type": "application/json", "Authorization": f"Basic {credentials}", }, - json={ + data={ "grant_type": "authorization_code", "code": code, }, From d758479f893b1de0e630b16b6462ad5f7de7efba Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 14:09:34 +0800 Subject: [PATCH 10/73] =?UTF-8?q?feat:=20OAuth2=E9=85=8D=E7=BD=AE=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E5=A2=9E=E5=8A=A0Token=20URL/UserInfo=20URL/Scope?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/EnterpriseSettings.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/EnterpriseSettings.tsx b/frontend/src/pages/EnterpriseSettings.tsx index 93b4ffa4..358a3ef5 100644 --- a/frontend/src/pages/EnterpriseSettings.tsx +++ b/frontend/src/pages/EnterpriseSettings.tsx @@ -483,7 +483,19 @@ function OrgTab({ tenant }: { tenant: any }) {
- setForm({ ...form, authorize_url: e.target.value })} /> + setForm({ ...form, authorize_url: e.target.value })} placeholder="https://sso.example.com/oauth2/authorize" /> +
+
+ + setForm({ ...form, token_url: e.target.value })} placeholder="Leave empty to auto-derive from Authorize URL" /> +
+
+ + setForm({ ...form, user_info_url: e.target.value })} placeholder="Leave empty to auto-derive from Authorize URL" /> +
+
+ + setForm({ ...form, scope: e.target.value })} placeholder="openid profile email" />
) : type === 'wecom' ? ( From 124a4edf1d3c6592ea6dc312a8e0c5967e643b1f Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 14:36:16 +0800 Subject: [PATCH 11/73] =?UTF-8?q?feat:=20=E5=9F=9F=E5=90=8D=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E7=AE=A1=E7=90=86=20-=20=E4=B8=89=E7=BA=A7=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E9=93=BE(=E7=A7=9F=E6=88=B7=E2=86=92=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E2=86=92=E8=AF=B7=E6=B1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/enterprise.py | 6 +-- backend/app/api/sso.py | 6 ++- backend/app/api/tenants.py | 60 +++++++++++++++++++++-- backend/app/core/domain.py | 47 ++++++++++++++++++ frontend/src/pages/EnterpriseSettings.tsx | 4 +- frontend/src/pages/Login.tsx | 12 ++--- 6 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 backend/app/core/domain.py diff --git a/backend/app/api/enterprise.py b/backend/app/api/enterprise.py index c52eb2e7..04096ef3 100644 --- a/backend/app/api/enterprise.py +++ b/backend/app/api/enterprise.py @@ -561,9 +561,9 @@ async def _sync_tenant_sso_state(db: AsyncSession, tenant_id: uuid.UUID): tenant.sso_enabled = active_sso_count > 0 - # Auto-assign subdomain on first SSO enablement - if tenant.sso_enabled and not tenant.sso_domain: - tenant.sso_domain = f"{tenant.slug}.clawith.ai" + # sso_domain is now optional — when empty, the fallback chain + # (global platform URL -> request URL) handles domain resolution. + # No longer auto-assign {slug}.clawith.ai await db.commit() diff --git a/backend/app/api/sso.py b/backend/app/api/sso.py index 8356fd6d..a286fc24 100644 --- a/backend/app/api/sso.py +++ b/backend/app/api/sso.py @@ -100,7 +100,11 @@ async def get_sso_config(sid: uuid.UUID, request: Request, db: AsyncSession = De # Determine the base URL for OAuth callbacks: # Use the actual request origin (scheme + host + port) for accurate redirect_uri - public_base = str(request.base_url).rstrip("/") + from app.core.domain import resolve_base_url + public_base = await resolve_base_url( + db, request=request, + tenant_id=str(session.tenant_id) if session.tenant_id else None + ) auth_urls = [] for p in providers: diff --git a/backend/app/api/tenants.py b/backend/app/api/tenants.py index f8309724..93f9ee8f 100644 --- a/backend/app/api/tenants.py +++ b/backend/app/api/tenants.py @@ -20,6 +20,7 @@ from app.database import get_db from app.models.tenant import Tenant from app.models.user import User +from app.models.system_settings import SystemSetting router = APIRouter(prefix="/tenants", tags=["tenants"]) @@ -213,6 +214,7 @@ async def resolve_tenant_by_domain( Lookup precedence: 1. Exact match on tenant.sso_domain (e.g. "acme.clawith.ai") 2. Extract slug from "{slug}.clawith.ai" and match tenant.slug + 3. Match platform global domain -> return default tenant """ # 1. Try exact sso_domain match first result = await db.execute(select(Tenant).where(Tenant.sso_domain == domain)) @@ -220,16 +222,49 @@ async def resolve_tenant_by_domain( # 2. Fallback: extract slug from subdomain pattern if not tenant: - import re m = re.match(r"^([a-z0-9][a-z0-9\-]*[a-z0-9])\.clawith\.ai$", domain.lower()) if m: slug = m.group(1) result = await db.execute(select(Tenant).where(Tenant.slug == slug)) tenant = result.scalar_one_or_none() + # 3. Match platform global domain -> return default tenant + if not tenant: + from urllib.parse import urlparse + setting_r = await db.execute( + select(SystemSetting).where(SystemSetting.key == "platform") + ) + platform = setting_r.scalar_one_or_none() + if platform and platform.value.get("public_base_url"): + parsed = urlparse(platform.value["public_base_url"]) + global_host = parsed.hostname + if parsed.port and parsed.port not in (80, 443): + global_host = f"{global_host}:{parsed.port}" + if domain == global_host: + result = await db.execute( + select(Tenant).where(Tenant.is_active == True) + .order_by(Tenant.created_at.asc()).limit(1) + ) + tenant = result.scalar_one_or_none() + if not tenant or not tenant.is_active: raise HTTPException(status_code=404, detail="Tenant not found or not active") - + + # Build effective_base_url + platform_result = await db.execute( + select(SystemSetting).where(SystemSetting.key == "platform") + ) + platform_setting = platform_result.scalar_one_or_none() + global_base_url = platform_setting.value.get("public_base_url") if platform_setting else None + + if tenant.sso_domain: + d = tenant.sso_domain + effective_base_url = d if d.startswith("http") else f"https://{d}" + elif global_base_url: + effective_base_url = global_base_url + else: + effective_base_url = None + return { "id": tenant.id, "name": tenant.name, @@ -237,6 +272,7 @@ async def resolve_tenant_by_domain( "sso_enabled": tenant.sso_enabled, "sso_domain": tenant.sso_domain, "is_active": tenant.is_active, + "effective_base_url": effective_base_url, } # ─── Authenticated: List / Get ────────────────────────── @@ -266,7 +302,25 @@ async def get_tenant( tenant = result.scalar_one_or_none() if not tenant: raise HTTPException(status_code=404, detail="Tenant not found") - return TenantOut.model_validate(tenant) + + # Build effective_base_url + platform_result = await db.execute( + select(SystemSetting).where(SystemSetting.key == "platform") + ) + platform_setting = platform_result.scalar_one_or_none() + global_base_url = platform_setting.value.get("public_base_url") if platform_setting else None + + if tenant.sso_domain: + d = tenant.sso_domain + effective_base_url = d if d.startswith("http") else f"https://{d}" + elif global_base_url: + effective_base_url = global_base_url + else: + effective_base_url = None + + out = TenantOut.model_validate(tenant).model_dump() + out["effective_base_url"] = effective_base_url + return out @router.put("/{tenant_id}", response_model=TenantOut) diff --git a/backend/app/core/domain.py b/backend/app/core/domain.py new file mode 100644 index 00000000..3ead9560 --- /dev/null +++ b/backend/app/core/domain.py @@ -0,0 +1,47 @@ +"""Domain resolution with fallback chain.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import Request + +from app.models.system_settings import SystemSetting + + +async def resolve_base_url( + db: AsyncSession, + request: Request | None = None, + tenant_id: str | None = None, +) -> str: + """Resolve the effective base URL using the fallback chain: + + 1. Tenant-specific sso_domain (if tenant_id provided and tenant has sso_domain) + 2. Platform global public_base_url (from system_settings) + 3. Request origin (from request.base_url) + 4. Hardcoded fallback + + Returns a full URL like "https://acme.example.com" or "http://localhost:3008" + """ + # Level 1: Tenant-specific domain + if tenant_id: + from app.models.tenant import Tenant + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if tenant and tenant.sso_domain: + domain = tenant.sso_domain + # sso_domain stores pure domain (with optional port), add https + return f"https://{domain}".rstrip("/") + + # Level 2: Platform global setting + result = await db.execute( + select(SystemSetting).where(SystemSetting.key == "platform") + ) + setting = result.scalar_one_or_none() + if setting and setting.value.get("public_base_url"): + return setting.value["public_base_url"].rstrip("/") + + # Level 3: Request origin + if request: + return str(request.base_url).rstrip("/") + + # Level 4: Hardcoded fallback + return "http://localhost:8000" diff --git a/frontend/src/pages/EnterpriseSettings.tsx b/frontend/src/pages/EnterpriseSettings.tsx index 358a3ef5..8164e8dc 100644 --- a/frontend/src/pages/EnterpriseSettings.tsx +++ b/frontend/src/pages/EnterpriseSettings.tsx @@ -754,7 +754,7 @@ function OrgTab({ tenant }: { tenant: any }) {
+
+ +
+ { + const v = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''); + setSubdomainPrefix(v); + setPrefixStatus('idle'); + }} + onBlur={() => checkPrefix(subdomainPrefix)} + placeholder="acme" + style={{ fontSize: '13px', maxWidth: '120px' }} + /> + {globalHostname && ( + + .{globalHostname} + + )} + {prefixStatus === 'checking' && checking...} + {prefixStatus === 'available' && Available} + {prefixStatus === 'taken' && Taken} +
+ {subdomainPrefix && globalHostname && ( +
+ {(() => { try { return new URL(publicBaseUrl).protocol + '//'; } catch { return 'https://'; } })()}{subdomainPrefix}.{globalHostname} +
+ )} +
+
{/* SSO status is now derived from per-channel toggles — no global switch */} + {/* Company URL */} + {tenant?.subdomain_prefix && ( +
+
+ {t('enterprise.org.companyUrl', 'Company URL')} +
+
+ {tenant.effective_base_url} +
+
+ )} + {/* 1. Identity Providers Section */}
From b4068f545e48f605797585b2853f0631ccdfa308 Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 15:53:18 +0800 Subject: [PATCH 16/73] feat: multi-dimension user matching for DingTalk bot messages Prevent duplicate user creation when the same person uses both DingTalk bot and SSO login. Match chain: username -> unionId (org_members) -> mobile -> email -> create new. - Add _get_corp_access_token() with in-memory cache (2h, refresh 5min early) - Add _get_dingtalk_user_detail() for corp API user lookup - Load ChannelConfig early for API calls before user matching - Graceful degradation: API failure falls back to creating new user --- backend/app/api/dingtalk.py | 172 +++++++++++++++++++++++++++++++++++- 1 file changed, 171 insertions(+), 1 deletion(-) diff --git a/backend/app/api/dingtalk.py b/backend/app/api/dingtalk.py index 98edb23f..7e8fcf26 100644 --- a/backend/app/api/dingtalk.py +++ b/backend/app/api/dingtalk.py @@ -19,6 +19,83 @@ router = APIRouter(tags=["dingtalk"]) +# --- DingTalk Corp API helpers ----------------------------------------- +import time as _time + +_corp_token_cache: dict[str, tuple[str, float]] = {} # {app_key: (token, expire_ts)} + + +async def _get_corp_access_token(app_key: str, app_secret: str) -> str | None: + """Get corp access_token with in-memory cache (2h validity, refresh 5min early).""" + import httpx + + cached = _corp_token_cache.get(app_key) + if cached and cached[1] > _time.time(): + return cached[0] + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + "https://oapi.dingtalk.com/gettoken", + params={"appkey": app_key, "appsecret": app_secret}, + ) + data = resp.json() + token = data.get("access_token") + expires_in = data.get("expires_in", 7200) + if not token: + logger.warning(f"[DingTalk] Failed to get corp access_token: {data}") + return None + _corp_token_cache[app_key] = (token, _time.time() + expires_in - 300) + return token + except Exception as e: + logger.warning(f"[DingTalk] _get_corp_access_token error: {e}") + return None + + +async def _get_dingtalk_user_detail( + app_key: str, + app_secret: str, + staff_id: str, +) -> dict | None: + """Query DingTalk user detail via corp API to get unionId/mobile/email. + + Uses /topapi/v2/user/get, requires contact.user.read permission. + Returns None on failure (graceful degradation). + """ + import httpx + + try: + access_token = await _get_corp_access_token(app_key, app_secret) + if not access_token: + return None + + async with httpx.AsyncClient(timeout=10) as client: + user_resp = await client.post( + "https://oapi.dingtalk.com/topapi/v2/user/get", + params={"access_token": access_token}, + json={"userid": staff_id, "language": "zh_CN"}, + ) + user_data = user_resp.json() + + if user_data.get("errcode") != 0: + logger.warning( + f"[DingTalk] /topapi/v2/user/get failed for {staff_id}: " + f"errcode={user_data.get('errcode')} errmsg={user_data.get('errmsg')}" + ) + return None + + result = user_data.get("result", {}) + return { + "unionid": result.get("unionid", ""), + "mobile": result.get("mobile", ""), + "email": result.get("email", "") or result.get("org_email", ""), + } + + except Exception as e: + logger.warning(f"[DingTalk] _get_dingtalk_user_detail error for {staff_id}: {e}") + return None + + # ─── Config CRUD ──────────────────────────────────────── @@ -177,11 +254,102 @@ async def process_dingtalk_message( # P2P / single chat conv_id = f"dingtalk_p2p_{sender_staff_id}" - # Find or create platform user + # -- Load ChannelConfig early for DingTalk corp API calls -- + _early_cfg_r = await db.execute( + _select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == "dingtalk", + ) + ) + _early_cfg = _early_cfg_r.scalar_one_or_none() + _early_app_key = _early_cfg.app_id if _early_cfg else None + _early_app_secret = _early_cfg.app_secret if _early_cfg else None + + # -- Multi-dimension user matching to prevent duplicate creation -- dt_username = f"dingtalk_{sender_staff_id}" + + # Step 1: Exact username match (backward compatible) u_r = await db.execute(_select(UserModel).where(UserModel.username == dt_username)) platform_user = u_r.scalar_one_or_none() + + if not platform_user and _early_app_key and _early_app_secret: + # Step 2: Call DingTalk corp API to get unionId/mobile/email + dt_user_detail = await _get_dingtalk_user_detail( + _early_app_key, _early_app_secret, sender_staff_id + ) + + if dt_user_detail: + dt_unionid = dt_user_detail.get("unionid", "") + dt_mobile = dt_user_detail.get("mobile", "") + dt_email = dt_user_detail.get("email", "") + + # Step 3: Match via org_members unionId (SSO users) + if dt_unionid and not platform_user: + from app.models.org import OrgMember + from app.models.identity import IdentityProvider + from sqlalchemy import or_ as _or + _ip_r = await db.execute( + _select(IdentityProvider).where( + IdentityProvider.provider_type == "dingtalk", + IdentityProvider.tenant_id == agent_obj.tenant_id, + ) + ) + _ip = _ip_r.scalar_one_or_none() + if _ip: + _om_r = await db.execute( + _select(OrgMember).where( + OrgMember.provider_id == _ip.id, + OrgMember.status == "active", + _or( + OrgMember.unionid == dt_unionid, + OrgMember.external_id == dt_unionid, + ), + ) + ) + _om = _om_r.scalar_one_or_none() + if _om and _om.user_id: + _u_r = await db.execute( + _select(UserModel).where(UserModel.id == _om.user_id) + ) + platform_user = _u_r.scalar_one_or_none() + if platform_user: + logger.info( + f"[DingTalk] Matched user via org_members unionid " + f"{dt_unionid}: {platform_user.username}" + ) + + # Step 4: Match via mobile + if dt_mobile and not platform_user: + _u_r = await db.execute( + _select(UserModel).where( + UserModel.primary_mobile == dt_mobile, + UserModel.tenant_id == agent_obj.tenant_id, + ) + ) + platform_user = _u_r.scalar_one_or_none() + if platform_user: + logger.info( + f"[DingTalk] Matched user via mobile {dt_mobile}: " + f"{platform_user.username}" + ) + + # Step 5: Match via email + if dt_email and not platform_user: + _u_r = await db.execute( + _select(UserModel).where( + UserModel.email == dt_email, + UserModel.tenant_id == agent_obj.tenant_id, + ) + ) + platform_user = _u_r.scalar_one_or_none() + if platform_user: + logger.info( + f"[DingTalk] Matched user via email {dt_email}: " + f"{platform_user.username}" + ) + if not platform_user: + # Step 6: No match found, create new user import uuid as _uuid platform_user = UserModel( username=dt_username, @@ -193,6 +361,8 @@ async def process_dingtalk_message( ) db.add(platform_user) await db.flush() + logger.info(f"[DingTalk] Created new user: {dt_username}") + platform_user_id = platform_user.id # Find or create session From e94536b5062f6249d3aa81002431fb2b3b2d81c0 Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 15:56:00 +0800 Subject: [PATCH 17/73] =?UTF-8?q?fix:=20registration=5Fservice=20Tenant.cu?= =?UTF-8?q?stom=5Fdomain/domain=20=E2=86=92=20sso=5Fdomain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/registration_service.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/backend/app/services/registration_service.py b/backend/app/services/registration_service.py index b7551e6b..1474d428 100644 --- a/backend/app/services/registration_service.py +++ b/backend/app/services/registration_service.py @@ -43,10 +43,7 @@ async def detect_tenant_by_email(self, db: AsyncSession, email: str) -> Tenant | # Try to find tenant by custom domain result = await db.execute( select(Tenant).where( - or_( - Tenant.custom_domain.ilike(f"%{domain}%"), - Tenant.domain.ilike(f"%{domain}%"), - ), + Tenant.sso_domain.ilike(f"%{domain}%"), Tenant.is_active == True, ) ) From d6aa285f74b24f6201c1fd108319e1c958f6494a Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 16:10:39 +0800 Subject: [PATCH 18/73] fix: add subdomain_prefix to admin companies API, remove sso_domain input from EditCompanyModal - CompanyStats model now includes subdomain_prefix field - list_companies endpoint now returns subdomain_prefix from tenant - EditCompanyModal: removed the sso_domain (Custom Access Domain) input as subdomain_prefix + global domain covers this use case - handleSave no longer sends sso_domain in payload --- backend/app/api/admin.py | 2 ++ frontend/src/pages/AdminCompanies.tsx | 12 ------------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 4f63b33a..26c71c5c 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -33,6 +33,7 @@ class CompanyStats(BaseModel): is_active: bool sso_enabled: bool = False sso_domain: str | None = None + subdomain_prefix: str | None = None created_at: datetime | None = None user_count: int = 0 agent_count: int = 0 @@ -115,6 +116,7 @@ async def list_companies( is_active=tenant.is_active, sso_enabled=tenant.sso_enabled, sso_domain=tenant.sso_domain, + subdomain_prefix=tenant.subdomain_prefix, created_at=tenant.created_at, user_count=user_count, agent_count=agent_count, diff --git a/frontend/src/pages/AdminCompanies.tsx b/frontend/src/pages/AdminCompanies.tsx index 6e7734b6..e326c9a3 100644 --- a/frontend/src/pages/AdminCompanies.tsx +++ b/frontend/src/pages/AdminCompanies.tsx @@ -793,7 +793,6 @@ function CompaniesTab() { function EditCompanyModal({ company, publicBaseUrl, onClose, onUpdated }: { company: any, publicBaseUrl: string, onClose: () => void, onUpdated: () => void }) { const { t } = useTranslation(); const [ssoEnabled, setSsoEnabled] = useState(!!company.sso_enabled); - const [ssoDomain, setSsoDomain] = useState(company.sso_domain || ''); const [subdomainPrefix, setSubdomainPrefix] = useState(company.subdomain_prefix || ''); const [prefixStatus, setPrefixStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle'); const [saving, setSaving] = useState(false); @@ -819,7 +818,6 @@ function EditCompanyModal({ company, publicBaseUrl, onClose, onUpdated }: { comp try { await adminApi.updateCompany(company.id, { sso_enabled: ssoEnabled, - sso_domain: ssoDomain.trim() || null, subdomain_prefix: subdomainPrefix.trim() || null, }); onUpdated(); @@ -905,16 +903,6 @@ function EditCompanyModal({ company, publicBaseUrl, onClose, onUpdated }: { comp )}
-
- - setSsoDomain(e.target.value)} - placeholder={t('admin.ssoDomainPlaceholder', 'e.g. acme.clawith.com')} - style={{ fontSize: '13px' }} - /> -
{error &&
{error}
} From 58793b57cc6392ccef84b5d52a7020ed8e8edbdc Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Mon, 30 Mar 2026 16:21:04 +0800 Subject: [PATCH 19/73] feat: remove manual SSO toggle from company edit modal and org settings The backend _sync_tenant_sso_state() already auto-manages sso_enabled based on active identity providers. The manual toggle was redundant and caused confusion (users could configure SSO providers but forget to enable the toggle). Changes: - AdminCompanies.tsx: remove ssoEnabled state/checkbox from EditCompanyModal, remove sso_enabled from updateCompany API call, update description text - EnterpriseSettings.tsx: remove SSO on/off toggle from OrgTab SsoStatus, keep custom domain field always visible, remove sso_enabled from save payload --- frontend/src/pages/AdminCompanies.tsx | 16 +--- frontend/src/pages/EnterpriseSettings.tsx | 99 +++++++---------------- 2 files changed, 28 insertions(+), 87 deletions(-) diff --git a/frontend/src/pages/AdminCompanies.tsx b/frontend/src/pages/AdminCompanies.tsx index e326c9a3..375ecc2b 100644 --- a/frontend/src/pages/AdminCompanies.tsx +++ b/frontend/src/pages/AdminCompanies.tsx @@ -792,7 +792,6 @@ function CompaniesTab() { // ─── Edit Company Modal ─────────────────────────────── function EditCompanyModal({ company, publicBaseUrl, onClose, onUpdated }: { company: any, publicBaseUrl: string, onClose: () => void, onUpdated: () => void }) { const { t } = useTranslation(); - const [ssoEnabled, setSsoEnabled] = useState(!!company.sso_enabled); const [subdomainPrefix, setSubdomainPrefix] = useState(company.subdomain_prefix || ''); const [prefixStatus, setPrefixStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle'); const [saving, setSaving] = useState(false); @@ -817,7 +816,6 @@ function EditCompanyModal({ company, publicBaseUrl, onClose, onUpdated }: { comp setError(''); try { await adminApi.updateCompany(company.id, { - sso_enabled: ssoEnabled, subdomain_prefix: subdomainPrefix.trim() || null, }); onUpdated(); @@ -854,22 +852,10 @@ function EditCompanyModal({ company, publicBaseUrl, onClose, onUpdated }: { comp {t('admin.ssoConfigTitle', 'SSO & Domain Configuration')}

- {t('admin.ssoConfigDesc', 'Configure SSO and custom domain for this company.')} + {t('admin.ssoConfigDesc', 'Configure company-specific access domain. SSO login options will appear automatically after configuring an identity provider in enterprise settings.')}

-
- -
-