feat: cow desktop first version

This commit is contained in:
zhayujie
2026-03-21 16:11:05 +08:00
parent b8b57e34ff
commit 3bc6e89b74
34 changed files with 12765 additions and 0 deletions

184
desktop/src/main/index.ts Normal file
View File

@@ -0,0 +1,184 @@
import { app, BrowserWindow, shell, ipcMain, dialog, nativeImage } from 'electron'
import path from 'path'
import fs from 'fs'
import http from 'http'
import { PythonBackend } from './python-manager'
let mainWindow: BrowserWindow | null = null
let pythonBackend: PythonBackend | null = null
const isDev = !app.isPackaged
const VITE_DEV_PORTS = [5173, 5174, 5175, 5176]
function probePort(port: number): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`http://localhost:${port}`, (res) => {
resolve(res.statusCode !== undefined)
})
req.on('error', () => resolve(false))
req.setTimeout(500, () => { req.destroy(); resolve(false) })
})
}
async function findViteDevServer(): Promise<string | null> {
for (const port of VITE_DEV_PORTS) {
if (await probePort(port)) {
return `http://localhost:${port}`
}
}
return null
}
function getIconPath(ext: string = 'png'): string | undefined {
const iconFile = `icon.${ext}`
const iconPath = isDev
? path.resolve(__dirname, '../../resources', iconFile)
: path.join(process.resourcesPath, iconFile)
if (fs.existsSync(iconPath)) return iconPath
return undefined
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 900,
minHeight: 600,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 12, y: 18 },
backgroundColor: '#111111',
icon: getIconPath(),
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
const rendererHtml = path.join(__dirname, '../renderer/index.html')
if (isDev) {
findViteDevServer().then((devUrl) => {
if (devUrl) {
console.log(`[Electron] Loading Vite dev server: ${devUrl}`)
mainWindow?.loadURL(devUrl)
mainWindow?.webContents.openDevTools()
} else if (fs.existsSync(rendererHtml)) {
console.log('[Electron] Vite dev server not found, loading built files')
mainWindow?.loadFile(rendererHtml)
} else {
console.error('[Electron] No renderer available. Run "npm run build:renderer" first.')
}
})
} else {
mainWindow.loadFile(rendererHtml)
}
mainWindow.once('ready-to-show', () => {
mainWindow?.show()
})
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
mainWindow.on('closed', () => {
mainWindow = null
})
}
function getBackendPath(): string {
if (isDev) {
return path.resolve(__dirname, '../../..')
}
return path.join(process.resourcesPath, 'backend')
}
async function startBackend() {
const backendPath = getBackendPath()
pythonBackend = new PythonBackend(backendPath)
pythonBackend.on('ready', (port: number) => {
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
})
pythonBackend.on('error', (error: string) => {
mainWindow?.webContents.send('backend-status', { status: 'error', error })
})
pythonBackend.on('log', (line: string) => {
mainWindow?.webContents.send('backend-log', line)
})
await pythonBackend.start()
}
function setupIPC() {
ipcMain.handle('get-backend-port', () => {
return pythonBackend?.getPort() ?? null
})
ipcMain.handle('get-backend-status', () => {
return pythonBackend?.getStatus() ?? 'stopped'
})
ipcMain.handle('restart-backend', async () => {
await pythonBackend?.restart()
return true
})
ipcMain.handle('select-directory', async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
})
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('select-file', async (_event, filters?: Electron.FileFilter[]) => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: filters || [{ name: 'All Files', extensions: ['*'] }],
})
return result.canceled ? null : result.filePaths[0]
})
}
app.whenReady().then(async () => {
// Set Dock icon on macOS (PNG is most reliable for nativeImage)
if (process.platform === 'darwin') {
const pngPath = getIconPath('png')
if (pngPath) {
const icon = nativeImage.createFromPath(pngPath)
if (!icon.isEmpty()) {
app.dock.setIcon(icon)
console.log('[Electron] Dock icon set:', pngPath)
} else {
console.warn('[Electron] Dock icon loaded but empty:', pngPath)
}
} else {
console.warn('[Electron] Dock icon not found in resources/')
}
}
setupIPC()
createWindow()
await startBackend()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', () => {
pythonBackend?.stop()
})

View File

@@ -0,0 +1,19 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
getBackendPort: () => ipcRenderer.invoke('get-backend-port'),
getBackendStatus: () => ipcRenderer.invoke('get-backend-status'),
restartBackend: () => ipcRenderer.invoke('restart-backend'),
selectDirectory: () => ipcRenderer.invoke('select-directory'),
selectFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('select-file', filters),
onBackendStatus: (callback: (data: { status: string; port?: number; error?: string }) => void) => {
ipcRenderer.on('backend-status', (_event, data) => callback(data))
},
onBackendLog: (callback: (line: string) => void) => {
ipcRenderer.on('backend-log', (_event, line) => callback(line))
},
platform: process.platform,
})

View File

@@ -0,0 +1,196 @@
import { ChildProcess, spawn } from 'child_process'
import { EventEmitter } from 'events'
import path from 'path'
import fs from 'fs'
import http from 'http'
export class PythonBackend extends EventEmitter {
private process: ChildProcess | null = null
private backendPath: string
private port: number = 9899
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
constructor(backendPath: string) {
super()
this.backendPath = backendPath
}
getPort(): number {
return this.port
}
getStatus(): string {
return this.status
}
private findPython(): string {
const venvPaths = [
path.join(this.backendPath, '.venv', 'bin', 'python'),
path.join(this.backendPath, '.venv', 'Scripts', 'python.exe'),
path.join(this.backendPath, 'venv', 'bin', 'python'),
path.join(this.backendPath, 'venv', 'Scripts', 'python.exe'),
]
for (const p of venvPaths) {
if (fs.existsSync(p)) {
return p
}
}
return process.platform === 'win32' ? 'python' : 'python3'
}
private readPort(): number {
try {
const configPath = path.join(this.backendPath, 'config.json')
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (config.web_port) {
return config.web_port
}
}
} catch {
// ignore
}
return 9899
}
async start(): Promise<void> {
if (this.status === 'ready' || this.status === 'starting') {
return
}
this.status = 'starting'
this.port = this.readPort()
const alreadyRunning = await this.probeHealth()
if (alreadyRunning) {
this.status = 'ready'
this.emit('log', `Backend already running on port ${this.port}`)
this.emit('ready', this.port)
return
}
const pythonPath = this.findPython()
const appPath = path.join(this.backendPath, 'app.py')
if (!fs.existsSync(appPath)) {
this.status = 'error'
this.emit('error', `app.py not found at ${appPath}`)
return
}
this.emit('log', `Starting Python backend: ${pythonPath} ${appPath}`)
this.process = spawn(pythonPath, [appPath], {
cwd: this.backendPath,
env: { ...process.env, PYTHONUNBUFFERED: '1' },
stdio: ['pipe', 'pipe', 'pipe'],
})
this.process.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(Boolean)
for (const line of lines) {
this.emit('log', line)
}
})
this.process.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(Boolean)
for (const line of lines) {
this.emit('log', line)
}
})
this.process.on('exit', (code) => {
this.status = 'stopped'
this.emit('log', `Python process exited with code ${code}`)
if (code !== 0 && code !== null) {
this.emit('error', `Python process exited with code ${code}`)
}
})
this.process.on('error', (err) => {
this.status = 'error'
this.emit('error', `Failed to start Python: ${err.message}`)
})
await this.waitForReady()
}
private probeHealth(): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
resolve(res.statusCode === 200)
})
req.on('error', () => resolve(false))
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
})
}
private waitForReady(): Promise<void> {
return new Promise((resolve) => {
const maxAttempts = 120
let attempts = 0
const check = () => {
attempts++
if (attempts % 10 === 0) {
this.emit('log', `Waiting for backend... (${attempts}s)`)
}
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
if (res.statusCode === 200) {
this.status = 'ready'
this.emit('log', `Backend ready on port ${this.port} after ${attempts}s`)
this.emit('ready', this.port)
resolve()
} else {
retry()
}
})
req.on('error', () => retry())
req.setTimeout(2000, () => {
req.destroy()
retry()
})
}
const retry = () => {
if (this.status === 'stopped') {
resolve()
return
}
if (attempts >= maxAttempts) {
this.status = 'error'
this.emit('error', `Backend failed to start within ${maxAttempts} seconds`)
resolve()
return
}
setTimeout(check, 1000)
}
setTimeout(check, 2000)
})
}
stop(): void {
if (this.process) {
this.process.kill('SIGTERM')
setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill('SIGKILL')
}
}, 5000)
this.process = null
}
this.status = 'stopped'
}
async restart(): Promise<void> {
this.stop()
await new Promise((resolve) => setTimeout(resolve, 2000))
await this.start()
}
}

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="zh" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CowAgent</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
(function() {
var theme = localStorage.getItem('cow_theme') || 'dark';
if (theme === 'dark') document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
})();
</script>
<style>
body { margin: 0; }
</style>
</head>
<body class="h-screen overflow-hidden">
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,116 @@
import React, { useState, useCallback } from 'react'
import { Routes, Route, useLocation } from 'react-router-dom'
import Sidebar from './components/Sidebar'
import StatusScreen from './components/StatusScreen'
import { useTheme } from './hooks/useTheme'
import { useBackend } from './hooks/useBackend'
import { t, getLang, setLang, Lang } from './i18n'
import ChatPage from './pages/ChatPage'
import ConfigPage from './pages/ConfigPage'
import SkillsPage from './pages/SkillsPage'
import MemoryPage from './pages/MemoryPage'
import ChannelsPage from './pages/ChannelsPage'
import TasksPage from './pages/TasksPage'
import LogsPage from './pages/LogsPage'
const APP_VERSION = 'v2.0.3'
const VIEW_META: Record<string, { group: string; page: string }> = {
'/': { group: 'nav_chat', page: 'menu_chat' },
'/config': { group: 'nav_manage', page: 'menu_config' },
'/skills': { group: 'nav_manage', page: 'menu_skills' },
'/memory': { group: 'nav_manage', page: 'menu_memory' },
'/channels': { group: 'nav_manage', page: 'menu_channels' },
'/tasks': { group: 'nav_manage', page: 'menu_tasks' },
'/logs': { group: 'nav_monitor', page: 'menu_logs' },
}
const App: React.FC = () => {
const { theme, toggleTheme } = useTheme()
const backend = useBackend()
const location = useLocation()
const [, forceUpdate] = useState(0)
const toggleLanguage = useCallback(() => {
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
setLang(next)
forceUpdate((n) => n + 1)
}, [])
if (backend.status !== 'ready') {
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
}
const meta = VIEW_META[location.pathname] || VIEW_META['/']
return (
<div className="flex h-screen overflow-hidden">
<Sidebar version={APP_VERSION} />
<div className="flex-1 flex flex-col min-w-0 h-screen">
{/* Top Header */}
<header className="h-[52px] flex items-center gap-3 px-4 border-b border-slate-200 dark:border-white/10 bg-white dark:bg-[#1A1A1A] flex-shrink-0 z-10 titlebar-drag">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm min-w-0 titlebar-no-drag">
<span className="text-slate-400 dark:text-slate-500 truncate">{t(meta.group)}</span>
<i className="fas fa-chevron-right text-[10px] text-slate-300 dark:text-slate-600" />
<span className="font-medium text-slate-700 dark:text-slate-200 truncate">{t(meta.page)}</span>
</div>
<div className="flex-1" />
{/* Language Toggle */}
<button
onClick={toggleLanguage}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
>
<i className="fas fa-globe text-xs" />
<span>{getLang() === 'zh' ? 'EN' : '中'}</span>
</button>
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
>
<i className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'}`} />
</button>
{/* Docs */}
<a
href="https://docs.cowagent.ai"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
>
<i className="fas fa-book text-base" />
</a>
{/* GitHub */}
<a
href="https://github.com/zhayujie/chatgpt-on-wechat"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
>
<i className="fab fa-github text-lg" />
</a>
</header>
{/* Content Area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<Routes>
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
<Route path="/config" element={<ConfigPage baseUrl={backend.baseUrl} />} />
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
<Route path="/memory" element={<MemoryPage baseUrl={backend.baseUrl} />} />
<Route path="/channels" element={<ChannelsPage baseUrl={backend.baseUrl} />} />
<Route path="/tasks" element={<TasksPage baseUrl={backend.baseUrl} />} />
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
</Routes>
</div>
</div>
</div>
)
}
export default App

View File

@@ -0,0 +1,175 @@
import type { ConfigData, ChannelInfo, SkillInfo, ToolInfo, MemoryItem, SchedulerTask, Attachment } from '../types'
class ApiClient {
private baseUrl: string = 'http://127.0.0.1:9899'
setBaseUrl(url: string) {
this.baseUrl = url
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json()
}
// Chat
async sendMessage(
sessionId: string,
message: string,
stream: boolean = true,
attachments?: Attachment[]
): Promise<{ status: string; request_id: string; stream: boolean }> {
return this.request('/message', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, message, stream, attachments }),
})
}
async poll(sessionId: string): Promise<{
status: string
has_content: boolean
content?: string
request_id?: string
timestamp?: number
}> {
return this.request('/poll', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
})
}
createSSEStream(requestId: string): EventSource {
return new EventSource(`${this.baseUrl}/stream?request_id=${requestId}`)
}
async uploadFile(file: File, sessionId?: string): Promise<{
status: string
file_path: string
file_name: string
file_type: string
preview_url: string
}> {
const formData = new FormData()
formData.append('file', file)
if (sessionId) {
formData.append('session_id', sessionId)
}
const res = await fetch(`${this.baseUrl}/upload`, {
method: 'POST',
body: formData,
})
return res.json()
}
// Config
async getConfig(): Promise<ConfigData> {
const data = await this.request<{ status: string } & ConfigData>('/config')
return data
}
async updateConfig(updates: Partial<ConfigData>): Promise<{ status: string; applied: Record<string, unknown> }> {
return this.request('/config', {
method: 'POST',
body: JSON.stringify({ updates }),
})
}
// Channels
async getChannels(): Promise<ChannelInfo[]> {
const data = await this.request<{ status: string; channels: ChannelInfo[] }>('/api/channels')
return data.channels
}
async channelAction(
action: 'save' | 'connect' | 'disconnect',
channel: string,
config?: Record<string, unknown>
): Promise<{ status: string }> {
return this.request('/api/channels', {
method: 'POST',
body: JSON.stringify({ action, channel, config }),
})
}
// Tools & Skills
async getTools(): Promise<ToolInfo[]> {
const data = await this.request<{ status: string; tools: ToolInfo[] }>('/api/tools')
return data.tools
}
async getSkills(): Promise<SkillInfo[]> {
const data = await this.request<{ status: string; skills: SkillInfo[] }>('/api/skills')
return data.skills
}
async toggleSkill(name: string, action: 'open' | 'close'): Promise<{ status: string }> {
return this.request('/api/skills', {
method: 'POST',
body: JSON.stringify({ action, name }),
})
}
// Memory
async getMemoryList(page: number = 1, pageSize: number = 20): Promise<{ list: MemoryItem[]; total: number }> {
const data = await this.request<{ status: string; list: MemoryItem[]; total: number }>(
`/api/memory?page=${page}&page_size=${pageSize}`
)
return { list: data.list, total: data.total }
}
async getMemoryContent(filename: string): Promise<string> {
const data = await this.request<{ status: string; content: string }>(
`/api/memory/content?filename=${encodeURIComponent(filename)}`
)
return data.content
}
// Scheduler
async getSchedulerTasks(): Promise<SchedulerTask[]> {
const data = await this.request<{ status: string; tasks: SchedulerTask[] }>('/api/scheduler')
return data.tasks
}
// History
async getHistory(
sessionId: string,
page: number = 1,
pageSize: number = 20
): Promise<{ messages: ChatMessage[]; has_more: boolean }> {
const data = await this.request<{ status: string; messages: ChatMessage[]; has_more: boolean }>(
`/api/history?session_id=${sessionId}&page=${page}&page_size=${pageSize}`
)
return { messages: data.messages, has_more: data.has_more }
}
// Logs SSE
createLogStream(): EventSource {
return new EventSource(`${this.baseUrl}/api/logs`)
}
getFileUrl(previewUrl: string): string {
return `${this.baseUrl}${previewUrl}`
}
}
interface ChatMessage {
role: string
content: string
timestamp: number
}
export const apiClient = new ApiClient()
export default apiClient

View File

@@ -0,0 +1,158 @@
import React, { useState, useRef, useCallback } from 'react'
import { t } from '../i18n'
import type { Attachment } from '../types'
import apiClient from '../api/client'
interface ChatInputProps {
onSend: (message: string, attachments: Attachment[]) => void
onNewChat: () => void
disabled: boolean
sessionId: string
}
const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sessionId }) => {
const [text, setText] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [uploading, setUploading] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleSubmit = useCallback(() => {
const trimmed = text.trim()
if (!trimmed && attachments.length === 0) return
if (disabled) return
onSend(trimmed, attachments)
setText('')
setAttachments([])
if (textareaRef.current) {
textareaRef.current.style.height = '42px'
}
}, [text, attachments, disabled, onSend])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
e.preventDefault()
handleSubmit()
}
}
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(e.target.value)
const el = e.target
el.style.height = '42px'
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
}
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
setUploading(true)
try {
for (const file of Array.from(files)) {
const result = await apiClient.uploadFile(file, sessionId)
if (result.status === 'success') {
setAttachments((prev) => [
...prev,
{
file_path: result.file_path,
file_name: result.file_name,
file_type: result.file_type as 'image' | 'video' | 'file',
preview_url: result.preview_url,
},
])
}
}
} catch (err) {
console.error('Upload failed:', err)
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index))
}
return (
<div className="flex-shrink-0 border-t border-slate-200 dark:border-white/10 bg-white dark:bg-[#1A1A1A] px-4 py-3">
<div className="max-w-3xl mx-auto">
{/* Attachment preview */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{attachments.map((att, i) => (
<div key={i} className="relative">
{att.file_type === 'image' && att.preview_url ? (
<div className="relative">
<img src={apiClient.getFileUrl(att.preview_url)} alt={att.file_name}
className="w-16 h-16 rounded-lg object-cover border border-slate-200 dark:border-white/10" />
<button onClick={() => removeAttachment(i)}
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-red-500 text-white flex items-center justify-center cursor-pointer">
<i className="fas fa-times text-[8px]" />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-slate-100 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-lg text-xs text-slate-500 dark:text-slate-400 max-w-[180px] relative pr-7">
<i className="fas fa-file text-[10px]" />
<span className="truncate">{att.file_name}</span>
<button onClick={() => removeAttachment(i)}
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-red-500 text-white flex items-center justify-center cursor-pointer">
<i className="fas fa-times text-[8px]" />
</button>
</div>
)}
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<div className="flex items-center flex-shrink-0">
<button
onClick={onNewChat}
className="w-9 h-10 flex items-center justify-center rounded-lg text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 cursor-pointer transition-colors duration-150"
title="New Chat"
>
<i className="fas fa-plus text-base" />
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="w-9 h-10 flex items-center justify-center rounded-lg text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 cursor-pointer transition-colors duration-150"
title="Attach file"
>
<i className="fas fa-paperclip text-base" />
</button>
</div>
<input ref={fileInputRef} type="file" className="hidden" multiple onChange={handleFileSelect}
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md" />
<textarea
ref={textareaRef}
id="chat-input"
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
placeholder={t('input_placeholder')}
disabled={disabled}
rows={1}
className="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:outline-none focus:ring-0 focus:border-primary-600 text-sm leading-relaxed"
/>
<button
onClick={handleSubmit}
disabled={disabled || (!text.trim() && attachments.length === 0)}
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg bg-primary-400 text-white hover:bg-primary-500 disabled:bg-slate-300 dark:disabled:bg-slate-600 disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
>
<i className="fas fa-paper-plane text-sm" />
</button>
</div>
</div>
</div>
)
}
export default ChatInput

View File

@@ -0,0 +1,160 @@
import React, { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
import type { ChatMessage, ToolCall } from '../types'
interface MessageBubbleProps {
message: ChatMessage
theme: 'light' | 'dark'
baseUrl: string
}
const ToolStep: React.FC<{ tool: ToolCall; theme: 'light' | 'dark' }> = ({ tool, theme }) => {
const [expanded, setExpanded] = useState(false)
const isRunning = tool.type === 'tool_start' && !tool.status
const isSuccess = tool.status === 'success'
const iconClass = isRunning
? 'fas fa-cog fa-spin text-slate-400'
: isSuccess
? 'fas fa-check text-primary-400'
: 'fas fa-times text-red-400'
return (
<div className="border-b border-slate-100 dark:border-white/5 last:border-0">
<div
className="tool-header flex items-center gap-2 px-3 py-2"
onClick={() => setExpanded(!expanded)}
>
<i className={`${iconClass} text-xs w-4 text-center`} />
<span className={`text-sm font-medium flex-1 ${tool.status === 'failed' ? 'text-red-400' : 'text-slate-700 dark:text-slate-300'}`}>
{tool.tool}
</span>
{tool.execution_time !== undefined && (
<span className="text-xs text-slate-400">{tool.execution_time.toFixed(1)}s</span>
)}
<i className={`fas fa-chevron-right text-[10px] text-slate-400 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`} />
</div>
{expanded && (
<div className="tool-detail rounded-lg mx-3 mb-3 p-3 space-y-2">
{tool.arguments && (
<div>
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1">Input</div>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto text-slate-600 dark:text-slate-400">
{JSON.stringify(tool.arguments, null, 2)}
</pre>
</div>
)}
{tool.result && (
<div>
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1">Output</div>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto text-slate-600 dark:text-slate-400">
{tool.result.length > 1000 ? tool.result.slice(0, 1000) + '...' : tool.result}
</pre>
</div>
)}
</div>
)}
</div>
)
}
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, theme, baseUrl }) => {
const isUser = message.role === 'user'
const [copiedId, setCopiedId] = useState<string | null>(null)
const handleCopy = (text: string, id: string) => {
navigator.clipboard.writeText(text)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
}
if (isUser) {
return (
<div className="flex justify-end px-4 sm:px-6 py-3">
<div className="max-w-[75%] sm:max-w-[60%]">
{/* User attachments */}
{message.attachments && message.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2 justify-end">
{message.attachments.map((att, i) => (
<div key={i}>
{att.file_type === 'image' && att.preview_url ? (
<img src={`${baseUrl}${att.preview_url}`} alt={att.file_name}
className="max-w-[200px] max-h-[160px] rounded-lg object-cover" />
) : (
<div className="px-3 py-2 bg-slate-100 dark:bg-white/5 rounded-lg text-sm text-slate-500">
<i className="fas fa-file text-xs mr-1.5" />{att.file_name}
</div>
)}
</div>
))}
</div>
)}
<div className="bg-primary-400 text-white rounded-2xl px-4 py-2.5">
<div className="msg-content text-sm whitespace-pre-wrap">{message.content}</div>
</div>
</div>
</div>
)
}
// Assistant message
return (
<div className="flex gap-3 px-4 sm:px-6 py-3">
<img src="./logo.jpg" alt="CowAgent" className="w-8 h-8 rounded-lg flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl overflow-hidden">
{/* Agent tool steps */}
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="border-b border-slate-200 dark:border-white/8">
{message.toolCalls.map((tool, i) => (
<ToolStep key={i} tool={tool} theme={theme} />
))}
</div>
)}
{/* Answer content */}
<div className="px-4 py-3 msg-content text-sm text-slate-800 dark:text-slate-200">
<ReactMarkdown
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
const codeStr = String(children).replace(/\n$/, '')
if (match) {
const codeId = `code-${message.id}-${match[1]}-${codeStr.length}`
return (
<div className="relative group">
<button
onClick={() => handleCopy(codeStr, codeId)}
className="absolute top-2 right-2 p-1.5 rounded-md bg-slate-700/50 hover:bg-slate-700/80 text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
>
<i className={`fas ${copiedId === codeId ? 'fa-check' : 'fa-copy'} text-xs`} />
</button>
<SyntaxHighlighter
style={theme === 'dark' ? oneDark : oneLight}
language={match[1]}
PreTag="div"
>
{codeStr}
</SyntaxHighlighter>
</div>
)
}
return <code {...props}>{children}</code>
},
}}
>
{message.content}
</ReactMarkdown>
{message.isStreaming && (
<span className="inline-block w-[6px] h-[14px] bg-primary-400 ml-0.5 animate-blink" />
)}
</div>
</div>
</div>
</div>
)
}
export default MessageBubble

View File

@@ -0,0 +1,116 @@
import React, { useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { t } from '../i18n'
interface SidebarProps {
version: string
}
interface MenuGroup {
key: string
labelKey: string
items: { path: string; labelKey: string; icon: string }[]
}
const menuGroups: MenuGroup[] = [
{
key: 'chat',
labelKey: 'nav_chat',
items: [{ path: '/', labelKey: 'menu_chat', icon: 'fas fa-message' }],
},
{
key: 'manage',
labelKey: 'nav_manage',
items: [
{ path: '/config', labelKey: 'menu_config', icon: 'fas fa-sliders' },
{ path: '/skills', labelKey: 'menu_skills', icon: 'fas fa-bolt' },
{ path: '/memory', labelKey: 'menu_memory', icon: 'fas fa-brain' },
{ path: '/channels', labelKey: 'menu_channels', icon: 'fas fa-tower-broadcast' },
{ path: '/tasks', labelKey: 'menu_tasks', icon: 'fas fa-clock' },
],
},
{
key: 'monitor',
labelKey: 'nav_monitor',
items: [{ path: '/logs', labelKey: 'menu_logs', icon: 'fas fa-terminal' }],
},
]
const Sidebar: React.FC<SidebarProps> = ({ version }) => {
const location = useLocation()
const navigate = useNavigate()
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
chat: true,
manage: true,
monitor: true,
})
const toggleGroup = (key: string) => {
setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] }))
}
return (
<aside className="w-64 bg-[#0A0A0A] text-neutral-400 flex flex-col flex-shrink-0 h-full">
{/* Logo area with traffic light spacing on macOS */}
<div className="flex items-center gap-3 pl-20 pr-5 h-[52px] border-b border-white/10 flex-shrink-0 titlebar-drag">
<img src="./logo.jpg" alt="CowAgent" className="w-8 h-8 rounded-lg flex-shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-white font-semibold text-sm truncate">CowAgent</span>
<span className="text-neutral-500 text-xs">{t('console')}</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
{menuGroups.map((group) => {
const isOpen = openGroups[group.key] !== false
return (
<div key={group.key} className={`menu-group ${isOpen ? 'open' : ''}`}>
<button
onClick={() => toggleGroup(group.key)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500 hover:text-neutral-300 cursor-pointer transition-colors duration-150"
>
<i className="fas fa-chevron-right text-[10px] chevron" />
<span>{t(group.labelKey)}</span>
</button>
<div className="menu-group-items pl-2">
{group.items.map((item) => {
const isActive = location.pathname === item.path
return (
<a
key={item.path}
onClick={() => navigate(item.path)}
className={`sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px] ${
isActive ? 'active' : ''
}`}
>
<i className={`${item.icon} item-icon text-xs w-5 text-center`} />
<span>{t(item.labelKey)}</span>
</a>
)
})}
</div>
</div>
)
})}
</nav>
{/* Footer */}
<div className="px-4 py-3 border-t border-white/10 flex-shrink-0">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<i className="fas fa-circle text-[6px] text-primary-400" />
<a
href="https://github.com/zhayujie/chatgpt-on-wechat/releases"
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary-400 transition-colors duration-150 cursor-pointer"
>
{version}
</a>
</div>
</div>
</aside>
)
}
export default Sidebar

View File

@@ -0,0 +1,58 @@
import React from 'react'
import { t } from '../i18n'
interface StatusScreenProps {
status: 'connecting' | 'error'
error?: string
onRetry: () => void
}
const StatusScreen: React.FC<StatusScreenProps> = ({ status, error, onRetry }) => {
return (
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
<div className="text-center space-y-6 max-w-md px-8">
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
{status === 'connecting' && (
<>
<div className="space-y-2">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">
{t('status_starting')}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t('status_starting_desc')}
</p>
</div>
<div className="flex justify-center gap-1">
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0s' }} />
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0.2s' }} />
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0.4s' }} />
</div>
</>
)}
{status === 'error' && (
<>
<div className="space-y-2">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">
{t('status_error')}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{error || t('status_error_desc')}
</p>
</div>
<button
onClick={onRetry}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
>
<i className="fas fa-rotate-right text-xs" />
{t('status_retry')}
</button>
</>
)}
</div>
</div>
)
}
export default StatusScreen

View File

@@ -0,0 +1,98 @@
import { useState, useEffect, useCallback, useRef } from 'react'
interface BackendState {
status: 'connecting' | 'ready' | 'error'
port: number
error?: string
}
export function useBackend() {
const [state, setState] = useState<BackendState>({
status: 'connecting',
port: 9899,
})
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
try {
const res = await fetch(`http://127.0.0.1:${port}/config`, {
signal: AbortSignal.timeout(3000),
})
return res.ok
} catch {
return false
}
}, [])
useEffect(() => {
let cancelled = false
const api = window.electronAPI
const startPolling = async (port: number) => {
let attempts = 0
const maxAttempts = 90
const poll = async () => {
if (cancelled) return
attempts++
const ready = await probeBackend(port)
if (cancelled) return
if (ready) {
setState({ status: 'ready', port })
return
}
if (attempts >= maxAttempts) {
setState({ status: 'error', port, error: 'Backend failed to start. Check if Python and dependencies are installed.' })
return
}
pollingRef.current = setTimeout(poll, 1000)
}
await poll()
}
if (api) {
api.getBackendPort().then((port) => {
const p = port || 9899
setState((prev) => ({ ...prev, port: p }))
startPolling(p)
})
api.onBackendStatus((data) => {
if (data.status === 'ready' && data.port) {
setState({ status: 'ready', port: data.port })
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
} else if (data.status === 'error') {
setState((prev) => ({ ...prev, status: 'error', error: data.error }))
}
})
} else {
startPolling(9899)
}
return () => {
cancelled = true
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
}
}, [probeBackend])
const restart = useCallback(async () => {
setState((prev) => ({ ...prev, status: 'connecting', error: undefined }))
if (window.electronAPI) {
await window.electronAPI.restartBackend()
}
}, [])
const baseUrl = `http://127.0.0.1:${state.port}`
return { ...state, baseUrl, restart }
}

View File

@@ -0,0 +1,27 @@
import { useState, useEffect, useCallback } from 'react'
type Theme = 'light' | 'dark'
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
const saved = localStorage.getItem('cow_theme')
if (saved === 'dark' || saved === 'light') return saved
return 'dark'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
localStorage.setItem('cow_theme', theme)
}, [theme])
const toggleTheme = useCallback(() => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'))
}, [])
return { theme, toggleTheme }
}

View File

@@ -0,0 +1,167 @@
const translations: Record<string, Record<string, string>> = {
zh: {
console: '控制台',
nav_chat: '对话',
nav_manage: '管理',
nav_monitor: '监控',
menu_chat: '对话',
menu_config: '配置',
menu_skills: '技能',
menu_memory: '记忆',
menu_channels: '通道',
menu_tasks: '定时',
menu_logs: '日志',
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
example_sys_title: '系统管理',
example_sys_text: '帮我查看工作空间里有哪些文件',
example_task_title: '技能系统',
example_task_text: '查看所有支持的工具和技能',
example_code_title: '编程助手',
example_code_text: '帮我编写一个Python爬虫脚本',
input_placeholder: '输入消息...',
config_title: '配置管理',
config_desc: '管理模型和 Agent 配置',
config_model: '模型配置',
config_agent: 'Agent 配置',
config_provider: '模型厂商',
config_model_name: '模型',
config_custom_model_hint: '输入自定义模型名称',
config_save: '保存',
config_saved: '已保存',
config_save_error: '保存失败',
config_custom_option: '自定义...',
config_max_tokens: '最大上下文 Token',
config_max_turns: '最大上下文轮次',
config_max_steps: '最大步数',
skills_title: '技能管理',
skills_desc: '查看、启用或禁用 Agent 技能',
tools_section_title: '内置工具',
skills_section_title: '自定义技能',
tools_loading: '加载工具中...',
skills_loading: '加载技能中...',
skills_loading_desc: '技能将在加载后显示',
skill_enabled: '已启用',
skill_disabled: '已禁用',
memory_title: '记忆',
memory_desc: '查看 Agent 记忆文件和内容',
memory_loading: '加载记忆文件中...',
memory_loading_desc: '记忆文件将在加载后显示',
memory_col_name: '文件名',
memory_col_type: '类型',
memory_col_size: '大小',
memory_col_updated: '更新时间',
memory_back: '返回',
channels_title: '通道管理',
channels_desc: '查看和管理消息通道',
channels_add: '连接',
channels_connected: '已连接',
channels_disconnected: '未连接',
channels_connect: '连接',
channels_disconnect: '断开',
tasks_title: '定时任务',
tasks_desc: '查看和管理定时任务',
tasks_active: '运行中',
tasks_paused: '已暂停',
tasks_empty: '暂无定时任务',
logs_title: '日志',
logs_desc: '实时日志输出 (run.log)',
logs_live: '实时',
logs_connecting: '日志流将在连接后显示...',
status_starting: '正在启动 CowAgent...',
status_starting_desc: '正在初始化后端服务,请稍候',
status_error: '启动失败',
status_error_desc: '无法连接到后端服务',
status_retry: '重试',
},
en: {
console: 'Console',
nav_chat: 'Chat',
nav_manage: 'Management',
nav_monitor: 'Monitor',
menu_chat: 'Chat',
menu_config: 'Config',
menu_skills: 'Skills',
menu_memory: 'Memory',
menu_channels: 'Channels',
menu_tasks: 'Tasks',
menu_logs: 'Logs',
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills,\nand keep growing through long-term memory.',
example_sys_title: 'System',
example_sys_text: 'Show me the files in the workspace',
example_task_title: 'Skills',
example_task_text: 'Show current tools and skills',
example_code_title: 'Coding',
example_code_text: 'Write a Python web scraper script',
input_placeholder: 'Type a message...',
config_title: 'Configuration',
config_desc: 'Manage model and agent settings',
config_model: 'Model Configuration',
config_agent: 'Agent Configuration',
config_provider: 'Provider',
config_model_name: 'Model',
config_custom_model_hint: 'Enter custom model name',
config_save: 'Save',
config_saved: 'Saved',
config_save_error: 'Save failed',
config_custom_option: 'Custom...',
config_max_tokens: 'Max Context Tokens',
config_max_turns: 'Max Context Turns',
config_max_steps: 'Max Steps',
skills_title: 'Skills',
skills_desc: 'View, enable, or disable agent skills',
tools_section_title: 'Built-in Tools',
skills_section_title: 'Skills',
tools_loading: 'Loading tools...',
skills_loading: 'Loading skills...',
skills_loading_desc: 'Skills will be displayed here after loading',
skill_enabled: 'Enabled',
skill_disabled: 'Disabled',
memory_title: 'Memory',
memory_desc: 'View agent memory files and contents',
memory_loading: 'Loading memory files...',
memory_loading_desc: 'Memory files will be displayed here',
memory_col_name: 'Filename',
memory_col_type: 'Type',
memory_col_size: 'Size',
memory_col_updated: 'Updated',
memory_back: 'Back',
channels_title: 'Channels',
channels_desc: 'View and manage messaging channels',
channels_add: 'Connect',
channels_connected: 'Connected',
channels_disconnected: 'Disconnected',
channels_connect: 'Connect',
channels_disconnect: 'Disconnect',
tasks_title: 'Scheduled Tasks',
tasks_desc: 'View and manage scheduled tasks',
tasks_active: 'Active',
tasks_paused: 'Paused',
tasks_empty: 'No scheduled tasks',
logs_title: 'Logs',
logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live',
logs_connecting: 'Log streaming will connect shortly...',
status_starting: 'Starting CowAgent...',
status_starting_desc: 'Initializing the backend service, please wait',
status_error: 'Failed to Start',
status_error_desc: 'Unable to connect to the backend service',
status_retry: 'Retry',
},
}
export type Lang = 'zh' | 'en'
let currentLang: Lang = (localStorage.getItem('cow_lang') as Lang) || 'zh'
export function t(key: string): string {
return translations[currentLang]?.[key] || translations['en']?.[key] || key
}
export function getLang(): Lang {
return currentLang
}
export function setLang(lang: Lang) {
currentLang = lang
localStorage.setItem('cow_lang', lang)
}

View File

@@ -0,0 +1,243 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
scrollbar-width: thin;
scrollbar-color: #94a3b8 transparent;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #64748b; }
.dark ::-webkit-scrollbar-thumb { background: #475569; }
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
body {
@apply bg-gray-50 text-slate-800 overflow-hidden;
font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', sans-serif;
}
.dark body {
@apply bg-[#111111] text-slate-200;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.animate-blink {
animation: blink 1s step-end infinite;
}
/* Sidebar */
.sidebar-item.active {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.sidebar-item.active .item-icon {
color: #4ABE6E;
}
.menu-group .chevron {
transition: transform 0.25s ease;
}
.menu-group.open .chevron {
transform: rotate(90deg);
}
.menu-group-items {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease;
}
.menu-group.open .menu-group-items {
max-height: 500px;
transition: max-height 0.35s ease;
}
/* Markdown content */
.msg-content pre {
@apply rounded-lg overflow-x-auto my-3;
background: #f1f5f9;
}
.dark .msg-content pre {
background: #111111;
}
.msg-content :not(pre) > code {
padding: 2px 6px;
border-radius: 4px;
font-size: 0.875em;
background: rgba(74, 190, 110, 0.1);
color: #1C6B3B;
}
.dark .msg-content :not(pre) > code {
background: rgba(74, 190, 110, 0.15);
color: #74E9A4;
}
.msg-content p { @apply my-2 leading-relaxed; }
.msg-content ul, .msg-content ol { @apply pl-6 my-2; }
.msg-content li { @apply my-1; }
.msg-content a {
color: #35A85B;
}
.msg-content a:hover {
color: #228547;
}
.msg-content table {
@apply border-collapse w-full my-3;
}
.msg-content th, .msg-content td {
@apply border px-3 py-2 text-sm;
border-color: #e2e8f0;
}
.dark .msg-content th, .dark .msg-content td {
border-color: rgba(255, 255, 255, 0.1);
}
.msg-content th {
@apply font-medium;
background: #f1f5f9;
}
.dark .msg-content th {
background: #111111;
}
.msg-content blockquote {
@apply pl-4 my-3;
border-left: 4px solid #4ABE6E;
}
.dark .msg-content blockquote {
background: rgba(74, 190, 110, 0.08);
}
.msg-content hr {
@apply my-4;
border-color: #e2e8f0;
}
.dark .msg-content hr {
border-color: rgba(255, 255, 255, 0.1);
}
/* Tool steps */
.tool-header {
@apply cursor-pointer transition-colors duration-150;
}
.tool-header:hover {
background: rgba(0, 0, 0, 0.02);
}
.dark .tool-header:hover {
background: rgba(255, 255, 255, 0.02);
}
.tool-detail {
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.04);
}
.dark .tool-detail {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.04);
}
/* Config dropdown */
.cfg-dropdown { @apply relative; }
.cfg-dropdown-selected {
@apply flex items-center justify-between px-3 rounded-lg border cursor-pointer transition-colors text-sm;
height: 40px;
border-color: #e2e8f0;
background: #f8fafc;
}
.dark .cfg-dropdown-selected {
border-color: #475569;
background: rgba(255, 255, 255, 0.05);
color: #f1f5f9;
}
.cfg-dropdown-menu {
@apply absolute left-0 right-0 z-50 rounded-lg border shadow-lg overflow-y-auto hidden;
top: calc(100% + 4px);
max-height: 240px;
padding: 4px;
border-color: #e2e8f0;
background: #fff;
}
.dark .cfg-dropdown-menu {
border-color: #334155;
background: #1e1e1e;
}
.cfg-dropdown.open .cfg-dropdown-menu {
@apply block;
}
.cfg-dropdown-item {
@apply px-3 py-2 rounded-md text-sm cursor-pointer transition-colors;
color: #334155;
}
.cfg-dropdown-item:hover {
background: #f1f5f9;
}
.dark .cfg-dropdown-item {
color: #cbd5e1;
}
.dark .cfg-dropdown-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.cfg-dropdown-item.active {
background: rgba(74, 190, 110, 0.1);
color: #4ABE6E;
}
.dark .cfg-dropdown-item.active {
background: rgba(74, 190, 110, 0.15);
color: #74E9A4;
}
/* API Key mask */
.cfg-key-masked {
-webkit-text-security: disc;
}
/* Chat input */
#chat-input {
height: 42px;
max-height: 180px;
resize: none;
}
/* Titlebar drag region for macOS */
.titlebar-drag {
-webkit-app-region: drag;
}
.titlebar-no-drag {
-webkit-app-region: no-drag;
}

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,138 @@
import React, { useState, useEffect } from 'react'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { ChannelInfo } from '../types'
interface ChannelsPageProps {
baseUrl: string
}
const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
const [channels, setChannels] = useState<ChannelInfo[]>([])
const [loading, setLoading] = useState(true)
const [configValues, setConfigValues] = useState<Record<string, Record<string, string>>>({})
const [actionLoading, setActionLoading] = useState<string | null>(null)
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
loadChannels()
}, [baseUrl])
const loadChannels = async () => {
try {
setLoading(true)
const data = await apiClient.getChannels()
setChannels(data || [])
} catch (err) {
console.error('Failed to load channels:', err)
} finally {
setLoading(false)
}
}
const handleFieldChange = (ch: string, key: string, val: string) => {
setConfigValues((prev) => ({ ...prev, [ch]: { ...prev[ch], [key]: val } }))
}
const handleAction = async (channel: ChannelInfo, action: 'save' | 'connect' | 'disconnect') => {
setActionLoading(`${channel.name}-${action}`)
try {
await apiClient.channelAction(action, channel.name, configValues[channel.name])
await loadChannels()
} catch (err) {
console.error(`Action failed:`, err)
} finally {
setActionLoading(null)
}
}
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('channels_title')}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('channels_desc')}</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20 text-slate-400">
<i className="fas fa-spinner fa-spin mr-2" />Loading...
</div>
) : (
<div className="grid gap-4">
{channels.map((ch) => (
<div key={ch.name} className="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
<i className={`fas ${ch.icon || 'fa-tower-broadcast'} text-blue-500 text-sm`} />
</div>
<span className="font-semibold text-slate-800 dark:text-slate-100">{ch.label?.zh || ch.label?.en || ch.name}</span>
</div>
<span className={`text-xs px-2.5 py-1 rounded-full ${
ch.active
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
: 'bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400'
}`}>
{ch.active ? t('channels_connected') : t('channels_disconnected')}
</span>
</div>
{ch.fields && ch.fields.length > 0 && (
<div className="space-y-3 mb-4">
{ch.fields.map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">
{field.label?.zh || field.label?.en || field.key}
{field.required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
type={field.type === 'password' ? 'password' : 'text'}
value={configValues[ch.name]?.[field.key] || ''}
onChange={(e) => handleFieldChange(ch.name, field.key, e.target.value)}
placeholder={field.placeholder?.zh || field.placeholder?.en || ''}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 transition-colors"
/>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<button
onClick={() => handleAction(ch, 'save')}
disabled={actionLoading === `${ch.name}-save`}
className="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 text-sm hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors"
>
<i className="fas fa-save text-xs mr-1.5" />{t('config_save')}
</button>
{ch.active ? (
<button
onClick={() => handleAction(ch, 'disconnect')}
disabled={actionLoading === `${ch.name}-disconnect`}
className="px-3 py-1.5 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm hover:bg-red-100 dark:hover:bg-red-900/30 cursor-pointer transition-colors"
>
<i className="fas fa-unlink text-xs mr-1.5" />{t('channels_disconnect')}
</button>
) : (
<button
onClick={() => handleAction(ch, 'connect')}
disabled={actionLoading === `${ch.name}-connect`}
className="px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm cursor-pointer transition-colors"
>
<i className="fas fa-link text-xs mr-1.5" />{t('channels_connect')}
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default ChannelsPage

View File

@@ -0,0 +1,205 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import MessageBubble from '../components/MessageBubble'
import ChatInput from '../components/ChatInput'
import { useTheme } from '../hooks/useTheme'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { ChatMessage, Attachment, ToolCall } from '../types'
interface ChatPageProps {
baseUrl: string
}
const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isStreaming, setIsStreaming] = useState(false)
const [sessionId, setSessionId] = useState(() => `session_${Date.now().toString(36)}`)
const messagesEndRef = useRef<HTMLDivElement>(null)
const { theme } = useTheme()
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
}, [baseUrl])
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [])
useEffect(() => {
scrollToBottom()
}, [messages, scrollToBottom])
const handleNewChat = useCallback(() => {
setMessages([])
setSessionId(`session_${Date.now().toString(36)}`)
}, [])
const handleSend = useCallback(async (text: string, attachments: Attachment[]) => {
const userMessage: ChatMessage = {
id: `user_${Date.now()}`,
role: 'user',
content: text,
timestamp: Date.now() / 1000,
attachments: attachments.length > 0 ? attachments : undefined,
}
setMessages((prev) => [...prev, userMessage])
const assistantId = `assistant_${Date.now()}`
const assistantMessage: ChatMessage = {
id: assistantId,
role: 'assistant',
content: '',
timestamp: Date.now() / 1000,
isStreaming: true,
toolCalls: [],
}
setMessages((prev) => [...prev, assistantMessage])
setIsStreaming(true)
try {
const response = await apiClient.sendMessage(
sessionId, text, true,
attachments.length > 0 ? attachments : undefined
)
if (response.status === 'success' && response.stream) {
const eventSource = apiClient.createSSEStream(response.request_id)
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
switch (data.type) {
case 'delta':
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: msg.content + data.content } : msg
)
)
break
case 'tool_start':
case 'tool_end': {
const toolCall: ToolCall = {
type: data.type, tool: data.tool,
arguments: data.arguments, result: data.result,
status: data.status, execution_time: data.execution_time,
}
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, toolCalls: [...(msg.toolCalls || []), toolCall] } : msg
)
)
break
}
case 'done':
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: data.content || msg.content, isStreaming: false } : msg
)
)
setIsStreaming(false)
eventSource.close()
break
case 'error':
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: `Error: ${data.message}`, isStreaming: false } : msg
)
)
setIsStreaming(false)
eventSource.close()
break
}
} catch { /* ignore keepalive */ }
}
eventSource.onerror = () => {
setMessages((prev) =>
prev.map((msg) => (msg.id === assistantId ? { ...msg, isStreaming: false } : msg))
)
setIsStreaming(false)
eventSource.close()
}
}
} catch (err) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: `Connection error: ${err}`, isStreaming: false } : msg
)
)
setIsStreaming(false)
}
}, [sessionId])
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Messages */}
<div className="flex-1 overflow-y-auto">
{messages.length === 0 ? (
/* Welcome Screen — exact match with Web console */
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-6 shadow-lg shadow-primary-500/20" />
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-3">CowAgent</h1>
<p className="text-slate-500 dark:text-slate-400 text-center max-w-lg mb-10 leading-relaxed whitespace-pre-line">
{t('welcome_subtitle')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full max-w-2xl">
{/* System card */}
<div
onClick={() => handleSend(t('example_sys_text'), [])}
className="group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200"
>
<div className="flex items-center gap-2 mb-2">
<div className="w-7 h-7 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
<i className="fas fa-folder-open text-blue-500 text-xs" />
</div>
<span className="font-medium text-sm text-slate-700 dark:text-slate-200">{t('example_sys_title')}</span>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">{t('example_sys_text')}</p>
</div>
{/* Skills card */}
<div
onClick={() => handleSend(t('example_task_text'), [])}
className="group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200"
>
<div className="flex items-center gap-2 mb-2">
<div className="w-7 h-7 rounded-lg bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
<i className="fas fa-clock text-amber-500 text-xs" />
</div>
<span className="font-medium text-sm text-slate-700 dark:text-slate-200">{t('example_task_title')}</span>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">{t('example_task_text')}</p>
</div>
{/* Coding card */}
<div
onClick={() => handleSend(t('example_code_text'), [])}
className="group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200"
>
<div className="flex items-center gap-2 mb-2">
<div className="w-7 h-7 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
<i className="fas fa-code text-emerald-500 text-xs" />
</div>
<span className="font-medium text-sm text-slate-700 dark:text-slate-200">{t('example_code_title')}</span>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">{t('example_code_text')}</p>
</div>
</div>
</div>
) : (
<div className="py-2">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} theme={theme} baseUrl={baseUrl} />
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
<ChatInput onSend={handleSend} onNewChat={handleNewChat} disabled={isStreaming} sessionId={sessionId} />
</div>
)
}
export default ChatPage

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { ConfigData } from '../types'
interface ConfigPageProps {
baseUrl: string
}
interface DropdownProps {
id: string
value: string
options: string[]
onChange: (val: string) => void
}
const Dropdown: React.FC<DropdownProps> = ({ id, value, options, onChange }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
return (
<div ref={ref} id={id} className={`cfg-dropdown ${open ? 'open' : ''}`} onClick={() => setOpen(!open)}>
<div className="cfg-dropdown-selected">
<span className="cfg-dropdown-text truncate">{value || '--'}</span>
<i className="fas fa-chevron-down text-xs text-slate-400" />
</div>
<div className="cfg-dropdown-menu">
{options.map((opt) => (
<div
key={opt}
className={`cfg-dropdown-item ${opt === value ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); onChange(opt); setOpen(false); }}
>
{opt}
</div>
))}
</div>
</div>
)
}
const ConfigPage: React.FC<ConfigPageProps> = ({ baseUrl }) => {
const [config, setConfig] = useState<ConfigData | null>(null)
const [loading, setLoading] = useState(true)
const [provider, setProvider] = useState('')
const [model, setModel] = useState('')
const [customModel, setCustomModel] = useState('')
const [showCustom, setShowCustom] = useState(false)
const [apiKey, setApiKey] = useState('')
const [apiBase, setApiBase] = useState('')
const [keyMasked, setKeyMasked] = useState(true)
const [maxTokens, setMaxTokens] = useState(50000)
const [maxTurns, setMaxTurns] = useState(20)
const [maxSteps, setMaxSteps] = useState(15)
const [modelStatus, setModelStatus] = useState('')
const [agentStatus, setAgentStatus] = useState('')
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
loadConfig()
}, [baseUrl])
const loadConfig = async () => {
try {
setLoading(true)
const data = await apiClient.getConfig()
setConfig(data)
setModel(data.model || '')
setMaxTokens(data.agent_max_context_tokens || 50000)
setMaxTurns(data.agent_max_context_turns || 20)
setMaxSteps(data.agent_max_steps || 15)
if (data.providers) {
const providerNames = Object.keys(data.providers)
if (providerNames.length > 0) {
const currentProvider = data.bot_type || providerNames[0]
setProvider(currentProvider)
const pData = (data.providers as Record<string, any>)[currentProvider]
if (pData) {
setApiKey(pData.api_key || '')
setApiBase(pData.api_base || '')
}
}
}
} catch (err) {
console.error('Failed to load config:', err)
} finally {
setLoading(false)
}
}
const getModels = useCallback((): string[] => {
if (!config?.providers || !provider) return []
const pData = (config.providers as Record<string, any>)[provider]
const models = pData?.models || []
return [...models, t('config_custom_option')]
}, [config, provider])
const handleProviderChange = (val: string) => {
setProvider(val)
if (config?.providers) {
const pData = (config.providers as Record<string, any>)[val]
if (pData) {
setApiKey(pData.api_key || '')
setApiBase(pData.api_base || '')
const models = pData.models || []
if (models.length > 0) setModel(models[0])
}
}
setShowCustom(false)
setCustomModel('')
}
const handleModelChange = (val: string) => {
if (val === t('config_custom_option')) {
setShowCustom(true)
setModel('')
} else {
setShowCustom(false)
setModel(val)
setCustomModel('')
}
}
const saveModelConfig = async () => {
try {
const finalModel = showCustom ? customModel : model
await apiClient.updateConfig({
model: finalModel,
bot_type: provider,
api_keys: { [provider]: apiKey },
api_bases: { [provider]: apiBase },
} as any)
setModelStatus(t('config_saved'))
setTimeout(() => setModelStatus(''), 2000)
} catch {
setModelStatus(t('config_save_error'))
setTimeout(() => setModelStatus(''), 2000)
}
}
const saveAgentConfig = async () => {
try {
await apiClient.updateConfig({
agent_max_context_tokens: maxTokens,
agent_max_context_turns: maxTurns,
agent_max_steps: maxSteps,
} as any)
setAgentStatus(t('config_saved'))
setTimeout(() => setAgentStatus(''), 2000)
} catch {
setAgentStatus(t('config_save_error'))
setTimeout(() => setAgentStatus(''), 2000)
}
}
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-slate-400"><i className="fas fa-spinner fa-spin mr-2" />Loading...</div>
</div>
)
}
const providers = config?.providers ? Object.keys(config.providers) : []
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('config_title')}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('config_desc')}</p>
</div>
</div>
<div className="grid gap-6">
{/* Model Config Card */}
<div className="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div className="flex items-center gap-3 mb-5">
<div className="w-9 h-9 rounded-lg bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
<i className="fas fa-microchip text-primary-500 text-sm" />
</div>
<h3 className="font-semibold text-slate-800 dark:text-slate-100">{t('config_model')}</h3>
</div>
<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">{t('config_provider')}</label>
<Dropdown id="cfg-provider" value={provider} options={providers} onChange={handleProviderChange} />
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">{t('config_model_name')}</label>
<Dropdown id="cfg-model" value={showCustom ? t('config_custom_option') : model} options={getModels()} onChange={handleModelChange} />
{showCustom && (
<input
type="text" value={customModel} onChange={(e) => setCustomModel(e.target.value)}
className="mt-2 w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder={t('config_custom_model_hint')}
/>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
<div className="relative">
<input
type="text" value={apiKey} onChange={(e) => setApiKey(e.target.value)}
className={`w-full px-3 py-2 pr-10 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors ${keyMasked ? 'cfg-key-masked' : ''}`}
placeholder="sk-..."
/>
<button
type="button" onClick={() => setKeyMasked(!keyMasked)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer transition-colors p-1"
>
<i className={`fas ${keyMasked ? 'fa-eye' : 'fa-eye-slash'} text-xs`} />
</button>
</div>
</div>
{apiBase && (
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
<input
type="text" value={apiBase} onChange={(e) => setApiBase(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="https://..."
/>
</div>
)}
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-primary-500 transition-opacity duration-300 ${modelStatus ? 'opacity-100' : 'opacity-0'}`}>{modelStatus}</span>
<button onClick={saveModelConfig}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium cursor-pointer transition-colors duration-150">
{t('config_save')}
</button>
</div>
</div>
</div>
{/* Agent Config Card */}
<div className="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div className="flex items-center gap-3 mb-5">
<div className="w-9 h-9 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
<i className="fas fa-robot text-emerald-500 text-sm" />
</div>
<h3 className="font-semibold text-slate-800 dark:text-slate-100">{t('config_agent')}</h3>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">{t('config_max_tokens')}</label>
<input type="number" min={1000} max={200000} step={1000} value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors" />
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">{t('config_max_turns')}</label>
<input type="number" min={1} max={100} step={1} value={maxTurns}
onChange={(e) => setMaxTurns(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors" />
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">{t('config_max_steps')}</label>
<input type="number" min={1} max={50} step={1} value={maxSteps}
onChange={(e) => setMaxSteps(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors" />
</div>
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-primary-500 transition-opacity duration-300 ${agentStatus ? 'opacity-100' : 'opacity-0'}`}>{agentStatus}</span>
<button onClick={saveAgentConfig}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium cursor-pointer transition-colors duration-150">
{t('config_save')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default ConfigPage

View File

@@ -0,0 +1,104 @@
import React, { useState, useEffect, useRef } from 'react'
import { t } from '../i18n'
import apiClient from '../api/client'
interface LogsPageProps {
baseUrl: string
}
const LogsPage: React.FC<LogsPageProps> = ({ baseUrl }) => {
const [logs, setLogs] = useState<string[]>([])
const [autoScroll, setAutoScroll] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
const es = apiClient.createLogStream()
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'init' && data.content) {
setLogs(data.content.split('\n').filter(Boolean))
} else if (data.type === 'line' && data.content) {
setLogs((prev) => {
const next = [...prev, data.content]
if (next.length > 2000) return next.slice(-1500)
return next
})
}
} catch { /* ignore */ }
}
return () => es.close()
}, [baseUrl])
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [logs, autoScroll])
const handleScroll = () => {
if (!containerRef.current) return
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
setAutoScroll(scrollHeight - scrollTop - clientHeight < 50)
}
const getLogColor = (line: string) => {
if (line.includes('ERROR') || line.includes('error')) return 'text-red-400'
if (line.includes('WARNING') || line.includes('warn')) return 'text-amber-400'
if (line.includes('DEBUG')) return 'text-slate-500'
return 'text-slate-300'
}
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('logs_title')}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('logs_desc')}</p>
</div>
</div>
{/* Terminal-style log viewer */}
<div className="bg-slate-900 rounded-xl border border-slate-700 overflow-hidden shadow-lg">
{/* Terminal header */}
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-800 border-b border-slate-700">
<div className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-red-500/80" />
<span className="w-3 h-3 rounded-full bg-amber-500/80" />
<span className="w-3 h-3 rounded-full bg-emerald-500/80" />
</div>
<span className="text-xs text-slate-400 ml-2 font-mono">run.log</span>
<div className="flex-1" />
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-xs text-slate-500">{t('logs_live')}</span>
</div>
</div>
{/* Log content */}
<div
ref={containerRef}
onScroll={handleScroll}
className="p-4 overflow-y-auto font-mono text-xs leading-relaxed whitespace-pre-wrap break-all"
style={{ height: 'calc(100vh - 272px)' }}
>
{logs.length > 0 ? (
logs.map((line, i) => (
<div key={i} className={getLogColor(line)}>{line}</div>
))
) : (
<p className="text-slate-500">{t('logs_connecting')}</p>
)}
</div>
</div>
</div>
</div>
)
}
export default LogsPage

View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { MemoryItem } from '../types'
interface MemoryPageProps {
baseUrl: string
}
const formatSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const formatTime = (ts: number): string => {
return new Date(ts * 1000).toLocaleString()
}
const MemoryPage: React.FC<MemoryPageProps> = ({ baseUrl }) => {
const [items, setItems] = useState<MemoryItem[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [viewing, setViewing] = useState<string | null>(null)
const [content, setContent] = useState('')
const [loadingContent, setLoadingContent] = useState(false)
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
loadMemory()
}, [baseUrl])
const loadMemory = async () => {
try {
setLoading(true)
const data = await apiClient.getMemoryList()
setItems(data.list || [])
setTotal(data.total)
} catch (err) {
console.error('Failed to load memory:', err)
} finally {
setLoading(false)
}
}
const viewFile = async (filename: string) => {
setViewing(filename)
setLoadingContent(true)
try {
const text = await apiClient.getMemoryContent(filename)
setContent(text)
} catch (err) {
setContent(`Failed to load: ${err}`)
} finally {
setLoadingContent(false)
}
}
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto">
{!viewing ? (
/* List panel */
<>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('memory_title')}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('memory_desc')}</p>
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-purple-50 dark:bg-purple-900/20 flex items-center justify-center mb-4">
<i className="fas fa-brain text-purple-400 text-xl" />
</div>
<p className="text-slate-500 dark:text-slate-400 font-medium">{t('memory_loading')}</p>
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1">{t('memory_loading_desc')}</p>
</div>
) : items.length > 0 ? (
<div className="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 dark:border-white/10">
<th className="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{t('memory_col_name')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{t('memory_col_size')}</th>
<th className="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{t('memory_col_updated')}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.filename}
onClick={() => viewFile(item.filename)}
className="border-b border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors"
>
<td className="px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-200 font-mono">{item.filename}</td>
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">{formatSize(item.size)}</td>
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">{formatTime(item.modified)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-purple-50 dark:bg-purple-900/20 flex items-center justify-center mb-4">
<i className="fas fa-brain text-purple-400 text-xl" />
</div>
<p className="text-slate-500 dark:text-slate-400 font-medium">{t('memory_loading')}</p>
</div>
)}
</>
) : (
/* File viewer */
<>
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => setViewing(null)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 transition-colors cursor-pointer"
>
<i className="fas fa-arrow-left text-xs" />
<span>{t('memory_back')}</span>
</button>
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100 font-mono truncate">{viewing}</h2>
</div>
<div className="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
<div className="p-5 overflow-y-auto text-sm msg-content text-slate-700 dark:text-slate-200" style={{ maxHeight: 'calc(100vh - 220px)' }}>
{loadingContent ? (
<div className="text-slate-400"><i className="fas fa-spinner fa-spin mr-2" />Loading...</div>
) : (
<pre className="whitespace-pre-wrap">{content}</pre>
)}
</div>
</div>
</>
)}
</div>
</div>
)
}
export default MemoryPage

View File

@@ -0,0 +1,139 @@
import React, { useState, useEffect } from 'react'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { ToolInfo, SkillInfo } from '../types'
interface SkillsPageProps {
baseUrl: string
}
const SkillsPage: React.FC<SkillsPageProps> = ({ baseUrl }) => {
const [tools, setTools] = useState<ToolInfo[]>([])
const [skills, setSkills] = useState<SkillInfo[]>([])
const [loading, setLoading] = useState(true)
const [toggling, setToggling] = useState<string | null>(null)
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
loadData()
}, [baseUrl])
const loadData = async () => {
try {
setLoading(true)
const [toolsData, skillsData] = await Promise.all([apiClient.getTools(), apiClient.getSkills()])
setTools(toolsData || [])
setSkills(skillsData || [])
} catch (err) {
console.error('Failed to load skills:', err)
} finally {
setLoading(false)
}
}
const handleToggle = async (skill: SkillInfo) => {
setToggling(skill.name)
try {
await apiClient.toggleSkill(skill.name, skill.enabled ? 'close' : 'open')
setSkills((prev) => prev.map((s) => (s.name === skill.name ? { ...s, enabled: !s.enabled } : s)))
} catch (err) {
console.error('Toggle failed:', err)
} finally {
setToggling(null)
}
}
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('skills_title')}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('skills_desc')}</p>
</div>
</div>
{/* Built-in Tools */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">{t('tools_section_title')}</span>
{tools.length > 0 && (
<span className="px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400">{tools.length}</span>
)}
</div>
{loading ? (
<div className="flex items-center gap-2 py-4 text-slate-400 dark:text-slate-500 text-sm">
<i className="fas fa-spinner fa-spin text-xs" />
<span>{t('tools_loading')}</span>
</div>
) : tools.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{tools.map((tool) => (
<div key={tool.name} className="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4">
<div className="flex items-center gap-2 mb-1.5">
<i className="fas fa-cog text-xs text-primary-400" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{tool.display_name || tool.name}</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">{tool.description}</p>
</div>
))}
</div>
) : null}
</div>
{/* Skills */}
<div>
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">{t('skills_section_title')}</span>
{skills.length > 0 && (
<span className="px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400">{skills.length}</span>
)}
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-12">
<div className="w-14 h-14 rounded-2xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-3">
<i className="fas fa-bolt text-amber-400 text-lg" />
</div>
<p className="text-slate-500 dark:text-slate-400 font-medium">{t('skills_loading')}</p>
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1">{t('skills_loading_desc')}</p>
</div>
) : skills.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{skills.map((skill) => (
<div key={skill.name} className="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<i className="fas fa-bolt text-xs text-amber-400" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{skill.display_name || skill.name}</span>
</div>
<button
onClick={() => handleToggle(skill)}
disabled={toggling === skill.name}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors cursor-pointer ${
skill.enabled ? 'bg-primary-400' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
skill.enabled ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">{skill.description}</p>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12">
<div className="w-14 h-14 rounded-2xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-3">
<i className="fas fa-bolt text-amber-400 text-lg" />
</div>
<p className="text-slate-500 dark:text-slate-400 font-medium">{t('skills_loading')}</p>
</div>
)}
</div>
</div>
</div>
)
}
export default SkillsPage

View File

@@ -0,0 +1,86 @@
import React, { useState, useEffect } from 'react'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { SchedulerTask } from '../types'
interface TasksPageProps {
baseUrl: string
}
const TasksPage: React.FC<TasksPageProps> = ({ baseUrl }) => {
const [tasks, setTasks] = useState<SchedulerTask[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
loadTasks()
}, [baseUrl])
const loadTasks = async () => {
try {
setLoading(true)
const data = await apiClient.getSchedulerTasks()
setTasks(data || [])
} catch (err) {
console.error('Failed to load tasks:', err)
} finally {
setLoading(false)
}
}
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('tasks_title')}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('tasks_desc')}</p>
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
<i className="fas fa-clock text-rose-400 text-xl" />
</div>
<p className="text-slate-500 dark:text-slate-400 font-medium">Loading...</p>
</div>
) : tasks.length > 0 ? (
<div className="grid gap-4">
{tasks.map((task) => (
<div key={task.id} className="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<i className="fas fa-clock text-sm text-rose-400" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{task.name}</span>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${
task.enabled
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
: 'bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400'
}`}>
{task.enabled ? t('tasks_active') : t('tasks_paused')}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400">
<span>Cron: <code className="bg-slate-100 dark:bg-white/10 px-1.5 py-0.5 rounded font-mono">{task.cron}</code></span>
{task.next_run && <span>Next: {task.next_run}</span>}
{task.last_run && <span>Last: {task.last_run}</span>}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
<i className="fas fa-clock text-rose-400 text-xl" />
</div>
<p className="text-slate-500 dark:text-slate-400 font-medium">{t('tasks_empty')}</p>
</div>
)}
</div>
</div>
)
}
export default TasksPage

View File

@@ -0,0 +1,108 @@
export interface ElectronAPI {
getBackendPort: () => Promise<number | null>
getBackendStatus: () => Promise<string>
restartBackend: () => Promise<boolean>
selectDirectory: () => Promise<string | null>
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>
onBackendStatus: (callback: (data: BackendStatusEvent) => void) => void
onBackendLog: (callback: (line: string) => void) => void
platform: string
}
export interface BackendStatusEvent {
status: 'ready' | 'error' | 'starting'
port?: number
error?: string
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: number
attachments?: Attachment[]
toolCalls?: ToolCall[]
isStreaming?: boolean
}
export interface Attachment {
file_path: string
file_name: string
file_type: 'image' | 'video' | 'file'
preview_url?: string
}
export interface ToolCall {
type: 'tool_start' | 'tool_end'
tool: string
arguments?: Record<string, unknown>
result?: string
status?: string
execution_time?: number
}
export interface ConfigData {
use_agent: boolean
title: string
model: string
bot_type: string
use_linkai: boolean
channel_type: string
agent_max_context_tokens: number
agent_max_context_turns: number
agent_max_steps: number
api_bases: Record<string, string>
api_keys: Record<string, string>
providers: Record<string, unknown>
}
export interface ChannelInfo {
name: string
label: Record<string, string>
icon: string
color: string
active: boolean
fields: ChannelField[]
}
export interface ChannelField {
key: string
label: Record<string, string>
type: string
required?: boolean
placeholder?: Record<string, string>
}
export interface SkillInfo {
name: string
display_name: string
description: string
enabled: boolean
}
export interface ToolInfo {
name: string
display_name: string
description: string
}
export interface MemoryItem {
filename: string
modified: number
size: number
}
export interface SchedulerTask {
id: string
name: string
cron: string
enabled: boolean
last_run?: string
next_run?: string
}
declare global {
interface Window {
electronAPI?: ElectronAPI
}
}