+
+ {showModelPicker && (
+
+ {MODELS.map((model) => (
+
+ ))}
+
+ )}
@@ -110,3 +159,132 @@ export function ChatArea() {
>
);
}
+
+/** Lightweight markdown renderer — handles code blocks, inline code, bold, italic, links */
+function renderMarkdown(text: string): React.ReactNode[] {
+ const nodes: React.ReactNode[] = [];
+ const lines = text.split('\n');
+ let i = 0;
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ // Fenced code block
+ if (line.startsWith('```')) {
+ const lang = line.slice(3).trim();
+ const codeLines: string[] = [];
+ i++;
+ while (i < lines.length && !lines[i].startsWith('```')) {
+ codeLines.push(lines[i]);
+ i++;
+ }
+ i++; // skip closing ```
+ nodes.push(
+
+ {lang && {lang}
}
+ {codeLines.join('\n')}
+
+ );
+ continue;
+ }
+
+ // Normal line — parse inline markdown
+ nodes.push(
+
+ {i > 0 && lines[i - 1] !== undefined && !nodes[nodes.length - 1]?.toString().includes('pre') && '\n'}
+ {renderInline(line)}
+
+ );
+ i++;
+ }
+
+ return nodes;
+}
+
+function renderInline(text: string): React.ReactNode[] {
+ const parts: React.ReactNode[] = [];
+ // Pattern: **bold**, *italic*, `code`, [text](url)
+ const regex = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)|(\[(.+?)\]\((.+?)\))/g;
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = regex.exec(text)) !== null) {
+ // Text before match
+ if (match.index > lastIndex) {
+ parts.push(text.slice(lastIndex, match.index));
+ }
+
+ if (match[1]) {
+ // **bold**
+ parts.push(
{match[2]});
+ } else if (match[3]) {
+ // *italic*
+ parts.push(
{match[4]});
+ } else if (match[5]) {
+ // `code`
+ parts.push(
+
+ {match[6]}
+
+ );
+ } else if (match[7]) {
+ // [text](url)
+ parts.push(
+
{match[8]}
+ );
+ }
+
+ lastIndex = match.index + match[0].length;
+ }
+
+ if (lastIndex < text.length) {
+ parts.push(text.slice(lastIndex));
+ }
+
+ return parts.length > 0 ? parts : [text];
+}
+
+function MessageBubble({ message }: { message: Message }) {
+ if (message.role === 'tool') {
+ return (
+
+
+
+ {message.toolName || 'tool'}
+
+ {message.toolInput && (
+
{message.toolInput}
+ )}
+ {message.content && (
+
{message.content}
+ )}
+
+ );
+ }
+
+ const isUser = message.role === 'user';
+
+ return (
+
+
+ {isUser ? '用' : '🦞'}
+
+
+
+
+ {message.content
+ ? (isUser ? message.content : renderMarkdown(message.content))
+ : (message.streaming ? '' : '...')}
+ {message.streaming && }
+
+ {message.error && (
+
{message.error}
+ )}
+
+
+
+ );
+}
diff --git a/desktop/src/components/CloneManager.tsx b/desktop/src/components/CloneManager.tsx
new file mode 100644
index 0000000..26ffa21
--- /dev/null
+++ b/desktop/src/components/CloneManager.tsx
@@ -0,0 +1,139 @@
+import { useState, useEffect } from 'react';
+import { useGatewayStore } from '../store/gatewayStore';
+import { useChatStore } from '../store/chatStore';
+import { Plus, Trash2, Bot, X } from 'lucide-react';
+
+interface CloneFormData {
+ name: string;
+ role: string;
+ scenarios: string;
+}
+
+export function CloneManager() {
+ const { clones, loadClones, createClone, deleteClone, connectionState } = useGatewayStore();
+ const { agents } = useChatStore();
+ const [showForm, setShowForm] = useState(false);
+ const [form, setForm] = useState
({ name: '', role: '', scenarios: '' });
+
+ const connected = connectionState === 'connected';
+
+ useEffect(() => {
+ if (connected) {
+ loadClones();
+ }
+ }, [connected]);
+
+ const handleCreate = async () => {
+ if (!form.name.trim()) return;
+ await createClone({
+ name: form.name,
+ role: form.role || undefined,
+ scenarios: form.scenarios ? form.scenarios.split(',').map(s => s.trim()) : undefined,
+ });
+ setForm({ name: '', role: '', scenarios: '' });
+ setShowForm(false);
+ };
+
+ const handleDelete = async (id: string) => {
+ if (confirm('确定删除该分身?')) {
+ await deleteClone(id);
+ }
+ };
+
+ // Merge gateway clones with local agents for display
+ const displayClones = clones.length > 0 ? clones : agents.map(a => ({
+ id: a.id,
+ name: a.name,
+ role: '默认助手',
+ createdAt: '',
+ }));
+
+ return (
+
+ {/* Header */}
+
+
分身列表
+
+
+
+ {/* Create form */}
+ {showForm && (
+
+ )}
+
+ {/* Clone list */}
+
+ {displayClones.map((clone) => (
+
+
+
+
+
+
{clone.name}
+
{clone.role || '默认助手'}
+
+
+
+ ))}
+
+ {displayClones.length === 0 && (
+
+
+
暂无分身
+
点击 + 创建你的第一个分身
+
+ )}
+
+
+ );
+}
diff --git a/desktop/src/components/ConversationList.tsx b/desktop/src/components/ConversationList.tsx
new file mode 100644
index 0000000..2fe543e
--- /dev/null
+++ b/desktop/src/components/ConversationList.tsx
@@ -0,0 +1,113 @@
+import { useChatStore } from '../store/chatStore';
+import { MessageSquare, Trash2, SquarePen } from 'lucide-react';
+
+export function ConversationList() {
+ const {
+ conversations, currentConversationId, messages,
+ newConversation, switchConversation, deleteConversation,
+ } = useChatStore();
+
+ const hasActiveChat = messages.length > 0;
+
+ return (
+
+ {/* Header */}
+
+ 对话历史
+
+
+
+
+ {/* Current active chat (unsaved) */}
+ {hasActiveChat && !currentConversationId && (
+
+
+
+
+
+
当前对话
+
+ {messages.filter(m => m.role === 'user').length} 条消息
+
+
+
+ )}
+
+ {/* Saved conversations */}
+ {conversations.map((conv) => {
+ const isActive = conv.id === currentConversationId;
+ const msgCount = conv.messages.filter(m => m.role === 'user').length;
+ const timeStr = formatTime(conv.updatedAt);
+
+ return (
+
switchConversation(conv.id)}
+ className={`group flex items-center gap-3 px-3 py-3 cursor-pointer border-b border-gray-50 transition-colors ${
+ isActive ? 'bg-orange-50' : 'hover:bg-gray-100'
+ }`}
+ >
+
+
+
+
+
+ {conv.title}
+
+
+ {msgCount} 条消息 · {timeStr}
+
+
+
+
+ );
+ })}
+
+ {conversations.length === 0 && !hasActiveChat && (
+
+ )}
+
+
+ );
+}
+
+function formatTime(date: Date): string {
+ const now = new Date();
+ const d = new Date(date);
+ const diffMs = now.getTime() - d.getTime();
+ const diffMin = Math.floor(diffMs / 60000);
+
+ if (diffMin < 1) return '刚刚';
+ if (diffMin < 60) return `${diffMin} 分钟前`;
+
+ const diffHr = Math.floor(diffMin / 60);
+ if (diffHr < 24) return `${diffHr} 小时前`;
+
+ const diffDay = Math.floor(diffHr / 24);
+ if (diffDay < 7) return `${diffDay} 天前`;
+
+ return `${d.getMonth() + 1}/${d.getDate()}`;
+}
diff --git a/desktop/src/components/RightPanel.tsx b/desktop/src/components/RightPanel.tsx
index fdfe018..4202b9d 100644
--- a/desktop/src/components/RightPanel.tsx
+++ b/desktop/src/components/RightPanel.tsx
@@ -1,103 +1,214 @@
-import { FileText, User, Target, CheckSquare } from 'lucide-react';
+import { useEffect } from 'react';
+import { useGatewayStore } from '../store/gatewayStore';
+import { useChatStore } from '../store/chatStore';
+import {
+ Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
+ MessageSquare, Cpu, Activity,
+} from 'lucide-react';
export function RightPanel() {
+ const {
+ connectionState, gatewayVersion, error, clones, usageStats, pluginStatus,
+ connect, loadClones, loadUsageStats, loadPluginStatus,
+ } = useGatewayStore();
+ const { messages, currentModel } = useChatStore();
+
+ const connected = connectionState === 'connected';
+
+ // Load data when connected
+ useEffect(() => {
+ if (connected) {
+ loadClones();
+ loadUsageStats();
+ loadPluginStatus();
+ }
+ }, [connected]);
+
+ const handleReconnect = () => {
+ connect().catch(() => {});
+ };
+
+ const userMsgCount = messages.filter(m => m.role === 'user').length;
+ const assistantMsgCount = messages.filter(m => m.role === 'assistant').length;
+ const toolCallCount = messages.filter(m => m.role === 'tool').length;
+
return (
-