Initial commit

This commit is contained in:
2025-11-07 17:50:22 +08:00
commit 53c8befd0e
52 changed files with 13388 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
+8
View File
@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+57
View File
@@ -0,0 +1,57 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="-6fc3bd37:19a494edbda:-5643" />
</MTProjectMetadataState>
</option>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KubernetesApiProvider"><![CDATA[{}]]></component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="homebrew-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ai-desktop.iml" filepath="$PROJECT_DIR$/.idea/ai-desktop.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
</component>
</project>
Generated
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+2
View File
@@ -0,0 +1,2 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
+6
View File
@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
+4
View File
@@ -0,0 +1,4 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
}
+39
View File
@@ -0,0 +1,39 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 60000,
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
+34
View File
@@ -0,0 +1,34 @@
# ai-desktop
An Electron application with React and TypeScript
## Recommended IDE Setup
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
## Project Setup
### Install
```bash
$ npm install
```
### Development
```bash
$ npm run dev
```
### Build
```bash
# For windows
$ npm run build:win
# For macOS
$ npm run build:mac
# For Linux
$ npm run build:linux
```
+12
View File
@@ -0,0 +1,12 @@
<?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-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+3
View File
@@ -0,0 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: ai-desktop-updater
+45
View File
@@ -0,0 +1,45 @@
appId: com.electron.app
productName: ai-desktop
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: ai-desktop
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
+29
View File
@@ -0,0 +1,29 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
}
},
plugins: [react()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
floating: resolve(__dirname, 'src/renderer/floating.html'),
settings: resolve(__dirname, 'src/renderer/settings.html')
}
}
}
}
})
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from 'eslint/config'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginReact from 'eslint-plugin-react'
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
export default defineConfig(
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
tseslint.configs.recommended,
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
{
settings: {
react: {
version: 'detect'
}
}
},
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': eslintPluginReactHooks,
'react-refresh': eslintPluginReactRefresh
},
rules: {
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactRefresh.configs.vite.rules
}
},
eslintConfigPrettier
)
+11241
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "ai-desktop",
"version": "1.0.0",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://electron-vite.org",
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"antd": "^5.28.0",
"axios": "^1.13.2",
"electron-updater": "^6.3.9"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/node": "^22.19.0",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"electron": "^38.6.0",
"electron-builder": "^25.1.8",
"electron-vite": "^4.0.1",
"eslint": "^9.36.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"prettier": "^3.6.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.6"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+203
View File
@@ -0,0 +1,203 @@
import { app, BrowserWindow, ipcMain, screen, globalShortcut, clipboard } from 'electron'
import { join } from 'path'
let floatingWindow: BrowserWindow | null = null
let settingsWindow: BrowserWindow | null = null
let floatingWindowReady = false
let tooltipOpenCache = false
function createFloatingWindow(): void {
const { width } = screen.getPrimaryDisplay().workAreaSize
floatingWindow = new BrowserWindow({
width: 400,
height: 400,
x: width - 420,
y: 60,
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
hasShadow: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
nodeIntegration: false,
contextIsolation: true
}
})
floatingWindow.setIgnoreMouseEvents(true, { forward: true })
floatingWindow.setAlwaysOnTop(true, 'floating')
floatingWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
// Mark window as ready when content is loaded
floatingWindow.webContents.on('did-finish-load', () => {
floatingWindowReady = true
})
// Handle mouse enter/leave events to control click-through
ipcMain.on('set-ignore-mouse-events', (_, ignore: boolean, options?: { forward: boolean }) => {
if (floatingWindow) {
floatingWindow.setIgnoreMouseEvents(ignore, options)
}
})
// Handle context menu
ipcMain.on('show-context-menu', () => {
if (floatingWindow) {
const { Menu } = require('electron')
const menu = Menu.buildFromTemplate([
{
label: '设置',
click: () => {
createSettingsWindow()
}
},
{
label: '退出',
click: () => {
app.quit()
}
}
])
menu.popup({ window: floatingWindow })
}
})
// Load the floating window HTML
if (process.env['ELECTRON_RENDERER_URL']) {
floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`)
} else {
floatingWindow.loadFile(join(__dirname, '../renderer/floating.html'))
}
// Handle window drag
ipcMain.on('floating-window-move', (_, { x, y }) => {
if (floatingWindow) {
floatingWindow.setPosition(x, y)
}
})
// Handle get window bounds
ipcMain.handle('get-window-bounds', () => {
if (floatingWindow) {
const bounds = floatingWindow.getBounds()
return { x: bounds.x, y: bounds.y }
}
return { x: 0, y: 0 }
})
// Listen for tooltip state changes from renderer to update cache
ipcMain.on('tooltip-state-changed', (_, isOpen: boolean) => {
tooltipOpenCache = isOpen
})
// Handle check if tooltip is open
ipcMain.handle('is-tooltip-open', () => {
if (floatingWindow) {
return floatingWindow.webContents.executeJavaScript('window.__tooltipOpen || false')
}
return false
})
}
function createSettingsWindow(): void {
// If settings window already exists, focus it
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus()
return
}
settingsWindow = new BrowserWindow({
width: 900,
height: 600,
title: '设置',
resizable: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
nodeIntegration: false,
contextIsolation: true
}
})
// Load settings page
if (process.env['ELECTRON_RENDERER_URL']) {
settingsWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/settings.html`)
} else {
settingsWindow.loadFile(join(__dirname, '../renderer/settings.html'))
}
settingsWindow.on('closed', () => {
settingsWindow = null
})
}
function registerGlobalShortcuts(): void {
// Register Cmd+K (Mac) or Ctrl+K (Windows/Linux)
const shortcut = process.platform === 'darwin' ? 'Command+K' : 'Control+K'
const registered = globalShortcut.register(shortcut, () => {
if (floatingWindow && !floatingWindow.isDestroyed() && floatingWindowReady) {
// Get clipboard text immediately for faster response
const selectedText = clipboard.readText('selection')
const text = selectedText || clipboard.readText()
// Use cached state for instant response
if (tooltipOpenCache) {
// If tooltip is open, close it
tooltipOpenCache = false
floatingWindow.webContents.send('close-tooltip')
} else {
// If tooltip is closed, open it with selected text
if (text && text.trim()) {
tooltipOpenCache = true
floatingWindow.webContents.send('show-text-action-prompt', text)
floatingWindow.focus()
}
}
}
})
if (!registered) {
console.error('Global shortcut registration failed')
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
// IPC test
ipcMain.on('ping', () => console.log('pong'))
createFloatingWindow()
registerGlobalShortcuts()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createFloatingWindow()
}
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// Unregister all shortcuts when app is about to quit
app.on('will-quit', () => {
globalShortcut.unregisterAll()
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
+8
View File
@@ -0,0 +1,8 @@
import { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: unknown
}
}
+22
View File
@@ -0,0 +1,22 @@
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
}
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Floating Ball</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
-webkit-app-region: no-drag;
}
#root {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/floating.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>设置</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/settings.tsx"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
import Versions from './components/Versions'
import electronLogo from './assets/electron.svg'
import { Button, Card, Space, Typography, ConfigProvider, theme } from 'antd'
import { RocketOutlined, ThunderboltOutlined } from '@ant-design/icons'
const { Title, Paragraph } = Typography
function App(): React.JSX.Element {
const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
return (
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm
}}
>
<div style={{ padding: '24px', maxWidth: '800px', margin: '0 auto' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<img alt="logo" className="logo" src={electronLogo} style={{ width: '120px' }} />
<Title level={2}>AI Desktop Application</Title>
<Paragraph type="secondary">
Build with Electron + React + TypeScript + Ant Design
</Paragraph>
</div>
<Card title="Welcome to Ant Design">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Paragraph>
Ant Design has been successfully installed! You can now use all the beautiful
components from Ant Design in your AI desktop application.
</Paragraph>
<Space wrap>
<Button type="primary" icon={<RocketOutlined />}>
Primary Button
</Button>
<Button type="default" icon={<ThunderboltOutlined />} onClick={ipcHandle}>
Send IPC
</Button>
<Button type="dashed">Dashed Button</Button>
<Button type="link" href="https://ant.design" target="_blank">
Ant Design Docs
</Button>
</Space>
</Space>
</Card>
<Card title="Quick Start">
<Paragraph>
Press <code>F12</code> to open DevTools and start building your AI features!
</Paragraph>
</Card>
<Versions></Versions>
</Space>
</div>
</ConfigProvider>
)
}
export default App
+67
View File
@@ -0,0 +1,67 @@
:root {
--ev-c-white: #ffffff;
--ev-c-white-soft: #f8f8f8;
--ev-c-white-mute: #f2f2f2;
--ev-c-black: #1b1b1f;
--ev-c-black-soft: #222222;
--ev-c-black-mute: #282828;
--ev-c-gray-1: #515c67;
--ev-c-gray-2: #414853;
--ev-c-gray-3: #32363f;
--ev-c-text-1: rgba(255, 255, 245, 0.86);
--ev-c-text-2: rgba(235, 235, 245, 0.6);
--ev-c-text-3: rgba(235, 235, 245, 0.38);
--ev-button-alt-border: transparent;
--ev-button-alt-text: var(--ev-c-text-1);
--ev-button-alt-bg: var(--ev-c-gray-3);
--ev-button-alt-hover-border: transparent;
--ev-button-alt-hover-text: var(--ev-c-text-1);
--ev-button-alt-hover-bg: var(--ev-c-gray-2);
}
:root {
--color-background: var(--ev-c-black);
--color-background-soft: var(--ev-c-black-soft);
--color-background-mute: var(--ev-c-black-mute);
--color-text: var(--ev-c-text-1);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
ul {
list-style: none;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="64" cy="64" r="64" fill="#2F3242"/>
<ellipse cx="63.9835" cy="23.2036" rx="4.48794" ry="4.495" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.6153 43.5751L30.1748 44.4741L30.1748 44.4741L28.6153 43.5751ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM53.7489 81.7014L52.8478 83.2597L53.7489 81.7014ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM99.3169 43.6354L97.7574 44.5344L99.3169 43.6354ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM53.7836 46.3728L54.6847 47.931L53.7836 46.3728ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM84.3832 64.0673H82.5832H84.3832ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077V84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027V89.4027V89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.8501 68.0857C62.6341 68.5652 60.451 67.1547 59.9713 64.9353C59.4934 62.7159 60.9007 60.5293 63.1167 60.0489C65.3326 59.5693 67.5157 60.9798 67.9954 63.1992C68.4742 65.4186 67.066 67.6052 64.8501 68.0857Z" fill="#A2ECFB"/>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

+171
View File
@@ -0,0 +1,171 @@
@import './base.css';
body {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-image: url('./wavy-lines.svg');
background-size: cover;
user-select: none;
}
code {
font-weight: 600;
padding: 3px 5px;
border-radius: 2px;
background-color: var(--color-background-mute);
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 85%;
}
#root {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin-bottom: 80px;
}
.logo {
margin-bottom: 20px;
-webkit-user-drag: none;
height: 128px;
width: 128px;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 1.2em #6988e6aa);
}
.creator {
font-size: 14px;
line-height: 16px;
color: var(--ev-c-text-2);
font-weight: 600;
margin-bottom: 10px;
}
.text {
font-size: 28px;
color: var(--ev-c-text-1);
font-weight: 700;
line-height: 32px;
text-align: center;
margin: 0 10px;
padding: 16px 0;
}
.tip {
font-size: 16px;
line-height: 24px;
color: var(--ev-c-text-2);
font-weight: 600;
}
.react {
background: -webkit-linear-gradient(315deg, #087ea4 55%, #7c93ee);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
}
.ts {
background: -webkit-linear-gradient(315deg, #3178c6 45%, #f0dc4e);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
}
.actions {
display: flex;
padding-top: 32px;
margin: -6px;
flex-wrap: wrap;
justify-content: flex-start;
}
.action {
flex-shrink: 0;
padding: 6px;
}
.action a {
cursor: pointer;
text-decoration: none;
display: inline-block;
border: 1px solid transparent;
text-align: center;
font-weight: 600;
white-space: nowrap;
border-radius: 20px;
padding: 0 20px;
line-height: 38px;
font-size: 14px;
border-color: var(--ev-button-alt-border);
color: var(--ev-button-alt-text);
background-color: var(--ev-button-alt-bg);
}
.action a:hover {
border-color: var(--ev-button-alt-hover-border);
color: var(--ev-button-alt-hover-text);
background-color: var(--ev-button-alt-hover-bg);
}
.versions {
position: absolute;
bottom: 30px;
margin: 0 auto;
padding: 15px 0;
font-family: 'Menlo', 'Lucida Console', monospace;
display: inline-flex;
overflow: hidden;
align-items: center;
border-radius: 22px;
background-color: #202127;
backdrop-filter: blur(24px);
}
.versions li {
display: block;
float: left;
border-right: 1px solid var(--ev-c-gray-1);
padding: 0 20px;
font-size: 14px;
line-height: 14px;
opacity: 0.8;
&:last-child {
border: none;
}
}
@media (max-width: 720px) {
.text {
font-size: 20px;
}
}
@media (max-width: 620px) {
.versions {
display: none;
}
}
@media (max-width: 350px) {
.tip,
.actions {
display: none;
}
}
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1422 800" opacity="0.3">
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="oooscillate-grad">
<stop stop-color="hsl(206, 75%, 49%)" stop-opacity="1" offset="0%"></stop>
<stop stop-color="hsl(331, 90%, 56%)" stop-opacity="1" offset="100%"></stop>
</linearGradient>
</defs>
<g stroke-width="1" stroke="url(#oooscillate-grad)" fill="none" stroke-linecap="round">
<path d="M 0 448 Q 355.5 -100 711 400 Q 1066.5 900 1422 448" opacity="0.05"></path>
<path d="M 0 420 Q 355.5 -100 711 400 Q 1066.5 900 1422 420" opacity="0.11"></path>
<path d="M 0 392 Q 355.5 -100 711 400 Q 1066.5 900 1422 392" opacity="0.18"></path>
<path d="M 0 364 Q 355.5 -100 711 400 Q 1066.5 900 1422 364" opacity="0.24"></path>
<path d="M 0 336 Q 355.5 -100 711 400 Q 1066.5 900 1422 336" opacity="0.30"></path>
<path d="M 0 308 Q 355.5 -100 711 400 Q 1066.5 900 1422 308" opacity="0.37"></path>
<path d="M 0 280 Q 355.5 -100 711 400 Q 1066.5 900 1422 280" opacity="0.43"></path>
<path d="M 0 252 Q 355.5 -100 711 400 Q 1066.5 900 1422 252" opacity="0.49"></path>
<path d="M 0 224 Q 355.5 -100 711 400 Q 1066.5 900 1422 224" opacity="0.56"></path>
<path d="M 0 196 Q 355.5 -100 711 400 Q 1066.5 900 1422 196" opacity="0.62"></path>
<path d="M 0 168 Q 355.5 -100 711 400 Q 1066.5 900 1422 168" opacity="0.68"></path>
<path d="M 0 140 Q 355.5 -100 711 400 Q 1066.5 900 1422 140" opacity="0.75"></path>
<path d="M 0 112 Q 355.5 -100 711 400 Q 1066.5 900 1422 112" opacity="0.81"></path>
<path d="M 0 84 Q 355.5 -100 711 400 Q 1066.5 900 1422 84" opacity="0.87"></path>
<path d="M 0 56 Q 355.5 -100 711 400 Q 1066.5 900 1422 56" opacity="0.94"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,591 @@
import React, { useState, useRef, useEffect } from 'react'
import { streamChat } from '../services/aiService'
const FloatingBall: React.FC = () => {
const [isTooltipOpen, setIsTooltipOpen] = useState(false)
const [isBlinking, setIsBlinking] = useState(false)
const [selectedText, setSelectedText] = useState<string>('')
const [inputValue, setInputValue] = useState<string>('')
const [aiResponse, setAiResponse] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const isDraggingRef = useRef(false)
const startPosRef = useRef({ x: 0, y: 0 })
const windowStartRef = useRef({ x: 0, y: 0 })
const blinkTimerRef = useRef<NodeJS.Timeout | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const responseRef = useRef<HTMLDivElement>(null)
// Initialize and expose tooltip state to main process via window object immediately
useEffect(() => {
// Initialize immediately on mount
;(window as Window & { __tooltipOpen?: boolean }).__tooltipOpen = false
}, [])
// Update tooltip state whenever it changes
useEffect(() => {
;(window as Window & { __tooltipOpen?: boolean }).__tooltipOpen = isTooltipOpen
// Notify main process to update cache for faster shortcut response
window.electron.ipcRenderer.send('tooltip-state-changed', isTooltipOpen)
}, [isTooltipOpen])
// Handle ESC key to close tooltip
useEffect(() => {
const handleEscapeKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape' && isTooltipOpen) {
setIsTooltipOpen(false)
setSelectedText('')
setInputValue('')
setAiResponse('')
}
}
document.addEventListener('keydown', handleEscapeKey)
return (): void => {
document.removeEventListener('keydown', handleEscapeKey)
}
}, [isTooltipOpen])
// Listen for global shortcut trigger from main process
useEffect(() => {
const handleTextActionPrompt = (_event: unknown, text: string): void => {
setSelectedText(text)
setIsTooltipOpen(true)
}
const handleCloseTooltip = (): void => {
setIsTooltipOpen(false)
setSelectedText('')
setInputValue('')
setAiResponse('')
}
const unsubscribe1 = window.electron.ipcRenderer.on(
'show-text-action-prompt',
handleTextActionPrompt
)
const unsubscribe2 = window.electron.ipcRenderer.on('close-tooltip', handleCloseTooltip)
return (): void => {
if (unsubscribe1) {
unsubscribe1()
}
if (unsubscribe2) {
unsubscribe2()
}
}
}, [])
// Auto-scroll to bottom when AI response updates
useEffect(() => {
if (responseRef.current) {
responseRef.current.scrollTop = responseRef.current.scrollHeight
}
}, [aiResponse])
// Blinking animation - blink every 3-5 seconds when not active
useEffect(() => {
const scheduleNextBlink = (): void => {
const delay = Math.random() * 2000 + 3000 // Random delay between 3-5 seconds
blinkTimerRef.current = setTimeout(() => {
if (!isTooltipOpen) {
setIsBlinking(true)
setTimeout(() => {
setIsBlinking(false)
scheduleNextBlink()
}, 200) // Blink duration: 200ms
} else {
scheduleNextBlink()
}
}, delay)
}
scheduleNextBlink()
return (): void => {
if (blinkTimerRef.current) {
clearTimeout(blinkTimerRef.current)
}
}
}, [isTooltipOpen])
const handleMouseEnterBall = (): void => {
// When mouse enters the ball area, stop ignoring mouse events
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
}
const handleMouseLeaveBall = (): void => {
// When mouse leaves the ball area, always restore click-through
// If mouse enters tooltip, the tooltip's onMouseEnter will disable click-through again
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
}
const handleMouseEnterTooltip = (): void => {
// When mouse enters tooltip, stop ignoring mouse events
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
}
const handleMouseLeaveTooltip = (): void => {
// When mouse leaves tooltip, restore click-through
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
}
const handleContextMenu = (e: React.MouseEvent): void => {
e.preventDefault()
// Show context menu via IPC
window.electron.ipcRenderer.send('show-context-menu')
}
const handleSendMessage = async (): Promise<void> => {
const message = inputValue.trim()
if (!message) return
setIsLoading(true)
setAiResponse('')
try {
await streamChat(message, {
onStart: () => {
setAiResponse('')
},
onToken: (token: string) => {
setAiResponse((prev) => prev + token)
},
onComplete: () => {
setIsLoading(false)
},
onError: (error: Error) => {
setIsLoading(false)
setAiResponse(`错误: ${error.message}`)
}
})
} catch (error) {
setIsLoading(false)
setAiResponse(`错误: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const handleMouseDown = async (e: React.MouseEvent): Promise<void> => {
e.preventDefault()
e.stopPropagation()
// Ignore right click for context menu
if (e.button === 2) {
return
}
isDraggingRef.current = false
startPosRef.current = { x: e.screenX, y: e.screenY }
try {
const bounds = await window.electron.ipcRenderer.invoke('get-window-bounds')
windowStartRef.current = { x: bounds.x, y: bounds.y }
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.screenX - startPosRef.current.x
const deltaY = moveEvent.screenY - startPosRef.current.y
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
// Only start dragging if moved more than 3 pixels
if (distance > 3) {
isDraggingRef.current = true
}
if (isDraggingRef.current) {
const newX = windowStartRef.current.x + deltaX
const newY = windowStartRef.current.y + deltaY
window.electron.ipcRenderer.send('floating-window-move', { x: newX, y: newY })
}
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// If not dragged, treat as a click - toggle tooltip
if (!isDraggingRef.current) {
// Use setTimeout to avoid state update conflicts
setTimeout(() => {
setIsTooltipOpen((prev) => {
if (prev) {
// Closing tooltip, clear all states
setSelectedText('')
setInputValue('')
setAiResponse('')
}
return !prev
})
}, 0)
}
isDraggingRef.current = false
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
} catch (error) {
console.error('Failed to get window bounds:', error)
}
}
return (
<>
<style>{`
.ai-response-container::-webkit-scrollbar {
width: 8px;
}
.ai-response-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
.ai-response-container::-webkit-scrollbar-thumb {
background: rgba(25, 118, 210, 0.3);
border-radius: 4px;
}
.ai-response-container::-webkit-scrollbar-thumb:hover {
background: rgba(25, 118, 210, 0.5);
}
`}</style>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
pointerEvents: 'none'
}}
>
{/* Tooltip - Separate from ball container for proper click-through */}
{isTooltipOpen && (
<div
onMouseEnter={handleMouseEnterTooltip}
onMouseLeave={handleMouseLeaveTooltip}
style={{
position: 'absolute',
bottom: 'calc(50% + 42px)',
left: '50%',
transform: 'translateX(-50%)',
background: 'white',
padding: '12px',
borderRadius: '8px',
boxShadow: '0 4px 16px rgba(33, 150, 243, 0.3)',
zIndex: 2,
border: '1px solid #e3f2fd',
minWidth: '280px',
maxWidth: '350px',
maxHeight: '500px',
display: 'flex',
flexDirection: 'column',
pointerEvents: 'auto'
}}
>
{/* Close button */}
<button
onClick={() => {
setIsTooltipOpen(false)
setSelectedText('')
setInputValue('')
setAiResponse('')
}}
style={{
position: 'absolute',
top: '8px',
right: '8px',
width: '20px',
height: '20px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: '16px',
color: '#999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
lineHeight: 1
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#1976d2'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#999'
}}
>
×
</button>
{/* Title */}
<div
style={{
fontSize: '13px',
fontWeight: 500,
color: '#1976d2',
marginBottom: '8px',
paddingRight: '20px',
flexShrink: 0
}}
>
{selectedText ? '你想对这段文字做什么?' : '你好!我能帮你做些什么?'}
</div>
{/* Selected text display */}
{selectedText && (
<div
style={{
padding: '8px',
background: '#f5f5f5',
borderRadius: '4px',
fontSize: '12px',
marginBottom: '8px',
maxHeight: '60px',
overflow: 'auto',
color: '#666',
flexShrink: 0
}}
>
{selectedText}
</div>
)}
{/* AI Response - scrollable area */}
{aiResponse && (
<div
ref={responseRef}
className="ai-response-container"
style={{
padding: '8px',
background: '#e3f2fd',
borderRadius: '4px',
fontSize: '12px',
marginBottom: '8px',
maxHeight: '300px',
overflowY: 'auto',
overflowX: 'hidden',
color: '#333',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
flexShrink: 1
}}
>
{aiResponse}
</div>
)}
{/* Input box - fixed at bottom */}
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
<input
ref={inputRef}
type="text"
placeholder="输入你的问题..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
style={{
flex: 1,
padding: '8px 10px',
border: '1px solid #e3f2fd',
borderRadius: '4px',
fontSize: '12px',
outline: 'none',
boxSizing: 'border-box',
opacity: isLoading ? 0.6 : 1
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#1976d2'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#e3f2fd'
}}
/>
<button
onClick={handleSendMessage}
disabled={isLoading || !inputValue.trim()}
style={{
padding: '8px 16px',
background: isLoading || !inputValue.trim() ? '#ccc' : '#1976d2',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
cursor: isLoading || !inputValue.trim() ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap'
}}
>
{isLoading ? '发送中...' : '发送'}
</button>
</div>
{/* Arrow */}
<div
style={{
position: 'absolute',
bottom: '-6px',
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '6px solid white',
pointerEvents: 'none'
}}
/>
</div>
)}
{/* Robot Ball Container - Separate for proper pointer events */}
<div
style={{
position: 'relative',
width: '60px',
height: '60px',
pointerEvents: 'auto'
}}
>
{/* Robot Ball */}
<div
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
onMouseEnter={(e) => {
handleMouseEnterBall()
e.currentTarget.style.boxShadow = '0 4px 14px rgba(33, 150, 243, 0.6)'
e.currentTarget.style.transform = 'scale(1.05)'
}}
onMouseLeave={(e) => {
handleMouseLeaveBall()
e.currentTarget.style.boxShadow = '0 3px 10px rgba(33, 150, 243, 0.4)'
e.currentTarget.style.transform = 'scale(1)'
}}
style={
{
width: '60px',
height: '60px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #2196f3 0%, #1976d2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'grab',
boxShadow: '0 3px 10px rgba(33, 150, 243, 0.4)',
transition: 'box-shadow 0.3s ease, transform 0.1s ease',
userSelect: 'none',
WebkitUserDrag: 'none',
WebkitAppRegion: 'no-drag',
border: '2px solid rgba(255, 255, 255, 0.3)'
} as React.CSSProperties
}
>
{/* Robot Icon - Changes based on tooltip state */}
{!isTooltipOpen ? (
// Normal state - smiling robot
<svg
viewBox="0 0 100 100"
width="48"
height="48"
fill="none"
style={{ pointerEvents: 'none' }}
>
{/* Antenna */}
<circle cx="50" cy="10" r="4" fill="white" />
<line x1="50" y1="14" x2="50" y2="25" stroke="white" strokeWidth="2.5" />
{/* Head */}
<rect x="20" y="25" width="60" height="50" rx="12" fill="white" />
{/* Face screen */}
<rect x="26" y="31" width="48" height="38" rx="8" fill="#e3f2fd" />
{/* Eyes */}
{isBlinking ? (
<>
<line
x1="33"
y1="47"
x2="43"
y2="47"
stroke="#1976d2"
strokeWidth="2.5"
strokeLinecap="round"
/>
<line
x1="57"
y1="47"
x2="67"
y2="47"
stroke="#1976d2"
strokeWidth="2.5"
strokeLinecap="round"
/>
</>
) : (
<>
<circle cx="38" cy="47" r="4.5" fill="#1976d2" />
<circle cx="62" cy="47" r="4.5" fill="#1976d2" />
</>
)}
{/* Smile */}
<path
d="M 38 58 Q 50 64 62 58"
stroke="#1976d2"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
/>
{/* Ears - elliptical, only showing outer half */}
<ellipse cx="20" cy="50" rx="6" ry="10" fill="white" />
<ellipse cx="80" cy="50" rx="6" ry="10" fill="white" />
</svg>
) : (
// Active state - excited robot
<svg
viewBox="0 0 100 100"
width="48"
height="48"
fill="none"
style={{ pointerEvents: 'none' }}
>
{/* Antenna with sparkles */}
<circle cx="50" cy="10" r="4" fill="white" />
<line x1="50" y1="14" x2="50" y2="25" stroke="white" strokeWidth="2.5" />
{/* Sparkle effects */}
<line x1="62" y1="12" x2="68" y2="12" stroke="white" strokeWidth="2" />
<line x1="65" y1="9" x2="65" y2="15" stroke="white" strokeWidth="2" />
{/* Head */}
<rect x="20" y="25" width="60" height="50" rx="12" fill="white" />
{/* Face screen */}
<rect x="26" y="31" width="48" height="38" rx="8" fill="#e3f2fd" />
{/* Eyes - excited/happy */}
<circle cx="38" cy="47" r="4.5" fill="#1976d2" />
<circle cx="62" cy="47" r="4.5" fill="#1976d2" />
{/* Open mouth - happy expression */}
<ellipse cx="50" cy="60" rx="10" ry="7" fill="#1976d2" />
{/* Ears - elliptical, only showing outer half */}
<ellipse cx="20" cy="50" rx="6" ry="10" fill="white" />
<ellipse cx="80" cy="50" rx="6" ry="10" fill="white" />
</svg>
)}
</div>
</div>
</div>
</>
)
}
export default FloatingBall
+311
View File
@@ -0,0 +1,311 @@
import React, { useState, useEffect } from 'react'
import {
Form,
Input,
Select,
Button,
message,
Card,
List,
Modal,
Radio,
Space,
Typography,
Divider,
Empty
} from 'antd'
import { PlusOutlined, DeleteOutlined, CheckCircleOutlined } from '@ant-design/icons'
const { Option } = Select
const { Title, Text } = Typography
interface ModelConfig {
id: string
name: string
provider: string
model: string
apiKey: string
baseUrl: string
}
const Settings: React.FC = () => {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [provider, setProvider] = useState('openai')
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([])
const [activeModelId, setActiveModelId] = useState<string>('')
const [isModalVisible, setIsModalVisible] = useState(false)
useEffect(() => {
// Load model configs from localStorage
const savedConfigs = localStorage.getItem('ai-model-configs')
if (savedConfigs) {
const configs = JSON.parse(savedConfigs) as ModelConfig[]
setModelConfigs(configs)
}
// Load active model id
const savedActiveId = localStorage.getItem('ai-active-model-id')
if (savedActiveId) {
setActiveModelId(savedActiveId)
}
}, [])
const handleProviderChange = (value: string): void => {
setProvider(value)
// Update default values based on provider
if (value === 'openai') {
form.setFieldsValue({
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-3.5-turbo'
})
} else if (value === 'deepseek') {
form.setFieldsValue({
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat'
})
}
}
const handleAddModel = async (): Promise<void> => {
try {
setLoading(true)
const values = await form.validateFields()
const newConfig: ModelConfig = {
id: Date.now().toString(),
name: values.name || `${values.provider}-${values.model}`,
provider: values.provider,
model: values.model,
apiKey: values.apiKey,
baseUrl: values.baseUrl
}
const updatedConfigs = [...modelConfigs, newConfig]
setModelConfigs(updatedConfigs)
localStorage.setItem('ai-model-configs', JSON.stringify(updatedConfigs))
// If this is the first model, set it as active
if (modelConfigs.length === 0) {
setActiveModelId(newConfig.id)
localStorage.setItem('ai-active-model-id', newConfig.id)
}
message.success('模型添加成功')
setIsModalVisible(false)
form.resetFields()
} catch {
message.error('请填写完整信息')
} finally {
setLoading(false)
}
}
const handleDeleteModel = (id: string): void => {
const updatedConfigs = modelConfigs.filter((config) => config.id !== id)
setModelConfigs(updatedConfigs)
localStorage.setItem('ai-model-configs', JSON.stringify(updatedConfigs))
// If deleted active model, clear active id
if (activeModelId === id) {
const newActiveId = updatedConfigs.length > 0 ? updatedConfigs[0].id : ''
setActiveModelId(newActiveId)
localStorage.setItem('ai-active-model-id', newActiveId)
}
message.success('模型删除成功')
}
const handleSetActive = (id: string): void => {
setActiveModelId(id)
localStorage.setItem('ai-active-model-id', id)
message.success('已切换活跃模型')
}
return (
<div
style={{
padding: '32px',
maxWidth: '900px',
margin: '0 auto',
background: '#f5f5f5',
minHeight: '100vh'
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={2} style={{ margin: 0 }}>
AI
</Title>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={() => setIsModalVisible(true)}
>
</Button>
</div>
<Card
title={
<Space>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<Text strong></Text>
</Space>
}
bordered={false}
style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}
>
{modelConfigs.length === 0 ? (
<Empty
description={
<Space direction="vertical">
<Text type="secondary"></Text>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsModalVisible(true)}
>
</Button>
</Space>
}
/>
) : (
<List
dataSource={modelConfigs}
renderItem={(config) => (
<List.Item
actions={[
<Button
key="delete"
type="link"
danger
icon={<DeleteOutlined />}
onClick={() => handleDeleteModel(config.id)}
>
</Button>
]}
>
<List.Item.Meta
avatar={
<Radio
checked={activeModelId === config.id}
onChange={() => handleSetActive(config.id)}
/>
}
title={
<Space>
<Text strong>{config.name}</Text>
{activeModelId === config.id && (
<Text type="success" style={{ fontSize: '12px' }}>
(使)
</Text>
)}
</Space>
}
description={
<Space split={<Divider type="vertical" />}>
<Text type="secondary">{config.provider}</Text>
<Text type="secondary">{config.model}</Text>
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
</Space>
<Modal
title={
<Title level={4} style={{ margin: 0 }}>
</Title>
}
open={isModalVisible}
width={600}
onCancel={() => {
setIsModalVisible(false)
form.resetFields()
}}
footer={
<Space>
<Button onClick={() => setIsModalVisible(false)}></Button>
<Button type="primary" loading={loading} onClick={handleAddModel}>
</Button>
</Space>
}
>
<Divider style={{ marginTop: 0 }} />
<Form
form={form}
layout="vertical"
initialValues={{ provider: 'openai' }}
style={{ marginTop: '16px' }}
>
<Form.Item
label={<Text strong></Text>}
name="name"
rules={[{ required: true, message: '请输入配置名称' }]}
>
<Input placeholder="例如:我的 GPT-4" size="large" />
</Form.Item>
<Form.Item
label={<Text strong></Text>}
name="provider"
rules={[{ required: true, message: '请选择平台' }]}
>
<Select placeholder="选择平台" size="large" onChange={handleProviderChange}>
<Option value="openai">OpenAI</Option>
<Option value="deepseek">DeepSeek</Option>
</Select>
</Form.Item>
<Form.Item
label={<Text strong></Text>}
name="model"
rules={[{ required: true, message: '请选择模型' }]}
>
{provider === 'openai' ? (
<Select placeholder="选择模型" size="large">
<Option value="gpt-3.5-turbo">GPT-3.5 Turbo</Option>
<Option value="gpt-4">GPT-4</Option>
<Option value="gpt-4-turbo">GPT-4 Turbo</Option>
<Option value="gpt-4o">GPT-4o</Option>
</Select>
) : (
<Select placeholder="选择模型" size="large">
<Option value="deepseek-chat">DeepSeek Chat</Option>
<Option value="deepseek-coder">DeepSeek Coder</Option>
</Select>
)}
</Form.Item>
<Form.Item
label={<Text strong>API Key</Text>}
name="apiKey"
rules={[{ required: true, message: '请输入 API Key' }]}
>
<Input.Password placeholder="sk-..." size="large" />
</Form.Item>
<Form.Item
label={<Text strong>Base URL</Text>}
name="baseUrl"
rules={[{ required: true, message: '请输入 Base URL' }]}
>
<Input placeholder="https://api.openai.com/v1" size="large" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default Settings
+15
View File
@@ -0,0 +1,15 @@
import { useState } from 'react'
function Versions(): React.JSX.Element {
const [versions] = useState(window.electron.process.versions)
return (
<ul className="versions">
<li className="electron-version">Electron v{versions.electron}</li>
<li className="chrome-version">Chromium v{versions.chrome}</li>
<li className="node-version">Node v{versions.node}</li>
</ul>
)
}
export default Versions
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+22
View File
@@ -0,0 +1,22 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import FloatingBall from './components/FloatingBall'
import { ConfigProvider, theme } from 'antd'
const FloatingApp: React.FC = () => {
return (
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm
}}
>
<FloatingBall />
</ConfigProvider>
)
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<FloatingApp />
</React.StrictMode>
)
+11
View File
@@ -0,0 +1,11 @@
import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
+114
View File
@@ -0,0 +1,114 @@
interface ModelConfig {
id: string
name: string
provider: string
model: string
apiKey: string
baseUrl: string
}
export interface StreamCallbacks {
onStart?: () => void
onToken: (token: string) => void
onComplete: () => void
onError: (error: Error) => void
}
export async function streamChat(message: string, callbacks: StreamCallbacks): Promise<void> {
const { onStart, onToken, onComplete, onError } = callbacks
try {
// Get active model config
const activeModelId = localStorage.getItem('ai-active-model-id')
if (!activeModelId) {
throw new Error('请先在设置中配置并选择一个 AI 模型')
}
const configsStr = localStorage.getItem('ai-model-configs')
if (!configsStr) {
throw new Error('未找到模型配置')
}
const configs: ModelConfig[] = JSON.parse(configsStr)
const activeConfig = configs.find((c) => c.id === activeModelId)
if (!activeConfig) {
throw new Error('未找到活跃的模型配置')
}
onStart?.()
// Build API request based on provider
const endpoint =
activeConfig.provider === 'openai'
? `${activeConfig.baseUrl}/chat/completions`
: `${activeConfig.baseUrl}/chat/completions`
const requestBody = {
model: activeConfig.model,
messages: [
{
role: 'user',
content: message
}
],
stream: true
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${activeConfig.apiKey}`
},
body: JSON.stringify(requestBody)
})
if (!response.ok) {
throw new Error(`API 请求失败: ${response.status} ${response.statusText}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('无法读取响应流')
}
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine || trimmedLine === 'data: [DONE]') continue
if (trimmedLine.startsWith('data: ')) {
try {
const jsonStr = trimmedLine.substring(6)
const data = JSON.parse(jsonStr)
// Handle OpenAI format
if (data.choices?.[0]?.delta?.content) {
onToken(data.choices[0].delta.content)
}
// Handle DeepSeek format (same as OpenAI)
else if (data.choices?.[0]?.delta?.content !== undefined) {
onToken(data.choices[0].delta.content)
}
} catch (e) {
console.warn('Failed to parse SSE data:', trimmedLine, e)
}
}
}
}
onComplete()
} catch (error) {
onError(error as Error)
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import Settings from './components/Settings'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<Settings />
</ConfigProvider>
</React.StrictMode>
)
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@renderer/*": [
"src/renderer/src/*"
]
}
}
}