refactor(home): 将首页改造成opencode服务入口页面
- 移除ChatGPT风格对话界面相关代码 - 添加在首页自动启动/停止opencode serve的IPC调用 - 首页使用webview加载opencode服务器界面
This commit is contained in:
@@ -6,6 +6,7 @@ import log from 'electron-log';
|
|||||||
import { generatePdf } from './services/pdfGenerator';
|
import { generatePdf } from './services/pdfGenerator';
|
||||||
import { selectHtmlFile } from './services/htmlImport';
|
import { selectHtmlFile } from './services/htmlImport';
|
||||||
import { electronState } from './state';
|
import { electronState } from './state';
|
||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
|
||||||
log.initialize();
|
log.initialize();
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ if (!fs.existsSync(process.env.NOTEBOOK_ROOT)) {
|
|||||||
|
|
||||||
electronState.setDevelopment(!app.isPackaged);
|
electronState.setDevelopment(!app.isPackaged);
|
||||||
|
|
||||||
|
let opencodeProcess: ChildProcess | null = null;
|
||||||
|
const OPENCODE_PORT = 4096;
|
||||||
|
|
||||||
let lastClipboardText = '';
|
let lastClipboardText = '';
|
||||||
|
|
||||||
function startClipboardWatcher() {
|
function startClipboardWatcher() {
|
||||||
@@ -358,6 +362,62 @@ ipcMain.handle('remote-download-file', async (_event, id: string, serverHost: st
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('opencode-start-server', async () => {
|
||||||
|
if (opencodeProcess) {
|
||||||
|
log.info('Opencode server already running');
|
||||||
|
return { success: true, port: OPENCODE_PORT };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info('Starting opencode server...');
|
||||||
|
opencodeProcess = spawn('opencode', ['serve'], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
shell: true,
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
opencodeProcess.stdout?.on('data', (data) => {
|
||||||
|
log.info(`[opencode] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
opencodeProcess.stderr?.on('data', (data) => {
|
||||||
|
log.error(`[opencode error] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
opencodeProcess.on('error', (err) => {
|
||||||
|
log.error('Opencode process error:', err);
|
||||||
|
opencodeProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
opencodeProcess.on('exit', (code) => {
|
||||||
|
log.info(`Opencode process exited with code ${code}`);
|
||||||
|
opencodeProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, port: OPENCODE_PORT };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Failed to start opencode server:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('opencode-stop-server', async () => {
|
||||||
|
if (!opencodeProcess) {
|
||||||
|
log.info('Opencode server not running');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info('Stopping opencode server...');
|
||||||
|
opencodeProcess.kill('SIGTERM');
|
||||||
|
opencodeProcess = null;
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Failed to stop opencode server:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
if (electronState.isDevelopment()) {
|
if (electronState.isDevelopment()) {
|
||||||
log.info('In dev mode, assuming external servers are running.');
|
log.info('In dev mode, assuming external servers are running.');
|
||||||
|
|||||||
@@ -39,4 +39,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('remote-upload-file', id, serverHost, port, filePath, remotePath, password),
|
ipcRenderer.invoke('remote-upload-file', id, serverHost, port, filePath, remotePath, password),
|
||||||
remoteDownloadFile: (id: string, serverHost: string, port: number, fileName: string, remotePath: string, localPath: string, password?: string) =>
|
remoteDownloadFile: (id: string, serverHost: string, port: number, fileName: string, remotePath: string, localPath: string, password?: string) =>
|
||||||
ipcRenderer.invoke('remote-download-file', id, serverHost, port, fileName, remotePath, localPath, password),
|
ipcRenderer.invoke('remote-download-file', id, serverHost, port, fileName, remotePath, localPath, password),
|
||||||
|
opencodeStartServer: () => ipcRenderer.invoke('opencode-start-server'),
|
||||||
|
opencodeStopServer: () => ipcRenderer.invoke('opencode-stop-server'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,62 @@
|
|||||||
import { ChatLayout } from './components/ChatLayout'
|
import { useRef, useEffect, useState } from 'react'
|
||||||
|
|
||||||
export const HomePage = () => {
|
export const HomePage = () => {
|
||||||
return <ChatLayout />
|
const webviewRef = useRef<HTMLWebViewElement>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const startOpencodeServer = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.opencodeStartServer()
|
||||||
|
if (!result?.success) {
|
||||||
|
setError(result?.error || 'Failed to start opencode server')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startOpencodeServer()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.electronAPI?.opencodeStopServer()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const webview = webviewRef.current
|
||||||
|
if (!webview) return
|
||||||
|
|
||||||
|
webview.addEventListener('did-fail-load', (e) => {
|
||||||
|
console.error('[HomePage] Failed to load:', e)
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
webview.addEventListener('did-finish-load', () => {
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full relative">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white dark:bg-gray-900 z-10">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">正在启动 opencode 服务...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white dark:bg-gray-900 z-10">
|
||||||
|
<div className="text-red-500">启动失败: {error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<webview
|
||||||
|
ref={webviewRef}
|
||||||
|
src="http://127.0.0.1:4096"
|
||||||
|
className="w-full h-full"
|
||||||
|
nodeintegration={false}
|
||||||
|
webpreferences="contextIsolation=yes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import { Send } from 'lucide-react'
|
|
||||||
import { useChatStore, type ChatMessage } from '@/stores/chatStore'
|
|
||||||
import { useState, useRef, useEffect } from 'react'
|
|
||||||
|
|
||||||
const MessageItem = ({ message }: { message: ChatMessage }) => {
|
|
||||||
const isUser = message.role === 'user'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
|
|
||||||
<div
|
|
||||||
className={`flex gap-3 max-w-[80%] ${
|
|
||||||
isUser ? 'flex-row-reverse' : 'flex-row'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
|
|
||||||
isUser ? 'bg-gray-600' : 'bg-green-600'
|
|
||||||
}`}>
|
|
||||||
{isUser ? (
|
|
||||||
<span className="text-white text-sm">👤</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-white text-sm">🤖</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`px-4 py-2.5 rounded-lg text-sm leading-relaxed whitespace-pre-wrap ${
|
|
||||||
isUser
|
|
||||||
? 'bg-[#343541] text-white'
|
|
||||||
: 'bg-transparent text-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{message.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatContent = () => {
|
|
||||||
const { currentChatId, chats, addMessage, updateChatTitle } = useChatStore()
|
|
||||||
const [input, setInput] = useState('')
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const currentChat = chats.find((c) => c.id === currentChatId)
|
|
||||||
const messages = currentChat?.messages ?? []
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
}, [messages.length])
|
|
||||||
|
|
||||||
const handleSend = () => {
|
|
||||||
if (!input.trim() || !currentChatId) return
|
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
|
||||||
id: Math.random().toString(36).substring(2, 15),
|
|
||||||
role: 'user',
|
|
||||||
content: input.trim(),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage(currentChatId, userMessage)
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
|
||||||
const title = input.trim().slice(0, 20) + (input.trim().length > 20 ? '...' : '')
|
|
||||||
updateChatTitle(currentChatId, title)
|
|
||||||
}
|
|
||||||
|
|
||||||
setInput('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSend()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col h-full bg-[#343541]">
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<h1 className="text-2xl font-light text-gray-400">
|
|
||||||
开始今天的工作吧!
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-w-3xl mx-auto pt-4">
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<MessageItem key={msg.id} message={msg} />
|
|
||||||
))}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 pb-4">
|
|
||||||
<div className="max-w-3xl mx-auto flex gap-2 items-end">
|
|
||||||
<div className="flex-1">
|
|
||||||
<textarea
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="发送消息..."
|
|
||||||
rows={1}
|
|
||||||
className="w-full bg-[#40414f] text-white rounded-lg px-4 py-3 resize-none focus:outline-none focus:ring-2 focus:ring-green-500 placeholder-gray-400 text-sm"
|
|
||||||
style={{ minHeight: '48px', maxHeight: '200px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={!input.trim()}
|
|
||||||
className="px-4 py-3 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg transition-colors shrink-0"
|
|
||||||
>
|
|
||||||
<Send size={18} className="text-white" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { ChatSidebar } from './ChatSidebar'
|
|
||||||
import { ChatContent } from './ChatContent'
|
|
||||||
|
|
||||||
export const ChatLayout = () => {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full overflow-hidden">
|
|
||||||
<ChatSidebar />
|
|
||||||
<ChatContent />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Plus, Trash2 } from 'lucide-react'
|
|
||||||
import { useChatStore } from '@/stores/chatStore'
|
|
||||||
|
|
||||||
export const ChatSidebar = () => {
|
|
||||||
const { chats, currentChatId, createChat, deleteChat, selectChat } = useChatStore()
|
|
||||||
|
|
||||||
const handleNewChat = () => {
|
|
||||||
createChat()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-[260px] h-full flex flex-col bg-[#262626] dark:bg-[#262626]">
|
|
||||||
<button
|
|
||||||
onClick={handleNewChat}
|
|
||||||
className="flex items-center gap-2 px-3 py-2.5 mx-2 mt-2 rounded-md border border-gray-600 hover:bg-gray-700 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
<span>新对话</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto mt-3 px-2">
|
|
||||||
{chats.map((chat) => (
|
|
||||||
<div
|
|
||||||
key={chat.id}
|
|
||||||
onClick={() => selectChat(chat.id)}
|
|
||||||
className={`group flex items-center justify-between px-3 py-2 rounded-md cursor-pointer text-sm mb-1 transition-colors ${
|
|
||||||
currentChatId === chat.id
|
|
||||||
? 'bg-[#343541] text-white'
|
|
||||||
: 'text-gray-300 hover:bg-[#343541]/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="truncate flex-1">{chat.title}</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
deleteChat(chat.id)
|
|
||||||
}}
|
|
||||||
className="opacity-0 group-hover:opacity-100 hover:text-red-400 transition-opacity p-1"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{chats.length === 0 && (
|
|
||||||
<div className="text-gray-500 text-sm text-center py-8">
|
|
||||||
暂无对话
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -7,5 +7,3 @@ export default createFrontendModule(HOME_MODULE, {
|
|||||||
icon: Home,
|
icon: Home,
|
||||||
component: HomePage,
|
component: HomePage,
|
||||||
})
|
})
|
||||||
|
|
||||||
export { HomePage }
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { create } from 'zustand'
|
|
||||||
import { persist } from 'zustand/middleware'
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
id: string
|
|
||||||
role: 'user' | 'assistant'
|
|
||||||
content: string
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Chat {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
messages: ChatMessage[]
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatState {
|
|
||||||
chats: Chat[]
|
|
||||||
currentChatId: string | null
|
|
||||||
|
|
||||||
createChat: () => string
|
|
||||||
deleteChat: (id: string) => void
|
|
||||||
selectChat: (id: string) => void
|
|
||||||
updateChatTitle: (id: string, title: string) => void
|
|
||||||
|
|
||||||
addMessage: (chatId: string, message: ChatMessage) => void
|
|
||||||
getCurrentChat: () => Chat | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateId = () => Math.random().toString(36).substring(2, 15)
|
|
||||||
|
|
||||||
export const useChatStore = create<ChatState>()(
|
|
||||||
persist(
|
|
||||||
(set, get) => ({
|
|
||||||
chats: [],
|
|
||||||
currentChatId: null,
|
|
||||||
|
|
||||||
createChat: () => {
|
|
||||||
const id = generateId()
|
|
||||||
const now = Date.now()
|
|
||||||
const newChat: Chat = {
|
|
||||||
id,
|
|
||||||
title: '新对话',
|
|
||||||
messages: [],
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
set((state) => ({
|
|
||||||
chats: [newChat, ...state.chats],
|
|
||||||
currentChatId: id,
|
|
||||||
}))
|
|
||||||
return id
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteChat: (id) => {
|
|
||||||
set((state) => {
|
|
||||||
const newChats = state.chats.filter((c) => c.id !== id)
|
|
||||||
const newCurrentId = state.currentChatId === id
|
|
||||||
? (newChats[0]?.id ?? null)
|
|
||||||
: state.currentChatId
|
|
||||||
return { chats: newChats, currentChatId: newCurrentId }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
selectChat: (id) => {
|
|
||||||
set({ currentChatId: id })
|
|
||||||
},
|
|
||||||
|
|
||||||
updateChatTitle: (id, title) => {
|
|
||||||
set((state) => ({
|
|
||||||
chats: state.chats.map((c) =>
|
|
||||||
c.id === id ? { ...c, title, updatedAt: Date.now() } : c
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
addMessage: (chatId, message) => {
|
|
||||||
set((state) => ({
|
|
||||||
chats: state.chats.map((c) =>
|
|
||||||
c.id === chatId
|
|
||||||
? { ...c, messages: [...c.messages, message], updatedAt: Date.now() }
|
|
||||||
: c
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
getCurrentChat: () => {
|
|
||||||
const state = get()
|
|
||||||
return state.chats.find((c) => c.id === state.currentChatId) ?? null
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'chat-storage',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -38,6 +38,8 @@ export interface ElectronAPI {
|
|||||||
error?: string
|
error?: string
|
||||||
canceled?: boolean
|
canceled?: boolean
|
||||||
}>
|
}>
|
||||||
|
opencodeStartServer: () => Promise<{ success: boolean; port?: number; error?: string }>
|
||||||
|
opencodeStopServer: () => Promise<{ success: boolean; error?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in New Issue
Block a user