mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 03:03:19 +08:00
feat: cow desktop first version
This commit is contained in:
6
desktop/.gitignore
vendored
Normal file
6
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
81
desktop/README.md
Normal file
81
desktop/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# CowAgent Desktop
|
||||
|
||||
Cross-platform desktop client for CowAgent, built with Electron + React + TypeScript.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
- Python 3.7+ (for the backend)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run in Development
|
||||
|
||||
Start the renderer dev server and Electron together:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Or run them separately:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start Vite dev server
|
||||
npm run dev:renderer
|
||||
|
||||
# Terminal 2: Start Electron (after renderer is ready)
|
||||
npm run dev:main
|
||||
```
|
||||
|
||||
The app will automatically start the Python backend from the parent directory.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Build for current platform
|
||||
npm run dist
|
||||
|
||||
# Build for macOS only
|
||||
npm run dist:mac
|
||||
|
||||
# Build for Windows only
|
||||
npm run dist:win
|
||||
```
|
||||
|
||||
Build outputs are placed in the `release/` directory.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
desktop/
|
||||
├── src/
|
||||
│ ├── main/ # Electron main process
|
||||
│ │ ├── index.ts # Window management, IPC
|
||||
│ │ ├── python-manager.ts # Python backend lifecycle
|
||||
│ │ └── preload.ts # Context bridge for renderer
|
||||
│ └── renderer/ # React UI (Vite)
|
||||
│ └── src/
|
||||
│ ├── api/ # HTTP client for backend APIs
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── hooks/ # React hooks
|
||||
│ ├── pages/ # Page components
|
||||
│ └── types.ts # TypeScript types
|
||||
├── resources/ # App icons
|
||||
├── package.json # Dependencies and build config
|
||||
└── vite.config.ts # Vite config
|
||||
```
|
||||
|
||||
### How it Works
|
||||
|
||||
1. Electron main process starts and creates the app window
|
||||
2. It spawns the Python backend (`app.py`) as a child process
|
||||
3. The React UI communicates with the Python backend via HTTP APIs
|
||||
4. SSE (Server-Sent Events) is used for streaming chat responses and live logs
|
||||
9514
desktop/package-lock.json
generated
Normal file
9514
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
94
desktop/package.json
Normal file
94
desktop/package.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "cowagent-desktop",
|
||||
"version": "1.0.0",
|
||||
"description": "CowAgent Desktop Client - AI Agent on your desktop",
|
||||
"main": "dist/main/index.js",
|
||||
"author": "CowAgent",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npm run build && electron .",
|
||||
"dev:hot": "concurrently \"npm run dev:renderer\" \"sleep 2 && npm run dev:main\"",
|
||||
"dev:main": "tsc -p tsconfig.main.json && electron .",
|
||||
"dev:renderer": "vite",
|
||||
"build": "npm run build:renderer && npm run build:main",
|
||||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build:renderer": "vite build",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:mac": "npm run build && electron-builder --mac",
|
||||
"dist:win": "npm run build && electron-builder --win"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"lucide-react": "^0.460.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.0",
|
||||
"electron": "^33.2.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.cowagent.desktop",
|
||||
"productName": "CowAgent",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"resources/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../",
|
||||
"to": "backend",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!desktop/**",
|
||||
"!.git/**",
|
||||
"!__pycache__/**",
|
||||
"!**/__pycache__/**",
|
||||
"!*.pyc",
|
||||
"!run.log",
|
||||
"!workspace/**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity",
|
||||
"icon": "resources/icon.icns",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["universal"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"icon": "resources/icon.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
}
|
||||
}
|
||||
}
|
||||
6
desktop/postcss.config.js
Normal file
6
desktop/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
desktop/resources/icon.icns
Normal file
BIN
desktop/resources/icon.icns
Normal file
Binary file not shown.
BIN
desktop/resources/icon.png
Normal file
BIN
desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
184
desktop/src/main/index.ts
Normal file
184
desktop/src/main/index.ts
Normal 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()
|
||||
})
|
||||
19
desktop/src/main/preload.ts
Normal file
19
desktop/src/main/preload.ts
Normal 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,
|
||||
})
|
||||
196
desktop/src/main/python-manager.ts
Normal file
196
desktop/src/main/python-manager.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
26
desktop/src/renderer/index.html
Normal file
26
desktop/src/renderer/index.html
Normal 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>
|
||||
116
desktop/src/renderer/src/App.tsx
Normal file
116
desktop/src/renderer/src/App.tsx
Normal 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
|
||||
175
desktop/src/renderer/src/api/client.ts
Normal file
175
desktop/src/renderer/src/api/client.ts
Normal 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
|
||||
158
desktop/src/renderer/src/components/ChatInput.tsx
Normal file
158
desktop/src/renderer/src/components/ChatInput.tsx
Normal 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
|
||||
160
desktop/src/renderer/src/components/MessageBubble.tsx
Normal file
160
desktop/src/renderer/src/components/MessageBubble.tsx
Normal 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
|
||||
116
desktop/src/renderer/src/components/Sidebar.tsx
Normal file
116
desktop/src/renderer/src/components/Sidebar.tsx
Normal 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
|
||||
58
desktop/src/renderer/src/components/StatusScreen.tsx
Normal file
58
desktop/src/renderer/src/components/StatusScreen.tsx
Normal 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
|
||||
98
desktop/src/renderer/src/hooks/useBackend.ts
Normal file
98
desktop/src/renderer/src/hooks/useBackend.ts
Normal 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 }
|
||||
}
|
||||
27
desktop/src/renderer/src/hooks/useTheme.ts
Normal file
27
desktop/src/renderer/src/hooks/useTheme.ts
Normal 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 }
|
||||
}
|
||||
167
desktop/src/renderer/src/i18n.ts
Normal file
167
desktop/src/renderer/src/i18n.ts
Normal 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)
|
||||
}
|
||||
243
desktop/src/renderer/src/index.css
Normal file
243
desktop/src/renderer/src/index.css
Normal 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;
|
||||
}
|
||||
13
desktop/src/renderer/src/main.tsx
Normal file
13
desktop/src/renderer/src/main.tsx
Normal 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>
|
||||
)
|
||||
138
desktop/src/renderer/src/pages/ChannelsPage.tsx
Normal file
138
desktop/src/renderer/src/pages/ChannelsPage.tsx
Normal 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
|
||||
205
desktop/src/renderer/src/pages/ChatPage.tsx
Normal file
205
desktop/src/renderer/src/pages/ChatPage.tsx
Normal 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
|
||||
288
desktop/src/renderer/src/pages/ConfigPage.tsx
Normal file
288
desktop/src/renderer/src/pages/ConfigPage.tsx
Normal 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
|
||||
104
desktop/src/renderer/src/pages/LogsPage.tsx
Normal file
104
desktop/src/renderer/src/pages/LogsPage.tsx
Normal 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
|
||||
142
desktop/src/renderer/src/pages/MemoryPage.tsx
Normal file
142
desktop/src/renderer/src/pages/MemoryPage.tsx
Normal 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
|
||||
139
desktop/src/renderer/src/pages/SkillsPage.tsx
Normal file
139
desktop/src/renderer/src/pages/SkillsPage.tsx
Normal 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
|
||||
86
desktop/src/renderer/src/pages/TasksPage.tsx
Normal file
86
desktop/src/renderer/src/pages/TasksPage.tsx
Normal 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
|
||||
108
desktop/src/renderer/src/types.ts
Normal file
108
desktop/src/renderer/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
37
desktop/tailwind.config.js
Normal file
37
desktop/tailwind.config.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/renderer/**/*.{html,tsx,ts}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', '"PingFang SC"', '"Hiragino Sans GB"', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#EDFDF3',
|
||||
100: '#D4FAE2',
|
||||
200: '#ABF4C7',
|
||||
300: '#74E9A4',
|
||||
400: '#4ABE6E',
|
||||
500: '#35A85B',
|
||||
600: '#228547',
|
||||
700: '#1C6B3B',
|
||||
800: '#1A5532',
|
||||
900: '#16462A',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'pulse-dot': 'pulseDot 1.4s infinite ease-in-out both',
|
||||
},
|
||||
keyframes: {
|
||||
pulseDot: {
|
||||
'0%, 80%, 100%': { transform: 'scale(0.6)', opacity: '0.4' },
|
||||
'40%': { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
22
desktop/tsconfig.json
Normal file
22
desktop/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/renderer"]
|
||||
}
|
||||
17
desktop/tsconfig.main.json
Normal file
17
desktop/tsconfig.main.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "dist/main",
|
||||
"rootDir": "src/main",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/main/**/*"]
|
||||
}
|
||||
22
desktop/vite.config.ts
Normal file
22
desktop/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: path.resolve(__dirname, 'src/renderer'),
|
||||
base: './',
|
||||
publicDir: path.resolve(__dirname, '../channel/web/static'),
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, 'dist/renderer'),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src/renderer/src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user