Merge remote-tracking branch 'origin/main'

This commit is contained in:
isYangs
2026-04-02 10:40:50 +08:00
47 changed files with 2475 additions and 384 deletions

View File

@@ -41,7 +41,7 @@ and integrated Git/file/terminal workflows.
- Local conversation ingestion with structured rendering
- Parallel development with built-in `git worktree` flows
- **Project Boot** — visually scaffold new projects with live preview
- **Chat Channels** — connect Telegram, Lark (Feishu) and more to your coding agents for real-time notifications and interactive commands
- **Chat Channels** — connect Telegram, Lark (Feishu) and more to your coding agents for real-time notifications, full session interaction, and remote task control
- MCP management (local scan + registry search/install)
- Skills management (global and project scope)
- Git remote account management (GitHub and other Git servers)
@@ -69,7 +69,7 @@ Currently supports **shadcn/ui** project scaffolding, with a tab-based design re
## Chat Channels
Connect your favorite messaging apps — Telegram, Lark (Feishu), and more — to your AI coding agents. Receive real-time notifications when agents complete tasks or encounter errors, query conversation history from your phone, and get automated daily reports — all without leaving your chat app.
Connect your favorite messaging apps — Telegram, Lark (Feishu), and more — to your AI coding agents. Create tasks, send follow-up messages, approve permissions, resume sessions, and monitor activity — all from your chat app. Receive real-time agent responses with tool-call details, permission prompts, and completion summaries without ever opening a browser.
### Supported Channels
@@ -82,8 +82,10 @@ Connect your favorite messaging apps — Telegram, Lark (Feishu), and more — t
### Key Features
- **Event Notifications** — agent turn completions and errors are pushed to all enabled channels in real time
- **Interactive Commands** — send commands (`/recent`, `/search`, `/detail`, `/today`, `/status`, `/help`) from your chat app and get instant responses; configurable command prefix. Conversation-related commands (e.g. start, stop, approve) are planned for upcoming releases
- **Session Interaction** — run full agent sessions from your chat app: `/folder` to pick a project, `/agent` to choose an agent, `/task <description>` to start a task, then send follow-up messages as plain text. `/resume` to continue a previous session, `/cancel` to abort, `/sessions` to list active sessions
- **Permission Control** — agents can request tool-execution permissions in-chat; respond with `/approve` (or `/approve always` for auto-approve) and `/deny` without switching context
- **Event Notifications** — agent turn completions, tool calls, and errors are pushed to all enabled channels in real time with rich formatting
- **Query Commands** — `/search <keyword>`, `/today`, `/status`, `/help` for quick lookups; configurable command prefix
- **Daily Reports** — automated daily summary at a scheduled time, including conversation counts, agent-type breakdown, and project activity
- **Multi-Language** — message templates in 10 languages (English, Simplified/Traditional Chinese, Japanese, Korean, Spanish, German, French, Portuguese, Arabic)
- **Secure Credentials** — tokens stored in the OS keyring, never exposed in config files or logs

View File

@@ -39,7 +39,7 @@ OpenClaw، وغيرها) في تطبيق سطح مكتب أو خادم مستق
- استيعاب محلي للمحادثات مع عرض منظّم
- تطوير متوازي مع تدفقات `git worktree` مدمجة
- **مُنشئ المشروع** — إنشاء مشاريع جديدة بصريًا مع معاينة حية
- **قنوات الدردشة** — ربط Telegram وLark (Feishu) والمزيد بوكلاء البرمجة للحصول على إشعارات فورية وأوامر تفاعلية
- **قنوات الدردشة** — ربط Telegram وLark (Feishu) والمزيد بوكلاء البرمجة للتفاعل الكامل مع الجلسات والتحكم عن بُعد في المهام
- إدارة MCP (فحص محلي + بحث/تثبيت من السجل)
- إدارة Skills (نطاق عام ونطاق المشروع)
- إدارة حسابات Git البعيدة (GitHub وخوادم Git الأخرى)
@@ -67,7 +67,7 @@ OpenClaw، وغيرها) في تطبيق سطح مكتب أو خادم مستق
## قنوات الدردشة
اربط تطبيقات المراسلة المفضلة لديك — Telegram وLark (Feishu) والمزيد — بوكلاء البرمجة بالذكاء الاصطناعي. استقبل إشعارات فورية عند إكمال الوكلاء للمهام أو مواجهة أخطاء، واستعلم عن سجل المحادثات من هاتفك، واحصل على تقارير يومية تلقائية — كل ذلك دون مغادرة تطبيق الدردشة.
اربط تطبيقات المراسلة المفضلة لديك — Telegram وLark (Feishu) والمزيد — بوكلاء البرمجة بالذكاء الاصطناعي. أنشئ مهامًا، وأرسل رسائل متابعة، ووافق على الأذونات، واستأنف الجلسات، وراقب النشاط من تطبيق الدردشة — واستقبل ردود الوكلاء الفورية مع تفاصيل استدعاءات الأدوات وطلبات الأذونات وملخصات الإنجاز دون الحاجة لفتح المتصفح.
### القنوات المدعومة
@@ -80,8 +80,10 @@ OpenClaw، وغيرها) في تطبيق سطح مكتب أو خادم مستق
### الميزات الرئيسية
- **إشعارات الأحداث** — يتم دفع إتمام أدوار الوكلاء والأخطاء إلى جميع القنوات المُفعَّلة في الوقت الفعلي
- **أوامر تفاعلية** — أرسل أوامر (`/recent`، `/search`، `/detail`، `/today`، `/status`، `/help`) من تطبيق الدردشة واحصل على ردود فورية؛ بادئة الأمر قابلة للتكوين. الأوامر المتعلقة بالمحادثات (البدء، الإيقاف، الموافقة) مخططة للإصدارات القادمة
- **تفاعل الجلسات** — شغّل جلسات وكيل كاملة: `/folder` لاختيار المشروع، `/agent` لاختيار الوكيل، `/task <الوصف>` لبدء مهمة، وأرسل رسائل المتابعة كنص عادي. `/resume` لاستئناف جلسة سابقة، `/cancel` للإلغاء، `/sessions` لعرض الجلسات النشطة
- **التحكم في الأذونات** — يطلب الوكلاء أذونات تنفيذ الأدوات داخل الدردشة؛ `/approve` (أو `/approve always` للموافقة التلقائية) و`/deny`
- **إشعارات الأحداث** — يتم دفع إتمام أدوار الوكلاء واستدعاءات الأدوات والأخطاء في الوقت الفعلي بتنسيق غني
- **أوامر الاستعلام** — `/search <كلمة مفتاحية>`، `/today`، `/status`، `/help` للبحث السريع؛ بادئة الأمر قابلة للتكوين
- **التقارير اليومية** — ملخص يومي تلقائي في وقت مجدول، يشمل عدد المحادثات وتوزيع أنواع الوكلاء ونشاط المشروع
- **متعدد اللغات** — قوالب رسائل بـ 10 لغات (الإنجليزية، الصينية المبسطة/التقليدية، اليابانية، الكورية، الإسبانية، الألمانية، الفرنسية، البرتغالية، العربية)
- **بيانات اعتماد آمنة** — يتم تخزين الرموز في حلقة مفاتيح نظام التشغيل، ولا تُكشف أبدًا في ملفات التكوين أو السجلات

View File

@@ -42,7 +42,7 @@ paralleler `git worktree`-Entwicklung, MCP/Skills-Verwaltung, Chat-Kanal-Integra
- Lokale Konversationserfassung mit strukturierter Darstellung
- Parallele Entwicklung mit integrierten `git worktree`-Abläufen
- **Projekt-Starter** — neue Projekte visuell erstellen mit Live-Vorschau
- **Chat-Kanäle** — Telegram, Lark (Feishu) und mehr mit Ihren Coding-Agenten verbinden für Echtzeit-Benachrichtigungen und interaktive Befehle
- **Chat-Kanäle** — Telegram, Lark (Feishu) und mehr mit Ihren Coding-Agenten verbinden für vollständige Sitzungsinteraktion und Remote-Aufgabensteuerung
- MCP-Verwaltung (lokaler Scan + Registry-Suche/Installation)
- Skills-Verwaltung (global und projektbezogen)
- Git-Remote-Kontoverwaltung (GitHub und andere Git-Server)
@@ -70,7 +70,7 @@ Unterstützt derzeit **shadcn/ui**-Projekt-Scaffolding, mit einem Tab-basierten
## Chat-Kanäle
Verbinden Sie Ihre bevorzugten Messaging-Apps — Telegram, Lark (Feishu) und mehr — mit Ihren KI-Coding-Agenten. Erhalten Sie Echtzeit-Benachrichtigungen, wenn Agenten Aufgaben abschließen oder auf Fehler stoßen, fragen Sie den Konversationsverlauf von Ihrem Smartphone ab und erhalten Sie automatisierte Tagesberichte — alles ohne Ihre Chat-App zu verlassen.
Verbinden Sie Ihre bevorzugten Messaging-Apps — Telegram, Lark (Feishu) und mehr — mit Ihren KI-Coding-Agenten. Erstellen Sie Aufgaben, senden Sie Folgenachrichten, genehmigen Sie Berechtigungen, setzen Sie Sitzungen fort und überwachen Sie die Aktivität direkt aus dem Chat — empfangen Sie Echtzeit-Antworten der Agenten mit Tool-Call-Details, Berechtigungsanfragen und Abschlusszusammenfassungen, ohne einen Browser zu öffnen.
### Unterstützte Kanäle
@@ -83,8 +83,10 @@ Verbinden Sie Ihre bevorzugten Messaging-Apps — Telegram, Lark (Feishu) und me
### Hauptfunktionen
- **Ereignisbenachrichtigungen** — Agenten-Rundenvervollständigungen und Fehler werden in Echtzeit an alle aktivierten Kanäle gepusht
- **Interaktive Befehle** — senden Sie Befehle (`/recent`, `/search`, `/detail`, `/today`, `/status`, `/help`) aus Ihrer Chat-App und erhalten Sie sofortige Antworten; konfigurierbarer Befehlspräfix. Konversationsbezogene Befehle (Start, Stopp, Genehmigung) sind für kommende Releases geplant
- **Sitzungs-Interaktion** — vollständige Agenten-Sitzungen ausführen: `/folder` Projekt wählen, `/agent` Agent auswählen, `/task <Beschreibung>` Aufgabe starten, Folgenachrichten als Klartext senden. `/resume` vorherige Sitzung fortsetzen, `/cancel` abbrechen, `/sessions` aktive Sitzungen auflisten
- **Berechtigungssteuerung** — Agenten fordern Tool-Ausführungsberechtigungen im Chat an; `/approve` (oder `/approve always` für automatische Genehmigung) und `/deny`
- **Ereignisbenachrichtigungen** — Agenten-Rundenvervollständigungen, Tool-Calls und Fehler werden in Echtzeit mit Rich-Formatierung gepusht
- **Abfragebefehle** — `/search <Suchbegriff>`, `/today`, `/status`, `/help` für schnelle Abfragen; konfigurierbarer Befehlspräfix
- **Tagesberichte** — automatisierte tägliche Zusammenfassung zu einer geplanten Zeit, einschließlich Konversationszählung, Aufschlüsselung nach Agent-Typ und Projektaktivität
- **Mehrsprachig** — Nachrichtenvorlagen in 10 Sprachen (Englisch, vereinfachtes/traditionelles Chinesisch, Japanisch, Koreanisch, Spanisch, Deutsch, Französisch, Portugiesisch, Arabisch)
- **Sichere Anmeldedaten** — Token werden im OS-Schlüsselbund gespeichert, nie in Konfigurationsdateien oder Logs exponiert

View File

@@ -42,7 +42,7 @@ interacción con canales de chat (Telegram, Lark, etc.) y flujos integrados de G
- Ingesta local de conversaciones con renderizado estructurado
- Desarrollo paralelo con flujos integrados de `git worktree`
- **Inicio de Proyecto** — crea nuevos proyectos visualmente con vista previa en tiempo real
- **Canales de Chat** — conecta Telegram, Lark (Feishu) y más a tus agentes de codificación para notificaciones en tiempo real y comandos interactivos
- **Canales de Chat** — conecta Telegram, Lark (Feishu) y más a tus agentes de codificación para interacción completa con sesiones y control remoto de tareas
- Gestión de MCP (escaneo local + búsqueda/instalación desde registro)
- Gestión de Skills (ámbito global y por proyecto)
- Gestión de cuentas remotas de Git (GitHub y otros servidores Git)
@@ -70,7 +70,7 @@ Actualmente soporta scaffolding de proyectos **shadcn/ui**, con un diseño basad
## Canales de Chat
Conecta tus aplicaciones de mensajería favoritas — Telegram, Lark (Feishu) y más — a tus agentes de codificación IA. Recibe notificaciones en tiempo real cuando los agentes completen tareas o encuentren errores, consulta el historial de conversaciones desde tu teléfono y obtén informes diarios automatizados — todo sin salir de tu aplicación de chat.
Conecta tus aplicaciones de mensajería favoritas — Telegram, Lark (Feishu) y más — a tus agentes de codificación IA. Crea tareas, envía mensajes de seguimiento, aprueba permisos, reanuda sesiones y monitorea la actividad directamente desde el chat — recibe respuestas del agente en tiempo real con detalles de llamadas a herramientas, solicitudes de permisos y resúmenes de finalización sin necesidad de abrir un navegador.
### Canales soportados
@@ -83,12 +83,14 @@ Conecta tus aplicaciones de mensajería favoritas — Telegram, Lark (Feishu) y
### Características principales
- **Notificaciones de eventos** — las finalizaciones de turno y errores de los agentes se envían a todos los canales habilitados en tiempo real
- **Comandos interactivos** — envía comandos (`/recent`, `/search`, `/detail`, `/today`, `/status`, `/help`) desde tu app de chat y obtén respuestas instantáneas; prefijo de comando configurable. Los comandos relacionados con conversaciones (inicio, parada, aprobación) están planificados para próximas versiones
- **Interacción de sesiones** — ejecuta sesiones completas de agente: `/folder` para elegir proyecto, `/agent` para seleccionar agente, `/task <descripción>` para iniciar una tarea, envía mensajes de seguimiento como texto plano. `/resume` para continuar una sesión anterior, `/cancel` para cancelar, `/sessions` para listar las sesiones activas
- **Control de permisos** — los agentes solicitan permisos de ejecución de herramientas directamente en el chat; `/approve` (o `/approve always` para aprobación automática) y `/deny`
- **Notificaciones de eventos** — las finalizaciones de turno, llamadas a herramientas y errores de los agentes se envían en tiempo real con formato enriquecido
- **Comandos de consulta** — `/search <palabra clave>`, `/today`, `/status`, `/help` para búsquedas rápidas; prefijo de comando configurable
- **Informes diarios** — resumen diario automatizado a una hora programada, incluyendo recuento de conversaciones, desglose por tipo de agente y actividad del proyecto
- **Multi-idioma** — plantillas de mensajes en 10 idiomas (inglés, chino simplificado/tradicional, japonés, coreano, español, alemán, francés, portugués, árabe)
- **Credenciales seguras** — los tokens se almacenan en el llavero del SO, nunca se exponen en archivos de configuración ni logs
- **Mensajes enriquecidos** — formato Markdown para Telegram, diseño basado en tarjetas para Lark; respaldo en texto plano para todas las plataformas
- **Mensajes enriquecidos** — formato Markdown para Telegram, diseño de tarjetas para Lark; respaldo en texto plano para todas las plataformas
### Configuración

View File

@@ -41,7 +41,7 @@ et workflows intégrés Git/fichiers/terminal.
- Ingestion locale des conversations avec rendu structuré
- Développement parallèle avec flux `git worktree` intégré
- **Lanceur de projet** — créez visuellement de nouveaux projets avec aperçu en temps réel
- **Canaux de chat** — connectez Telegram, Lark (Feishu) et plus à vos agents de codage pour des notifications en temps réel et des commandes interactives
- **Canaux de chat** — connectez Telegram, Lark (Feishu) et plus à vos agents de codage pour une interaction complète avec les sessions et le contrôle à distance des tâches
- Gestion MCP (scan local + recherche/installation depuis le registre)
- Gestion des Skills (portée globale et projet)
- Gestion des comptes distants Git (GitHub et autres serveurs Git)
@@ -69,7 +69,7 @@ Prend actuellement en charge le scaffolding de projets **shadcn/ui**, avec un de
## Canaux de chat
Connectez vos applications de messagerie préférées — Telegram, Lark (Feishu) et plus — à vos agents de codage IA. Recevez des notifications en temps réel lorsque les agents terminent des tâches ou rencontrent des erreurs, consultez l'historique des conversations depuis votre téléphone et obtenez des rapports quotidiens automatisés — le tout sans quitter votre application de chat.
Connectez vos applications de messagerie préférées — Telegram, Lark (Feishu) et plus — à vos agents de codage IA. Créez des tâches, envoyez des messages de suivi, approuvez les permissions, reprenez des sessions et surveillez l'activité directement depuis votre chat — recevez les réponses des agents en temps réel avec les détails des appels d'outils, les demandes de permissions et les résumés de complétion, le tout sans ouvrir de navigateur.
### Canaux pris en charge
@@ -82,8 +82,10 @@ Connectez vos applications de messagerie préférées — Telegram, Lark (Feishu
### Fonctionnalités clés
- **Notifications d'événements** — les complétions de tour et les erreurs des agents sont poussées vers tous les canaux activés en temps réel
- **Commandes interactives** — envoyez des commandes (`/recent`, `/search`, `/detail`, `/today`, `/status`, `/help`) depuis votre application de chat et obtenez des réponses instantanées ; préfixe de commande configurable. Les commandes liées aux conversations (démarrage, arrêt, approbation) sont prévues pour les prochaines versions
- **Interaction de session** — exécutez des sessions complètes avec les agents : `/folder` pour choisir le projet, `/agent` pour sélectionner l'agent, `/task <description>` pour lancer une tâche, envoyez des messages de suivi en texte libre. `/resume` pour reprendre une session précédente, `/cancel` pour annuler, `/sessions` pour lister les sessions actives
- **Contrôle des permissions** — les agents demandent les permissions d'exécution d'outils directement dans le chat ; `/approve` (ou `/approve always` pour l'approbation automatique) et `/deny`
- **Notifications d'événements** — les complétions de tour, les appels d'outils et les erreurs des agents sont poussés en temps réel avec un formatage enrichi
- **Commandes de requête** — `/search <mot-clé>`, `/today`, `/status`, `/help` pour des consultations rapides ; préfixe de commande configurable
- **Rapports quotidiens** — résumé quotidien automatisé à une heure programmée, incluant le nombre de conversations, la répartition par type d'agent et l'activité du projet
- **Multi-langue** — modèles de messages en 10 langues (anglais, chinois simplifié/traditionnel, japonais, coréen, espagnol, allemand, français, portugais, arabe)
- **Identifiants sécurisés** — les tokens sont stockés dans le trousseau du système d'exploitation, jamais exposés dans les fichiers de configuration ou les logs

View File

@@ -39,7 +39,7 @@ Claude Code、Codex CLI、OpenCode、Gemini CLI、OpenClaw などのローカル
- ローカル会話の取り込みと構造化レンダリング
- 内蔵 `git worktree` フローによる並列開発
- **プロジェクトブート** — ビジュアル設定とライブプレビューで新規プロジェクトを作成
- **チャットチャンネル** — Telegram、LarkFeishuなどをコーディング Agent に接続し、リアルタイム通知やインタラクティブコマンドを利用
- **チャットチャンネル** — Telegram、LarkFeishuなどをコーディング Agent に接続し、チャットからフルセッション操作やリモートタスク制御を実行
- MCP 管理(ローカルスキャン + レジストリ検索/インストール)
- Skills 管理(グローバルおよびプロジェクトスコープ)
- Git リモートアカウント管理GitHub およびその他の Git サーバー)
@@ -67,7 +67,7 @@ Claude Code、Codex CLI、OpenCode、Gemini CLI、OpenClaw などのローカル
## チャットチャンネル
お気に入りのメッセージングアプリ — Telegram、LarkFeishuなど — を AI コーディング Agent に接続。Agent がタスクを完了したりエラーが発生した際にリアルタイム通知を受信し、スマートフォンから会話履歴を検索し、自動日次レポートを取得 — チャットアプリを離れることなくすべて対応可能。
お気に入りのメッセージングアプリ — Telegram、LarkFeishuなど — を AI コーディング Agent に接続。チャットからタスクの作成、フォローアップメッセージの送信、権限の承認、セッションの再開、アクティビティの監視が可能です。Agent のレスポンスはツールコール詳細、権限プロンプト、完了サマリーとともにリアルタイムで受信 — ブラウザを開くことなくすべて対応可能。
### 対応チャンネル
@@ -80,8 +80,10 @@ Claude Code、Codex CLI、OpenCode、Gemini CLI、OpenClaw などのローカル
### 主な機能
- **イベント通知** — Agent のターン完了やエラーがすべての有効なチャンネルにリアルタイムでプッシュ
- **インタラクティブコマンド** — チャットアプリからコマンド(`/recent``/search``/detail``/today``/status``/help`)を送信し即座に応答を取得。コマンドプレフィックスの設定が可能。会話関連コマンド(開始、停止、承認など)は今後のリリースで対応予定
- **セッション操作** — チャットからフル Agent セッションを実行:`/folder` でプロジェクト選択、`/agent` で Agent 選択、`/task <説明>` でタスク開始、プレーンテキストでフォローアップ送信。`/resume` で前回セッションを継続、`/cancel` で中止、`/sessions` でアクティブセッション一覧を表示
- **権限制御** — Agent がツール実行権限をチャット内でリクエスト。`/approve`(または `/approve always` で自動承認)と `/deny` で応答
- **イベント通知** — Agent のターン完了、ツールコール、エラーがリッチフォーマットでリアルタイムにプッシュ
- **クエリコマンド** — `/search <キーワード>``/today``/status``/help` でクイック検索。コマンドプレフィックスの設定が可能
- **日次レポート** — 予定された時刻に自動日次サマリーを生成会話数、Agent タイプ別内訳、プロジェクトアクティビティを含む)
- **多言語対応** — メッセージテンプレートは 10 言語に対応(英語、簡体字/繁体字中国語、日本語、韓国語、スペイン語、ドイツ語、フランス語、ポルトガル語、アラビア語)
- **セキュアな認証情報** — トークンは OS キーリングに保存され、設定ファイルやログに公開されません

View File

@@ -39,7 +39,7 @@ Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw 등 로컬 AI 코딩 Agen
- 로컬 대화 수집 및 구조화 렌더링
- 내장 `git worktree` 플로를 통한 병렬 개발
- **프로젝트 부트** — 시각적 설정과 실시간 미리보기로 새 프로젝트 생성
- **채팅 채널** — Telegram, Lark(Feishu) 등을 코딩 에이전트에 연결하여 실시간 알림 및 대화형 명령 사용
- **채팅 채널** — Telegram, Lark(Feishu) 등을 코딩 에이전트에 연결하여 전체 세션 상호작용 및 원격 작업 제어
- MCP 관리 (로컬 스캔 + 레지스트리 검색/설치)
- Skills 관리 (글로벌 및 프로젝트 범위)
- Git 원격 계정 관리 (GitHub 및 기타 Git 서버)
@@ -67,7 +67,7 @@ Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw 등 로컬 AI 코딩 Agen
## 채팅 채널
즐겨 사용하는 메신저 앱 — Telegram, Lark(Feishu) 등 — 을 AI 코딩 에이전트에 연결하세요. 에이전트가 작업을 완료하거나 오류가 발생하면 실시간 알림을 받고, 스마트폰에서 대화 기록을 조회하고, 자동화된 일일 리포트를 받을 수 있습니다 — 채팅 앱을 떠나지 않고도 모든 것이 가능합니다.
즐겨 사용하는 메신저 앱 — Telegram, Lark(Feishu) 등 — 을 AI 코딩 에이전트에 연결하세요. 채팅에서 직접 작업을 생성하고, 후속 메시지를 보내고, 권한을 승인하고, 세션을 재개하고, 활동을 모니터링할 수 있습니다 — 도구 호출 상세 정보, 권한 프롬프트, 완료 요약이 포함된 실시간 에이전트 응답을 브라우저를 열지 않고도 받을 수 있습니다.
### 지원 채널
@@ -80,8 +80,10 @@ Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw 등 로컬 AI 코딩 Agen
### 주요 기능
- **이벤트 알림** — 에이전트의 턴 완료 및 오류가 활성화된 모든 채널에 실시간으로 푸시
- **대화형 명령** — 채팅 앱에서 명령(`/recent`, `/search`, `/detail`, `/today`, `/status`, `/help`)을 보내고 즉시 응답을 받을 수 있으며, 명령 접두사 설정 가능. 대화 관련 명령(시작, 중지, 승인 등)은 향후 릴리스에서 지원 예정
- **세션 상호작용** — 전체 에이전트 세션 실행: `/folder`로 프로젝트 선택, `/agent`로 에이전트 선택, `/task <설명>`으로 작업 시작, 일반 텍스트로 후속 메시지 전송. `/resume`으로 이전 세션 재개, `/cancel`로 중단, `/sessions`로 활성 세션 목록 확인
- **권한 제어** — 에이전트가 채팅에서 도구 실행 권한을 요청; `/approve`(또는 `/approve always`로 자동 승인) 및 `/deny`로 응답
- **이벤트 알림** — 에이전트 턴 완료, 도구 호출, 오류가 리치 포맷으로 실시간 푸시
- **조회 명령어** — `/search <키워드>`, `/today`, `/status`, `/help`로 빠른 조회; 명령 접두사 설정 가능
- **일일 리포트** — 예약된 시간에 자동 일일 요약 생성(대화 수, 에이전트 유형별 분석, 프로젝트 활동 포함)
- **다국어 지원** — 10개 언어의 메시지 템플릿(영어, 간체/번체 중국어, 일본어, 한국어, 스페인어, 독일어, 프랑스어, 포르투갈어, 아랍어)
- **보안 자격증명** — 토큰은 OS 키링에 저장되며 설정 파일이나 로그에 노출되지 않음

View File

@@ -41,7 +41,7 @@ e fluxos integrados de Git/arquivos/terminal.
- Ingestão local de conversas com renderização estruturada
- Desenvolvimento paralelo com fluxos `git worktree` integrados
- **Inicializador de Projeto** — crie novos projetos visualmente com pré-visualização em tempo real
- **Canais de Chat** — conecte Telegram, Lark (Feishu) e mais aos seus agentes de codificação para notificações em tempo real e comandos interativos
- **Canais de Chat** — conecte Telegram, Lark (Feishu) e mais aos seus agentes de codificação para interação completa de sessão e controle remoto de tarefas
- Gerenciamento de MCP (varredura local + busca/instalação no registro)
- Gerenciamento de Skills (escopo global e por projeto)
- Gerenciamento de contas remotas Git (GitHub e outros servidores Git)
@@ -69,7 +69,7 @@ Atualmente suporta scaffolding de projetos **shadcn/ui**, com um design baseado
## Canais de Chat
Conecte seus aplicativos de mensagens favoritos — Telegram, Lark (Feishu) e mais — aos seus agentes de codificação IA. Receba notificações em tempo real quando os agentes concluírem tarefas ou encontrarem erros, consulte o histórico de conversas pelo celular e receba relatórios diários automatizados — tudo sem sair do seu app de chat.
Conecte seus aplicativos de mensagens favoritos — Telegram, Lark (Feishu) e mais — aos seus agentes de codificação IA. Crie tarefas, envie mensagens de acompanhamento, aprove permissões, retome sessões e monitore a atividade diretamente do chat — recebendo respostas do agente em tempo real com detalhes de chamadas de ferramentas, prompts de permissão e resumos de conclusão, tudo sem abrir o navegador.
### Canais suportados
@@ -82,8 +82,10 @@ Conecte seus aplicativos de mensagens favoritos — Telegram, Lark (Feishu) e ma
### Recursos principais
- **Notificações de eventos** — conclusões de turno e erros dos agentes são enviados a todos os canais habilitados em tempo real
- **Comandos interativos** — envie comandos (`/recent`, `/search`, `/detail`, `/today`, `/status`, `/help`) do seu app de chat e receba respostas instantâneas; prefixo de comando configurável. Comandos relacionados a conversas (iniciar, parar, aprovar) estão planejados para próximas versões
- **Interação de sessão** — execute sessões completas de agente: `/folder` para selecionar o projeto, `/agent` para escolher o agente, `/task <descrição>` para iniciar uma tarefa, envie mensagens de acompanhamento como texto simples. `/resume` para continuar uma sessão anterior, `/cancel` para cancelar, `/sessions` para listar sessões ativas
- **Controle de permissões** — os agentes solicitam permissões de execução de ferramentas diretamente no chat; `/approve` (ou `/approve always` para aprovação automática) e `/deny`
- **Notificações de eventos** — conclusões de turno, chamadas de ferramentas e erros dos agentes são enviados em tempo real com formatação rica
- **Comandos de consulta** — `/search <palavra-chave>`, `/today`, `/status`, `/help` para consultas rápidas; prefixo de comando configurável
- **Relatórios diários** — resumo diário automatizado em um horário programado, incluindo contagem de conversas, divisão por tipo de agente e atividade do projeto
- **Multi-idioma** — templates de mensagens em 10 idiomas (inglês, chinês simplificado/tradicional, japonês, coreano, espanhol, alemão, francês, português, árabe)
- **Credenciais seguras** — tokens armazenados no chaveiro do SO, nunca expostos em arquivos de configuração ou logs

View File

@@ -39,7 +39,7 @@ OpenClaw、Cline 等)统一到桌面应用、独立服务器或 Docker 容器
- 本地对话解析与结构化渲染
- 内置 `git worktree` 并行开发流程
- **项目启动器** — 可视化创建新项目,实时预览效果
- **消息渠道** — 连接 Telegram、飞书等即时通讯应用到编码代理实时接收通知并进行交互式命令操作
- **消息渠道** — 连接 Telegram、飞书等即时通讯应用到编码代理实时接收通知、完整会话交互、远程任务控制
- MCP 管理(本地扫描 + 市场搜索/安装)
- Skills 管理(全局与项目级)
- Git 远程账号管理(支持 GitHub 及其它 Git 服务器)
@@ -67,7 +67,7 @@ OpenClaw、Cline 等)统一到桌面应用、独立服务器或 Docker 容器
## 消息渠道
连接你喜爱的即时通讯应用——Telegram、飞书等——到 AI 编码代理。当代理完成任务或遇到错误时接收实时通知,通过手机查询对话历史,获取自动化日报——无需离开聊天应用
连接你喜爱的即时通讯应用——Telegram、飞书等——到 AI 编码代理。直接在聊天中创建任务、发送后续消息、审批权限、恢复会话、监控活动。实时接收代理响应(包含工具调用详情、权限提示和完成摘要),无需打开浏览器
### 支持的渠道
@@ -80,8 +80,10 @@ OpenClaw、Cline 等)统一到桌面应用、独立服务器或 Docker 容器
### 主要功能
- **事件通知** — 代理的回合完成和错误事件实时推送到所有已启用的渠道
- **交互式命令** — 从聊天应用发送命令(`/recent``/search``/detail``/today``/status``/help`)并获得即时响应;支持自定义命令前缀。对话相关命令(如启动、停止、审批)将在后续版本中支持
- **会话交互** — 在聊天应用中运行完整代理会话:`/folder` 选择项目、`/agent` 选择代理、`/task <描述>` 启动任务,然后直接发送纯文本作为后续消息。`/resume` 恢复历史会话、`/cancel` 取消任务、`/sessions` 查看活跃会话
- **权限控制** — 代理可在聊天中请求工具执行权限;使用 `/approve`(或 `/approve always` 自动审批)和 `/deny` 响应,无需切换上下文
- **事件通知** — 代理回合完成、工具调用和错误事件实时推送到所有已启用渠道,支持富文本格式
- **查询命令** — `/search <关键词>``/today``/status``/help` 快速查询;支持自定义命令前缀
- **每日报告** — 在预设时间自动生成每日摘要,包括对话数量、代理类型分布和项目活跃度
- **多语言** — 消息模板支持 10 种语言(英语、简体中文/繁体中文、日语、韩语、西班牙语、德语、法语、葡萄牙语、阿拉伯语)
- **安全凭据** — 令牌存储在操作系统密钥环中,不会暴露在配置文件或日志中

View File

@@ -39,7 +39,7 @@ OpenClaw、Cline 等)整合到桌面應用、獨立伺服器或 Docker 容器
- 本地對話解析與結構化渲染
- 內建 `git worktree` 並行開發流程
- **專案啟動器** — 視覺化建立新專案,即時預覽效果
- **訊息渠道** — 連接 Telegram、飛書等即時通訊應用到編碼代理即時接收通知並進行互動式命令操作
- **訊息渠道** — 連接 Telegram、飛書等即時通訊應用到編碼代理完整會話交互、遠端任務控制
- MCP 管理(本地掃描 + 市場搜尋/安裝)
- Skills 管理(全域與專案級)
- Git 遠端帳號管理(支援 GitHub 及其他 Git 伺服器)
@@ -67,7 +67,7 @@ OpenClaw、Cline 等)整合到桌面應用、獨立伺服器或 Docker 容器
## 訊息渠道
連接你喜愛的即時通訊應用——Telegram、飛書等——到 AI 編碼代理。當代理完成任務或遇到錯誤時接收即時通知,透過手機查詢對話歷史,取得自動化日報——無需離開聊天應用
連接你喜愛的即時通訊應用——Telegram、飛書等——到 AI 編碼代理。直接在聊天中建立任務、發送後續訊息、審批權限請求、恢復會話、監控代理活動——即時接收代理回應,包含工具呼叫詳情、權限提示和完成摘要
### 支援的渠道
@@ -80,8 +80,10 @@ OpenClaw、Cline 等)整合到桌面應用、獨立伺服器或 Docker 容器
### 主要功能
- **事件通知** — 代理的回合完成和錯誤事件即時推送到所有已啟用的渠道
- **互動式命令** — 從聊天應用發送命令(`/recent``/search``/detail``/today``/status``/help`)並獲得即時回應;支援自訂命令前綴。對話相關命令(如啟動、停止、審批)將在後續版本中支援
- **會話交互** — 執行完整的代理會話:`/folder` 選擇專案、`/agent` 選擇代理、`/task <描述>` 啟動任務,直接發送純文字作為後續訊息。`/resume` 繼續上次會話、`/cancel` 中止任務、`/sessions` 列出活躍會話
- **權限控制** — 代理在聊天中請求工具執行權限;使用 `/approve`(或 `/approve always` 啟用自動審批)和 `/deny` 進行回應
- **事件通知** — 代理回合完成、工具呼叫和錯誤事件即時推送,支援豐富格式展示
- **查詢命令** — `/search <關鍵字>``/today``/status``/help` 快速查詢;支援自訂命令前綴
- **每日報告** — 在預設時間自動產生每日摘要,包括對話數量、代理類型分佈和專案活躍度
- **多語言** — 訊息範本支援 10 種語言(英語、簡體中文/繁體中文、日語、韓語、西班牙語、德語、法語、葡萄牙語、阿拉伯語)
- **安全憑據** — 令牌儲存在作業系統密鑰環中,不會暴露在設定檔或日誌中

View File

@@ -1,7 +1,7 @@
{
"name": "codeg",
"private": true,
"version": "0.6.2",
"version": "0.6.3",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",

2
src-tauri/Cargo.lock generated
View File

@@ -847,7 +847,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "codeg"
version = "0.6.2"
version = "0.6.3"
dependencies = [
"agent-client-protocol-schema",
"async-trait",

View File

@@ -1,6 +1,6 @@
[package]
name = "codeg"
version = "0.6.2"
version = "0.6.3"
description = "Agent Code Generation App"
authors = ["feitao"]
edition = "2021"

View File

@@ -53,7 +53,12 @@ async fn main() {
// Start chat channel background tasks (event subscriber, command dispatcher, scheduler, auto-connect)
state
.chat_channel_manager
.start_background(state.event_broadcaster.clone(), state.db.conn.clone())
.start_background(
state.event_broadcaster.clone(),
state.db.conn.clone(),
state.connection_manager.clone_ref(),
state.emitter.clone(),
)
.await;
// Build router

View File

@@ -162,6 +162,9 @@ impl ChatChannelBackend for TelegramBackend {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(updates) = body.get("result").and_then(|r| r.as_array()) {
if !updates.is_empty() {
eprintln!("[Telegram] got {} update(s)", updates.len());
}
for update in updates {
if let Some(uid) =
update.get("update_id").and_then(|u| u.as_i64())
@@ -184,6 +187,7 @@ impl ChatChannelBackend for TelegramBackend {
let at_bot =
format!("@{}", bot_username);
if !text.to_lowercase().contains(&at_bot) {
eprintln!("[Telegram] skipped group msg without @bot: {text}");
continue;
}
}
@@ -196,7 +200,8 @@ impl ChatChannelBackend for TelegramBackend {
.and_then(|i| i.as_i64())
.map(|i| i.to_string())
.unwrap_or_default();
let _ = command_tx
eprintln!("[Telegram] dispatching: {clean_text}");
let send_result = command_tx
.send(IncomingCommand {
channel_id,
sender_id,
@@ -204,9 +209,16 @@ impl ChatChannelBackend for TelegramBackend {
metadata: update.clone(),
})
.await;
if let Err(e) = send_result {
eprintln!("[Telegram] command_tx.send failed: {e}");
}
} else {
eprintln!("[Telegram] update without /message/text");
}
}
}
} else {
eprintln!("[Telegram] failed to parse response body");
}
}
Err(e) => {

View File

@@ -1,14 +1,19 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use sea_orm::DatabaseConnection;
use tokio::sync::mpsc;
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinHandle;
use super::command_handlers;
use super::i18n::{self, Lang};
use super::manager::ChatChannelManager;
use super::session_bridge::SessionBridge;
use super::session_commands;
use super::types::IncomingCommand;
use crate::acp::manager::ConnectionManager;
use crate::db::service::{app_metadata_service, chat_channel_message_log_service};
use crate::web::event_bridge::EventEmitter;
const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
const DEFAULT_COMMAND_PREFIX: &str = "/";
@@ -52,12 +57,19 @@ pub fn spawn_command_dispatcher(
mut command_rx: mpsc::Receiver<IncomingCommand>,
manager: ChatChannelManager,
db_conn: DatabaseConnection,
conn_mgr: ConnectionManager,
emitter: EventEmitter,
bridge: Arc<Mutex<SessionBridge>>,
) -> JoinHandle<()> {
tokio::spawn(async move {
let mut config = CommandConfigCache::new();
while let Some(cmd) = command_rx.recv().await {
let text = cmd.command_text.trim();
eprintln!(
"[ChatChannel] received command from channel={} sender={}: {:?}",
cmd.channel_id, cmd.sender_id, text
);
// Log inbound command
let _ = chat_channel_message_log_service::create_log(
@@ -73,7 +85,25 @@ pub fn spawn_command_dispatcher(
config.refresh_if_needed(&db_conn).await;
let response = dispatch_command(text, &config.prefix, &db_conn, &manager, config.lang).await;
let response = dispatch_command(
text,
&config.prefix,
&db_conn,
&manager,
&conn_mgr,
&emitter,
&bridge,
cmd.channel_id,
&cmd.sender_id,
config.lang,
)
.await;
eprintln!(
"[ChatChannel] dispatch result: title={:?}, body_len={}",
response.title,
response.body.len()
);
// Send response back via the same channel
let send_result = manager.send_to_channel(cmd.channel_id, &response).await;
@@ -102,17 +132,36 @@ pub fn spawn_command_dispatcher(
})
}
#[allow(clippy::too_many_arguments)]
async fn dispatch_command(
text: &str,
prefix: &str,
db: &DatabaseConnection,
manager: &ChatChannelManager,
conn_mgr: &ConnectionManager,
emitter: &EventEmitter,
bridge: &Arc<Mutex<SessionBridge>>,
channel_id: i32,
sender_id: &str,
lang: Lang,
) -> super::types::RichMessage {
// Strip prefix; if text doesn't start with it, show help
// Strip prefix; if text doesn't start with it, try as follow-up
let without_prefix = match text.strip_prefix(prefix) {
Some(rest) => rest,
None => return command_handlers::handle_help(prefix, lang),
None => {
// Check if sender has an active session for follow-up
let has_session = {
let guard = bridge.lock().await;
guard.find_by_sender(channel_id, sender_id).is_some()
};
if has_session {
return session_commands::handle_followup(
db, text, channel_id, sender_id, conn_mgr, bridge, lang, prefix,
)
.await;
}
return command_handlers::handle_help(prefix, lang);
}
};
let parts: Vec<&str> = without_prefix.splitn(2, ' ').collect();
@@ -120,7 +169,7 @@ async fn dispatch_command(
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
match command.as_str() {
"recent" => command_handlers::handle_recent(db, lang).await,
// Existing commands
"search" => {
if args.is_empty() {
super::types::RichMessage::info(i18n::search_usage(lang, prefix))
@@ -129,17 +178,50 @@ async fn dispatch_command(
command_handlers::handle_search(db, args, lang).await
}
}
"detail" => {
if let Ok(id) = args.parse::<i32>() {
command_handlers::handle_detail(db, id, lang).await
} else {
super::types::RichMessage::info(i18n::detail_usage(lang, prefix))
.with_title(i18n::invalid_args_title(lang))
}
}
"today" => command_handlers::handle_today(db, lang).await,
"status" => command_handlers::handle_status(manager, lang).await,
"help" | "start" => command_handlers::handle_help(prefix, lang),
// Session commands
"folder" => {
session_commands::handle_folder(db, args, channel_id, sender_id, lang, prefix).await
}
"agent" => {
session_commands::handle_agent(db, args, channel_id, sender_id, lang, prefix).await
}
"task" | "do" => {
session_commands::handle_task(
db, args, channel_id, sender_id, conn_mgr, emitter, bridge, lang, prefix,
)
.await
}
"sessions" => {
session_commands::handle_sessions(db, channel_id, sender_id, lang, prefix).await
}
"resume" => {
session_commands::handle_resume(
db, args, channel_id, sender_id, conn_mgr, emitter, bridge, lang, prefix,
)
.await
}
"cancel" => {
session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang)
.await
}
"approve" => {
let always = args.eq_ignore_ascii_case("always");
session_commands::handle_permission_response(
true, always, db, channel_id, sender_id, conn_mgr, bridge, lang,
)
.await
}
"deny" => {
session_commands::handle_permission_response(
false, false, db, channel_id, sender_id, conn_mgr, bridge, lang,
)
.await
}
_ => super::types::RichMessage::info(i18n::unknown_command(lang, prefix, &command))
.with_title(i18n::unknown_command_title(lang)),
}

View File

@@ -6,48 +6,6 @@ use super::manager::ChatChannelManager;
use super::types::{MessageLevel, RichMessage};
use crate::db::entities::conversation;
pub async fn handle_recent(db: &DatabaseConnection, lang: Lang) -> RichMessage {
let recent = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.order_by_desc(conversation::Column::CreatedAt)
.limit(5)
.all(db)
.await
{
Ok(rows) => rows,
Err(e) => {
return RichMessage {
title: Some(i18n::query_failed_title(lang).to_string()),
body: e.to_string(),
fields: Vec::new(),
level: MessageLevel::Error,
};
}
};
if recent.is_empty() {
return RichMessage::info(i18n::no_conversations(lang))
.with_title(i18n::recent_conversations_title(lang));
}
let mut body = String::new();
for (i, conv) in recent.iter().enumerate() {
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
let agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!(
"{}. [{}] {} ({})\n",
i + 1,
agent,
title,
time
));
}
RichMessage::info(body.trim_end())
.with_title(i18n::recent_n_conversations_title(lang, recent.len()))
}
pub async fn handle_search(
db: &DatabaseConnection,
keyword: &str,
@@ -78,15 +36,13 @@ pub async fn handle_search(
}
let mut body = String::new();
for (i, conv) in matched.iter().enumerate() {
for conv in &matched {
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
let agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!(
"{}. [{}] {} (ID:{})\n",
i + 1,
agent,
title,
conv.id
"#{} [{}] {} ({})\n",
conv.id, agent, title, time,
));
}
@@ -97,46 +53,6 @@ pub async fn handle_search(
))
}
pub async fn handle_detail(
db: &DatabaseConnection,
conversation_id: i32,
lang: Lang,
) -> RichMessage {
let conv = match conversation::Entity::find_by_id(conversation_id)
.filter(conversation::Column::DeletedAt.is_null())
.one(db)
.await
{
Ok(Some(c)) => c,
Ok(None) => {
return RichMessage::info(i18n::conversation_not_found(lang, conversation_id))
.with_title(i18n::not_found_title(lang));
}
Err(e) => {
return RichMessage {
title: Some(i18n::query_failed_title(lang).to_string()),
body: e.to_string(),
fields: Vec::new(),
level: MessageLevel::Error,
};
}
};
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
RichMessage::info(title)
.with_title(i18n::conversation_detail_title(lang, conv.id))
.with_field(i18n::field_agent(lang), &conv.agent_type)
.with_field(i18n::field_status(lang), format!("{:?}", conv.status))
.with_field(
i18n::field_message_count(lang),
conv.message_count.to_string(),
)
.with_field(
i18n::field_created_at(lang),
conv.created_at.format("%Y-%m-%d %H:%M").to_string(),
)
}
pub async fn handle_today(db: &DatabaseConnection, lang: Lang) -> RichMessage {
let now = Utc::now();
let today_start = now

View File

@@ -280,36 +280,6 @@ pub fn query_failed_title(lang: Lang) -> &'static str {
}
}
pub fn no_conversations(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "暂无会话记录",
Lang::ZhTw => "暫無對話記錄",
Lang::Ja => "セッション履歴なし",
Lang::Ko => "대화 기록 없음",
Lang::Es => "Sin conversaciones",
Lang::De => "Keine Sitzungen",
Lang::Fr => "Aucune session",
Lang::Pt => "Nenhuma sessão",
Lang::Ar => "لا توجد جلسات",
Lang::En => "No conversations found",
}
}
pub fn recent_conversations_title(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "最近会话",
Lang::ZhTw => "最近對話",
Lang::Ja => "最近のセッション",
Lang::Ko => "최근 대화",
Lang::Es => "Conversaciones recientes",
Lang::De => "Letzte Sitzungen",
Lang::Fr => "Sessions récentes",
Lang::Pt => "Sessões recentes",
Lang::Ar => "الجلسات الأخيرة",
Lang::En => "Recent Conversations",
}
}
pub fn untitled(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "(无标题)",
@@ -325,21 +295,6 @@ pub fn untitled(lang: Lang) -> &'static str {
}
}
pub fn recent_n_conversations_title(lang: Lang, n: usize) -> String {
match lang {
Lang::ZhCn => format!("最近 {n} 条会话"),
Lang::ZhTw => format!("最近 {n} 條對話"),
Lang::Ja => format!("最新 {n} セッション"),
Lang::Ko => format!("최근 {n}개 대화"),
Lang::Es => format!("{n} conversaciones más recientes"),
Lang::De => format!("Letzte {n} Sitzungen"),
Lang::Fr => format!("{n} dernières sessions"),
Lang::Pt => format!("{n} sessões mais recentes"),
Lang::Ar => format!("أحدث {n} جلسات"),
Lang::En => format!("{n} Most Recent Conversations"),
}
}
pub fn search_no_results(lang: Lang, keyword: &str) -> String {
match lang {
Lang::ZhCn => format!("未找到包含 \"{keyword}\" 的会话"),
@@ -385,110 +340,6 @@ pub fn search_results_count_title(lang: Lang, keyword: &str, count: usize) -> St
}
}
pub fn conversation_not_found(lang: Lang, id: i32) -> String {
match lang {
Lang::ZhCn => format!("会话 {id} 不存在"),
Lang::ZhTw => format!("對話 {id} 不存在"),
Lang::Ja => format!("セッション {id} が見つかりません"),
Lang::Ko => format!("대화 {id}를 찾을 수 없습니다"),
Lang::Es => format!("Conversación {id} no encontrada"),
Lang::De => format!("Sitzung {id} nicht gefunden"),
Lang::Fr => format!("Session {id} introuvable"),
Lang::Pt => format!("Sessão {id} não encontrada"),
Lang::Ar => format!("الجلسة {id} غير موجودة"),
Lang::En => format!("Conversation {id} not found"),
}
}
pub fn not_found_title(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "未找到",
Lang::ZhTw => "未找到",
Lang::Ja => "見つかりません",
Lang::Ko => "찾을 수 없음",
Lang::Es => "No encontrado",
Lang::De => "Nicht gefunden",
Lang::Fr => "Introuvable",
Lang::Pt => "Não encontrado",
Lang::Ar => "غير موجود",
Lang::En => "Not Found",
}
}
pub fn conversation_detail_title(lang: Lang, id: i32) -> String {
match lang {
Lang::ZhCn => format!("会话详情 #{id}"),
Lang::ZhTw => format!("對話詳情 #{id}"),
Lang::Ja => format!("セッション詳細 #{id}"),
Lang::Ko => format!("대화 상세 #{id}"),
Lang::Es => format!("Detalles #{id}"),
Lang::De => format!("Sitzungsdetails #{id}"),
Lang::Fr => format!("Détails #{id}"),
Lang::Pt => format!("Detalhes #{id}"),
Lang::Ar => format!("تفاصيل الجلسة #{id}"),
Lang::En => format!("Conversation Details #{id}"),
}
}
pub fn field_agent(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "代理",
Lang::ZhTw => "代理",
Lang::Ja => "エージェント",
Lang::Ko => "에이전트",
Lang::Es => "Agente",
Lang::De => "Agent",
Lang::Fr => "Agent",
Lang::Pt => "Agente",
Lang::Ar => "الوكيل",
Lang::En => "Agent",
}
}
pub fn field_status(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "状态",
Lang::ZhTw => "狀態",
Lang::Ja => "ステータス",
Lang::Ko => "상태",
Lang::Es => "Estado",
Lang::De => "Status",
Lang::Fr => "Statut",
Lang::Pt => "Status",
Lang::Ar => "الحالة",
Lang::En => "Status",
}
}
pub fn field_message_count(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "消息数",
Lang::ZhTw => "訊息數",
Lang::Ja => "メッセージ数",
Lang::Ko => "메시지 수",
Lang::Es => "Mensajes",
Lang::De => "Nachrichten",
Lang::Fr => "Messages",
Lang::Pt => "Mensagens",
Lang::Ar => "عدد الرسائل",
Lang::En => "Messages",
}
}
pub fn field_created_at(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "创建时间",
Lang::ZhTw => "建立時間",
Lang::Ja => "作成日時",
Lang::Ko => "생성 시간",
Lang::Es => "Creado",
Lang::De => "Erstellt",
Lang::Fr => "Créé",
Lang::Pt => "Criado",
Lang::Ar => "تاريخ الإنشاء",
Lang::En => "Created",
}
}
pub fn no_activity_today(lang: Lang) -> &'static str {
match lang {
@@ -610,84 +461,174 @@ pub fn help_title(lang: Lang) -> &'static str {
pub fn help_body(lang: Lang, prefix: &str) -> String {
match lang {
Lang::ZhCn => format!(
"{prefix}recent - 最近 5 条会话\n\
"{prefix}folder - 选择工作目录\n\
{prefix}agent - 选择 Agent\n\
{prefix}task <描述> - 创建会话并执行任务\n\
{prefix}sessions - 当前目录的活跃会话\n\
{prefix}resume [ID] - 最近会话 / 恢复指定会话\n\
{prefix}cancel - 取消当前任务\n\
{prefix}approve [always] - 批准权限请求\n\
{prefix}deny - 拒绝权限请求\n\
\n\
{prefix}search <关键词> - 搜索会话\n\
{prefix}detail <ID> - 会话详情\n\
{prefix}today - 今日活动汇总\n\
{prefix}status - 渠道连接状态\n\
{prefix}help - 显示帮助"
{prefix}help - 显示帮助\n\
\n\
有活跃会话时,直接发文本即可继续对话"
),
Lang::ZhTw => format!(
"{prefix}recent - 最近 5 條對話\n\
"{prefix}folder - 選擇工作目錄\n\
{prefix}agent - 選擇 Agent\n\
{prefix}task <描述> - 建立對話並執行任務\n\
{prefix}sessions - 當前目錄的活躍對話\n\
{prefix}resume [ID] - 最近對話 / 恢復指定對話\n\
{prefix}cancel - 取消當前任務\n\
{prefix}approve [always] - 批准權限請求\n\
{prefix}deny - 拒絕權限請求\n\
\n\
{prefix}search <關鍵字> - 搜尋對話\n\
{prefix}detail <ID> - 對話詳情\n\
{prefix}today - 今日活動匯總\n\
{prefix}status - 頻道連線狀態\n\
{prefix}help - 顯示幫助"
{prefix}help - 顯示幫助\n\
\n\
有活躍對話時,直接發文字即可繼續對話"
),
Lang::Ja => format!(
"{prefix}recent - 最新5件のセッション\n\
"{prefix}folder - 作業フォルダを選択\n\
{prefix}agent - エージェントを選択\n\
{prefix}task <説明> - セッションを作成してタスクを実行\n\
{prefix}sessions - フォルダ内のアクティブセッション\n\
{prefix}resume [ID] - 最近のセッション / セッションを再開\n\
{prefix}cancel - 現在のタスクをキャンセル\n\
{prefix}approve [always] - 権限を承認\n\
{prefix}deny - 権限を拒否\n\
\n\
{prefix}search <キーワード> - セッション検索\n\
{prefix}detail <ID> - セッション詳細\n\
{prefix}today - 本日の活動まとめ\n\
{prefix}status - チャンネル接続状況\n\
{prefix}help - ヘルプを表示"
{prefix}help - ヘルプを表示\n\
\n\
セッションがアクティブな場合、テキストを送信するだけで会話を続けられます"
),
Lang::Ko => format!(
"{prefix}recent - 최근 5개 대화\n\
"{prefix}folder - 작업 폴더 선택\n\
{prefix}agent - 에이전트 선택\n\
{prefix}task <설명> - 세션 생성 및 작업 실행\n\
{prefix}sessions - 폴더 내 활성 세션\n\
{prefix}resume [ID] - 최근 대화 / 세션 재개\n\
{prefix}cancel - 현재 작업 취소\n\
{prefix}approve [always] - 권한 승인\n\
{prefix}deny - 권한 거부\n\
\n\
{prefix}search <키워드> - 대화 검색\n\
{prefix}detail <ID> - 대화 상세\n\
{prefix}today - 오늘의 활동 요약\n\
{prefix}status - 채널 연결 상태\n\
{prefix}help - 도움말 표시"
{prefix}help - 도움말 표시\n\
\n\
세션이 활성화된 경우 텍스트를 보내면 대화를 계속할 수 있습니다"
),
Lang::Es => format!(
"{prefix}recent - 5 conversaciones más recientes\n\
"{prefix}folder - Seleccionar carpeta de trabajo\n\
{prefix}agent - Seleccionar agente\n\
{prefix}task <desc> - Crear sesion y ejecutar tarea\n\
{prefix}sessions - Sesiones activas en la carpeta\n\
{prefix}resume [ID] - Recientes / reanudar una sesion\n\
{prefix}cancel - Cancelar tarea actual\n\
{prefix}approve [always] - Aprobar permiso\n\
{prefix}deny - Denegar permiso\n\
\n\
{prefix}search <palabra> - Buscar conversaciones\n\
{prefix}detail <ID> - Detalles de conversación\n\
{prefix}today - Resumen de hoy\n\
{prefix}status - Estado de canales\n\
{prefix}help - Mostrar ayuda"
{prefix}help - Mostrar ayuda\n\
\n\
Cuando hay una sesion activa, simplemente escriba texto para continuar"
),
Lang::De => format!(
"{prefix}recent - 5 neueste Sitzungen\n\
"{prefix}folder - Arbeitsordner auswahlen\n\
{prefix}agent - Agent auswahlen\n\
{prefix}task <Beschreibung> - Sitzung erstellen und Aufgabe ausfuhren\n\
{prefix}sessions - Aktive Sitzungen im Ordner\n\
{prefix}resume [ID] - Neueste Sitzungen / Sitzung fortsetzen\n\
{prefix}cancel - Aktuelle Aufgabe abbrechen\n\
{prefix}approve [always] - Berechtigung genehmigen\n\
{prefix}deny - Berechtigung verweigern\n\
\n\
{prefix}search <Stichwort> - Sitzungen suchen\n\
{prefix}detail <ID> - Sitzungsdetails\n\
{prefix}today - Heutige Zusammenfassung\n\
{prefix}status - Kanalstatus\n\
{prefix}help - Hilfe anzeigen"
{prefix}help - Hilfe anzeigen\n\
\n\
Bei aktiver Sitzung einfach Text eingeben, um das Gesprach fortzusetzen"
),
Lang::Fr => format!(
"{prefix}recent - 5 dernières sessions\n\
{prefix}search <mot-clé> - Rechercher des sessions\n\
{prefix}detail <ID> - Détails de la session\n\
{prefix}today - Résumé du jour\n\
"{prefix}folder - Selectionner le dossier de travail\n\
{prefix}agent - Selectionner l'agent\n\
{prefix}task <desc> - Creer une session et executer une tache\n\
{prefix}sessions - Sessions actives dans le dossier\n\
{prefix}resume [ID] - Sessions recentes / reprendre une session\n\
{prefix}cancel - Annuler la tache en cours\n\
{prefix}approve [always] - Approuver la permission\n\
{prefix}deny - Refuser la permission\n\
\n\
{prefix}search <mot-cle> - Rechercher des sessions\n\
{prefix}today - Resume du jour\n\
{prefix}status - Statut des canaux\n\
{prefix}help - Afficher l'aide"
{prefix}help - Afficher l'aide\n\
\n\
Lorsqu'une session est active, envoyez du texte pour continuer la conversation"
),
Lang::Pt => format!(
"{prefix}recent - 5 sessões mais recentes\n\
{prefix}search <palavra> - Buscar sessões\n\
{prefix}detail <ID> - Detalhes da sessão\n\
"{prefix}folder - Selecionar pasta de trabalho\n\
{prefix}agent - Selecionar agente\n\
{prefix}task <desc> - Criar sessao e executar tarefa\n\
{prefix}sessions - Sessoes ativas na pasta\n\
{prefix}resume [ID] - Recentes / retomar uma sessao\n\
{prefix}cancel - Cancelar tarefa atual\n\
{prefix}approve [always] - Aprovar permissao\n\
{prefix}deny - Negar permissao\n\
\n\
{prefix}search <palavra> - Buscar sessoes\n\
{prefix}today - Resumo de hoje\n\
{prefix}status - Status dos canais\n\
{prefix}help - Mostrar ajuda"
{prefix}help - Mostrar ajuda\n\
\n\
Quando uma sessao esta ativa, basta digitar texto para continuar a conversa"
),
Lang::Ar => format!(
"{prefix}recent - أحدث 5 جلسات\n\
"{prefix}folder - اختيار مجلد العمل\n\
{prefix}agent - اختيار الوكيل\n\
{prefix}task <وصف> - انشاء جلسة وتنفيذ مهمة\n\
{prefix}sessions - الجلسات النشطة في المجلد\n\
{prefix}resume [ID] - الجلسات الاخيرة / استئناف جلسة\n\
{prefix}cancel - الغاء المهمة الحالية\n\
{prefix}approve [always] - الموافقة على الاذن\n\
{prefix}deny - رفض الاذن\n\
\n\
{prefix}search <كلمة> - البحث في الجلسات\n\
{prefix}detail <ID> - تفاصيل الجلسة\n\
{prefix}today - ملخص اليوم\n\
{prefix}status - حالة القنوات\n\
{prefix}help - عرض المساعدة"
{prefix}help - عرض المساعدة\n\
\n\
عندما تكون الجلسة نشطة، ارسل نصا لمتابعة المحادثة"
),
Lang::En => format!(
"{prefix}recent - 5 most recent conversations\n\
"{prefix}folder - Select working folder\n\
{prefix}agent - Select agent\n\
{prefix}task <desc> - Create session & run task\n\
{prefix}sessions - Active sessions in folder\n\
{prefix}resume [ID] - Recent conversations / resume a session\n\
{prefix}cancel - Cancel current task\n\
{prefix}approve [always] - Approve permission\n\
{prefix}deny - Deny permission\n\
\n\
{prefix}search <keyword> - Search conversations\n\
{prefix}detail <ID> - Conversation details\n\
{prefix}today - Today's activity summary\n\
{prefix}status - Channel connection status\n\
{prefix}help - Show help"
{prefix}help - Show help\n\
\n\
When a session is active, just type text to continue the conversation"
),
}
}
@@ -724,21 +665,6 @@ pub fn search_usage(lang: Lang, prefix: &str) -> String {
}
}
pub fn detail_usage(lang: Lang, prefix: &str) -> String {
match lang {
Lang::ZhCn => format!("用法: {prefix}detail <会话ID>"),
Lang::ZhTw => format!("用法: {prefix}detail <對話ID>"),
Lang::Ja => format!("使い方: {prefix}detail <セッションID>"),
Lang::Ko => format!("사용법: {prefix}detail <대화ID>"),
Lang::Es => format!("Uso: {prefix}detail <ID>"),
Lang::De => format!("Verwendung: {prefix}detail <ID>"),
Lang::Fr => format!("Utilisation : {prefix}detail <ID>"),
Lang::Pt => format!("Uso: {prefix}detail <ID>"),
Lang::Ar => format!("الاستخدام: {prefix}detail <ID>"),
Lang::En => format!("Usage: {prefix}detail <ID>"),
}
}
pub fn unknown_command(lang: Lang, prefix: &str, command: &str) -> String {
match lang {
Lang::ZhCn => format!(
@@ -788,3 +714,20 @@ pub fn unknown_command_title(lang: Lang) -> &'static str {
Lang::En => "Unknown Command",
}
}
// ── Session progress messages ──
pub fn agent_responding(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "Claude Code 正在响应中...",
Lang::ZhTw => "Claude Code 正在回應中...",
Lang::Ja => "Claude Code が応答中...",
Lang::Ko => "Claude Code 응답 중...",
Lang::Es => "Claude Code respondiendo...",
Lang::De => "Claude Code antwortet...",
Lang::Fr => "Claude Code en cours de reponse...",
Lang::Pt => "Claude Code respondendo...",
Lang::Ar => "...Claude Code يستجيب",
Lang::En => "Claude Code is responding...",
}
}

View File

@@ -5,9 +5,11 @@ use sea_orm::DatabaseConnection;
use tokio::sync::{mpsc, Mutex};
use super::error::ChatChannelError;
use super::session_bridge::SessionBridge;
use super::traits::ChatChannelBackend;
use super::types::*;
use crate::web::event_bridge::WebEventBroadcaster;
use crate::acp::manager::ConnectionManager;
use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster};
struct ActiveChannel {
id: i32,
@@ -212,28 +214,49 @@ impl ChatChannelManager {
&self,
broadcaster: Arc<WebEventBroadcaster>,
db_conn: DatabaseConnection,
conn_mgr: ConnectionManager,
emitter: EventEmitter,
) {
// Store broadcaster for status event emission
*self.inner.broadcaster.lock().await = Some(broadcaster.clone());
let db_conn2 = db_conn.clone();
// Create shared session bridge
let bridge = Arc::new(Mutex::new(SessionBridge::new()));
// Spawn event subscriber
let manager_for_events = self.clone_ref();
super::event_subscriber::spawn_event_subscriber(
broadcaster,
broadcaster.clone(),
manager_for_events,
db_conn.clone(),
);
// Spawn session event subscriber (ACP event routing to channels)
let manager_for_session_events = self.clone_ref();
super::session_event_subscriber::spawn_session_event_subscriber(
broadcaster,
bridge.clone(),
manager_for_session_events,
conn_mgr.clone_ref(),
db_conn.clone(),
);
// Spawn command dispatcher
if let Some(command_rx) = self.take_command_receiver().await {
eprintln!("[ChatChannel] command dispatcher started");
let manager_for_cmds = self.clone_ref();
super::command_dispatcher::spawn_command_dispatcher(
command_rx,
manager_for_cmds,
db_conn.clone(),
conn_mgr,
emitter,
bridge,
);
} else {
eprintln!("[ChatChannel] WARNING: command_rx already taken, dispatcher NOT started");
}
// Spawn daily report scheduler

View File

@@ -7,5 +7,8 @@ pub mod i18n;
pub mod manager;
pub mod message_formatter;
pub mod scheduler;
pub mod session_bridge;
pub mod session_commands;
pub mod session_event_subscriber;
pub mod traits;
pub mod types;

View File

@@ -0,0 +1,77 @@
use std::collections::HashMap;
use std::time::Instant;
use crate::acp::types::PermissionOptionInfo;
use crate::chat_channel::types::SentMessageId;
pub struct PendingPermission {
pub request_id: String,
pub tool_description: String,
pub options: Vec<PermissionOptionInfo>,
pub sent_message_id: Option<SentMessageId>,
}
pub struct ActiveSession {
pub channel_id: i32,
pub sender_id: String,
pub conversation_id: i32,
pub connection_id: String,
pub content_buffer: String,
pub tool_calls: Vec<String>,
/// Stores raw_input by tool_call_id for detail extraction on completion.
pub tool_call_inputs: HashMap<String, String>,
pub last_flushed: Instant,
pub pending_prompt: Option<String>,
pub permission_pending: Option<PendingPermission>,
}
#[derive(Default)]
pub struct SessionBridge {
sessions: HashMap<String, ActiveSession>,
}
impl SessionBridge {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, connection_id: String, session: ActiveSession) {
self.sessions.insert(connection_id, session);
}
pub fn remove(&mut self, connection_id: &str) -> Option<ActiveSession> {
self.sessions.remove(connection_id)
}
pub fn get(&self, connection_id: &str) -> Option<&ActiveSession> {
self.sessions.get(connection_id)
}
pub fn get_mut(&mut self, connection_id: &str) -> Option<&mut ActiveSession> {
self.sessions.get_mut(connection_id)
}
pub fn find_by_sender(&self, channel_id: i32, sender_id: &str) -> Option<&ActiveSession> {
self.sessions.values().find(|s| {
s.channel_id == channel_id && s.sender_id == sender_id
})
}
pub fn find_by_sender_mut(
&mut self,
channel_id: i32,
sender_id: &str,
) -> Option<&mut ActiveSession> {
self.sessions.values_mut().find(|s| {
s.channel_id == channel_id && s.sender_id == sender_id
})
}
pub fn all_sessions(&self) -> impl Iterator<Item = &ActiveSession> {
self.sessions.values()
}
pub fn all_sessions_mut(&mut self) -> impl Iterator<Item = &mut ActiveSession> {
self.sessions.values_mut()
}
}

View File

@@ -0,0 +1,939 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Instant;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use tokio::sync::Mutex;
use super::i18n::{self, Lang};
use super::session_bridge::{ActiveSession, SessionBridge};
use super::types::{MessageLevel, RichMessage};
use crate::acp::manager::ConnectionManager;
use crate::acp::registry::all_acp_agents;
use crate::acp::types::PromptInputBlock;
use crate::db::entities::conversation;
use crate::db::service::{conversation_service, folder_service, sender_context_service};
use crate::models::agent::AgentType;
use crate::web::event_bridge::EventEmitter;
// ── /folder ──
pub async fn handle_folder(
db: &DatabaseConnection,
args: &str,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
if args.is_empty() {
return list_folders(db, channel_id, sender_id, lang, prefix).await;
}
// Try parse as index (1-based)
if let Ok(idx) = args.parse::<usize>() {
return select_folder_by_index(db, idx, channel_id, sender_id, lang, prefix).await;
}
// Treat as path
select_folder_by_path(db, args, channel_id, sender_id, lang).await
}
async fn list_folders(
db: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
let folders = match folder_service::list_folders(db).await {
Ok(f) => f,
Err(e) => return RichMessage::error(format!("Failed to list folders: {e}")),
};
if folders.is_empty() {
return RichMessage::info(t(lang, "No folders found.", "没有找到项目目录。"))
.with_title(t(lang, "Working Folder", "工作目录"));
}
let ctx = sender_context_service::get_or_create(db, channel_id, sender_id)
.await
.ok();
let mut body = String::new();
for (i, f) in folders.iter().take(10).enumerate() {
let current = ctx
.as_ref()
.and_then(|c| c.current_folder_id)
.map(|id| id == f.id)
.unwrap_or(false);
let marker = if current { " [*]" } else { "" };
body.push_str(&format!(
"{}. {}{} ({})\n",
i + 1,
f.name,
marker,
f.path
));
}
body.push_str(&format!(
"\n{}",
tp(
lang,
prefix,
"Reply {prefix}folder <number> to select.",
"回复 {prefix}folder <数字> 选择目录。"
)
));
RichMessage::info(body.trim_end())
.with_title(t(lang, "Working Folder", "工作目录"))
}
async fn select_folder_by_index(
db: &DatabaseConnection,
idx: usize,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
if idx == 0 {
return RichMessage::info(t(lang, "Index starts from 1.", "序号从 1 开始。"));
}
let folders = match folder_service::list_folders(db).await {
Ok(f) => f,
Err(e) => return RichMessage::error(format!("Failed to list folders: {e}")),
};
let Some(folder) = folders.get(idx - 1) else {
return RichMessage::info(tp(
lang,
prefix,
"Index out of range. Use {prefix}folder to list.",
"序号超出范围,请使用 {prefix}folder 查看列表。",
));
};
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id))
.await;
RichMessage::info(format!("{} ({})", folder.name, folder.path))
.with_title(t(lang, "Folder Selected", "已选择目录"))
}
async fn select_folder_by_path(
db: &DatabaseConnection,
path: &str,
channel_id: i32,
sender_id: &str,
lang: Lang,
) -> RichMessage {
let entry = match folder_service::add_folder(db, path).await {
Ok(e) => e,
Err(e) => return RichMessage::error(format!("Failed to add folder: {e}")),
};
let _ =
sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await;
RichMessage::info(format!("{} ({})", entry.name, entry.path))
.with_title(t(lang, "Folder Selected", "已选择目录"))
}
// ── /agent ──
pub async fn handle_agent(
db: &DatabaseConnection,
args: &str,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
if args.is_empty() {
return list_agents(db, channel_id, sender_id, lang, prefix).await;
}
// Try parse as index
if let Ok(idx) = args.parse::<usize>() {
return select_agent_by_index(db, idx, channel_id, sender_id, lang, prefix).await;
}
// Try parse as agent type name
select_agent_by_name(db, args, channel_id, sender_id, lang).await
}
async fn list_agents(
db: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
let agents = all_acp_agents();
let ctx = sender_context_service::get_or_create(db, channel_id, sender_id)
.await
.ok();
let mut body = String::new();
for (i, at) in agents.iter().enumerate() {
let at_str = agent_type_to_string(*at);
let current = ctx
.as_ref()
.and_then(|c| c.current_agent_type.as_deref())
.map(|s| s == at_str)
.unwrap_or(false);
let marker = if current { " [*]" } else { "" };
body.push_str(&format!("{}. {}{}\n", i + 1, at, marker));
}
body.push_str(&format!(
"\n{}",
tp(
lang,
prefix,
"Reply {prefix}agent <number> or {prefix}agent <name> to select.",
"回复 {prefix}agent <数字> 或 {prefix}agent <名称> 选择。"
)
));
RichMessage::info(body.trim_end())
.with_title(t(lang, "Agent Selection", "选择 Agent"))
}
async fn select_agent_by_index(
db: &DatabaseConnection,
idx: usize,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
let agents = all_acp_agents();
if idx == 0 || idx > agents.len() {
return RichMessage::info(tp(
lang,
prefix,
"Index out of range. Use {prefix}agent to list.",
"序号超出范围,请使用 {prefix}agent 查看列表。",
));
}
let at = agents[idx - 1];
let at_str = agent_type_to_string(at);
let _ = sender_context_service::update_agent(db, channel_id, sender_id, Some(at_str)).await;
RichMessage::info(at.to_string())
.with_title(t(lang, "Agent Selected", "已选择 Agent"))
}
async fn select_agent_by_name(
db: &DatabaseConnection,
name: &str,
channel_id: i32,
sender_id: &str,
lang: Lang,
) -> RichMessage {
let at = match parse_agent_type(name) {
Some(a) => a,
None => {
return RichMessage::info(format!(
"{}{}",
t(lang, "Unknown agent: ", "未知 Agent: "),
name
));
}
};
let at_str = agent_type_to_string(at);
let _ = sender_context_service::update_agent(db, channel_id, sender_id, Some(at_str)).await;
RichMessage::info(at.to_string())
.with_title(t(lang, "Agent Selected", "已选择 Agent"))
}
// ── /task ──
#[allow(clippy::too_many_arguments)]
pub async fn handle_task(
db: &DatabaseConnection,
task_description: &str,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
emitter: &EventEmitter,
bridge: &Arc<Mutex<SessionBridge>>,
lang: Lang,
prefix: &str,
) -> RichMessage {
if task_description.is_empty() {
return RichMessage::info(tp(
lang,
prefix,
"Usage: {prefix}task <description>",
"用法: {prefix}task <任务描述>",
));
}
// 1. Load sender context
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
Ok(c) => c,
Err(e) => return RichMessage::error(format!("Failed to load context: {e}")),
};
let folder_id = match ctx.current_folder_id {
Some(id) => id,
None => {
return RichMessage::info(tp(
lang,
prefix,
"No folder selected. Use {prefix}folder first.",
"未选择工作目录,请先使用 {prefix}folder 选择。",
));
}
};
// 2. Get folder info
let folder = match folder_service::get_folder_by_id(db, folder_id).await {
Ok(Some(f)) => f,
_ => {
return RichMessage::info(tp(
lang,
prefix,
"Folder not found. Use {prefix}folder to select.",
"目录不存在,请使用 {prefix}folder 重新选择。",
));
}
};
// 3. Resolve agent type
let agent_type = resolve_agent_type(&ctx.current_agent_type, &folder.default_agent_type);
// 4. Create conversation record
let conv = match conversation_service::create(
db,
folder_id,
agent_type,
Some(truncate_title(task_description)),
folder.git_branch.clone(),
)
.await
{
Ok(c) => c,
Err(e) => return RichMessage::error(format!("Failed to create conversation: {e}")),
};
// 5. Spawn ACP agent
let owner_label = format!("chat_channel:{}:{}", channel_id, sender_id);
let connection_id = match conn_mgr
.spawn_agent(
agent_type,
Some(folder.path.clone()),
None,
BTreeMap::new(),
owner_label,
emitter.clone(),
)
.await
{
Ok(id) => id,
Err(e) => {
// Clean up the conversation record
let _ = conversation_service::update_status(
db,
conv.id,
conversation::ConversationStatus::Cancelled,
)
.await;
return RichMessage::error(format!(
"{}{e}",
t(lang, "Failed to start agent: ", "启动 Agent 失败: ")
));
}
};
// 6. Register in bridge (prompt will be sent after SessionStarted event)
{
let session = ActiveSession {
channel_id,
sender_id: sender_id.to_string(),
conversation_id: conv.id,
connection_id: connection_id.clone(),
content_buffer: String::new(),
tool_calls: Vec::new(),
tool_call_inputs: std::collections::HashMap::new(),
last_flushed: Instant::now(),
pending_prompt: Some(task_description.to_string()),
permission_pending: None,
};
bridge.lock().await.register(connection_id.clone(), session);
}
// 7. Update sender context
let _ = sender_context_service::update_session(
db,
channel_id,
sender_id,
Some(conv.id),
Some(connection_id),
)
.await;
RichMessage::info(format!(
"[{}] #{} @ {}",
agent_type, conv.id, folder.name,
))
.with_title(t(lang, "Task Started", "任务已启动"))
}
// ── /sessions ──
pub async fn handle_sessions(
db: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
Ok(c) => c,
Err(e) => return RichMessage::error(format!("Failed to load context: {e}")),
};
let folder_id = match ctx.current_folder_id {
Some(id) => id,
None => {
return RichMessage::info(tp(
lang,
prefix,
"No folder selected. Use {prefix}folder first.",
"未选择工作目录,请先使用 {prefix}folder 选择。",
));
}
};
let folder = match folder_service::get_folder_by_id(db, folder_id).await {
Ok(Some(f)) => f,
_ => {
return RichMessage::info(t(
lang,
"Folder not found.",
"目录不存在。",
));
}
};
let convs = match conversation_service::list_by_folder(
db,
folder_id,
None,
None,
None,
Some("in_progress".to_string()),
)
.await
{
Ok(c) => c,
Err(e) => return RichMessage::error(format!("Failed to list sessions: {e}")),
};
if convs.is_empty() {
return RichMessage::info(t(
lang,
"No active sessions in this folder.",
"当前目录没有进行中的会话。",
))
.with_title(format!(
"{} - {}",
t(lang, "Sessions", "会话列表"),
folder.name
));
}
let mut body = String::new();
for (i, c) in convs.iter().take(10).enumerate() {
let title = c.title.as_deref().unwrap_or("(untitled)");
let current = ctx
.current_conversation_id
.map(|id| id == c.id)
.unwrap_or(false);
let marker = if current { " [*]" } else { "" };
body.push_str(&format!(
"{}. [{}] {} (#{}){} \n",
i + 1,
c.agent_type,
title,
c.id,
marker,
));
}
body.push_str(&format!(
"\n{}",
tp(
lang,
prefix,
"Reply {prefix}resume <id> to continue.",
"回复 {prefix}resume <会话ID> 继续会话。"
)
));
RichMessage::info(body.trim_end()).with_title(format!(
"{} - {}",
t(lang, "Sessions", "会话列表"),
folder.name
))
}
// ── /resume ──
#[allow(clippy::too_many_arguments)]
pub async fn handle_resume(
db: &DatabaseConnection,
args: &str,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
emitter: &EventEmitter,
bridge: &Arc<Mutex<SessionBridge>>,
lang: Lang,
prefix: &str,
) -> RichMessage {
if args.is_empty() {
return list_recent_sessions(db, lang, prefix).await;
}
let conversation_id: i32 = match args.parse() {
Ok(id) => id,
Err(_) => {
return list_recent_sessions(db, lang, prefix).await;
}
};
let conv = match conversation_service::get_by_id(db, conversation_id).await {
Ok(c) => c,
Err(_) => {
return RichMessage::info(t(
lang,
"Conversation not found.",
"会话不存在。",
));
}
};
let folder = match folder_service::get_folder_by_id(db, conv.folder_id).await {
Ok(Some(f)) => f,
_ => {
return RichMessage::info(t(lang, "Folder not found.", "目录不存在。"));
}
};
// Spawn agent with session_id for resume
let owner_label = format!("chat_channel:{}:{}", channel_id, sender_id);
let connection_id = match conn_mgr
.spawn_agent(
conv.agent_type,
Some(folder.path.clone()),
conv.external_id.clone(),
BTreeMap::new(),
owner_label,
emitter.clone(),
)
.await
{
Ok(id) => id,
Err(e) => {
return RichMessage::error(format!(
"{}{e}",
t(lang, "Failed to start agent: ", "启动 Agent 失败: ")
));
}
};
// Register in bridge (no pending prompt for resume)
{
let session = ActiveSession {
channel_id,
sender_id: sender_id.to_string(),
conversation_id: conv.id,
connection_id: connection_id.clone(),
content_buffer: String::new(),
tool_calls: Vec::new(),
tool_call_inputs: std::collections::HashMap::new(),
last_flushed: Instant::now(),
pending_prompt: None,
permission_pending: None,
};
bridge.lock().await.register(connection_id.clone(), session);
}
// Update sender context
let _ = sender_context_service::update_session(
db,
channel_id,
sender_id,
Some(conv.id),
Some(connection_id),
)
.await;
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(conv.folder_id))
.await;
let title = conv.title.as_deref().unwrap_or("(untitled)");
RichMessage::info(format!(
"[{}] #{} {} @ {}",
conv.agent_type, conv.id, title, folder.name,
))
.with_title(t(lang, "Session Resumed", "会话已恢复"))
}
// ── /cancel ──
pub async fn handle_cancel(
db: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
bridge: &Arc<Mutex<SessionBridge>>,
lang: Lang,
) -> RichMessage {
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
Ok(c) => c,
Err(e) => return RichMessage::error(format!("Failed to load context: {e}")),
};
let connection_id = match &ctx.current_connection_id {
Some(id) => id.clone(),
None => {
return RichMessage::info(t(
lang,
"No active session to cancel.",
"没有进行中的任务可取消。",
));
}
};
// Cancel the ACP connection
let _ = conn_mgr.cancel(&connection_id).await;
// Remove from bridge
bridge.lock().await.remove(&connection_id);
// Update conversation status
if let Some(conv_id) = ctx.current_conversation_id {
let _ = conversation_service::update_status(
db,
conv_id,
conversation::ConversationStatus::Cancelled,
)
.await;
}
// Clear session from context
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
RichMessage::info(t(
lang,
"Current task has been cancelled.",
"当前任务已取消。",
))
.with_title(t(lang, "Task Cancelled", "任务已取消"))
}
// ── /approve, /deny ──
#[allow(clippy::too_many_arguments)]
pub async fn handle_permission_response(
approve: bool,
always: bool,
db: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
bridge: &Arc<Mutex<SessionBridge>>,
lang: Lang,
) -> RichMessage {
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
Ok(c) => c,
Err(e) => return RichMessage::error(format!("Failed to load context: {e}")),
};
let connection_id = match &ctx.current_connection_id {
Some(id) => id.clone(),
None => {
return RichMessage::info(t(
lang,
"No active session.",
"没有活跃的会话。",
));
}
};
let pending = {
let mut bridge_guard = bridge.lock().await;
let session = match bridge_guard.get_mut(&connection_id) {
Some(s) => s,
None => {
return RichMessage::info(t(
lang,
"No active session found.",
"未找到活跃的会话。",
));
}
};
session.permission_pending.take()
};
let pending = match pending {
Some(p) => p,
None => {
return RichMessage::info(t(
lang,
"No pending permission request.",
"没有待处理的权限请求。",
));
}
};
// Find the appropriate option_id
let option_id = if approve {
pending
.options
.iter()
.find(|o| o.kind == "allow" || o.kind == "allowForSession")
.or_else(|| pending.options.first())
.map(|o| o.option_id.clone())
} else {
pending
.options
.iter()
.find(|o| o.kind == "deny")
.or_else(|| pending.options.last())
.map(|o| o.option_id.clone())
};
let Some(option_id) = option_id else {
return RichMessage::info(t(
lang,
"No valid permission option found.",
"未找到有效的权限选项。",
));
};
if let Err(e) = conn_mgr
.respond_permission(&connection_id, &pending.request_id, &option_id)
.await
{
return RichMessage::error(format!(
"{}{e}",
t(
lang,
"Failed to respond to permission: ",
"权限响应失败: "
)
));
}
// Update auto_approve if requested
if always && approve {
let _ =
sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await;
}
let action = if approve {
t(lang, "Approved", "已批准")
} else {
t(lang, "Denied", "已拒绝")
};
let mut msg = RichMessage::info(format!("{}: {}", action, pending.tool_description));
if always && approve {
msg = msg.with_field(
"",
t(
lang,
"Auto-approve enabled for this session.",
"已启用自动批准。",
),
);
}
msg.with_title(t(lang, "Permission Response", "权限响应"))
}
// ── follow-up (non-command text) ──
pub async fn handle_followup(
db: &DatabaseConnection,
text: &str,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
bridge: &Arc<Mutex<SessionBridge>>,
lang: Lang,
prefix: &str,
) -> RichMessage {
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
Ok(c) => c,
Err(e) => return RichMessage::error(format!("Failed to load context: {e}")),
};
let connection_id = match &ctx.current_connection_id {
Some(id) => id.clone(),
None => {
return RichMessage::info(tp(
lang,
prefix,
"No active session. Use {prefix}task to start one.",
"没有活跃的会话,请使用 {prefix}task 开始新任务。",
));
}
};
// Check connection exists in bridge
{
let bridge_guard = bridge.lock().await;
if bridge_guard.get(&connection_id).is_none() {
// Connection lost, clear context
drop(bridge_guard);
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
return RichMessage::info(tp(
lang,
prefix,
"Session connection lost. Use {prefix}task to start a new one.",
"会话连接已断开,请使用 {prefix}task 开始新任务。",
));
}
}
// Send prompt to agent
let blocks = vec![PromptInputBlock::Text {
text: text.to_string(),
}];
if let Err(e) = conn_mgr.send_prompt(&connection_id, blocks).await {
// Connection may have died
bridge.lock().await.remove(&connection_id);
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
return RichMessage::error(format!(
"{}{e}",
t(lang, "Failed to send message: ", "发送消息失败: ")
));
}
RichMessage::info(t(lang, "Message sent.", "消息已发送。"))
}
// ── /resume (list recent) ──
async fn list_recent_sessions(
db: &DatabaseConnection,
lang: Lang,
prefix: &str,
) -> RichMessage {
let recent = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.order_by_desc(conversation::Column::CreatedAt)
.limit(10)
.all(db)
.await
{
Ok(rows) => rows,
Err(e) => {
return RichMessage {
title: Some(i18n::query_failed_title(lang).to_string()),
body: e.to_string(),
fields: Vec::new(),
level: MessageLevel::Error,
};
}
};
if recent.is_empty() {
return RichMessage::info(t(
lang,
"No conversations found.",
"暂无会话记录。",
))
.with_title(t(lang, "Recent Conversations", "最近会话"));
}
let mut body = String::new();
for conv in &recent {
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
let agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!(
"#{} [{}] {} ({})\n",
conv.id, agent, title, time,
));
}
body.push_str(&format!(
"\n{}",
tp(
lang,
prefix,
"Reply {prefix}resume <id> to resume a session.",
"回复 {prefix}resume <会话ID> 恢复会话。"
)
));
RichMessage::info(body.trim_end()).with_title(t(
lang,
"Recent Conversations",
"最近会话",
))
}
// ── Helpers ──
fn t(lang: Lang, en: &str, zh: &str) -> String {
match lang {
Lang::ZhCn | Lang::ZhTw => zh.to_string(),
_ => en.to_string(),
}
}
/// Like `t()` but replaces `{prefix}` placeholders with the actual command prefix.
fn tp(lang: Lang, prefix: &str, en: &str, zh: &str) -> String {
t(lang, en, zh).replace("{prefix}", prefix)
}
fn agent_type_to_string(at: AgentType) -> String {
serde_json::to_value(at)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default()
}
fn parse_agent_type(name: &str) -> Option<AgentType> {
let normalized = name.to_lowercase().replace([' ', '-'], "_");
serde_json::from_value(serde_json::Value::String(normalized)).ok()
}
fn resolve_agent_type(
sender_agent: &Option<String>,
folder_default: &Option<AgentType>,
) -> AgentType {
if let Some(ref at_str) = sender_agent {
if let Some(at) = parse_agent_type(at_str) {
return at;
}
}
if let Some(at) = folder_default {
return *at;
}
AgentType::ClaudeCode
}
fn truncate_title(s: &str) -> String {
if s.chars().count() <= 80 {
s.to_string()
} else {
let truncated: String = s.chars().take(77).collect();
format!("{truncated}...")
}
}

View File

@@ -0,0 +1,726 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use sea_orm::DatabaseConnection;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use super::i18n::Lang;
use super::session_bridge::{PendingPermission, SessionBridge};
use super::types::{MessageLevel, RichMessage};
use crate::acp::manager::ConnectionManager;
use crate::acp::types::PromptInputBlock;
use crate::db::service::{
app_metadata_service, conversation_service, sender_context_service,
};
use crate::web::event_bridge::WebEventBroadcaster;
use super::manager::ChatChannelManager;
const FLUSH_INTERVAL_SECS: u64 = 10;
const BUFFER_FLUSH_THRESHOLD: usize = 500;
const MAX_MESSAGE_LEN: usize = 2000;
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
const DEFAULT_COMMAND_PREFIX: &str = "/";
pub fn spawn_session_event_subscriber(
broadcaster: Arc<WebEventBroadcaster>,
bridge: Arc<Mutex<SessionBridge>>,
manager: ChatChannelManager,
conn_mgr: ConnectionManager,
db_conn: DatabaseConnection,
) -> JoinHandle<()> {
let mut rx = broadcaster.subscribe();
tokio::spawn(async move {
let mut last_heartbeat = Instant::now();
loop {
tokio::select! {
result = rx.recv() => {
let event = match result {
Ok(e) => e,
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
eprintln!("[SessionEventSub] lagged {n} events");
continue;
}
Err(_) => break,
};
if event.channel == "acp://event" {
handle_acp_event_payload(
&event.payload,
&bridge,
&manager,
&conn_mgr,
&db_conn,
)
.await;
}
}
_ = tokio::time::sleep(Duration::from_secs(FLUSH_INTERVAL_SECS)) => {
if last_heartbeat.elapsed() >= Duration::from_secs(FLUSH_INTERVAL_SECS) {
flush_progress(&bridge, &manager, &db_conn).await;
last_heartbeat = Instant::now();
}
}
}
}
})
}
async fn get_lang(db: &DatabaseConnection) -> Lang {
app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY)
.await
.ok()
.flatten()
.map(|v| Lang::from_str_lossy(&v))
.unwrap_or_default()
}
async fn get_prefix(db: &DatabaseConnection) -> String {
app_metadata_service::get_value(db, COMMAND_PREFIX_KEY)
.await
.ok()
.flatten()
.unwrap_or_else(|| DEFAULT_COMMAND_PREFIX.to_string())
}
async fn handle_acp_event_payload(
payload: &serde_json::Value,
bridge: &Arc<Mutex<SessionBridge>>,
manager: &ChatChannelManager,
conn_mgr: &ConnectionManager,
db: &DatabaseConnection,
) {
let event_type = match payload.get("type").and_then(|v| v.as_str()) {
Some(t) => t,
None => return,
};
let connection_id = match payload.get("connection_id").and_then(|v| v.as_str()) {
Some(id) => id,
None => return,
};
match event_type {
"session_started" => {
let session_id = payload
.get("session_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
let _ = conversation_service::update_external_id(
db,
session.conversation_id,
session_id.to_string(),
)
.await;
if let Some(prompt_text) = session.pending_prompt.take() {
let blocks = vec![PromptInputBlock::Text { text: prompt_text }];
if let Err(e) = conn_mgr.send_prompt(connection_id, blocks).await {
eprintln!("[SessionEventSub] failed to send pending prompt: {e}");
let channel_id = session.channel_id;
let msg = RichMessage::error(format!("Failed to send task: {e}"));
let _ = manager.send_to_channel(channel_id, &msg).await;
}
}
}
}
"content_delta" => {
let text = payload
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
session.content_buffer.push_str(text);
if session.content_buffer.len() >= BUFFER_FLUSH_THRESHOLD
&& session.last_flushed.elapsed() >= Duration::from_secs(2)
{
let channel_id = session.channel_id;
let last_tool = session.tool_calls.last().cloned();
session.last_flushed = Instant::now();
let lang = get_lang(db).await;
let mut status = super::i18n::agent_responding(lang).to_string();
if let Some(tool) = last_tool {
status.push_str(&format!(" | {tool}"));
}
drop(guard);
let msg = RichMessage::info(status);
let _ = manager.send_to_channel(channel_id, &msg).await;
}
}
}
"tool_call" => {
let title = payload
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("tool");
let tool_call_id = payload
.get("tool_call_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let raw_input = payload.get("raw_input").and_then(|v| v.as_str());
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
// Store title for progress indicator; store raw_input for later
session.tool_calls.push(title.to_string());
if let Some(input) = raw_input {
session
.tool_call_inputs
.insert(tool_call_id.to_string(), input.to_string());
}
}
}
"tool_call_update" => {
let title = payload.get("title").and_then(|v| v.as_str());
let status = payload.get("status").and_then(|v| v.as_str());
let tool_call_id = payload
.get("tool_call_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let raw_input = payload.get("raw_input").and_then(|v| v.as_str());
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
// Accumulate raw_input if newly available
if let Some(input) = raw_input {
session
.tool_call_inputs
.insert(tool_call_id.to_string(), input.to_string());
}
if status == Some("completed") {
let stored_input = session.tool_call_inputs.remove(tool_call_id);
let effective_title = title.unwrap_or("tool");
let input_ref = stored_input.as_deref().or(raw_input);
let detail = format_tool_call_detail(effective_title, input_ref);
let channel_id = session.channel_id;
drop(guard);
let msg = RichMessage::info(format!(">> {detail}"));
let _ = manager.send_to_channel(channel_id, &msg).await;
}
}
}
"permission_request" => {
let request_id = payload
.get("request_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let tool_call = payload
.get("tool_call")
.cloned()
.unwrap_or(serde_json::Value::Null);
let options: Vec<crate::acp::types::PermissionOptionInfo> = payload
.get("options")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
let channel_id = session.channel_id;
let sender_id = session.sender_id.clone();
let auto_approve = sender_context_service::get_or_create(
db,
channel_id,
&sender_id,
)
.await
.map(|ctx| ctx.auto_approve)
.unwrap_or(false);
if auto_approve {
let option_id = options
.iter()
.find(|o| o.kind == "allow" || o.kind == "allowForSession")
.or_else(|| options.first())
.map(|o| o.option_id.clone());
drop(guard);
if let Some(oid) = option_id {
let _ = conn_mgr
.respond_permission(connection_id, request_id, &oid)
.await;
}
return;
}
let tool_title = tool_call
.get("title")
.and_then(|v| v.as_str())
.or_else(|| tool_call.get("tool_name").and_then(|v| v.as_str()))
.unwrap_or("Unknown tool");
// Extract detail from rawInput / raw_input in the tool_call object
let raw_input_str = tool_call
.get("rawInput")
.or_else(|| tool_call.get("raw_input"))
.and_then(|v| match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Null => None,
other => Some(other.to_string()),
});
let tool_desc =
format_tool_call_detail(tool_title, raw_input_str.as_deref());
session.permission_pending = Some(PendingPermission {
request_id: request_id.to_string(),
tool_description: tool_desc.clone(),
options,
sent_message_id: None,
});
drop(guard);
let lang = get_lang(db).await;
let prefix = get_prefix(db).await;
let body = match lang {
Lang::ZhCn | Lang::ZhTw => {
format!("Agent 请求权限: {tool_desc}\n\n{prefix}approve 批准 | {prefix}deny 拒绝 | {prefix}approve always 自动批准")
}
_ => {
format!("Agent requests permission: {tool_desc}\n\n{prefix}approve | {prefix}deny | {prefix}approve always")
}
};
let msg = RichMessage {
title: Some(match lang {
Lang::ZhCn | Lang::ZhTw => "权限请求".to_string(),
_ => "Permission Request".to_string(),
}),
body,
fields: Vec::new(),
level: MessageLevel::Warning,
};
let _ = manager.send_to_channel(channel_id, &msg).await;
}
}
"turn_complete" => {
let stop_reason = payload
.get("stop_reason")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let agent_type = payload
.get("agent_type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
let channel_id = session.channel_id;
let conv_id = session.conversation_id;
let content = std::mem::take(&mut session.content_buffer);
let tool_count = session.tool_calls.len();
session.tool_calls.clear();
session.last_flushed = Instant::now();
drop(guard);
let lang = get_lang(db).await;
let body = format_completion(&content, tool_count, lang);
let msg = RichMessage::info(body)
.with_title(match lang {
Lang::ZhCn | Lang::ZhTw => "任务完成",
_ => "Turn Complete",
})
.with_field("Agent", agent_type)
.with_field(
match lang {
Lang::ZhCn | Lang::ZhTw => "结束原因",
_ => "Stop Reason",
},
localize_stop_reason(stop_reason, lang),
);
let _ = manager.send_to_channel(channel_id, &msg).await;
if stop_reason == "end_turn" {
let _ = conversation_service::update_status(
db,
conv_id,
crate::db::entities::conversation::ConversationStatus::Completed,
)
.await;
}
}
}
"error" => {
let message = payload
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
let agent_type = payload
.get("agent_type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
let mut guard = bridge.lock().await;
if let Some(session) = guard.remove(connection_id) {
let channel_id = session.channel_id;
let sender_id = session.sender_id.clone();
let conv_id = session.conversation_id;
drop(guard);
let lang = get_lang(db).await;
let msg = RichMessage {
title: Some(match lang {
Lang::ZhCn | Lang::ZhTw => "Agent 错误".to_string(),
_ => "Agent Error".to_string(),
}),
body: format!("[{agent_type}] {message}"),
fields: Vec::new(),
level: MessageLevel::Error,
};
let _ = manager.send_to_channel(channel_id, &msg).await;
let _ = conversation_service::update_status(
db,
conv_id,
crate::db::entities::conversation::ConversationStatus::Cancelled,
)
.await;
let _ =
sender_context_service::clear_session(db, channel_id, &sender_id).await;
}
}
"status_changed" => {
let status = payload
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("");
if status == "disconnected" || status == "error" {
let mut guard = bridge.lock().await;
if let Some(session) = guard.remove(connection_id) {
let channel_id = session.channel_id;
let sender_id = session.sender_id.clone();
drop(guard);
let _ =
sender_context_service::clear_session(db, channel_id, &sender_id).await;
}
}
}
_ => {}
}
}
async fn flush_progress(
bridge: &Arc<Mutex<SessionBridge>>,
manager: &ChatChannelManager,
db: &DatabaseConnection,
) {
let updates: Vec<(i32, String)> = {
let mut guard = bridge.lock().await;
let mut out = Vec::new();
for session in guard.all_sessions_mut() {
if !session.content_buffer.is_empty()
&& session.last_flushed.elapsed() >= Duration::from_secs(FLUSH_INTERVAL_SECS)
{
session.last_flushed = Instant::now();
let last_tool = session.tool_calls.last().cloned();
let lang = get_lang(db).await;
let mut status = super::i18n::agent_responding(lang).to_string();
if let Some(tool) = last_tool {
status.push_str(&format!(" | {tool}"));
}
out.push((session.channel_id, status));
}
}
out
};
for (channel_id, text) in updates {
let msg = RichMessage::info(text);
let _ = manager.send_to_channel(channel_id, &msg).await;
}
}
fn format_completion(content: &str, tool_count: usize, lang: Lang) -> String {
if content.is_empty() {
return match lang {
Lang::ZhCn | Lang::ZhTw => format!("(无文本输出, {tool_count} 次工具调用)"),
_ => format!("(No text output, {tool_count} tool calls)"),
};
}
if content.len() <= MAX_MESSAGE_LEN {
let mut body = content.to_string();
if tool_count > 0 {
body.push_str(&format!(
"\n\n[{} {}]",
tool_count,
match lang {
Lang::ZhCn | Lang::ZhTw => "次工具调用",
_ => "tool calls",
}
));
}
return body;
}
// Truncate long content (use char boundaries to avoid panic on multi-byte)
let head_end = content
.char_indices()
.nth(500)
.map(|(i, _)| i)
.unwrap_or(content.len());
let head = &content[..head_end];
let tail_start = content
.char_indices()
.rev()
.nth(499)
.map(|(i, _)| i)
.unwrap_or(0);
let tail = &content[tail_start..];
match lang {
Lang::ZhCn | Lang::ZhTw => {
format!(
"{head}\n\n...\n\n{tail}\n\n[完整回复: {} 字符, {tool_count} 次工具调用]",
content.len()
)
}
_ => {
format!(
"{head}\n\n...\n\n{tail}\n\n[Full response: {} chars, {tool_count} tool calls]",
content.len()
)
}
}
}
fn localize_stop_reason(reason: &str, lang: Lang) -> String {
match lang {
Lang::ZhCn => match reason {
"end_turn" => "正常结束",
"cancelled" => "已取消",
"max_tokens" => "达到最大长度",
"stop_sequence" => "遇到停止序列",
"error" => "错误",
"timeout" => "超时",
other => other,
},
Lang::ZhTw => match reason {
"end_turn" => "正常結束",
"cancelled" => "已取消",
"max_tokens" => "達到最大長度",
"stop_sequence" => "遇到停止序列",
"error" => "錯誤",
"timeout" => "逾時",
other => other,
},
Lang::Ja => match reason {
"end_turn" => "正常終了",
"cancelled" => "キャンセル",
"max_tokens" => "最大トークン数到達",
"stop_sequence" => "停止シーケンス",
"error" => "エラー",
"timeout" => "タイムアウト",
other => other,
},
Lang::Ko => match reason {
"end_turn" => "정상 종료",
"cancelled" => "취소됨",
"max_tokens" => "최대 길이 도달",
"stop_sequence" => "정지 시퀀스",
"error" => "오류",
"timeout" => "시간 초과",
other => other,
},
Lang::Es => match reason {
"end_turn" => "Finalizado",
"cancelled" => "Cancelado",
"max_tokens" => "Longitud máxima alcanzada",
"error" => "Error",
"timeout" => "Tiempo agotado",
other => other,
},
Lang::De => match reason {
"end_turn" => "Abgeschlossen",
"cancelled" => "Abgebrochen",
"max_tokens" => "Maximale Länge erreicht",
"error" => "Fehler",
"timeout" => "Zeitüberschreitung",
other => other,
},
Lang::Fr => match reason {
"end_turn" => "Terminé",
"cancelled" => "Annulé",
"max_tokens" => "Longueur maximale atteinte",
"error" => "Erreur",
"timeout" => "Délai dépassé",
other => other,
},
Lang::Pt => match reason {
"end_turn" => "Concluído",
"cancelled" => "Cancelado",
"max_tokens" => "Comprimento máximo atingido",
"error" => "Erro",
"timeout" => "Tempo esgotado",
other => other,
},
Lang::Ar => match reason {
"end_turn" => "اكتمل",
"cancelled" => "ملغى",
"max_tokens" => "تم بلوغ الحد الأقصى",
"error" => "خطأ",
"timeout" => "انتهت المهلة",
other => other,
},
Lang::En => match reason {
"end_turn" => "Completed",
"cancelled" => "Cancelled",
"max_tokens" => "Max length reached",
"stop_sequence" => "Stop sequence",
"error" => "Error",
"timeout" => "Timeout",
other => other,
},
}
.to_string()
}
/// Extract a concise detail string from a tool call's `raw_input` JSON.
///
/// Returns a formatted string like `"Read: src/main.rs"` or `"Bash: npm test"`.
/// Falls back to the original title if no detail can be extracted.
fn format_tool_call_detail(title: &str, raw_input: Option<&str>) -> String {
let parsed = raw_input.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
let normalized_title = title.to_lowercase().replace([' ', '-'], "_");
if let Some(ref obj) = parsed {
// File operations: read, edit, write, delete
if let Some(path) = obj
.get("file_path")
.or_else(|| obj.get("path"))
.or_else(|| obj.get("notebook_path"))
.and_then(|v| v.as_str())
{
let short = short_path(path);
let label = match normalized_title.as_str() {
s if s.contains("write") => "Write",
s if s.contains("edit") || s.contains("change") || s.contains("update") => "Edit",
s if s.contains("delete") => "Delete",
_ => "Read",
};
return format!("{label}: {short}");
}
// Bash / shell commands
if let Some(cmd) = obj
.get("command")
.or_else(|| obj.get("cmd"))
.and_then(|v| v.as_str())
{
let short = truncate_str(cmd.lines().next().unwrap_or(cmd), 80);
return format!("Bash: {short}");
}
// Grep / search
if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) {
let path = obj.get("path").and_then(|v| v.as_str());
return if let Some(p) = path {
format!("Grep: \"{}\" in {}", truncate_str(pattern, 40), short_path(p))
} else {
format!("Grep: \"{}\"", truncate_str(pattern, 60))
};
}
// Glob
if let Some(pat) = obj.get("glob").and_then(|v| v.as_str()) {
return format!("Glob: {pat}");
}
// Agent / task
if obj.get("subagent_type").is_some()
|| obj.get("task_id").is_some()
|| obj.get("subject").is_some()
{
let desc = obj
.get("description")
.or_else(|| obj.get("subject"))
.or_else(|| obj.get("prompt"))
.and_then(|v| v.as_str());
if let Some(d) = desc {
return format!("Agent: {}", truncate_str(d, 60));
}
}
// Web fetch
if let Some(url) = obj.get("url").and_then(|v| v.as_str()) {
return format!("Fetch: {}", truncate_str(url, 80));
}
// Web search
if let Some(query) = obj.get("query").and_then(|v| v.as_str()) {
return format!("Search: {}", truncate_str(query, 60));
}
// TodoWrite
if obj.get("todos").is_some() {
return "TodoWrite".to_string();
}
}
// Fallback: if raw_input is a plain string (e.g. a bare command), use it directly
if let Some(raw) = raw_input {
if !raw.starts_with('{') && !raw.starts_with('[') {
let short = truncate_str(raw.lines().next().unwrap_or(raw), 80);
if normalized_title.contains("bash")
|| normalized_title.contains("shell")
|| normalized_title.contains("exec")
{
return format!("Bash: {short}");
}
}
}
title.to_string()
}
fn short_path(path: &str) -> &str {
// Show last 2 path components at most, or the full path if short enough
if path.len() <= 60 {
return path;
}
let parts: Vec<&str> = path.rsplitn(3, '/').collect();
if parts.len() >= 2 {
// e.g. "src/main.rs" from "/very/long/path/src/main.rs"
let tail = &path[path.len() - parts[0].len() - parts[1].len() - 1..];
if tail.len() < path.len() {
return tail;
}
}
path
}
fn truncate_str(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
format!("{truncated}...")
}
}

View File

@@ -74,6 +74,15 @@ impl RichMessage {
}
}
pub fn error(body: impl Into<String>) -> Self {
Self {
title: None,
body: body.into(),
fields: Vec::new(),
level: MessageLevel::Error,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self

View File

@@ -0,0 +1,35 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "chat_channel_sender_context")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub channel_id: i32,
pub sender_id: String,
pub current_folder_id: Option<i32>,
pub current_agent_type: Option<String>,
pub current_conversation_id: Option<i32>,
pub current_connection_id: Option<String>,
pub auto_approve: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::chat_channel::Entity",
from = "Column::ChannelId",
to = "super::chat_channel::Column::Id"
)]
ChatChannel,
}
impl Related<super::chat_channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::ChatChannel.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -2,6 +2,7 @@ pub mod agent_setting;
pub mod app_metadata;
pub mod chat_channel;
pub mod chat_channel_message_log;
pub mod chat_channel_sender_context;
pub mod conversation;
pub mod folder;
pub mod folder_command;

View File

@@ -4,6 +4,7 @@ pub use super::agent_setting::Entity as AgentSetting;
pub use super::app_metadata::Entity as AppMetadata;
pub use super::chat_channel::Entity as ChatChannel;
pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog;
pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext;
pub use super::conversation::Entity as Conversation;
pub use super::folder::Entity as Folder;
pub use super::folder_command::Entity as FolderCommand;

View File

@@ -0,0 +1,124 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ChatChannelSenderContext::Table)
.if_not_exists()
.col(
ColumnDef::new(ChatChannelSenderContext::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(ChatChannelSenderContext::ChannelId)
.integer()
.not_null(),
)
.col(
ColumnDef::new(ChatChannelSenderContext::SenderId)
.string()
.not_null(),
)
.col(
ColumnDef::new(ChatChannelSenderContext::CurrentFolderId)
.integer()
.null(),
)
.col(
ColumnDef::new(ChatChannelSenderContext::CurrentAgentType)
.string()
.null(),
)
.col(
ColumnDef::new(ChatChannelSenderContext::CurrentConversationId)
.integer()
.null(),
)
.col(
ColumnDef::new(ChatChannelSenderContext::CurrentConnectionId)
.string()
.null(),
)
.col(
ColumnDef::new(ChatChannelSenderContext::AutoApprove)
.boolean()
.not_null()
.default(false),
)
.col(
ColumnDef::new(ChatChannelSenderContext::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(ChatChannelSenderContext::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name("fk_ccsc_channel_id")
.from(
ChatChannelSenderContext::Table,
ChatChannelSenderContext::ChannelId,
)
.to(ChatChannel::Table, ChatChannel::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_ccsc_channel_sender")
.table(ChatChannelSenderContext::Table)
.col(ChatChannelSenderContext::ChannelId)
.col(ChatChannelSenderContext::SenderId)
.unique()
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(
Table::drop()
.table(ChatChannelSenderContext::Table)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum ChatChannelSenderContext {
Table,
Id,
ChannelId,
SenderId,
CurrentFolderId,
CurrentAgentType,
CurrentConversationId,
CurrentConnectionId,
AutoApprove,
CreatedAt,
UpdatedAt,
}
#[derive(DeriveIden)]
enum ChatChannel {
Table,
Id,
}

View File

@@ -6,6 +6,7 @@ mod m20260221_000001_folder_is_open;
mod m20260226_000001_agent_setting;
mod m20260227_000001_folder_parent_branch;
mod m20260330_000001_chat_channel;
mod m20260401_000001_chat_channel_sender_context;
pub struct Migrator;
#[async_trait::async_trait]
@@ -18,6 +19,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260226_000001_agent_setting::Migration),
Box::new(m20260227_000001_folder_parent_branch::Migration),
Box::new(m20260330_000001_chat_channel::Migration),
Box::new(m20260401_000001_chat_channel_sender_context::Migration),
]
}
}

View File

@@ -6,3 +6,4 @@ pub mod conversation_service;
pub mod folder_command_service;
pub mod folder_service;
pub mod import_service;
pub mod sender_context_service;

View File

@@ -0,0 +1,101 @@
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
IntoActiveModel, QueryFilter, Set,
};
use crate::db::entities::chat_channel_sender_context;
use crate::db::error::DbError;
pub async fn get_or_create(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
) -> Result<chat_channel_sender_context::Model, DbError> {
let existing = chat_channel_sender_context::Entity::find()
.filter(chat_channel_sender_context::Column::ChannelId.eq(channel_id))
.filter(chat_channel_sender_context::Column::SenderId.eq(sender_id))
.one(conn)
.await?;
if let Some(model) = existing {
return Ok(model);
}
let now = Utc::now();
let active = chat_channel_sender_context::ActiveModel {
id: NotSet,
channel_id: Set(channel_id),
sender_id: Set(sender_id.to_string()),
current_folder_id: Set(None),
current_agent_type: Set(None),
current_conversation_id: Set(None),
current_connection_id: Set(None),
auto_approve: Set(false),
created_at: Set(now),
updated_at: Set(now),
};
Ok(active.insert(conn).await?)
}
pub async fn update_folder(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
folder_id: Option<i32>,
) -> Result<chat_channel_sender_context::Model, DbError> {
let model = get_or_create(conn, channel_id, sender_id).await?;
let mut active = model.into_active_model();
active.current_folder_id = Set(folder_id);
active.updated_at = Set(Utc::now());
Ok(active.update(conn).await?)
}
pub async fn update_agent(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
agent_type: Option<String>,
) -> Result<chat_channel_sender_context::Model, DbError> {
let model = get_or_create(conn, channel_id, sender_id).await?;
let mut active = model.into_active_model();
active.current_agent_type = Set(agent_type);
active.updated_at = Set(Utc::now());
Ok(active.update(conn).await?)
}
pub async fn update_session(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
conversation_id: Option<i32>,
connection_id: Option<String>,
) -> Result<chat_channel_sender_context::Model, DbError> {
let model = get_or_create(conn, channel_id, sender_id).await?;
let mut active = model.into_active_model();
active.current_conversation_id = Set(conversation_id);
active.current_connection_id = Set(connection_id);
active.updated_at = Set(Utc::now());
Ok(active.update(conn).await?)
}
pub async fn clear_session(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
) -> Result<chat_channel_sender_context::Model, DbError> {
update_session(conn, channel_id, sender_id, None, None).await
}
pub async fn update_auto_approve(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
auto_approve: bool,
) -> Result<chat_channel_sender_context::Model, DbError> {
let model = get_or_create(conn, channel_id, sender_id).await?;
let mut active = model.into_active_model();
active.auto_approve = Set(auto_approve);
active.updated_at = Set(Utc::now());
Ok(active.update(conn).await?)
}

View File

@@ -90,8 +90,10 @@ mod tauri_app {
let db_conn = app.state::<db::AppDatabase>().conn.clone();
let ccm_ref = ccm.clone_ref();
let br = broadcaster.inner().clone();
let cm = app.state::<ConnectionManager>().clone_ref();
let emitter = web::event_bridge::EventEmitter::Tauri(app.handle().clone());
tauri::async_runtime::spawn(async move {
ccm_ref.start_background(br, db_conn).await;
ccm_ref.start_background(br, db_conn, cm, emitter).await;
});
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "codeg",
"version": "0.6.2",
"version": "0.6.3",
"identifier": "app.codeg",
"build": {
"beforeDevCommand": "pnpm dev",

View File

@@ -15,7 +15,7 @@ import { cn } from "@/lib/utils"
import { useTranslations } from "next-intl"
import { cjk } from "@streamdown/cjk"
import { code } from "@streamdown/code"
import { math } from "@streamdown/math"
import { createMathPlugin } from "@streamdown/math"
import { mermaid } from "@streamdown/mermaid"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import {
@@ -326,6 +326,7 @@ export const MessageBranchPage = ({
export type MessageResponseProps = ComponentProps<typeof Streamdown>
const math = createMathPlugin({ singleDollarTextMath: true })
const streamdownPlugins = { cjk, code, math, mermaid }
function MessageResponseImpl({ className, ...props }: MessageResponseProps) {

View File

@@ -12,7 +12,7 @@ import {
import { cn } from "@/lib/utils"
import { cjk } from "@streamdown/cjk"
import { code } from "@streamdown/code"
import { math } from "@streamdown/math"
import { createMathPlugin } from "@streamdown/math"
import { mermaid } from "@streamdown/mermaid"
import { BrainIcon, ChevronDownIcon } from "lucide-react"
import {
@@ -212,6 +212,7 @@ export type ReasoningContentProps = ComponentProps<
children: string
}
const math = createMathPlugin({ singleDollarTextMath: true })
const streamdownPlugins = { cjk, code, math, mermaid }
export const ReasoningContent = memo(

View File

@@ -18,13 +18,14 @@ import {
} from "@/components/ui/context-menu"
import { cjk } from "@streamdown/cjk"
import { code } from "@streamdown/code"
import { math } from "@streamdown/math"
import { createMathPlugin } from "@streamdown/math"
import { mermaid } from "@streamdown/mermaid"
import { Streamdown } from "streamdown"
import { readFileBase64 } from "@/lib/api"
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
import "@/lib/monaco-local"
const math = createMathPlugin({ singleDollarTextMath: true })
const previewPlugins = { cjk, code, math, mermaid }
function resolveRelativePath(base: string, relative: string): string {

View File

@@ -10,9 +10,15 @@ import { Input } from "@/components/ui/input"
import { getChatCommandPrefix, setChatCommandPrefix } from "@/lib/api"
const BUILT_IN_COMMANDS = [
{ name: "recent", descKey: "recentDesc" },
{ name: "folder [n|path]", descKey: "folderDesc" },
{ name: "agent [n|name]", descKey: "agentDesc" },
{ name: "task <description>", descKey: "taskDesc" },
{ name: "sessions", descKey: "sessionsDesc" },
{ name: "resume [id]", descKey: "resumeDesc" },
{ name: "cancel", descKey: "cancelDesc" },
{ name: "approve [always]", descKey: "approveDesc" },
{ name: "deny", descKey: "denyDesc" },
{ name: "search <keyword>", descKey: "searchDesc" },
{ name: "detail <id>", descKey: "detailDesc" },
{ name: "today", descKey: "todayDesc" },
{ name: "status", descKey: "statusDesc" },
{ name: "help", descKey: "helpDesc" },

View File

@@ -1724,16 +1724,22 @@
},
"commands": {
"title": "الأوامر المدمجة",
"description": "أوامر البوت المتاحة في قنوات المحادثة.",
"description": "أوامر البوت المتاحة في قنوات المحادثة. في المحادثات الجماعية، يلزم @Bot لمعالجة الرسائل.",
"prefixLabel": "بادئة الأمر",
"prefixDescription": "1-3 أحرف غير أبجدية رقمية لتشغيل أوامر البوت (الافتراضي /).",
"prefixSaved": "تم حفظ بادئة الأمر.",
"prefixSaveFailed": "فشل حفظ بادئة الأمر.",
"prefixInvalid": "يجب أن تكون البادئة 1-3 أحرف غير أبجدية رقمية.",
"save": "حفظ",
"recentDesc": "عرض آخر 5 محادثات",
"folderDesc": "اختيار مجلد العمل",
"agentDesc": "اختيار وكيل الذكاء الاصطناعي",
"taskDesc": "إنشاء جلسة وتنفيذ المهمة",
"sessionsDesc": "عرض الجلسات النشطة في المجلد",
"resumeDesc": "المحادثات الأخيرة / استئناف جلسة",
"cancelDesc": "إلغاء المهمة الحالية",
"approveDesc": "الموافقة على طلب إذن الوكيل",
"denyDesc": "رفض طلب إذن الوكيل",
"searchDesc": "البحث في المحادثات حسب الكلمة المفتاحية",
"detailDesc": "عرض تفاصيل المحادثة",
"todayDesc": "ملخص نشاط اليوم",
"statusDesc": "حالة اتصال القناة",
"helpDesc": "عرض المساعدة"

View File

@@ -1058,7 +1058,7 @@
"pushWindow": {
"title": "Code pushen",
"noUnpushedCommits": "Keine ungepushten Commits",
"noRemoteConfigured": "Kein Git-Remote konfiguriert\nFüge einen unter \u201ERemotes verwalten\u201C hinzu",
"noRemoteConfigured": "Kein Git-Remote konfiguriert\nFüge einen unter Remotes verwalten hinzu",
"newBranchNoPushedCommits": "Neuer Branch — pushen, um Remote-Tracking-Branch zu erstellen",
"unpushed": "Nicht gepusht",
"selectFileToViewDiff": "Datei auswählen, um Unterschiede anzuzeigen",
@@ -1724,16 +1724,22 @@
},
"commands": {
"title": "Integrierte Befehle",
"description": "Im Chat-Kanal verfügbare Bot-Befehle.",
"description": "Im Chat-Kanal verfügbare Bot-Befehle. In Gruppenchats ist @Bot erforderlich, um Nachrichten zu verarbeiten.",
"prefixLabel": "Befehlspräfix",
"prefixDescription": "1-3 nicht-alphanumerische Zeichen zum Auslösen von Bot-Befehlen (Standard /).",
"prefixSaved": "Befehlspräfix gespeichert.",
"prefixSaveFailed": "Fehler beim Speichern des Präfixes.",
"prefixInvalid": "Das Präfix muss 1-3 nicht-alphanumerische Zeichen sein.",
"save": "Speichern",
"recentDesc": "Die 5 neuesten Konversationen anzeigen",
"folderDesc": "Arbeitsordner auswählen",
"agentDesc": "KI-Agent auswählen",
"taskDesc": "Sitzung erstellen und Aufgabe ausführen",
"sessionsDesc": "Aktive Sitzungen im Ordner anzeigen",
"resumeDesc": "Neueste Konversationen / Sitzung fortsetzen",
"cancelDesc": "Aktuelle Aufgabe abbrechen",
"approveDesc": "Berechtigungsanfrage des Agenten genehmigen",
"denyDesc": "Berechtigungsanfrage des Agenten ablehnen",
"searchDesc": "Konversationen nach Stichwort suchen",
"detailDesc": "Konversationsdetails anzeigen",
"todayDesc": "Heutige Aktivitätsübersicht",
"statusDesc": "Kanal-Verbindungsstatus",
"helpDesc": "Hilfe anzeigen"

View File

@@ -1724,16 +1724,22 @@
},
"commands": {
"title": "Built-in Commands",
"description": "Bot commands available in chat channels.",
"description": "Bot commands available in chat channels. In group chats, @Bot is required to process messages.",
"prefixLabel": "Command Prefix",
"prefixDescription": "1-3 non-alphanumeric characters used to trigger bot commands (default /).",
"prefixSaved": "Command prefix saved.",
"prefixSaveFailed": "Failed to save command prefix.",
"prefixInvalid": "Prefix must be 1-3 non-alphanumeric characters.",
"save": "Save",
"recentDesc": "Show 5 most recent conversations",
"folderDesc": "Select working folder",
"agentDesc": "Select AI agent",
"taskDesc": "Create session and run task",
"sessionsDesc": "List active sessions in folder",
"resumeDesc": "Recent conversations / resume a session",
"cancelDesc": "Cancel current task",
"approveDesc": "Approve agent permission request",
"denyDesc": "Deny agent permission request",
"searchDesc": "Search conversations by keyword",
"detailDesc": "Show conversation details",
"todayDesc": "Today's activity summary",
"statusDesc": "Channel connection status",
"helpDesc": "Show help message"

View File

@@ -1724,16 +1724,22 @@
},
"commands": {
"title": "Comandos integrados",
"description": "Comandos de bot disponibles en los canales de chat.",
"description": "Comandos de bot disponibles en los canales de chat. En chats grupales, se requiere @Bot para procesar mensajes.",
"prefixLabel": "Prefijo de comando",
"prefixDescription": "1-3 caracteres no alfanuméricos para activar comandos del bot (por defecto /).",
"prefixSaved": "Prefijo de comando guardado.",
"prefixSaveFailed": "Error al guardar el prefijo.",
"prefixInvalid": "El prefijo debe ser de 1-3 caracteres no alfanuméricos.",
"save": "Guardar",
"recentDesc": "Mostrar las 5 conversaciones más recientes",
"folderDesc": "Seleccionar carpeta de trabajo",
"agentDesc": "Seleccionar agente de IA",
"taskDesc": "Crear sesión y ejecutar tarea",
"sessionsDesc": "Listar sesiones activas en la carpeta",
"resumeDesc": "Conversaciones recientes / reanudar una sesión",
"cancelDesc": "Cancelar tarea actual",
"approveDesc": "Aprobar solicitud de permiso del agente",
"denyDesc": "Denegar solicitud de permiso del agente",
"searchDesc": "Buscar conversaciones por palabra clave",
"detailDesc": "Mostrar detalles de la conversación",
"todayDesc": "Resumen de actividad de hoy",
"statusDesc": "Estado de conexión del canal",
"helpDesc": "Mostrar ayuda"

View File

@@ -1724,16 +1724,22 @@
},
"commands": {
"title": "Commandes intégrées",
"description": "Commandes bot disponibles dans les canaux de chat.",
"description": "Commandes bot disponibles dans les canaux de chat. Dans les chats de groupe, @Bot est requis pour traiter les messages.",
"prefixLabel": "Préfixe de commande",
"prefixDescription": "1-3 caractères non alphanumériques pour déclencher les commandes du bot (par défaut /).",
"prefixSaved": "Préfixe de commande enregistré.",
"prefixSaveFailed": "Échec de l'enregistrement du préfixe.",
"prefixInvalid": "Le préfixe doit être de 1-3 caractères non alphanumériques.",
"save": "Enregistrer",
"recentDesc": "Afficher les 5 conversations les plus récentes",
"folderDesc": "Sélectionner le dossier de travail",
"agentDesc": "Sélectionner l'agent IA",
"taskDesc": "Créer une session et exécuter la tâche",
"sessionsDesc": "Lister les sessions actives du dossier",
"resumeDesc": "Conversations récentes / reprendre une session",
"cancelDesc": "Annuler la tâche en cours",
"approveDesc": "Approuver la demande de permission de l'agent",
"denyDesc": "Refuser la demande de permission de l'agent",
"searchDesc": "Rechercher des conversations par mot-clé",
"detailDesc": "Afficher les détails de la conversation",
"todayDesc": "Résumé de l'activité du jour",
"statusDesc": "État de connexion du canal",
"helpDesc": "Afficher l'aide"

View File

@@ -1724,16 +1724,22 @@
},
"commands": {
"title": "組み込みコマンド",
"description": "チャットチャンネルで使用可能な Bot コマンド。",
"description": "チャットチャンネルで使用可能な Bot コマンド。グループチャットではメッセージを処理するために @Bot が必要です。",
"prefixLabel": "コマンドプレフィックス",
"prefixDescription": "Bot コマンドを起動するプレフィックス、1-3 文字の英数字以外の文字(デフォルト /)。",
"prefixSaved": "コマンドプレフィックスを保存しました。",
"prefixSaveFailed": "コマンドプレフィックスの保存に失敗しました。",
"prefixInvalid": "プレフィックスは1-3文字の英数字以外の文字である必要があります。",
"save": "保存",
"recentDesc": "最近の会話5件を表示",
"folderDesc": "作業フォルダを選択",
"agentDesc": "AIエージェントを選択",
"taskDesc": "セッションを作成してタスクを実行",
"sessionsDesc": "フォルダ内のアクティブなセッション一覧",
"resumeDesc": "最近の会話 / セッションを再開",
"cancelDesc": "現在のタスクをキャンセル",
"approveDesc": "エージェントの権限リクエストを承認",
"denyDesc": "エージェントの権限リクエストを拒否",
"searchDesc": "キーワードで会話を検索",
"detailDesc": "会話の詳細を表示",
"todayDesc": "本日のアクティビティ概要",
"statusDesc": "チャンネル接続状態",
"helpDesc": "ヘルプを表示"

View File

@@ -1724,16 +1724,22 @@
},
"commands": {
"title": "내장 명령어",
"description": "채팅 채널에서 사용 가능한 Bot 명령어입니다.",
"description": "채팅 채널에서 사용 가능한 Bot 명령어입니다. 그룹 채팅에서는 메시지를 처리하려면 @Bot이 필요합니다.",
"prefixLabel": "명령어 접두사",
"prefixDescription": "Bot 명령어를 실행하는 접두사, 1-3개의 영숫자가 아닌 문자 (기본값 /).",
"prefixSaved": "명령어 접두사가 저장되었습니다.",
"prefixSaveFailed": "명령어 접두사 저장에 실패했습니다.",
"prefixInvalid": "접두사는 1-3개의 영숫자가 아닌 문자여야 합니다.",
"save": "저장",
"recentDesc": "최근 대화 5개 표시",
"folderDesc": "작업 폴더 선택",
"agentDesc": "AI 에이전트 선택",
"taskDesc": "세션 생성 및 작업 실행",
"sessionsDesc": "폴더 내 활성 세션 목록",
"resumeDesc": "최근 대화 / 세션 재개",
"cancelDesc": "현재 작업 취소",
"approveDesc": "에이전트 권한 요청 승인",
"denyDesc": "에이전트 권한 요청 거부",
"searchDesc": "키워드로 대화 검색",
"detailDesc": "대화 상세 정보 표시",
"todayDesc": "오늘의 활동 요약",
"statusDesc": "채널 연결 상태",
"helpDesc": "도움말 표시"

View File

@@ -1724,16 +1724,22 @@
},
"commands": {
"title": "Comandos integrados",
"description": "Comandos de bot disponíveis nos canais de chat.",
"description": "Comandos de bot disponíveis nos canais de chat. Em chats em grupo, @Bot é necessário para processar mensagens.",
"prefixLabel": "Prefixo de comando",
"prefixDescription": "1-3 caracteres não alfanuméricos para acionar comandos do bot (padrão /).",
"prefixSaved": "Prefixo de comando salvo.",
"prefixSaveFailed": "Falha ao salvar o prefixo.",
"prefixInvalid": "O prefixo deve ser de 1-3 caracteres não alfanuméricos.",
"save": "Salvar",
"recentDesc": "Mostrar as 5 conversas mais recentes",
"folderDesc": "Selecionar pasta de trabalho",
"agentDesc": "Selecionar agente de IA",
"taskDesc": "Criar sessão e executar tarefa",
"sessionsDesc": "Listar sessões ativas na pasta",
"resumeDesc": "Conversas recentes / retomar uma sessão",
"cancelDesc": "Cancelar tarefa atual",
"approveDesc": "Aprovar solicitação de permissão do agente",
"denyDesc": "Negar solicitação de permissão do agente",
"searchDesc": "Pesquisar conversas por palavra-chave",
"detailDesc": "Mostrar detalhes da conversa",
"todayDesc": "Resumo da atividade de hoje",
"statusDesc": "Status da conexão do canal",
"helpDesc": "Mostrar ajuda"

View File

@@ -1724,16 +1724,22 @@
},
"commands": {
"title": "内置指令",
"description": "消息渠道中可用的 Bot 指令。",
"description": "消息渠道中可用的 Bot 指令。群聊中需 @Bot 才会处理消息。",
"prefixLabel": "指令前缀",
"prefixDescription": "触发 Bot 指令的前缀1-3 个非字母数字字符(默认 /)。",
"prefixSaved": "指令前缀已保存。",
"prefixSaveFailed": "保存指令前缀失败。",
"prefixInvalid": "前缀必须是 1-3 个非字母数字字符。",
"save": "保存",
"recentDesc": "最近 5 条会话",
"folderDesc": "选择工作目录",
"agentDesc": "选择 AI Agent",
"taskDesc": "创建会话并执行任务",
"sessionsDesc": "列出当前目录的活跃会话",
"resumeDesc": "最近会话 / 恢复指定会话",
"cancelDesc": "取消当前任务",
"approveDesc": "批准 Agent 权限请求",
"denyDesc": "拒绝 Agent 权限请求",
"searchDesc": "按关键词搜索会话",
"detailDesc": "会话详情",
"todayDesc": "今日活动汇总",
"statusDesc": "渠道连接状态",
"helpDesc": "显示帮助"

View File

@@ -1724,16 +1724,22 @@
},
"commands": {
"title": "內建指令",
"description": "訊息頻道中可用的 Bot 指令。",
"description": "訊息頻道中可用的 Bot 指令。群組聊天中需 @Bot 才會處理訊息。",
"prefixLabel": "指令前綴",
"prefixDescription": "觸發 Bot 指令的前綴1-3 個非字母數字字元(預設 /)。",
"prefixSaved": "指令前綴已儲存。",
"prefixSaveFailed": "儲存指令前綴失敗。",
"prefixInvalid": "前綴必須是 1-3 個非字母數字字元。",
"save": "儲存",
"recentDesc": "最近 5 筆對話",
"folderDesc": "選擇工作目錄",
"agentDesc": "選擇 AI Agent",
"taskDesc": "建立會話並執行任務",
"sessionsDesc": "列出當前目錄的活躍會話",
"resumeDesc": "最近對話 / 恢復指定對話",
"cancelDesc": "取消當前任務",
"approveDesc": "批准 Agent 權限請求",
"denyDesc": "拒絕 Agent 權限請求",
"searchDesc": "依關鍵字搜尋對話",
"detailDesc": "對話詳情",
"todayDesc": "今日活動摘要",
"statusDesc": "頻道連線狀態",
"helpDesc": "顯示說明"