feat(web): redesign Chat page with slash commands, session drawer, and fix bridge permission handling
- 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
This commit is contained in:
1 parent
44b6ee4edb
commit
aef8cdade1
17 files changed
+1951
-89
No files matched your search
+62
-7
@@ -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 {
|
||||
|
||||
+11
-2
@@ -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) {
|
||||
|
||||
+4
-4
@@ -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() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="projects" element={<ProjectList />} />
|
||||
<Route path="projects/:name" element={<ProjectDetail />} />
|
||||
<Route path="sessions" element={<SessionList />} />
|
||||
<Route path="sessions/:project/:id" element={<SessionChat />} />
|
||||
<Route path="chat" element={<ChatList />} />
|
||||
<Route path="chat/:name" element={<ChatView />} />
|
||||
<Route path="cron" element={<CronList />} />
|
||||
<Route path="bridge" element={<BridgeAdapters />} />
|
||||
<Route path="system" element={<SystemConfig />} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cn } from '@/lib/utils';
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'nav.dashboard',
|
||||
'/projects': 'nav.projects',
|
||||
'/sessions': 'nav.sessions',
|
||||
'/chat': 'nav.chat',
|
||||
'/cron': 'nav.cron',
|
||||
'/bridge': 'nav.bridge',
|
||||
'/system': 'nav.system',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium backdrop-blur-sm',
|
||||
variants[variant]
|
||||
variants[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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<WebSocket | null>(null);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
onMessageRef.current = onMessage;
|
||||
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [status, setStatus] = useState<BridgeStatus>('disconnected');
|
||||
|
||||
const send = useCallback((data: Record<string, any>) => {
|
||||
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<typeof setTimeout>;
|
||||
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<BridgeConfig | null> {
|
||||
try {
|
||||
const status = await api.get<any>('/status');
|
||||
if (status.bridge?.enabled) {
|
||||
return {
|
||||
port: status.bridge.port,
|
||||
path: status.bridge.path,
|
||||
token: status.bridge.token,
|
||||
};
|
||||
}
|
||||
} catch { /* bridge not available */ }
|
||||
return null;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ダーク",
|
||||
|
||||
@@ -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": "深色",
|
||||
|
||||
@@ -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": "深色",
|
||||
|
||||
@@ -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<ChatEntry[]>([]);
|
||||
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 <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-1 animate-fade-in">
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState message={t('chat.noChats')} icon={MessageSquare} />
|
||||
) : (
|
||||
entries.map(({ project, latestSession }) => {
|
||||
const hasLive = latestSession?.live;
|
||||
const lastMsg = latestSession?.last_message;
|
||||
const ts = latestSession?.updated_at || latestSession?.created_at || '';
|
||||
|
||||
return (
|
||||
<Link key={project.name} to={`/chat/${project.name}`}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-4 px-4 py-3.5 rounded-xl transition-all duration-200 cursor-pointer',
|
||||
'hover:bg-gray-100/70 dark:hover:bg-white/[0.04]',
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-12 h-12 rounded-2xl flex items-center justify-center shrink-0',
|
||||
'bg-accent/10 ring-1 ring-accent/20',
|
||||
)}
|
||||
>
|
||||
<FolderKanban size={22} className="text-accent" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
{hasLive && <Circle size={5} className="fill-emerald-500 text-emerald-500 shrink-0" />}
|
||||
</div>
|
||||
{ts && (
|
||||
<span className="text-[11px] text-gray-400 shrink-0">{timeAgo(ts, t)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
{lastMsg ? (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate leading-relaxed">
|
||||
{lastMsg.role === 'user' ? (
|
||||
<User size={10} className="inline mr-1 -mt-0.5 opacity-60" />
|
||||
) : (
|
||||
<Bot size={10} className="inline mr-1 -mt-0.5 opacity-60" />
|
||||
)}
|
||||
{lastMsg.content.replace(/\n/g, ' ').slice(0, 80)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 italic">
|
||||
{t('chat.noMessages')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Badge className="text-[9px]">{project.agent_type}</Badge>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{project.sessions_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-md bg-gray-200/80 dark:bg-gray-700/80 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) {
|
||||
const codeEl = (children as any)?.props;
|
||||
const lang = codeEl?.className?.replace(/^language-/, '') || '';
|
||||
const code = typeof codeEl?.children === 'string' ? codeEl.children.replace(/\n$/, '') : '';
|
||||
return (
|
||||
<div className="not-prose relative group my-4">
|
||||
{lang && (
|
||||
<div className="absolute top-0 left-0 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 rounded-tl-lg rounded-br-lg border-b border-r border-gray-200 dark:border-gray-700 font-mono">
|
||||
{lang}
|
||||
</div>
|
||||
)}
|
||||
<CopyButton code={code} />
|
||||
<pre className="overflow-x-auto rounded-lg bg-[#fafafa] dark:bg-[#0d1117] border border-gray-200 dark:border-gray-700/60 p-4 pt-8 text-[13px] leading-[1.6] font-mono" {...props}>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineCode({ children, className, ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
if (className) return <code className={className} {...props}>{children}</code>;
|
||||
return (
|
||||
<code className="px-1.5 py-0.5 rounded-md bg-gray-100 dark:bg-gray-800 text-pink-600 dark:text-pink-400 text-[0.875em] font-mono border border-gray-200/60 dark:border-gray-700/40" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderMarkdown({ content }: { content: string }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'prose max-w-none dark:prose-invert',
|
||||
'prose-headings:font-semibold prose-headings:tracking-tight',
|
||||
'prose-h1:text-xl prose-h1:mt-5 prose-h1:mb-3 prose-h1:pb-1.5 prose-h1:border-b prose-h1:border-gray-200 dark:prose-h1:border-gray-700',
|
||||
'prose-h2:text-lg prose-h2:mt-5 prose-h2:mb-2',
|
||||
'prose-h3:text-base prose-h3:mt-4 prose-h3:mb-2',
|
||||
'prose-p:my-2.5 prose-p:leading-relaxed',
|
||||
'prose-li:my-0.5', 'prose-ul:my-2 prose-ol:my-2',
|
||||
'prose-a:text-accent prose-a:no-underline hover:prose-a:underline',
|
||||
'prose-strong:text-gray-900 dark:prose-strong:text-white prose-strong:font-semibold',
|
||||
'prose-blockquote:border-l-[3px] prose-blockquote:border-accent/40 prose-blockquote:bg-accent/[0.03] prose-blockquote:rounded-r-lg prose-blockquote:py-0.5 prose-blockquote:px-4 prose-blockquote:my-3 prose-blockquote:not-italic prose-blockquote:text-gray-600 dark:prose-blockquote:text-gray-300',
|
||||
'prose-hr:my-5 prose-hr:border-gray-200 dark:prose-hr:border-gray-700',
|
||||
'prose-table:text-sm prose-th:bg-gray-50 dark:prose-th:bg-gray-800 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2',
|
||||
'prose-img:rounded-lg prose-img:shadow-sm',
|
||||
)}>
|
||||
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={{ pre: PreBlock as any, code: InlineCode as any }}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{card.header && (
|
||||
<div className={cn('px-4 py-2.5 font-semibold text-sm text-white', colorToBg(card.header.color))}>
|
||||
{card.header.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 space-y-3">
|
||||
{card.elements?.map((el: any, i: number) => (
|
||||
<CardElement key={i} el={el} onAction={onAction} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardElement({ el, onAction }: { el: any; onAction: (v: string) => void }) {
|
||||
if (el.type === 'markdown') return <RenderMarkdown content={el.content} />;
|
||||
if (el.type === 'divider') return <hr className="border-gray-200 dark:border-gray-700" />;
|
||||
if (el.type === 'note') return <p className="text-xs text-gray-400">{el.text}</p>;
|
||||
if (el.type === 'actions') {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{el.buttons?.map((btn: any, j: number) => (
|
||||
<button key={j} onClick={() => onAction(btn.value)} className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
||||
btn.btn_type === 'primary' ? 'bg-accent text-black hover:bg-accent-dim' :
|
||||
btn.btn_type === 'danger' ? 'bg-red-500 text-white hover:bg-red-600' :
|
||||
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
)}>
|
||||
{btn.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (el.type === 'list_item') {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{el.text}</span>
|
||||
<button onClick={() => onAction(el.btn_value)} className="px-2.5 py-1 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim">
|
||||
{el.btn_text}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (el.type === 'select') {
|
||||
return (
|
||||
<select
|
||||
defaultValue={el.init_value}
|
||||
onChange={(e) => onAction(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{el.options?.map((opt: any, j: number) => (
|
||||
<option key={j} value={opt.value}>{opt.text}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function colorToBg(c?: string) {
|
||||
const map: Record<string, string> = {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<RenderMarkdown content={content} />
|
||||
{buttons.map((row, i) => (
|
||||
<div key={i} className="flex flex-wrap gap-2">
|
||||
{row.map((btn, j) => (
|
||||
<button key={j} onClick={() => onAction(btn.data)} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim transition-colors">
|
||||
{btn.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileBlock({ name, size }: { name: string; size?: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<FileText size={16} className="text-gray-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{name}</div>
|
||||
{size !== undefined && <div className="text-xs text-gray-400">{(size / 1024).toFixed(1)} KB</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageBlock({ url }: { url: string }) {
|
||||
return <img src={url} alt="" className="max-w-sm rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm" />;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: BridgeStatus }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'connected') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 px-1.5 py-0.5 rounded-full">
|
||||
<Circle size={5} className="fill-current" /> {t('sessions.bridgeConnected')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'connecting' || status === 'registering') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-1.5 py-0.5 rounded-full">
|
||||
<Loader2 size={9} className="animate-spin" /> {t('sessions.bridgeConnecting')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded-full">
|
||||
<WifiOff size={9} /> {t('sessions.bridgeDisconnected')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ───────────────────────────────────────────
|
||||
|
||||
export default function ChatView() {
|
||||
const { t } = useTranslation();
|
||||
const { name: projectName } = useParams<{ name: string }>();
|
||||
|
||||
// Session state
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [currentSession, setCurrentSession] = useState<SessionDetail | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [bridgeCfg, setBridgeCfg] = useState<BridgeConfig | null>(null);
|
||||
|
||||
// UI state
|
||||
const [cmdOpen, setCmdOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const messagesEnd = useRef<HTMLDivElement>(null);
|
||||
const previewHandleCounter = useRef(0);
|
||||
const cmdBtnRef = useRef<HTMLButtonElement>(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<BridgeIncoming, { type: 'reply_stream' }>;
|
||||
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<BridgeIncoming, { type: 'card' }>;
|
||||
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<BridgeIncoming, { type: 'buttons' }>;
|
||||
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<BridgeIncoming, { type: 'preview_start' }>;
|
||||
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<BridgeIncoming, { type: 'update_message' }>;
|
||||
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 <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-8rem)] animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-gray-200 dark:border-gray-800 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/chat" className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
|
||||
<ArrowLeft size={18} className="text-gray-400" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{projectName}</h2>
|
||||
<StatusBadge status={bridgeStatus} />
|
||||
</div>
|
||||
{currentSession && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-accent transition-colors mt-0.5"
|
||||
>
|
||||
<span>{currentSession.name || currentSession.id.slice(0, 8)}</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto py-6 space-y-5">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center py-12">
|
||||
<div className="w-16 h-16 rounded-2xl bg-accent/10 flex items-center justify-center mb-4">
|
||||
<Bot size={32} className="text-accent" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{t('chat.emptyHint')}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{t('chat.slashHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => {
|
||||
const isUser = msg.role === 'user';
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
|
||||
{!isUser && (
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
|
||||
<Bot size={16} className="text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(
|
||||
'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',
|
||||
msg.streaming && 'animate-pulse-subtle',
|
||||
)}>
|
||||
{msg.format === 'card' ? (
|
||||
<CardBlock card={msg.card} onAction={handleCardAction} />
|
||||
) : msg.format === 'buttons' && msg.buttons ? (
|
||||
<ButtonsBlock content={msg.content} buttons={msg.buttons} onAction={handleCardAction} />
|
||||
) : msg.format === 'image' && msg.imageUrl ? (
|
||||
<ImageBlock url={msg.imageUrl} />
|
||||
) : msg.format === 'file' && msg.fileName ? (
|
||||
<FileBlock name={msg.fileName} size={msg.fileSize} />
|
||||
) : isUser ? (
|
||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||
) : (
|
||||
<RenderMarkdown content={msg.content} />
|
||||
)}
|
||||
{msg.streaming && (
|
||||
<span className="inline-block w-1.5 h-4 bg-accent/60 rounded-sm ml-0.5 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
{isUser && (
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0 mt-1">
|
||||
<User size={16} className="text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{typing && !messages.some(m => m.streaming) && (
|
||||
<div className="flex gap-3 justify-start">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
|
||||
<Bot size={16} className="text-accent" />
|
||||
</div>
|
||||
<div className="rounded-2xl px-5 py-3.5 text-sm bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 rounded-bl-md shadow-sm">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-800 pt-3 shrink-0">
|
||||
{canSend ? (
|
||||
<div className="relative flex items-end gap-2">
|
||||
{/* Command palette trigger */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={cmdBtnRef}
|
||||
type="button"
|
||||
onClick={() => setCmdOpen(!cmdOpen)}
|
||||
className={cn(
|
||||
'p-3 rounded-xl transition-all duration-200',
|
||||
cmdOpen
|
||||
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.06]',
|
||||
)}
|
||||
title={t('chat.commands')}
|
||||
>
|
||||
<Slash size={18} />
|
||||
</button>
|
||||
<CommandPalette
|
||||
open={cmdOpen}
|
||||
onClose={() => setCmdOpen(false)}
|
||||
onSelect={handleCmdSelect}
|
||||
anchorRef={cmdBtnRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text input */}
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={sending || !input.trim()}
|
||||
className="p-3 rounded-xl bg-accent text-black hover:bg-accent-dim transition-colors disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{sending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
|
||||
<WifiOff size={14} />
|
||||
<span>{t('sessions.bridgeNotAvailable')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-sm text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span>{t('sessions.bridgeConnecting')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session drawer */}
|
||||
<SessionDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSession?.id || ''}
|
||||
onSelect={switchToSession}
|
||||
onNewSession={handleNewSession}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export default function CommandPalette({ open, onClose, onSelect, anchorRef }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(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 (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
'absolute bottom-full left-0 mb-2 w-80 max-h-[420px] flex flex-col rounded-xl overflow-hidden z-50',
|
||||
'bg-white/95 backdrop-blur-xl border border-gray-200/80 shadow-2xl shadow-black/15',
|
||||
'dark:bg-[rgba(20,20,20,0.96)] dark:border-white/[0.1] dark:shadow-black/50',
|
||||
'animate-in slide-in-from-bottom-2 fade-in duration-200',
|
||||
)}
|
||||
>
|
||||
<div className="px-3 pt-3 pb-2">
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-gray-100/80 dark:bg-white/[0.06] border border-gray-200/50 dark:border-white/[0.05]">
|
||||
<Search size={14} className="text-gray-400 shrink-0" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-1.5 pb-2">
|
||||
{grouped.map((g) => (
|
||||
<div key={g.key} className="mb-1">
|
||||
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{t(g.labelKey)}
|
||||
</div>
|
||||
{g.items.map((cmd) => {
|
||||
const idx = flatIdx++;
|
||||
const Icon = cmd.icon;
|
||||
return (
|
||||
<button
|
||||
key={cmd.cmd}
|
||||
type="button"
|
||||
onClick={() => onSelect(cmd)}
|
||||
onMouseEnter={() => setActiveIdx(idx)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors',
|
||||
idx === activeIdx
|
||||
? 'bg-accent/15 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-white/[0.06]',
|
||||
)}
|
||||
>
|
||||
<Icon size={15} className={idx === activeIdx ? 'text-accent' : 'text-gray-400'} />
|
||||
<span className="font-mono text-xs text-gray-500 dark:text-gray-400 w-24 text-left">{cmd.cmd}</span>
|
||||
<span className="flex-1 text-left truncate">{t(cmd.labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center text-sm text-gray-400 py-6">{t('common.noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<div className="fixed inset-0 bg-black/20 dark:bg-black/40 z-40 transition-opacity" onClick={onClose} />
|
||||
)}
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 right-0 h-full w-80 z-50 flex flex-col transition-transform duration-300 ease-out',
|
||||
'bg-white/95 backdrop-blur-xl border-l border-gray-200/80 shadow-2xl shadow-black/15',
|
||||
'dark:bg-[rgba(15,15,15,0.97)] dark:border-white/[0.08] dark:shadow-black/50',
|
||||
open ? 'translate-x-0' : 'translate-x-full',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-14 border-b border-gray-200/80 dark:border-white/[0.08] shrink-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{t('chat.sessions')}</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{onNewSession && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSession}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-accent hover:bg-accent/10 transition-colors"
|
||||
title={t('cmd.new')}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 overflow-y-auto py-2 px-2">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-center text-sm text-gray-400 py-8">{t('sessions.noSessions')}</div>
|
||||
) : (
|
||||
sessions.map((s) => {
|
||||
const isCurrent = s.id === currentSessionId;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(s)}
|
||||
className={cn(
|
||||
'w-full text-left p-3 rounded-xl mb-1 transition-all duration-200',
|
||||
isCurrent
|
||||
? 'bg-accent/10 ring-1 ring-accent/30'
|
||||
: 'hover:bg-gray-100/80 dark:hover:bg-white/[0.04]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<MessageSquare
|
||||
size={13}
|
||||
className={cn(s.live ? 'text-accent' : 'text-gray-400', 'shrink-0')}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{s.name || s.user_name || s.id.slice(0, 8)}
|
||||
</span>
|
||||
{s.live && <Circle size={4} className="fill-emerald-500 text-emerald-500 shrink-0" />}
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400 shrink-0 mt-0.5">
|
||||
{timeAgo(s.updated_at || s.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{s.last_message && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-1 leading-relaxed mb-1.5 pl-5">
|
||||
{s.last_message.role === 'user' ? (
|
||||
<User size={9} className="inline mr-0.5 -mt-0.5 opacity-60" />
|
||||
) : (
|
||||
<Bot size={9} className="inline mr-0.5 -mt-0.5 opacity-60" />
|
||||
)}
|
||||
{s.last_message.content.replace(/\n/g, ' ').slice(0, 80)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 pl-5">
|
||||
{s.platform && <Badge variant="info" className="text-[9px] px-1 py-0">{s.platform}</Badge>}
|
||||
<span className="text-[10px] text-gray-400 ml-auto">{s.history_count} msgs</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLPreElement>) {
|
||||
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 (
|
||||
<div className="not-prose relative group my-4">
|
||||
{lang && (
|
||||
@@ -41,10 +44,7 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
|
||||
</div>
|
||||
)}
|
||||
<CopyButton code={code} />
|
||||
<pre
|
||||
className="overflow-x-auto rounded-lg bg-[#fafafa] dark:bg-[#0d1117] border border-gray-200 dark:border-gray-700/60 p-4 pt-8 text-[13px] leading-[1.6] font-mono"
|
||||
{...props}
|
||||
>
|
||||
<pre className="overflow-x-auto rounded-lg bg-[#fafafa] dark:bg-[#0d1117] border border-gray-200 dark:border-gray-700/60 p-4 pt-8 text-[13px] leading-[1.6] font-mono" {...props}>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -60,15 +60,7 @@ function InlineCode({ children, className, ...props }: React.HTMLAttributes<HTML
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMarkdown({ content, isUser }: { content: string; isUser: boolean }) {
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none [&_*]:text-black [&_code]:bg-black/10 [&_code]:text-black [&_a]:text-black [&_a]:underline [&>p]:my-0.5 [&_li]:my-0">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderMarkdown({ content }: { content: string }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'prose max-w-none dark:prose-invert',
|
||||
@@ -77,69 +69,342 @@ function ChatMarkdown({ content, isUser }: { content: string; isUser: boolean })
|
||||
'prose-h2:text-lg prose-h2:mt-5 prose-h2:mb-2',
|
||||
'prose-h3:text-base prose-h3:mt-4 prose-h3:mb-2',
|
||||
'prose-p:my-2.5 prose-p:leading-relaxed',
|
||||
'prose-li:my-0.5',
|
||||
'prose-ul:my-2 prose-ol:my-2',
|
||||
'prose-li:my-0.5', 'prose-ul:my-2 prose-ol:my-2',
|
||||
'prose-a:text-accent prose-a:no-underline hover:prose-a:underline',
|
||||
'prose-strong:text-gray-900 dark:prose-strong:text-white prose-strong:font-semibold',
|
||||
'prose-blockquote:border-l-[3px] prose-blockquote:border-accent/40 prose-blockquote:bg-accent/[0.03] prose-blockquote:rounded-r-lg prose-blockquote:py-0.5 prose-blockquote:px-4 prose-blockquote:my-3 prose-blockquote:not-italic prose-blockquote:text-gray-600 dark:prose-blockquote:text-gray-300',
|
||||
'prose-hr:my-5 prose-hr:border-gray-200 dark:prose-hr:border-gray-700',
|
||||
'prose-table:text-sm prose-th:bg-gray-50 dark:prose-th:bg-gray-800 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2 prose-th:border-gray-200 dark:prose-th:border-gray-700 prose-td:border-gray-200 dark:prose-td:border-gray-700',
|
||||
'prose-table:text-sm prose-th:bg-gray-50 dark:prose-th:bg-gray-800 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2',
|
||||
'prose-img:rounded-lg prose-img:shadow-sm',
|
||||
)}>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
pre: PreBlock as any,
|
||||
code: InlineCode as any,
|
||||
}}
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={{ pre: PreBlock as any, code: InlineCode as any }}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{card.header && (
|
||||
<div className={cn('px-4 py-2.5 font-semibold text-sm text-white', colorToBg(card.header.color))}>
|
||||
{card.header.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 space-y-3">
|
||||
{card.elements?.map((el: any, i: number) => (
|
||||
<CardElement key={i} el={el} onAction={onAction} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardElement({ el, onAction }: { el: any; onAction: (v: string) => void }) {
|
||||
if (el.type === 'markdown') return <RenderMarkdown content={el.content} />;
|
||||
if (el.type === 'divider') return <hr className="border-gray-200 dark:border-gray-700" />;
|
||||
if (el.type === 'note') return <p className="text-xs text-gray-400">{el.text}</p>;
|
||||
if (el.type === 'actions') {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{el.buttons?.map((btn: any, j: number) => (
|
||||
<button key={j} onClick={() => onAction(btn.value)} className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
||||
btn.btn_type === 'primary' ? 'bg-accent text-black hover:bg-accent-dim' :
|
||||
btn.btn_type === 'danger' ? 'bg-red-500 text-white hover:bg-red-600' :
|
||||
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
)}>
|
||||
{btn.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (el.type === 'list_item') {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{el.text}</span>
|
||||
<button onClick={() => onAction(el.btn_value)} className="px-2.5 py-1 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim">
|
||||
{el.btn_text}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (el.type === 'select') {
|
||||
return (
|
||||
<select
|
||||
defaultValue={el.init_value}
|
||||
onChange={(e) => onAction(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{el.options?.map((opt: any, j: number) => (
|
||||
<option key={j} value={opt.value}>{opt.text}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function colorToBg(c?: string) {
|
||||
const map: Record<string, string> = {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<RenderMarkdown content={content} />
|
||||
{buttons.map((row, i) => (
|
||||
<div key={i} className="flex flex-wrap gap-2">
|
||||
{row.map((btn, j) => (
|
||||
<button key={j} onClick={() => onAction(btn.data)} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim transition-colors">
|
||||
{btn.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File/Image attachments ───────────────────────────────────
|
||||
|
||||
function FileBlock({ name, size }: { name: string; size?: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<FileText size={16} className="text-gray-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{name}</div>
|
||||
{size !== undefined && <div className="text-xs text-gray-400">{(size / 1024).toFixed(1)} KB</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageBlock({ url }: { url: string }) {
|
||||
return <img src={url} alt="" className="max-w-sm rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm" />;
|
||||
}
|
||||
|
||||
// ── Connection status badge ──────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: BridgeStatus }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'connected') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 px-1.5 py-0.5 rounded-full">
|
||||
<Circle size={5} className="fill-current" /> {t('sessions.bridgeConnected', 'connected')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'connecting' || status === 'registering') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-1.5 py-0.5 rounded-full">
|
||||
<Loader2 size={9} className="animate-spin" /> {t('sessions.bridgeConnecting', 'connecting...')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded-full">
|
||||
<WifiOff size={9} /> {t('sessions.bridgeDisconnected', 'disconnected')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ───────────────────────────────────────────
|
||||
|
||||
export default function SessionChat() {
|
||||
const { t } = useTranslation();
|
||||
const { project, id } = useParams<{ project: string; id: string }>();
|
||||
const [session, setSession] = useState<SessionDetail | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [bridgeCfg, setBridgeCfg] = useState<BridgeConfig | null>(null);
|
||||
const messagesEnd = useRef<HTMLDivElement>(null);
|
||||
const streamBufRef = useRef<Map<string, string>>(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<BridgeIncoming, { type: 'reply_stream' }>;
|
||||
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<BridgeIncoming, { type: 'card' }>;
|
||||
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<BridgeIncoming, { type: 'buttons' }>;
|
||||
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<BridgeIncoming, { type: 'preview_start' }>;
|
||||
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<BridgeIncoming, { type: 'update_message' }>;
|
||||
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 <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
|
||||
}
|
||||
|
||||
const canSend = bridgeStatus === 'connected';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-8rem)] animate-fade-in">
|
||||
{/* Header */}
|
||||
@@ -163,15 +435,7 @@ export default function SessionChat() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{session?.name || id}</h2>
|
||||
{session?.live ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 px-1.5 py-0.5 rounded-full">
|
||||
<Circle size={5} className="fill-current" /> live
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-[10px] text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded-full">
|
||||
<WifiOff size={9} /> {t('sessions.offline')}
|
||||
</span>
|
||||
)}
|
||||
<StatusBadge status={bridgeStatus} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Badge>{project}</Badge>
|
||||
@@ -187,13 +451,13 @@ export default function SessionChat() {
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto py-6 space-y-5">
|
||||
{(!session?.history || session.history.length === 0) && (
|
||||
{messages.length === 0 && !loading && (
|
||||
<p className="text-center text-sm text-gray-400 py-12">{t('sessions.noMessages')}</p>
|
||||
)}
|
||||
{session?.history?.map((msg, i) => {
|
||||
{messages.map((msg) => {
|
||||
const isUser = msg.role === 'user';
|
||||
return (
|
||||
<div key={i} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div key={msg.id} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
|
||||
{!isUser && (
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
|
||||
<Bot size={16} className="text-accent" />
|
||||
@@ -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',
|
||||
)}>
|
||||
<ChatMarkdown content={msg.content} isUser={isUser} />
|
||||
{msg.format === 'card' ? (
|
||||
<CardBlock card={msg.card} onAction={handleCardAction} />
|
||||
) : msg.format === 'buttons' && msg.buttons ? (
|
||||
<ButtonsBlock content={msg.content} buttons={msg.buttons} onAction={handleCardAction} />
|
||||
) : msg.format === 'image' && msg.imageUrl ? (
|
||||
<ImageBlock url={msg.imageUrl} />
|
||||
) : msg.format === 'file' && msg.fileName ? (
|
||||
<FileBlock name={msg.fileName} size={msg.fileSize} />
|
||||
) : isUser ? (
|
||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||
) : (
|
||||
<RenderMarkdown content={msg.content} />
|
||||
)}
|
||||
{msg.streaming && (
|
||||
<span className="inline-block w-1.5 h-4 bg-accent/60 rounded-sm ml-0.5 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
{isUser && (
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0 mt-1">
|
||||
@@ -215,12 +495,26 @@ export default function SessionChat() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{typing && !messages.some(m => m.streaming) && (
|
||||
<div className="flex gap-3 justify-start">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
|
||||
<Bot size={16} className="text-accent" />
|
||||
</div>
|
||||
<div className="rounded-2xl px-5 py-3.5 text-sm bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 rounded-bl-md shadow-sm">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-800 pt-4">
|
||||
{session?.live ? (
|
||||
{canSend ? (
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
value={input}
|
||||
@@ -236,19 +530,21 @@ export default function SessionChat() {
|
||||
className="px-4 py-3 rounded-xl bg-accent text-black hover:bg-accent-dim transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{sending ? (
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Send size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-sm text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
|
||||
) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
|
||||
<WifiOff size={14} />
|
||||
<span>{t('sessions.notLiveHint')}</span>
|
||||
<span>{t('sessions.bridgeNotAvailable', 'Bridge not available. Enable [bridge] in config.toml to chat from web.')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-sm text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span>{t('sessions.bridgeConnecting', 'Connecting to bridge...')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user