bubblepipe42 5 ヶ月 前
コミット
93e30703d4

+ 104 - 0
.github/workflows/electron-build.yml

@@ -0,0 +1,104 @@
+name: Build Electron App
+
+on:
+  push:
+    tags:
+      - 'v*.*.*'  # Triggers on version tags like v1.0.0
+  workflow_dispatch:  # Allows manual triggering
+
+jobs:
+  build:
+    strategy:
+      matrix:
+        os: [macos-latest, windows-latest]
+
+    runs-on: ${{ matrix.os }}
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: '20'
+
+      - name: Setup Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: '1.21'
+
+      - name: Build frontend
+        run: |
+          cd web
+          npm install --legacy-peer-deps
+          npm run build
+        env:
+          DISABLE_ESLINT_PLUGIN: 'true'
+          NODE_OPTIONS: '--max_old_space_size=4096'
+
+      - name: Build Go binary (macos/Linux)
+        if: runner.os != 'Windows'
+        run: |
+          go build -ldflags="-s -w" -o new-api
+
+      - name: Build Go binary (Windows)
+        if: runner.os == 'Windows'
+        run: |
+          go build -ldflags="-s -w" -o new-api.exe
+
+      - name: Install Electron dependencies
+        run: |
+          cd electron
+          npm install
+
+      - name: Build Electron app (macOS)
+        if: runner.os == 'macOS'
+        run: |
+          cd electron
+          npm run build:mac
+        env:
+          CSC_IDENTITY_AUTO_DISCOVERY: false  # Skip code signing
+
+      - name: Build Electron app (Windows)
+        if: runner.os == 'Windows'
+        run: |
+          cd electron
+          npm run build:win
+
+      - name: Upload artifacts (macOS)
+        if: runner.os == 'macOS'
+        uses: actions/upload-artifact@v4
+        with:
+          name: macos-build
+          path: |
+            electron/dist/*.dmg
+            electron/dist/*.zip
+
+      - name: Upload artifacts (Windows)
+        if: runner.os == 'Windows'
+        uses: actions/upload-artifact@v4
+        with:
+          name: windows-build
+          path: |
+            electron/dist/*.exe
+
+  release:
+    needs: build
+    runs-on: ubuntu-latest
+    if: startsWith(github.ref, 'refs/tags/')
+
+    steps:
+      - name: Download all artifacts
+        uses: actions/download-artifact@v4
+
+      - name: Create Release
+        uses: softprops/action-gh-release@v1
+        with:
+          files: |
+            macos-build/*
+            windows-build/*
+          draft: false
+          prerelease: false
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 5 - 1
.gitignore

@@ -11,4 +11,8 @@ web/dist
 one-api
 .DS_Store
 tiktoken_cache
-.eslintcache
+.eslintcache
+
+electron/node_modules
+electron/dist
+electron/package-lock.json

+ 73 - 0
electron/README.md

@@ -0,0 +1,73 @@
+# New API Electron Desktop App
+
+This directory contains the Electron wrapper for New API, providing a native desktop application with system tray support for Windows, macOS, and Linux.
+
+## Prerequisites
+
+### 1. Go Binary (Required)
+The Electron app requires the compiled Go binary to function. You have two options:
+
+**Option A: Use existing binary (without Go installed)**
+```bash
+# If you have a pre-built binary (e.g., new-api-macos)
+cp ../new-api-macos ../new-api
+```
+
+**Option B: Build from source (requires Go)**
+TODO
+
+### 3. Electron Dependencies
+```bash
+cd electron
+npm install
+```
+
+## Development
+
+Run the app in development mode:
+```bash
+npm start
+```
+
+This will:
+- Start the Go backend on port 3000
+- Open an Electron window with DevTools enabled
+- Create a system tray icon (menu bar on macOS)
+- Store database in `../data/new-api.db`
+
+## Building for Production
+
+### Quick Build
+```bash
+# Ensure Go binary exists in parent directory
+ls ../new-api  # Should exist
+
+# Build for current platform
+npm run build
+
+# Platform-specific builds
+npm run build:mac    # Creates .dmg and .zip
+npm run build:win    # Creates .exe installer
+npm run build:linux  # Creates .AppImage and .deb
+```
+
+### Build Output
+- Built applications are in `electron/dist/`
+- macOS: `.dmg` (installer) and `.zip` (portable)
+- Windows: `.exe` (installer) and portable exe
+- Linux: `.AppImage` and `.deb`
+
+## Configuration
+
+### Port
+Default port is 3000. To change, edit `main.js`:
+```javascript
+const PORT = 3000; // Change to desired port
+```
+
+### Database Location
+- **Development**: `../data/new-api.db` (project directory)
+- **Production**:
+  - macOS: `~/Library/Application Support/New API/data/`
+  - Windows: `%APPDATA%/New API/data/`
+  - Linux: `~/.config/New API/data/`

+ 41 - 0
electron/build.sh

@@ -0,0 +1,41 @@
+#!/bin/bash
+
+set -e
+
+echo "Building New API Electron App..."
+
+echo "Step 1: Building frontend..."
+cd ../web
+DISABLE_ESLINT_PLUGIN='true' bun run build
+cd ../electron
+
+echo "Step 2: Building Go backend..."
+cd ..
+
+if [[ "$OSTYPE" == "darwin"* ]]; then
+    echo "Building for macOS..."
+    CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api
+    cd electron
+    npm install
+    npm run build:mac
+elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
+    echo "Building for Linux..."
+    CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api
+    cd electron
+    npm install
+    npm run build:linux
+elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then
+    echo "Building for Windows..."
+    CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api.exe
+    cd electron
+    npm install
+    npm run build:win
+else
+    echo "Unknown OS, building for current platform..."
+    CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api
+    cd electron
+    npm install
+    npm run build
+fi
+
+echo "Build complete! Check electron/dist/ for output."

+ 60 - 0
electron/create-tray-icon.js

@@ -0,0 +1,60 @@
+// Create a simple tray icon for macOS
+// Run: node create-tray-icon.js
+
+const fs = require('fs');
+const { createCanvas } = require('canvas');
+
+function createTrayIcon() {
+  // For macOS, we'll use a Template image (black and white)
+  // Size should be 22x22 for Retina displays (@2x would be 44x44)
+  const canvas = createCanvas(22, 22);
+  const ctx = canvas.getContext('2d');
+
+  // Clear canvas
+  ctx.clearRect(0, 0, 22, 22);
+
+  // Draw a simple "API" icon
+  ctx.fillStyle = '#000000';
+  ctx.font = 'bold 10px system-ui';
+  ctx.textAlign = 'center';
+  ctx.textBaseline = 'middle';
+  ctx.fillText('API', 11, 11);
+
+  // Save as PNG
+  const buffer = canvas.toBuffer('image/png');
+  fs.writeFileSync('tray-icon.png', buffer);
+
+  // For Template images on macOS (will adapt to menu bar theme)
+  fs.writeFileSync('tray-iconTemplate.png', buffer);
+  fs.writeFileSync('tray-iconTemplate@2x.png', buffer);
+
+  console.log('Tray icon created successfully!');
+}
+
+// Check if canvas is installed
+try {
+  createTrayIcon();
+} catch (err) {
+  console.log('Canvas module not installed.');
+  console.log('For now, creating a placeholder. Install canvas with: npm install canvas');
+
+  // Create a minimal 1x1 transparent PNG as placeholder
+  const minimalPNG = Buffer.from([
+    0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
+    0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
+    0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
+    0x01, 0x03, 0x00, 0x00, 0x00, 0x25, 0xDB, 0x56,
+    0xCA, 0x00, 0x00, 0x00, 0x03, 0x50, 0x4C, 0x54,
+    0x45, 0x00, 0x00, 0x00, 0xA7, 0x7A, 0x3D, 0xDA,
+    0x00, 0x00, 0x00, 0x01, 0x74, 0x52, 0x4E, 0x53,
+    0x00, 0x40, 0xE6, 0xD8, 0x66, 0x00, 0x00, 0x00,
+    0x0A, 0x49, 0x44, 0x41, 0x54, 0x08, 0x1D, 0x62,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
+    0x00, 0x01, 0x0A, 0x2D, 0xCB, 0x59, 0x00, 0x00,
+    0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42,
+    0x60, 0x82
+  ]);
+
+  fs.writeFileSync('tray-icon.png', minimalPNG);
+  console.log('Created placeholder tray icon.');
+}

+ 18 - 0
electron/entitlements.mac.plist

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
+    <true/>
+    <key>com.apple.security.cs.allow-jit</key>
+    <true/>
+    <key>com.apple.security.cs.disable-library-validation</key>
+    <true/>
+    <key>com.apple.security.cs.allow-dyld-environment-variables</key>
+    <true/>
+    <key>com.apple.security.network.client</key>
+    <true/>
+    <key>com.apple.security.network.server</key>
+    <true/>
+</dict>
+</plist>

BIN
electron/icon.png


+ 239 - 0
electron/main.js

@@ -0,0 +1,239 @@
+const { app, BrowserWindow, dialog, Tray, Menu } = require('electron');
+const { spawn } = require('child_process');
+const path = require('path');
+const http = require('http');
+const fs = require('fs');
+
+let mainWindow;
+let serverProcess;
+let tray = null;
+const PORT = 3000;
+
+function getBinaryPath() {
+  const isDev = process.env.NODE_ENV === 'development';
+  const platform = process.platform;
+
+  if (isDev) {
+    const binaryName = platform === 'win32' ? 'new-api.exe' : 'new-api';
+    return path.join(__dirname, '..', binaryName);
+  }
+
+  let binaryName;
+  switch (platform) {
+    case 'win32':
+      binaryName = 'new-api.exe';
+      break;
+    case 'darwin':
+      binaryName = 'new-api';
+      break;
+    case 'linux':
+      binaryName = 'new-api';
+      break;
+    default:
+      binaryName = 'new-api';
+  }
+
+  return path.join(process.resourcesPath, 'bin', binaryName);
+}
+
+function startServer() {
+  return new Promise((resolve, reject) => {
+    const binaryPath = getBinaryPath();
+    const isDev = process.env.NODE_ENV === 'development';
+
+    console.log('Starting server from:', binaryPath);
+
+    const env = { ...process.env, PORT: PORT.toString() };
+
+    let dataDir;
+    if (isDev) {
+      dataDir = path.join(__dirname, '..', 'data');
+    } else {
+      const userDataPath = app.getPath('userData');
+      dataDir = path.join(userDataPath, 'data');
+    }
+
+    if (!fs.existsSync(dataDir)) {
+      fs.mkdirSync(dataDir, { recursive: true });
+    }
+
+    env.SQLITE_PATH = path.join(dataDir, 'new-api.db');
+
+    const workingDir = isDev
+      ? path.join(__dirname, '..')
+      : process.resourcesPath;
+
+    serverProcess = spawn(binaryPath, [], {
+      env,
+      cwd: workingDir
+    });
+
+    serverProcess.stdout.on('data', (data) => {
+      console.log(`Server: ${data}`);
+    });
+
+    serverProcess.stderr.on('data', (data) => {
+      console.error(`Server Error: ${data}`);
+    });
+
+    serverProcess.on('error', (err) => {
+      console.error('Failed to start server:', err);
+      reject(err);
+    });
+
+    serverProcess.on('close', (code) => {
+      console.log(`Server process exited with code ${code}`);
+      if (mainWindow && !mainWindow.isDestroyed()) {
+        mainWindow.close();
+      }
+    });
+
+    waitForServer(resolve, reject);
+  });
+}
+
+function waitForServer(resolve, reject, retries = 30) {
+  if (retries === 0) {
+    reject(new Error('Server failed to start within timeout'));
+    return;
+  }
+
+  const req = http.get(`http://localhost:${PORT}`, (res) => {
+    console.log('Server is ready');
+    resolve();
+  });
+
+  req.on('error', () => {
+    setTimeout(() => waitForServer(resolve, reject, retries - 1), 1000);
+  });
+
+  req.end();
+}
+
+function createWindow() {
+  mainWindow = new BrowserWindow({
+    width: 1400,
+    height: 900,
+    webPreferences: {
+      preload: path.join(__dirname, 'preload.js'),
+      nodeIntegration: false,
+      contextIsolation: true
+    },
+    title: 'New API',
+    icon: path.join(__dirname, 'icon.png')
+  });
+
+  mainWindow.loadURL(`http://localhost:${PORT}`);
+
+  if (process.env.NODE_ENV === 'development') {
+    mainWindow.webContents.openDevTools();
+  }
+
+  // Close to tray instead of quitting
+  mainWindow.on('close', (event) => {
+    if (!app.isQuitting) {
+      event.preventDefault();
+      mainWindow.hide();
+      if (process.platform === 'darwin') {
+        app.dock.hide();
+      }
+    }
+  });
+
+  mainWindow.on('closed', () => {
+    mainWindow = null;
+  });
+}
+
+function createTray() {
+  // Use template icon for macOS (black with transparency, auto-adapts to theme)
+  // Use colored icon for Windows
+  const trayIconPath = process.platform === 'darwin'
+    ? path.join(__dirname, 'tray-iconTemplate.png')
+    : path.join(__dirname, 'tray-icon-windows.png');
+
+  tray = new Tray(trayIconPath);
+
+  const contextMenu = Menu.buildFromTemplate([
+    {
+      label: 'Show New API',
+      click: () => {
+        if (mainWindow === null) {
+          createWindow();
+        } else {
+          mainWindow.show();
+          if (process.platform === 'darwin') {
+            app.dock.show();
+          }
+        }
+      }
+    },
+    { type: 'separator' },
+    {
+      label: 'Quit',
+      click: () => {
+        app.isQuitting = true;
+        app.quit();
+      }
+    }
+  ]);
+
+  tray.setToolTip('New API');
+  tray.setContextMenu(contextMenu);
+
+  // On macOS, clicking the tray icon shows the window
+  tray.on('click', () => {
+    if (mainWindow === null) {
+      createWindow();
+    } else {
+      mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
+      if (mainWindow.isVisible() && process.platform === 'darwin') {
+        app.dock.show();
+      }
+    }
+  });
+}
+
+app.whenReady().then(async () => {
+  try {
+    await startServer();
+    createTray();
+    createWindow();
+  } catch (err) {
+    console.error('Failed to start application:', err);
+    dialog.showErrorBox('Startup Error', `Failed to start server: ${err.message}`);
+    app.quit();
+  }
+});
+
+app.on('window-all-closed', () => {
+  // Don't quit when window is closed, keep running in tray
+  // Only quit when explicitly choosing Quit from tray menu
+});
+
+app.on('activate', () => {
+  if (BrowserWindow.getAllWindows().length === 0) {
+    createWindow();
+  }
+});
+
+app.on('before-quit', (event) => {
+  if (serverProcess) {
+    event.preventDefault();
+
+    console.log('Shutting down server...');
+    serverProcess.kill('SIGTERM');
+
+    setTimeout(() => {
+      if (serverProcess) {
+        serverProcess.kill('SIGKILL');
+      }
+      app.exit();
+    }, 5000);
+
+    serverProcess.on('close', () => {
+      serverProcess = null;
+      app.exit();
+    });
+  }
+});

+ 100 - 0
electron/package.json

@@ -0,0 +1,100 @@
+{
+  "name": "new-api-electron",
+  "version": "1.0.0",
+  "description": "New API - AI Model Gateway Desktop Application",
+  "main": "main.js",
+  "scripts": {
+    "start": "set NODE_ENV=development&& electron .",
+    "build": "electron-builder",
+    "build:mac": "electron-builder --mac",
+    "build:win": "electron-builder --win",
+    "build:linux": "electron-builder --linux"
+  },
+  "keywords": [
+    "ai",
+    "api",
+    "gateway",
+    "openai",
+    "claude"
+  ],
+  "author": "",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/Calcium-Ion/new-api"
+  },
+  "devDependencies": {
+    "electron": "^28.0.0",
+    "electron-builder": "^24.9.1"
+  },
+  "build": {
+    "appId": "com.newapi.desktop",
+    "productName": "New API",
+    "publish": null,
+    "directories": {
+      "output": "dist"
+    },
+    "files": [
+      "main.js",
+      "preload.js",
+      "icon.png",
+      "tray-iconTemplate.png",
+      "tray-iconTemplate@2x.png",
+      "tray-icon-windows.png"
+    ],
+    "mac": {
+      "category": "public.app-category.developer-tools",
+      "icon": "icon.png",
+      "identity": null,
+      "hardenedRuntime": false,
+      "gatekeeperAssess": false,
+      "entitlements": "entitlements.mac.plist",
+      "entitlementsInherit": "entitlements.mac.plist",
+      "target": [
+        "dmg",
+        "zip"
+      ],
+      "extraResources": [
+        {
+          "from": "../new-api",
+          "to": "bin/new-api"
+        },
+        {
+          "from": "../web/dist",
+          "to": "web/dist"
+        }
+      ]
+    },
+    "win": {
+      "icon": "icon.png",
+      "target": [
+        "nsis",
+        "portable"
+      ],
+      "extraResources": [
+        {
+          "from": "../new-api.exe",
+          "to": "bin/new-api.exe"
+        }
+      ]
+    },
+    "linux": {
+      "icon": "icon.png",
+      "target": [
+        "AppImage",
+        "deb"
+      ],
+      "category": "Development",
+      "extraResources": [
+        {
+          "from": "../new-api",
+          "to": "bin/new-api"
+        }
+      ]
+    },
+    "nsis": {
+      "oneClick": false,
+      "allowToChangeInstallationDirectory": true
+    }
+  }
+}

+ 6 - 0
electron/preload.js

@@ -0,0 +1,6 @@
+const { contextBridge } = require('electron');
+
+contextBridge.exposeInMainWorld('electron', {
+  version: process.versions.electron,
+  platform: process.platform
+});

BIN
electron/tray-icon-windows.png


BIN
electron/tray-iconTemplate.png


BIN
electron/tray-iconTemplate@2x.png


+ 1 - 0
web/package.json

@@ -10,6 +10,7 @@
     "@visactor/react-vchart": "~1.8.8",
     "@visactor/vchart": "~1.8.8",
     "@visactor/vchart-semi-theme": "~1.8.8",
+    "antd": "^5.27.4",
     "axios": "^0.27.2",
     "clsx": "^2.1.1",
     "country-flag-icons": "^1.5.19",