From aef8cdade1df862fc70f24974a6e81fb387cb36a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 14:04:04 +0800 Subject: [PATCH] feat(web): redesign Chat page with slash commands, session drawer, and fix bridge permission handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redesign Sessions → Chat: each project is a chat entry (IM-style) - Add CommandPalette with all slash commands grouped by category - Add SessionDrawer (right slide-out panel via /list command) - Auto-load latest session when entering a project chat - Fix bridge handleCardAction to properly dispatch perm:, askq:, cmd: actions as messages - Filter incoming bridge messages by session_key to prevent session mixing - Add chat/cmd i18n translations for all 5 languages - Update nav, routing, and header for Chat Made-with: Cursor --- core/bridge.go | 69 ++- core/management.go | 13 +- web/src/App.tsx | 8 +- web/src/components/Layout/Header.tsx | 2 +- web/src/components/Layout/Sidebar.tsx | 2 +- web/src/components/ui/Badge.tsx | 6 +- web/src/hooks/useBridgeSocket.ts | 159 ++++++ web/src/i18n/locales/en.json | 49 +- web/src/i18n/locales/es.json | 49 +- web/src/i18n/locales/ja.json | 49 +- web/src/i18n/locales/zh-TW.json | 49 +- web/src/i18n/locales/zh.json | 49 +- web/src/pages/Chat/ChatList.tsx | 148 ++++++ web/src/pages/Chat/ChatView.tsx | 642 +++++++++++++++++++++++++ web/src/pages/Chat/CommandPalette.tsx | 186 +++++++ web/src/pages/Chat/SessionDrawer.tsx | 130 +++++ web/src/pages/Sessions/SessionChat.tsx | 430 ++++++++++++++--- 17 files changed, 1951 insertions(+), 89 deletions(-) create mode 100644 web/src/hooks/useBridgeSocket.ts create mode 100644 web/src/pages/Chat/ChatList.tsx create mode 100644 web/src/pages/Chat/ChatView.tsx create mode 100644 web/src/pages/Chat/CommandPalette.tsx create mode 100644 web/src/pages/Chat/SessionDrawer.tsx diff --git a/core/bridge.go b/core/bridge.go index 38f52a4..3b34d7a 100644 --- a/core/bridge.go +++ b/core/bridge.go @@ -691,7 +691,42 @@ func (a *bridgeAdapter) handleCardAction(raw json.RawMessage) { slog.Debug("bridge: card_action", "platform", a.platform, "action", ca.Action, "session_key", ca.SessionKey) ref := a.server.resolveEngine(ca.SessionKey) - if ref == nil || ref.platform.navHandler == nil { + if ref == nil { + return + } + + // perm: — permission response; convert to a regular message for the engine + if strings.HasPrefix(ca.Action, "perm:") { + var responseText string + switch ca.Action { + case "perm:allow": + responseText = "allow" + case "perm:deny": + responseText = "deny" + case "perm:allow_all": + responseText = "allow all" + default: + return + } + a.dispatchAsMessage(ref, ca.SessionKey, ca.ReplyCtx, responseText) + return + } + + // askq: — AskUserQuestion answer; forward as a regular message + if strings.HasPrefix(ca.Action, "askq:") { + a.dispatchAsMessage(ref, ca.SessionKey, ca.ReplyCtx, ca.Action) + return + } + + // cmd: — command shortcut from a card button; forward as a message + if strings.HasPrefix(ca.Action, "cmd:") { + cmdText := strings.TrimPrefix(ca.Action, "cmd:") + a.dispatchAsMessage(ref, ca.SessionKey, ca.ReplyCtx, cmdText) + return + } + + // nav: / act: — card navigation and in-place updates + if ref.platform.navHandler == nil { return } @@ -700,12 +735,6 @@ func (a *bridgeAdapter) handleCardAction(raw json.RawMessage) { return } - rc := &bridgeReplyCtx{ - Platform: a.platform, - SessionKey: ca.SessionKey, - ReplyCtx: ca.ReplyCtx, - } - if a.capabilities["card"] { _ = a.server.sendToAdapter(a.platform, map[string]any{ "type": "card", @@ -714,10 +743,36 @@ func (a *bridgeAdapter) handleCardAction(raw json.RawMessage) { "card": serializeCard(card), }) } else { + rc := &bridgeReplyCtx{ + Platform: a.platform, + SessionKey: ca.SessionKey, + ReplyCtx: ca.ReplyCtx, + } _ = ref.platform.Reply(context.Background(), rc, card.RenderText()) } } +// dispatchAsMessage converts a card action into a regular user message +// and dispatches it to the engine's message handler. +func (a *bridgeAdapter) dispatchAsMessage(ref *bridgeEngineRef, sessionKey, replyCtx, content string) { + if ref.platform.handler == nil { + return + } + msg := &Message{ + SessionKey: sessionKey, + Platform: a.platform, + UserID: "web-admin", + UserName: "Web Admin", + Content: content, + ReplyCtx: &bridgeReplyCtx{ + Platform: a.platform, + SessionKey: sessionKey, + ReplyCtx: replyCtx, + }, + } + go ref.platform.handler(ref.platform, msg) +} + func (a *bridgeAdapter) handlePreviewAck(raw json.RawMessage) { var ack bridgePreviewAck if err := json.Unmarshal(raw, &ack); err != nil { diff --git a/core/management.go b/core/management.go index bdad4cb..5b4555d 100644 --- a/core/management.go +++ b/core/management.go @@ -212,13 +212,22 @@ func (m *ManagementServer) handleStatus(w http.ResponseWriter, r *http.Request) adapters = m.listBridgeAdapters() } - mgmtJSON(w, http.StatusOK, map[string]any{ + resp := map[string]any{ "version": CurrentVersion, "uptime_seconds": int(time.Since(m.startedAt).Seconds()), "connected_platforms": platforms, "projects_count": len(m.engines), "bridge_adapters": adapters, - }) + } + if m.bridgeServer != nil { + resp["bridge"] = map[string]any{ + "enabled": true, + "port": m.bridgeServer.port, + "path": m.bridgeServer.path, + "token": m.bridgeServer.token, + } + } + mgmtJSON(w, http.StatusOK, resp) } func (m *ManagementServer) handleRestart(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/App.tsx b/web/src/App.tsx index a002293..f599899 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,8 +5,8 @@ import Login from '@/pages/Login'; import Dashboard from '@/pages/Dashboard'; import ProjectList from '@/pages/Projects/ProjectList'; import ProjectDetail from '@/pages/Projects/ProjectDetail'; -import SessionList from '@/pages/Sessions/SessionList'; -import SessionChat from '@/pages/Sessions/SessionChat'; +import ChatList from '@/pages/Chat/ChatList'; +import ChatView from '@/pages/Chat/ChatView'; import CronList from '@/pages/Cron/CronList'; import BridgeAdapters from '@/pages/Bridge/BridgeAdapters'; import SystemConfig from '@/pages/System/Config'; @@ -28,8 +28,8 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/components/Layout/Header.tsx b/web/src/components/Layout/Header.tsx index 33f4f14..56756f1 100644 --- a/web/src/components/Layout/Header.tsx +++ b/web/src/components/Layout/Header.tsx @@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'; const routeTitles: Record = { '/': 'nav.dashboard', '/projects': 'nav.projects', - '/sessions': 'nav.sessions', + '/chat': 'nav.chat', '/cron': 'nav.cron', '/bridge': 'nav.bridge', '/system': 'nav.system', diff --git a/web/src/components/Layout/Sidebar.tsx b/web/src/components/Layout/Sidebar.tsx index 231adc1..c1788d6 100644 --- a/web/src/components/Layout/Sidebar.tsx +++ b/web/src/components/Layout/Sidebar.tsx @@ -23,7 +23,7 @@ import { useState } from 'react'; const navItems = [ { key: 'dashboard', path: '/', icon: LayoutDashboard }, { key: 'projects', path: '/projects', icon: FolderKanban }, - { key: 'sessions', path: '/sessions', icon: MessageSquare }, + { key: 'chat', path: '/chat', icon: MessageSquare }, { key: 'cron', path: '/cron', icon: Clock }, { key: 'bridge', path: '/bridge', icon: Cable }, { key: 'system', path: '/system', icon: Settings }, diff --git a/web/src/components/ui/Badge.tsx b/web/src/components/ui/Badge.tsx index b7825f7..17191bb 100644 --- a/web/src/components/ui/Badge.tsx +++ b/web/src/components/ui/Badge.tsx @@ -3,6 +3,7 @@ import { cn } from '@/lib/utils'; interface BadgeProps { children: React.ReactNode; variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'; + className?: string; } const variants = { @@ -16,12 +17,13 @@ const variants = { info: 'bg-blue-100/90 dark:bg-blue-900/25 text-blue-700 dark:text-blue-400 border border-blue-200/50 dark:border-blue-500/20', }; -export function Badge({ children, variant = 'default' }: BadgeProps) { +export function Badge({ children, variant = 'default', className }: BadgeProps) { return ( {children} diff --git a/web/src/hooks/useBridgeSocket.ts b/web/src/hooks/useBridgeSocket.ts new file mode 100644 index 0000000..fd3e755 --- /dev/null +++ b/web/src/hooks/useBridgeSocket.ts @@ -0,0 +1,159 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import api from '@/api/client'; + +export type BridgeIncoming = + | { type: 'register_ack'; ok: boolean; error?: string } + | { type: 'reply'; session_key: string; reply_ctx: string; content: string; format?: string } + | { type: 'reply_stream'; session_key: string; reply_ctx: string; delta: string; full_text: string; preview_handle?: string; done: boolean } + | { type: 'card'; session_key: string; reply_ctx: string; card: any } + | { type: 'buttons'; session_key: string; reply_ctx: string; content: string; buttons: { text: string; data: string }[][] } + | { type: 'typing_start'; session_key: string } + | { type: 'typing_stop'; session_key: string } + | { type: 'preview_start'; ref_id: string; session_key: string; reply_ctx: string; content: string } + | { type: 'update_message'; session_key: string; preview_handle: string; content: string } + | { type: 'delete_message'; session_key: string; preview_handle: string } + | { type: 'error'; code: string; message: string } + | { type: 'pong'; ts: number } + | { type: string; [key: string]: any }; + +export interface BridgeConfig { + port: number; + path: string; + token: string; +} + +export type BridgeStatus = 'connecting' | 'registering' | 'connected' | 'disconnected' | 'error'; + +export interface UseBridgeSocketOptions { + bridgeCfg: BridgeConfig | null; + platformName?: string; + sessionKey: string; + onMessage: (msg: BridgeIncoming) => void; +} + +export function useBridgeSocket({ bridgeCfg, platformName = 'web', sessionKey, onMessage }: UseBridgeSocketOptions) { + const wsRef = useRef(null); + const onMessageRef = useRef(onMessage); + onMessageRef.current = onMessage; + const pingRef = useRef | null>(null); + const [status, setStatus] = useState('disconnected'); + + const send = useCallback((data: Record) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(data)); + } + }, []); + + const sendMessage = useCallback((content: string) => { + send({ + type: 'message', + msg_id: `web-${Date.now()}`, + session_key: sessionKey, + user_id: 'web-admin', + user_name: 'Web Admin', + content, + reply_ctx: sessionKey, + }); + }, [send, sessionKey]); + + const sendCardAction = useCallback((action: string) => { + send({ + type: 'card_action', + session_key: sessionKey, + action, + reply_ctx: sessionKey, + }); + }, [send, sessionKey]); + + const sendPreviewAck = useCallback((refId: string, handle: string) => { + send({ type: 'preview_ack', ref_id: refId, preview_handle: handle }); + }, [send]); + + useEffect(() => { + if (!bridgeCfg) return; + + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.hostname; + const wsUrl = `${proto}//${host}:${bridgeCfg.port}${bridgeCfg.path}?token=${encodeURIComponent(bridgeCfg.token)}`; + + let ws: WebSocket; + let reconnectTimer: ReturnType; + let alive = true; + + const connect = () => { + if (!alive) return; + setStatus('connecting'); + ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + setStatus('registering'); + ws.send(JSON.stringify({ + type: 'register', + platform: platformName, + capabilities: ['text', 'card', 'buttons', 'typing', 'update_message', 'preview', 'reconstruct_reply'], + metadata: { version: '1.0.0', description: 'Web Admin Dashboard' }, + })); + }; + + ws.onmessage = (evt) => { + try { + const msg = JSON.parse(evt.data) as BridgeIncoming; + if (msg.type === 'register_ack') { + if (msg.ok) { + setStatus('connected'); + pingRef.current = setInterval(() => { + send({ type: 'ping', ts: Date.now() }); + }, 25000); + } else { + setStatus('error'); + } + } + onMessageRef.current(msg); + } catch { /* ignore parse errors */ } + }; + + ws.onclose = () => { + setStatus('disconnected'); + wsRef.current = null; + if (pingRef.current) clearInterval(pingRef.current); + if (alive) reconnectTimer = setTimeout(connect, 3000); + }; + + ws.onerror = () => { + setStatus('error'); + }; + }; + + connect(); + + return () => { + alive = false; + clearTimeout(reconnectTimer); + if (pingRef.current) clearInterval(pingRef.current); + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.close(); + wsRef.current = null; + } + setStatus('disconnected'); + }; + }, [bridgeCfg, platformName, send]); + + return { status, send, sendMessage, sendCardAction, sendPreviewAck }; +} + +// Fetch bridge config from the management API status endpoint. +export async function fetchBridgeConfig(): Promise { + try { + const status = await api.get('/status'); + if (status.bridge?.enabled) { + return { + port: status.bridge.port, + path: status.bridge.path, + token: status.bridge.token, + }; + } + } catch { /* bridge not available */ } + return null; +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 205c256..483bd73 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3,6 +3,7 @@ "dashboard": "Dashboard", "projects": "Projects", "sessions": "Sessions", + "chat": "Chat", "cron": "Cron", "bridge": "Bridge", "system": "System" @@ -57,7 +58,11 @@ "justNow": "just now", "allProjects": "All projects", "chat": "Chat", - "enterSession": "Open session" + "enterSession": "Open session", + "bridgeConnected": "connected", + "bridgeConnecting": "connecting...", + "bridgeDisconnected": "disconnected", + "bridgeNotAvailable": "Bridge not available. Enable [bridge] in config.toml to chat from web." }, "providers": { "title": "Providers", @@ -207,6 +212,48 @@ "channelSecret": "Channel Secret", "channelToken": "Channel Access Token" }, + "chat": { + "noChats": "No projects yet", + "noMessages": "No messages yet", + "sessions": "Sessions", + "emptyHint": "Start a conversation with your agent", + "slashHint": "Press / to see available commands", + "inputPlaceholder": "Type a message or press / for commands...", + "commands": "Commands" + }, + "cmd": { + "search": "Search commands...", + "groupSession": "Session", + "groupSettings": "Settings", + "groupInfo": "Info", + "groupAdvanced": "Advanced", + "new": "New session", + "list": "Session list", + "switch": "Switch session", + "current": "Current session", + "history": "History", + "stop": "Stop session", + "model": "Model", + "reasoning": "Reasoning", + "mode": "Mode", + "lang": "Language", + "provider": "Provider", + "quiet": "Quiet mode", + "status": "Status", + "help": "Help", + "doctor": "Diagnostics", + "version": "Version", + "whoami": "Who am I", + "commands": "All commands", + "dir": "Work directory", + "cron": "Scheduled jobs", + "heartbeat": "Heartbeat", + "alias": "Aliases", + "config": "Configuration", + "skills": "Skills", + "upgrade": "Upgrade", + "deleteMode": "Delete mode" + }, "theme": { "light": "Light", "dark": "Dark", diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index 744df61..5ccc5a3 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -3,6 +3,7 @@ "dashboard": "Panel", "projects": "Proyectos", "sessions": "Sesiones", + "chat": "Chat", "cron": "Cron", "bridge": "Puente", "system": "Sistema" @@ -57,7 +58,11 @@ "justNow": "ahora", "allProjects": "Todos los proyectos", "chat": "Chat", - "enterSession": "Abrir sesión" + "enterSession": "Abrir sesión", + "bridgeConnected": "conectado", + "bridgeConnecting": "conectando...", + "bridgeDisconnected": "desconectado", + "bridgeNotAvailable": "Bridge no disponible. Habilite [bridge] en config.toml para chatear desde la web." }, "providers": { "title": "Proveedores", @@ -207,6 +212,48 @@ "channelSecret": "Channel Secret", "channelToken": "Channel Access Token" }, + "chat": { + "noChats": "No hay proyectos", + "noMessages": "No hay mensajes", + "sessions": "Lista de sesiones", + "emptyHint": "Inicia una conversación con tu agente", + "slashHint": "Presiona / para ver los comandos disponibles", + "inputPlaceholder": "Escribe un mensaje o presiona / para comandos...", + "commands": "Comandos" + }, + "cmd": { + "search": "Buscar comandos...", + "groupSession": "Sesión", + "groupSettings": "Configuración", + "groupInfo": "Información", + "groupAdvanced": "Avanzado", + "new": "Nueva sesión", + "list": "Lista de sesiones", + "switch": "Cambiar sesión", + "current": "Sesión actual", + "history": "Historial", + "stop": "Detener sesión", + "model": "Modelo", + "reasoning": "Razonamiento", + "mode": "Modo", + "lang": "Idioma", + "provider": "Proveedor", + "quiet": "Modo silencioso", + "status": "Estado", + "help": "Ayuda", + "doctor": "Diagnóstico", + "version": "Versión", + "whoami": "Quién soy", + "commands": "Todos los comandos", + "dir": "Directorio de trabajo", + "cron": "Tareas programadas", + "heartbeat": "Heartbeat", + "alias": "Alias", + "config": "Configuración", + "skills": "Habilidades", + "upgrade": "Actualizar", + "deleteMode": "Modo eliminación" + }, "theme": { "light": "Claro", "dark": "Oscuro", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 6966b9e..2cb4270 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -3,6 +3,7 @@ "dashboard": "ダッシュボード", "projects": "プロジェクト", "sessions": "セッション", + "chat": "チャット", "cron": "Cron", "bridge": "ブリッジ", "system": "システム" @@ -57,7 +58,11 @@ "justNow": "たった今", "allProjects": "すべてのプロジェクト", "chat": "チャット", - "enterSession": "セッションを開く" + "enterSession": "セッションを開く", + "bridgeConnected": "接続済み", + "bridgeConnecting": "接続中...", + "bridgeDisconnected": "未接続", + "bridgeNotAvailable": "ブリッジが利用できません。config.toml で [bridge] を有効にしてください。" }, "providers": { "title": "プロバイダー", @@ -207,6 +212,48 @@ "channelSecret": "チャンネルシークレット", "channelToken": "チャンネルアクセストークン" }, + "chat": { + "noChats": "プロジェクトがありません", + "noMessages": "メッセージがありません", + "sessions": "セッション一覧", + "emptyHint": "エージェントとの会話を始めましょう", + "slashHint": "/ を押して利用可能なコマンドを表示", + "inputPlaceholder": "メッセージを入力、または / でコマンド...", + "commands": "コマンド" + }, + "cmd": { + "search": "コマンドを検索...", + "groupSession": "セッション", + "groupSettings": "設定", + "groupInfo": "情報", + "groupAdvanced": "詳細", + "new": "新規セッション", + "list": "セッション一覧", + "switch": "セッション切替", + "current": "現在のセッション", + "history": "履歴", + "stop": "セッション停止", + "model": "モデル", + "reasoning": "推論モード", + "mode": "動作モード", + "lang": "言語", + "provider": "プロバイダー", + "quiet": "静音モード", + "status": "ステータス", + "help": "ヘルプ", + "doctor": "診断", + "version": "バージョン", + "whoami": "自分の情報", + "commands": "全コマンド", + "dir": "作業ディレクトリ", + "cron": "スケジュールジョブ", + "heartbeat": "ハートビート", + "alias": "エイリアス", + "config": "設定", + "skills": "スキル", + "upgrade": "アップグレード", + "deleteMode": "削除モード" + }, "theme": { "light": "ライト", "dark": "ダーク", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 3d48c42..281f351 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -3,6 +3,7 @@ "dashboard": "總覽", "projects": "專案", "sessions": "工作階段", + "chat": "對話", "cron": "排程工作", "bridge": "橋接", "system": "系統" @@ -57,7 +58,11 @@ "justNow": "剛剛", "allProjects": "全部專案", "chat": "對話", - "enterSession": "進入工作階段" + "enterSession": "進入工作階段", + "bridgeConnected": "已連線", + "bridgeConnecting": "連線中...", + "bridgeDisconnected": "未連線", + "bridgeNotAvailable": "Bridge 未啟用。請在 config.toml 中啟用 [bridge] 以支援網頁聊天。" }, "providers": { "title": "模型供應商", @@ -207,6 +212,48 @@ "channelSecret": "Channel Secret", "channelToken": "Channel Access Token" }, + "chat": { + "noChats": "尚無專案", + "noMessages": "尚無訊息", + "sessions": "工作階段列表", + "emptyHint": "開始與 Agent 對話", + "slashHint": "按 / 查看可用命令", + "inputPlaceholder": "輸入訊息或按 / 使用命令...", + "commands": "命令" + }, + "cmd": { + "search": "搜尋命令...", + "groupSession": "工作階段", + "groupSettings": "設定", + "groupInfo": "資訊", + "groupAdvanced": "進階", + "new": "新建工作階段", + "list": "工作階段列表", + "switch": "切換工作階段", + "current": "目前工作階段", + "history": "歷史記錄", + "stop": "停止工作階段", + "model": "模型", + "reasoning": "推理模式", + "mode": "運行模式", + "lang": "語言", + "provider": "提供方", + "quiet": "靜音模式", + "status": "狀態", + "help": "說明", + "doctor": "診斷", + "version": "版本", + "whoami": "我是誰", + "commands": "所有命令", + "dir": "工作目錄", + "cron": "排程工作", + "heartbeat": "心跳", + "alias": "別名", + "config": "設定", + "skills": "技能", + "upgrade": "升級", + "deleteMode": "刪除模式" + }, "theme": { "light": "淺色", "dark": "深色", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ec5a7a8..0e8988a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -3,6 +3,7 @@ "dashboard": "概览", "projects": "项目", "sessions": "会话", + "chat": "对话", "cron": "定时任务", "bridge": "桥接", "system": "系统" @@ -57,7 +58,11 @@ "justNow": "刚刚", "allProjects": "全部项目", "chat": "对话", - "enterSession": "进入会话" + "enterSession": "进入会话", + "bridgeConnected": "已连接", + "bridgeConnecting": "连接中...", + "bridgeDisconnected": "未连接", + "bridgeNotAvailable": "Bridge 未启用。请在 config.toml 中启用 [bridge] 以支持网页聊天。" }, "providers": { "title": "模型提供方", @@ -207,6 +212,48 @@ "channelSecret": "Channel Secret", "channelToken": "Channel Access Token" }, + "chat": { + "noChats": "暂无项目", + "noMessages": "暂无消息", + "sessions": "会话列表", + "emptyHint": "开始和你的 Agent 对话", + "slashHint": "按 / 查看可用命令", + "inputPlaceholder": "输入消息或按 / 使用命令...", + "commands": "命令" + }, + "cmd": { + "search": "搜索命令...", + "groupSession": "会话", + "groupSettings": "设置", + "groupInfo": "信息", + "groupAdvanced": "高级", + "new": "新建会话", + "list": "会话列表", + "switch": "切换会话", + "current": "当前会话", + "history": "历史记录", + "stop": "停止会话", + "model": "模型", + "reasoning": "推理模式", + "mode": "运行模式", + "lang": "语言", + "provider": "提供方", + "quiet": "静默模式", + "status": "状态", + "help": "帮助", + "doctor": "诊断", + "version": "版本", + "whoami": "我是谁", + "commands": "所有命令", + "dir": "工作目录", + "cron": "定时任务", + "heartbeat": "心跳", + "alias": "别名", + "config": "配置", + "skills": "技能", + "upgrade": "升级", + "deleteMode": "删除模式" + }, "theme": { "light": "浅色", "dark": "深色", diff --git a/web/src/pages/Chat/ChatList.tsx b/web/src/pages/Chat/ChatList.tsx new file mode 100644 index 0000000..f3087c4 --- /dev/null +++ b/web/src/pages/Chat/ChatList.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { MessageSquare, Bot, User, Circle, FolderKanban } from 'lucide-react'; +import { EmptyState, Badge } from '@/components/ui'; +import { listProjects, type ProjectSummary } from '@/api/projects'; +import { listSessions, type Session } from '@/api/sessions'; +import { cn } from '@/lib/utils'; + +interface ChatEntry { + project: ProjectSummary; + latestSession: Session | null; +} + +function timeAgo(iso: string, t: (k: string) => string): string { + if (!iso) return ''; + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return t('sessions.justNow'); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +export default function ChatList() { + const { t } = useTranslation(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const { projects: projs } = await listProjects(); + if (!projs?.length) { + setEntries([]); + return; + } + const results = await Promise.all( + projs.map(async (p) => { + try { + const { sessions } = await listSessions(p.name); + const sorted = (sessions || []).sort( + (a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''), + ); + return { project: p, latestSession: sorted[0] || null }; + } catch { + return { project: p, latestSession: null }; + } + }), + ); + results.sort((a, b) => { + const ta = a.latestSession?.updated_at || a.latestSession?.created_at || ''; + const tb = b.latestSession?.updated_at || b.latestSession?.created_at || ''; + return tb.localeCompare(ta); + }); + setEntries(results); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + const handler = () => fetchData(); + window.addEventListener('cc:refresh', handler); + return () => window.removeEventListener('cc:refresh', handler); + }, [fetchData]); + + if (loading && entries.length === 0) { + return
Loading...
; + } + + return ( +
+ {entries.length === 0 ? ( + + ) : ( + entries.map(({ project, latestSession }) => { + const hasLive = latestSession?.live; + const lastMsg = latestSession?.last_message; + const ts = latestSession?.updated_at || latestSession?.created_at || ''; + + return ( + +
+ {/* Avatar */} +
+ +
+ + {/* Content */} +
+
+
+ + {project.name} + + {hasLive && } +
+ {ts && ( + {timeAgo(ts, t)} + )} +
+ +
+
+ {lastMsg ? ( +

+ {lastMsg.role === 'user' ? ( + + ) : ( + + )} + {lastMsg.content.replace(/\n/g, ' ').slice(0, 80)} +

+ ) : ( +

+ {t('chat.noMessages')} +

+ )} +
+
+ {project.agent_type} + + {project.sessions_count} + +
+
+
+
+ + ); + }) + )} +
+ ); +} diff --git a/web/src/pages/Chat/ChatView.tsx b/web/src/pages/Chat/ChatView.tsx new file mode 100644 index 0000000..aa6d01b --- /dev/null +++ b/web/src/pages/Chat/ChatView.tsx @@ -0,0 +1,642 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams, Link } from 'react-router-dom'; +import { + ArrowLeft, Send, User, Bot, Circle, WifiOff, + Copy, Check, FileText, Image as ImageIcon, Loader2, + Slash, ChevronDown, +} from 'lucide-react'; +import { Badge, Button } from '@/components/ui'; +import { listSessions, getSession, type Session, type SessionDetail } from '@/api/sessions'; +import { + useBridgeSocket, fetchBridgeConfig, + type BridgeConfig, type BridgeIncoming, type BridgeStatus, +} from '@/hooks/useBridgeSocket'; +import CommandPalette, { type SlashCommand } from './CommandPalette'; +import SessionDrawer from './SessionDrawer'; +import Markdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeHighlight from 'rehype-highlight'; +import { cn } from '@/lib/utils'; + +// ── Markdown renderers ─────────────────────────────────────── + +function CopyButton({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ( + + ); +} + +function PreBlock({ children, ...props }: React.HTMLAttributes) { + const codeEl = (children as any)?.props; + const lang = codeEl?.className?.replace(/^language-/, '') || ''; + const code = typeof codeEl?.children === 'string' ? codeEl.children.replace(/\n$/, '') : ''; + return ( +
+ {lang && ( +
+ {lang} +
+ )} + +
+        {children}
+      
+
+ ); +} + +function InlineCode({ children, className, ...props }: React.HTMLAttributes) { + if (className) return {children}; + return ( + + {children} + + ); +} + +function RenderMarkdown({ content }: { content: string }) { + return ( +
+ + {content} + +
+ ); +} + +// ── Chat message types ─────────────────────────────────────── + +interface ChatMsg { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + format?: 'text' | 'markdown' | 'card' | 'buttons' | 'image' | 'file'; + card?: any; + buttons?: { text: string; data: string }[][]; + imageUrl?: string; + fileName?: string; + fileSize?: number; + streaming?: boolean; + timestamp?: string; +} + +// ── Card renderer ──────────────────────────────────────────── + +function CardBlock({ card, onAction }: { card: any; onAction: (v: string) => void }) { + if (!card) return null; + return ( +
+ {card.header && ( +
+ {card.header.title} +
+ )} +
+ {card.elements?.map((el: any, i: number) => ( + + ))} +
+
+ ); +} + +function CardElement({ el, onAction }: { el: any; onAction: (v: string) => void }) { + if (el.type === 'markdown') return ; + if (el.type === 'divider') return
; + if (el.type === 'note') return

{el.text}

; + if (el.type === 'actions') { + return ( +
+ {el.buttons?.map((btn: any, j: number) => ( + + ))} +
+ ); + } + if (el.type === 'list_item') { + return ( +
+ {el.text} + +
+ ); + } + if (el.type === 'select') { + return ( + + ); + } + return null; +} + +function colorToBg(c?: string) { + const map: Record = { + blue: 'bg-blue-600', green: 'bg-green-600', red: 'bg-red-600', orange: 'bg-orange-500', + purple: 'bg-purple-600', grey: 'bg-gray-600', turquoise: 'bg-teal-600', violet: 'bg-violet-600', + indigo: 'bg-indigo-600', wathet: 'bg-sky-500', yellow: 'bg-yellow-500', carmine: 'bg-rose-600', + }; + return map[c || ''] || 'bg-gray-800'; +} + +function ButtonsBlock({ content, buttons, onAction }: { content: string; buttons: { text: string; data: string }[][]; onAction: (v: string) => void }) { + return ( +
+ + {buttons.map((row, i) => ( +
+ {row.map((btn, j) => ( + + ))} +
+ ))} +
+ ); +} + +function FileBlock({ name, size }: { name: string; size?: number }) { + return ( +
+ +
+
{name}
+ {size !== undefined &&
{(size / 1024).toFixed(1)} KB
} +
+
+ ); +} + +function ImageBlock({ url }: { url: string }) { + return ; +} + +function StatusBadge({ status }: { status: BridgeStatus }) { + const { t } = useTranslation(); + if (status === 'connected') { + return ( + + {t('sessions.bridgeConnected')} + + ); + } + if (status === 'connecting' || status === 'registering') { + return ( + + {t('sessions.bridgeConnecting')} + + ); + } + return ( + + {t('sessions.bridgeDisconnected')} + + ); +} + +// ── Main component ─────────────────────────────────────────── + +export default function ChatView() { + const { t } = useTranslation(); + const { name: projectName } = useParams<{ name: string }>(); + + // Session state + const [sessions, setSessions] = useState([]); + const [currentSession, setCurrentSession] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const [loading, setLoading] = useState(true); + const [typing, setTyping] = useState(false); + const [bridgeCfg, setBridgeCfg] = useState(null); + + // UI state + const [cmdOpen, setCmdOpen] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + + const messagesEnd = useRef(null); + const previewHandleCounter = useRef(0); + const cmdBtnRef = useRef(null); + const sessionKeyRef = useRef(''); + + const sessionKey = currentSession?.session_key || ''; + sessionKeyRef.current = sessionKey; + + // Load project sessions and auto-select latest + const fetchData = useCallback(async () => { + if (!projectName) return; + setLoading(true); + try { + const [{ sessions: allSessions }, cfg] = await Promise.all([ + listSessions(projectName), + fetchBridgeConfig(), + ]); + setBridgeCfg(cfg); + const sorted = (allSessions || []).sort( + (a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''), + ); + setSessions(sorted); + + if (sorted.length > 0) { + const latest = sorted[0]; + const detail = await getSession(projectName, latest.id, 200); + setCurrentSession(detail); + if (detail.history) { + setMessages(detail.history.map((h, i) => ({ + id: `hist-${i}`, + role: h.role as 'user' | 'assistant', + content: h.content, + format: 'markdown', + timestamp: h.timestamp, + }))); + } + } else { + setCurrentSession(null); + setMessages([]); + } + } finally { + setLoading(false); + } + }, [projectName]); + + useEffect(() => { fetchData(); }, [fetchData]); + + // Switch to a different session + const switchToSession = useCallback(async (s: Session) => { + if (!projectName) return; + setDrawerOpen(false); + setLoading(true); + try { + const detail = await getSession(projectName, s.id, 200); + setCurrentSession(detail); + if (detail.history) { + setMessages(detail.history.map((h, i) => ({ + id: `hist-${i}`, + role: h.role as 'user' | 'assistant', + content: h.content, + format: 'markdown', + timestamp: h.timestamp, + }))); + } else { + setMessages([]); + } + } finally { + setLoading(false); + } + }, [projectName]); + + // Handle bridge incoming messages — only process messages for the current session + const handleBridgeMessage = useCallback((msg: BridgeIncoming) => { + const msgKey = (msg as any).session_key; + if (msgKey && sessionKeyRef.current && msgKey !== sessionKeyRef.current) { + return; // ignore messages for other sessions + } + + if (msg.type === 'reply') { + setMessages(prev => { + const streamIdx = prev.findIndex(m => m.streaming && m.role === 'assistant'); + if (streamIdx >= 0) { + const updated = [...prev]; + updated[streamIdx] = { ...updated[streamIdx], content: msg.content, format: (msg as any).format === 'markdown' ? 'markdown' : 'text', streaming: false }; + return updated; + } + return [...prev, { id: `reply-${Date.now()}`, role: 'assistant', content: msg.content, format: (msg as any).format === 'markdown' ? 'markdown' : 'text' }]; + }); + setTyping(false); + } else if (msg.type === 'reply_stream') { + const stream = msg as Extract; + if (stream.done) { + setMessages(prev => { + const idx = prev.findIndex(m => m.streaming); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], content: stream.full_text, streaming: false }; + return updated; + } + return [...prev, { id: `stream-done-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown' }]; + }); + setTyping(false); + } else { + setMessages(prev => { + const idx = prev.findIndex(m => m.streaming); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], content: stream.full_text }; + return updated; + } + return [...prev, { id: `stream-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown', streaming: true }]; + }); + } + } else if (msg.type === 'card') { + const card = msg as Extract; + setMessages(prev => [...prev, { id: `card-${Date.now()}`, role: 'assistant', content: '', format: 'card', card: card.card }]); + setTyping(false); + } else if (msg.type === 'buttons') { + const btns = msg as Extract; + setMessages(prev => [...prev, { id: `btn-${Date.now()}`, role: 'assistant', content: btns.content, format: 'buttons', buttons: btns.buttons }]); + setTyping(false); + } else if (msg.type === 'typing_start') { + setTyping(true); + } else if (msg.type === 'typing_stop') { + setTyping(false); + } else if (msg.type === 'preview_start') { + const ps = msg as Extract; + const handle = `web-preview-${++previewHandleCounter.current}`; + sendPreviewAck(ps.ref_id, handle); + setMessages(prev => [...prev, { id: `stream-${handle}`, role: 'assistant', content: ps.content, format: 'markdown', streaming: true }]); + } else if (msg.type === 'update_message') { + const um = msg as Extract; + setMessages(prev => { + const idx = prev.findIndex(m => m.streaming); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], content: um.content }; + return updated; + } + return prev; + }); + } else if (msg.type === 'delete_message') { + setMessages(prev => { + const idx = prev.findIndex(m => m.streaming); + if (idx >= 0) return prev.filter((_, i) => i !== idx); + return prev; + }); + } + }, []); + + const { status: bridgeStatus, sendMessage: bridgeSend, sendCardAction, sendPreviewAck } = useBridgeSocket({ + bridgeCfg, + sessionKey, + onMessage: handleBridgeMessage, + }); + + // Scroll to bottom on new messages + useEffect(() => { + messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, typing]); + + // Send message + const handleSend = useCallback(() => { + if (!input.trim() || bridgeStatus !== 'connected') return; + const content = input.trim(); + setInput(''); + setSending(true); + setMessages(prev => [...prev, { id: `user-${Date.now()}`, role: 'user', content }]); + bridgeSend(content); + setTimeout(() => setSending(false), 300); + }, [input, bridgeStatus, bridgeSend]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + if (e.key === '/' && !input) { + e.preventDefault(); + setCmdOpen(true); + } + }; + + const handleCmdSelect = useCallback((cmd: SlashCommand) => { + setCmdOpen(false); + if (cmd.cmd === '/list') { + setDrawerOpen(true); + return; + } + if (bridgeStatus !== 'connected') return; + setMessages(prev => [...prev, { id: `user-${Date.now()}`, role: 'user', content: cmd.cmd }]); + bridgeSend(cmd.cmd); + }, [bridgeStatus, bridgeSend]); + + const handleCardAction = useCallback((value: string) => { + if (bridgeStatus !== 'connected') return; + sendCardAction(value); + }, [bridgeStatus, sendCardAction]); + + const handleNewSession = useCallback(() => { + if (bridgeStatus !== 'connected') return; + setMessages(prev => [...prev, { id: `user-${Date.now()}`, role: 'user', content: '/new' }]); + bridgeSend('/new'); + setDrawerOpen(false); + }, [bridgeStatus, bridgeSend]); + + const canSend = bridgeStatus === 'connected'; + + if (loading && !currentSession && sessions.length === 0) { + return
Loading...
; + } + + return ( +
+ {/* Header */} +
+
+ + + +
+
+

{projectName}

+ +
+ {currentSession && ( + + )} +
+
+
+ + {/* Messages */} +
+ {messages.length === 0 && !loading && ( +
+
+ +
+

{t('chat.emptyHint')}

+

{t('chat.slashHint')}

+
+ )} + {messages.map((msg) => { + const isUser = msg.role === 'user'; + return ( +
+ {!isUser && ( +
+ +
+ )} +
+ {msg.format === 'card' ? ( + + ) : msg.format === 'buttons' && msg.buttons ? ( + + ) : msg.format === 'image' && msg.imageUrl ? ( + + ) : msg.format === 'file' && msg.fileName ? ( + + ) : isUser ? ( +
{msg.content}
+ ) : ( + + )} + {msg.streaming && ( + + )} +
+ {isUser && ( +
+ +
+ )} +
+ ); + })} + {typing && !messages.some(m => m.streaming) && ( +
+
+ +
+
+
+ + + +
+
+
+ )} +
+
+ + {/* Input area */} +
+ {canSend ? ( +
+ {/* Command palette trigger */} +
+ + setCmdOpen(false)} + onSelect={handleCmdSelect} + anchorRef={cmdBtnRef} + /> +
+ + {/* Text input */} +
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t('chat.inputPlaceholder')} + className="w-full px-4 py-3 text-sm rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent transition-colors placeholder:text-gray-400" + disabled={sending} + /> +
+ + {/* Send button */} + +
+ ) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? ( +
+ + {t('sessions.bridgeNotAvailable')} +
+ ) : ( +
+ + {t('sessions.bridgeConnecting')} +
+ )} +
+ + {/* Session drawer */} + setDrawerOpen(false)} + sessions={sessions} + currentSessionId={currentSession?.id || ''} + onSelect={switchToSession} + onNewSession={handleNewSession} + /> +
+ ); +} diff --git a/web/src/pages/Chat/CommandPalette.tsx b/web/src/pages/Chat/CommandPalette.tsx new file mode 100644 index 0000000..5c735e3 --- /dev/null +++ b/web/src/pages/Chat/CommandPalette.tsx @@ -0,0 +1,186 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Slash, Search, MessageSquarePlus, List, ArrowRightLeft, Eye, History, + Square, Brain, Cpu, Languages, Layers, Activity, Stethoscope, Info, + Settings, Timer, HeartPulse, Terminal, Tag, Wrench, Upload, Trash2, + Volume2, VolumeX, FolderOpen, HelpCircle, User, BookOpen, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface SlashCommand { + cmd: string; + labelKey: string; + icon: React.ElementType; + group: 'session' | 'settings' | 'info' | 'advanced'; + local?: boolean; // handled locally, not sent to bridge +} + +export const slashCommands: SlashCommand[] = [ + // Session + { cmd: '/new', labelKey: 'cmd.new', icon: MessageSquarePlus, group: 'session' }, + { cmd: '/list', labelKey: 'cmd.list', icon: List, group: 'session', local: true }, + { cmd: '/switch', labelKey: 'cmd.switch', icon: ArrowRightLeft, group: 'session' }, + { cmd: '/current', labelKey: 'cmd.current', icon: Eye, group: 'session' }, + { cmd: '/history', labelKey: 'cmd.history', icon: History, group: 'session' }, + { cmd: '/stop', labelKey: 'cmd.stop', icon: Square, group: 'session' }, + // Settings + { cmd: '/model', labelKey: 'cmd.model', icon: Brain, group: 'settings' }, + { cmd: '/reasoning', labelKey: 'cmd.reasoning', icon: Cpu, group: 'settings' }, + { cmd: '/mode', labelKey: 'cmd.mode', icon: Layers, group: 'settings' }, + { cmd: '/lang', labelKey: 'cmd.lang', icon: Languages, group: 'settings' }, + { cmd: '/provider', labelKey: 'cmd.provider', icon: Activity, group: 'settings' }, + { cmd: '/quiet', labelKey: 'cmd.quiet', icon: VolumeX, group: 'settings' }, + // Info + { cmd: '/status', labelKey: 'cmd.status', icon: Info, group: 'info' }, + { cmd: '/help', labelKey: 'cmd.help', icon: HelpCircle, group: 'info' }, + { cmd: '/doctor', labelKey: 'cmd.doctor', icon: Stethoscope, group: 'info' }, + { cmd: '/version', labelKey: 'cmd.version', icon: Tag, group: 'info' }, + { cmd: '/whoami', labelKey: 'cmd.whoami', icon: User, group: 'info' }, + { cmd: '/commands', labelKey: 'cmd.commands', icon: Terminal, group: 'info' }, + // Advanced + { cmd: '/dir', labelKey: 'cmd.dir', icon: FolderOpen, group: 'advanced' }, + { cmd: '/cron', labelKey: 'cmd.cron', icon: Timer, group: 'advanced' }, + { cmd: '/heartbeat', labelKey: 'cmd.heartbeat', icon: HeartPulse, group: 'advanced' }, + { cmd: '/alias', labelKey: 'cmd.alias', icon: Tag, group: 'advanced' }, + { cmd: '/config', labelKey: 'cmd.config', icon: Settings, group: 'advanced' }, + { cmd: '/skills', labelKey: 'cmd.skills', icon: BookOpen, group: 'advanced' }, + { cmd: '/upgrade', labelKey: 'cmd.upgrade', icon: Upload, group: 'advanced' }, + { cmd: '/delete-mode', labelKey: 'cmd.deleteMode', icon: Trash2, group: 'advanced' }, +]; + +const groupOrder: { key: string; labelKey: string }[] = [ + { key: 'session', labelKey: 'cmd.groupSession' }, + { key: 'settings', labelKey: 'cmd.groupSettings' }, + { key: 'info', labelKey: 'cmd.groupInfo' }, + { key: 'advanced', labelKey: 'cmd.groupAdvanced' }, +]; + +interface Props { + open: boolean; + onClose: () => void; + onSelect: (cmd: SlashCommand) => void; + anchorRef: React.RefObject; +} + +export default function CommandPalette({ open, onClose, onSelect, anchorRef }: Props) { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + const [activeIdx, setActiveIdx] = useState(0); + const panelRef = useRef(null); + const searchRef = useRef(null); + + const filtered = useMemo(() => { + if (!query) return slashCommands; + const q = query.toLowerCase().replace(/^\//, ''); + return slashCommands.filter( + (c) => c.cmd.toLowerCase().includes(q) || t(c.labelKey).toLowerCase().includes(q), + ); + }, [query, t]); + + useEffect(() => { + if (open) { + setQuery(''); + setActiveIdx(0); + setTimeout(() => searchRef.current?.focus(), 50); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const handleClick = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node) && + anchorRef.current && !anchorRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open, onClose, anchorRef]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIdx((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIdx((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter' && filtered[activeIdx]) { + e.preventDefault(); + onSelect(filtered[activeIdx]); + } else if (e.key === 'Escape') { + onClose(); + } + }; + + if (!open) return null; + + const grouped = groupOrder + .map((g) => ({ + ...g, + items: filtered.filter((c) => c.group === g.key), + })) + .filter((g) => g.items.length > 0); + + let flatIdx = 0; + + return ( +
+
+
+ + { setQuery(e.target.value); setActiveIdx(0); }} + onKeyDown={handleKeyDown} + placeholder={t('cmd.search', 'Search commands...')} + className="flex-1 bg-transparent text-sm text-gray-900 dark:text-white placeholder:text-gray-400 outline-none" + /> +
+
+
+ {grouped.map((g) => ( +
+
+ {t(g.labelKey)} +
+ {g.items.map((cmd) => { + const idx = flatIdx++; + const Icon = cmd.icon; + return ( + + ); + })} +
+ ))} + {filtered.length === 0 && ( +
{t('common.noData')}
+ )} +
+
+ ); +} diff --git a/web/src/pages/Chat/SessionDrawer.tsx b/web/src/pages/Chat/SessionDrawer.tsx new file mode 100644 index 0000000..4f2b53a --- /dev/null +++ b/web/src/pages/Chat/SessionDrawer.tsx @@ -0,0 +1,130 @@ +import { useTranslation } from 'react-i18next'; +import { + X, MessageSquare, Circle, User, Bot, Plus, +} from 'lucide-react'; +import { Badge } from '@/components/ui'; +import type { Session } from '@/api/sessions'; +import { cn } from '@/lib/utils'; + +function timeAgo(iso: string): string { + if (!iso) return ''; + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return '<1m'; + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +interface Props { + open: boolean; + onClose: () => void; + sessions: Session[]; + currentSessionId: string; + onSelect: (session: Session) => void; + onNewSession?: () => void; +} + +export default function SessionDrawer({ open, onClose, sessions, currentSessionId, onSelect, onNewSession }: Props) { + const { t } = useTranslation(); + + return ( + <> + {/* Backdrop */} + {open && ( +
+ )} + + {/* Drawer */} +
+ {/* Header */} +
+

{t('chat.sessions')}

+
+ {onNewSession && ( + + )} + +
+
+ + {/* Session list */} +
+ {sessions.length === 0 ? ( +
{t('sessions.noSessions')}
+ ) : ( + sessions.map((s) => { + const isCurrent = s.id === currentSessionId; + return ( + + ); + }) + )} +
+
+ + ); +} diff --git a/web/src/pages/Sessions/SessionChat.tsx b/web/src/pages/Sessions/SessionChat.tsx index bf9628b..5ff2dcd 100644 --- a/web/src/pages/Sessions/SessionChat.tsx +++ b/web/src/pages/Sessions/SessionChat.tsx @@ -1,14 +1,20 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams, Link } from 'react-router-dom'; -import { ArrowLeft, Send, User, Bot, RotateCw, Circle, WifiOff, Copy, Check } from 'lucide-react'; +import { + ArrowLeft, Send, User, Bot, RotateCw, Circle, WifiOff, + Copy, Check, FileText, Image as ImageIcon, Loader2, +} from 'lucide-react'; import { Badge, Button } from '@/components/ui'; -import { getSession, sendMessage, type SessionDetail } from '@/api/sessions'; +import { getSession, type SessionDetail } from '@/api/sessions'; +import { useBridgeSocket, fetchBridgeConfig, type BridgeConfig, type BridgeIncoming, type BridgeStatus } from '@/hooks/useBridgeSocket'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; import { cn } from '@/lib/utils'; +// ── Markdown renderers ─────────────────────────────────────── + function CopyButton({ code }: { code: string }) { const [copied, setCopied] = useState(false); const handleCopy = () => { @@ -29,10 +35,7 @@ function CopyButton({ code }: { code: string }) { function PreBlock({ children, ...props }: React.HTMLAttributes) { const codeEl = (children as any)?.props; const lang = codeEl?.className?.replace(/^language-/, '') || ''; - const code = typeof codeEl?.children === 'string' - ? codeEl.children.replace(/\n$/, '') - : ''; - + const code = typeof codeEl?.children === 'string' ? codeEl.children.replace(/\n$/, '') : ''; return (
{lang && ( @@ -41,10 +44,7 @@ function PreBlock({ children, ...props }: React.HTMLAttributes)
)} -
+      
         {children}
       
@@ -60,15 +60,7 @@ function InlineCode({ children, className, ...props }: React.HTMLAttributes - {content} -
- ); - } - +function RenderMarkdown({ content }: { content: string }) { return (
- + {content}
); } +// ── Chat message types ─────────────────────────────────────── + +interface ChatMsg { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + format?: 'text' | 'markdown' | 'card' | 'buttons' | 'image' | 'file'; + card?: any; + buttons?: { text: string; data: string }[][]; + imageUrl?: string; + fileName?: string; + fileSize?: number; + streaming?: boolean; + timestamp?: string; +} + +// ── Card renderer ──────────────────────────────────────────── + +function CardBlock({ card, onAction }: { card: any; onAction: (v: string) => void }) { + if (!card) return null; + return ( +
+ {card.header && ( +
+ {card.header.title} +
+ )} +
+ {card.elements?.map((el: any, i: number) => ( + + ))} +
+
+ ); +} + +function CardElement({ el, onAction }: { el: any; onAction: (v: string) => void }) { + if (el.type === 'markdown') return ; + if (el.type === 'divider') return
; + if (el.type === 'note') return

{el.text}

; + if (el.type === 'actions') { + return ( +
+ {el.buttons?.map((btn: any, j: number) => ( + + ))} +
+ ); + } + if (el.type === 'list_item') { + return ( +
+ {el.text} + +
+ ); + } + if (el.type === 'select') { + return ( + + ); + } + return null; +} + +function colorToBg(c?: string) { + const map: Record = { + blue: 'bg-blue-600', green: 'bg-green-600', red: 'bg-red-600', orange: 'bg-orange-500', + purple: 'bg-purple-600', grey: 'bg-gray-600', turquoise: 'bg-teal-600', violet: 'bg-violet-600', + indigo: 'bg-indigo-600', wathet: 'bg-sky-500', yellow: 'bg-yellow-500', carmine: 'bg-rose-600', + }; + return map[c || ''] || 'bg-gray-800'; +} + +// ── Buttons renderer ───────────────────────────────────────── + +function ButtonsBlock({ content, buttons, onAction }: { content: string; buttons: { text: string; data: string }[][]; onAction: (v: string) => void }) { + return ( +
+ + {buttons.map((row, i) => ( +
+ {row.map((btn, j) => ( + + ))} +
+ ))} +
+ ); +} + +// ── File/Image attachments ─────────────────────────────────── + +function FileBlock({ name, size }: { name: string; size?: number }) { + return ( +
+ +
+
{name}
+ {size !== undefined &&
{(size / 1024).toFixed(1)} KB
} +
+
+ ); +} + +function ImageBlock({ url }: { url: string }) { + return ; +} + +// ── Connection status badge ────────────────────────────────── + +function StatusBadge({ status }: { status: BridgeStatus }) { + const { t } = useTranslation(); + if (status === 'connected') { + return ( + + {t('sessions.bridgeConnected', 'connected')} + + ); + } + if (status === 'connecting' || status === 'registering') { + return ( + + {t('sessions.bridgeConnecting', 'connecting...')} + + ); + } + return ( + + {t('sessions.bridgeDisconnected', 'disconnected')} + + ); +} + +// ── Main component ─────────────────────────────────────────── + export default function SessionChat() { const { t } = useTranslation(); const { project, id } = useParams<{ project: string; id: string }>(); const [session, setSession] = useState(null); + const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const [loading, setLoading] = useState(true); + const [typing, setTyping] = useState(false); + const [bridgeCfg, setBridgeCfg] = useState(null); const messagesEnd = useRef(null); + const streamBufRef = useRef>(new Map()); + const previewHandleCounter = useRef(0); + const sessionKey = session?.session_key || ''; + + // Load session data + bridge config const fetchSession = useCallback(async () => { if (!project || !id) return; try { setLoading(true); - const data = await getSession(project, id, 200); + const [data, cfg] = await Promise.all([ + getSession(project, id, 200), + fetchBridgeConfig(), + ]); setSession(data); + setBridgeCfg(cfg); + if (data.history) { + setMessages(data.history.map((h, i) => ({ + id: `hist-${i}`, + role: h.role as 'user' | 'assistant', + content: h.content, + format: 'markdown', + timestamp: h.timestamp, + }))); + } } finally { setLoading(false); } }, [project, id]); - useEffect(() => { - fetchSession(); - }, [fetchSession]); + useEffect(() => { fetchSession(); }, [fetchSession]); + // Handle bridge incoming messages + const handleBridgeMessage = useCallback((msg: BridgeIncoming) => { + if (msg.type === 'reply') { + setMessages(prev => { + const streamIdx = prev.findIndex(m => m.streaming && m.role === 'assistant'); + if (streamIdx >= 0) { + const updated = [...prev]; + updated[streamIdx] = { ...updated[streamIdx], content: msg.content, format: (msg as any).format === 'markdown' ? 'markdown' : 'text', streaming: false }; + return updated; + } + return [...prev, { + id: `reply-${Date.now()}`, + role: 'assistant', + content: msg.content, + format: (msg as any).format === 'markdown' ? 'markdown' : 'text', + }]; + }); + setTyping(false); + } else if (msg.type === 'reply_stream') { + const stream = msg as Extract; + if (stream.done) { + setMessages(prev => { + const idx = prev.findIndex(m => m.streaming); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], content: stream.full_text, streaming: false }; + return updated; + } + return [...prev, { id: `stream-done-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown' }]; + }); + setTyping(false); + } else { + setMessages(prev => { + const idx = prev.findIndex(m => m.streaming); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], content: stream.full_text }; + return updated; + } + return [...prev, { id: `stream-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown', streaming: true }]; + }); + } + } else if (msg.type === 'card') { + const card = msg as Extract; + setMessages(prev => [...prev, { + id: `card-${Date.now()}`, + role: 'assistant', + content: '', + format: 'card', + card: card.card, + }]); + setTyping(false); + } else if (msg.type === 'buttons') { + const btns = msg as Extract; + setMessages(prev => [...prev, { + id: `btn-${Date.now()}`, + role: 'assistant', + content: btns.content, + format: 'buttons', + buttons: btns.buttons, + }]); + setTyping(false); + } else if (msg.type === 'typing_start') { + setTyping(true); + } else if (msg.type === 'typing_stop') { + setTyping(false); + } else if (msg.type === 'preview_start') { + const ps = msg as Extract; + const handle = `web-preview-${++previewHandleCounter.current}`; + sendPreviewAck(ps.ref_id, handle); + setMessages(prev => [...prev, { + id: `stream-${handle}`, + role: 'assistant', + content: ps.content, + format: 'markdown', + streaming: true, + }]); + } else if (msg.type === 'update_message') { + const um = msg as Extract; + setMessages(prev => { + const idx = prev.findIndex(m => m.streaming); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], content: um.content }; + return updated; + } + return prev; + }); + } else if (msg.type === 'delete_message') { + setMessages(prev => { + const idx = prev.findIndex(m => m.streaming); + if (idx >= 0) { + return prev.filter((_, i) => i !== idx); + } + return prev; + }); + } + }, []); + + const { status: bridgeStatus, sendMessage: bridgeSend, sendCardAction, sendPreviewAck } = useBridgeSocket({ + bridgeCfg, + sessionKey, + onMessage: handleBridgeMessage, + }); + + // Scroll to bottom on new messages useEffect(() => { messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }); - }, [session?.history]); + }, [messages, typing]); - const handleSend = async () => { - if (!input.trim() || !project || !session) return; - const msg = input.trim(); + const handleSend = useCallback(() => { + if (!input.trim() || bridgeStatus !== 'connected') return; + const content = input.trim(); setInput(''); setSending(true); - try { - await sendMessage(project, { session_key: session.session_key, message: msg }); - setTimeout(fetchSession, 1500); - } finally { - setSending(false); - } - }; + setMessages(prev => [...prev, { + id: `user-${Date.now()}`, + role: 'user', + content, + }]); + bridgeSend(content); + setTimeout(() => setSending(false), 300); + }, [input, bridgeStatus, bridgeSend]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -148,10 +413,17 @@ export default function SessionChat() { } }; + const handleCardAction = useCallback((value: string) => { + if (bridgeStatus !== 'connected') return; + sendCardAction(value); + }, [bridgeStatus, sendCardAction]); + if (loading && !session) { return
Loading...
; } + const canSend = bridgeStatus === 'connected'; + return (
{/* Header */} @@ -163,15 +435,7 @@ export default function SessionChat() {

{session?.name || id}

- {session?.live ? ( - - live - - ) : ( - - {t('sessions.offline')} - - )} +
{project} @@ -187,13 +451,13 @@ export default function SessionChat() { {/* Messages */}
- {(!session?.history || session.history.length === 0) && ( + {messages.length === 0 && !loading && (

{t('sessions.noMessages')}

)} - {session?.history?.map((msg, i) => { + {messages.map((msg) => { const isUser = msg.role === 'user'; return ( -
+
{!isUser && (
@@ -203,9 +467,25 @@ export default function SessionChat() { 'rounded-2xl px-5 py-3.5 text-sm', isUser ? 'max-w-[70%] bg-accent text-black rounded-br-md' - : 'max-w-[85%] bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 text-gray-900 dark:text-gray-100 rounded-bl-md shadow-sm' + : 'max-w-[85%] bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 text-gray-900 dark:text-gray-100 rounded-bl-md shadow-sm', + msg.streaming && 'animate-pulse-subtle', )}> - + {msg.format === 'card' ? ( + + ) : msg.format === 'buttons' && msg.buttons ? ( + + ) : msg.format === 'image' && msg.imageUrl ? ( + + ) : msg.format === 'file' && msg.fileName ? ( + + ) : isUser ? ( +
{msg.content}
+ ) : ( + + )} + {msg.streaming && ( + + )}
{isUser && (
@@ -215,12 +495,26 @@ export default function SessionChat() {
); })} + {typing && !messages.some(m => m.streaming) && ( +
+
+ +
+
+
+ + + +
+
+
+ )}
{/* Input */}
- {session?.live ? ( + {canSend ? (
{sending ? ( - - - - + ) : ( )}
- ) : ( -
+ ) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? ( +
- {t('sessions.notLiveHint')} + {t('sessions.bridgeNotAvailable', 'Bridge not available. Enable [bridge] in config.toml to chat from web.')} +
+ ) : ( +
+ + {t('sessions.bridgeConnecting', 'Connecting to bridge...')}
)}