v0.1.0: Electron 桌面端骨架 + pi SDK 聊天链路
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { registerIpc } from './ipc'
|
||||
|
||||
process.env.APP_ROOT = join(__dirname, '..')
|
||||
|
||||
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL
|
||||
export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer')
|
||||
|
||||
const agentService = registerIpc()
|
||||
|
||||
async function createWindow(): Promise<void> {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 840,
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
show: false,
|
||||
backgroundColor: '#09090b',
|
||||
title: '店铺运营 Agent',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.cjs'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
win.on('ready-to-show', () => {
|
||||
win.show()
|
||||
// pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现
|
||||
agentService.ensureReady().catch((err) => {
|
||||
console.error('[main] pi session init failed:', err)
|
||||
})
|
||||
})
|
||||
|
||||
// 外部链接交给系统默认浏览器,不在应用内开新窗口
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// 渲染层日志/加载失败转发到主进程 stdout,便于排查白屏/黑屏问题
|
||||
win.webContents.on('console-message', (_e, level, message, line, sourceId) => {
|
||||
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
|
||||
})
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.log(`[renderer:load-fail] ${code} ${desc} ${url}`)
|
||||
})
|
||||
win.webContents.on('render-process-gone', (_e, details) => {
|
||||
console.log(`[renderer:gone] ${details.reason}`)
|
||||
})
|
||||
if (process.env.ASO_DEVTOOLS === '1') {
|
||||
win.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
await win.loadURL(VITE_DEV_SERVER_URL)
|
||||
} else {
|
||||
await win.loadFile(join(RENDERER_DIST, 'index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
// 确保应用单实例(第二实例激活已有窗口)
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.focus()
|
||||
}
|
||||
})
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
void createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) void createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { AgentService } from './pi/agent-service'
|
||||
|
||||
/**
|
||||
* 注册 IPC 通道并把 agent 事件转发到所有渲染窗口。
|
||||
* 返回 service 实例供入口进程使用。
|
||||
*/
|
||||
export function registerIpc(): AgentService {
|
||||
const service = new AgentService()
|
||||
|
||||
service.onEvent((event) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('agent:event', event)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:send', (_event, text: unknown) => {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error('empty prompt')
|
||||
}
|
||||
return service.send(text)
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:steer', (_event, text: unknown) => {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error('empty steer text')
|
||||
}
|
||||
return service.steer(text)
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:stop', () => service.stop())
|
||||
|
||||
ipcMain.handle('agent:state', () =>
|
||||
service.getState().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('app:versions', () => ({
|
||||
app: process.env.npm_package_version ?? '0.0.0',
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
chrome: process.versions.chrome
|
||||
}))
|
||||
|
||||
return service
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
createAgentSession,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
type CreateAgentSessionResult
|
||||
} from '@earendil-works/pi-coding-agent'
|
||||
import type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
|
||||
const DELTA_FLUSH_MS = 40
|
||||
|
||||
/**
|
||||
* pi AgentSession 的主进程封装:
|
||||
* - 懒初始化会话(默认复用 ~/.pi 的配置、凭据与会话持久化)
|
||||
* - 订阅 pi 事件流,归一化为 AgentUiEvent 转发给渲染层
|
||||
* - 提供 prompt / abort / 状态与统计查询
|
||||
*/
|
||||
export class AgentService {
|
||||
private result: CreateAgentSessionResult | null = null
|
||||
private initPromise: Promise<CreateAgentSessionResult> | null = null
|
||||
private listeners = new Set<(event: AgentUiEvent) => void>()
|
||||
private deltaQueue: AgentUiEvent[] = []
|
||||
private flushTimer: NodeJS.Timeout | null = null
|
||||
|
||||
onEvent(fn: (event: AgentUiEvent) => void): () => void {
|
||||
this.listeners.add(fn)
|
||||
return () => this.listeners.delete(fn)
|
||||
}
|
||||
|
||||
private emit(event: AgentUiEvent): void {
|
||||
for (const fn of this.listeners) {
|
||||
try {
|
||||
fn(event)
|
||||
} catch (err) {
|
||||
console.error('[agent-service] listener error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 增量类事件合批下发,避免高频 delta 打爆 IPC */
|
||||
private emitDelta(event: AgentUiEvent): void {
|
||||
this.deltaQueue.push(event)
|
||||
if (this.flushTimer) return
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null
|
||||
const batch = this.deltaQueue
|
||||
this.deltaQueue = []
|
||||
for (const e of batch) this.emit(e)
|
||||
}, DELTA_FLUSH_MS)
|
||||
}
|
||||
|
||||
private getSession(): Promise<CreateAgentSessionResult> {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this.create().catch((err) => {
|
||||
this.initPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
private async create(): Promise<CreateAgentSessionResult> {
|
||||
const result = await createAgentSession({
|
||||
cwd: app.getPath('home')
|
||||
})
|
||||
result.session.subscribe((event) => this.handleEvent(event))
|
||||
return result
|
||||
}
|
||||
|
||||
/** 初始化完成事件由调用方(ipc 层)在 ready 后主动拉取状态 */
|
||||
async ensureReady(): Promise<SessionState> {
|
||||
try {
|
||||
const { session } = await this.getSession()
|
||||
const state = this.buildState(session)
|
||||
this.emit({ type: 'session_ready', state })
|
||||
return state
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
this.emit({ type: 'session_error', message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: AgentSessionEvent): void {
|
||||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
this.emit({ type: 'agent_start' })
|
||||
break
|
||||
case 'agent_end':
|
||||
this.emit({ type: 'agent_end' })
|
||||
break
|
||||
case 'agent_settled':
|
||||
this.emit({ type: 'agent_settled' })
|
||||
void this.emitStats()
|
||||
break
|
||||
case 'message_start':
|
||||
this.emit({ type: 'message_start', role: readRole(event.message) })
|
||||
break
|
||||
case 'message_update': {
|
||||
const inner = event.assistantMessageEvent
|
||||
if (inner?.type === 'text_delta' && inner.delta) {
|
||||
this.emitDelta({ type: 'delta', kind: 'text', delta: inner.delta })
|
||||
} else if (inner?.type === 'thinking_delta' && inner.delta) {
|
||||
this.emitDelta({ type: 'delta', kind: 'thinking', delta: inner.delta })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'message_end':
|
||||
this.emit({ type: 'message_end', message: toCloneable(event.message) })
|
||||
break
|
||||
case 'tool_execution_start':
|
||||
this.emit({
|
||||
type: 'tool_start',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
args: toCloneable(event.args)
|
||||
})
|
||||
break
|
||||
case 'tool_execution_update':
|
||||
this.emit({
|
||||
type: 'tool_update',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
partialResult: toCloneable(event.partialResult)
|
||||
})
|
||||
break
|
||||
case 'tool_execution_end':
|
||||
this.emit({
|
||||
type: 'tool_end',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
result: toCloneable(event.result),
|
||||
isError: event.isError
|
||||
})
|
||||
break
|
||||
case 'queue_update':
|
||||
this.emit({ type: 'queue_update', steering: [...event.steering], followUp: [...event.followUp] })
|
||||
break
|
||||
case 'auto_retry_start':
|
||||
this.emit({
|
||||
type: 'retry',
|
||||
attempt: event.attempt,
|
||||
maxAttempts: event.maxAttempts,
|
||||
errorMessage: event.errorMessage
|
||||
})
|
||||
break
|
||||
case 'session_info_changed':
|
||||
this.emit({ type: 'session_info', name: event.name })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private buildState(session: AgentSession): SessionState {
|
||||
const model = session.model
|
||||
const modelInfo: ModelInfo | undefined = model
|
||||
? {
|
||||
id: String(model.id ?? ''),
|
||||
provider: String(model.provider ?? ''),
|
||||
name: String(model.name ?? model.id ?? '')
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
sessionName: session.sessionName,
|
||||
model: modelInfo,
|
||||
streaming: session.isStreaming,
|
||||
activeTools: session.getActiveToolNames(),
|
||||
messageCount: session.messages.length
|
||||
}
|
||||
}
|
||||
|
||||
private async emitStats(): Promise<void> {
|
||||
try {
|
||||
const { session } = await this.getSession()
|
||||
const stats = session.getSessionStats()
|
||||
const usage = session.getContextUsage()
|
||||
const info: SessionStatsInfo = {
|
||||
tokens: { ...stats.tokens },
|
||||
cost: stats.cost,
|
||||
contextPercent: usage && typeof usage.percent === 'number' ? Math.round(usage.percent) : undefined
|
||||
}
|
||||
this.emit({ type: 'stats', stats: info })
|
||||
} catch {
|
||||
// 统计失败不打断主流程
|
||||
}
|
||||
}
|
||||
|
||||
async getState(): Promise<SessionState> {
|
||||
const { session } = await this.getSession()
|
||||
return this.buildState(session)
|
||||
}
|
||||
|
||||
async send(text: string): Promise<void> {
|
||||
const { session } = await this.getSession()
|
||||
if (session.isStreaming) {
|
||||
await session.prompt(text, { streamingBehavior: 'followUp' })
|
||||
} else {
|
||||
await session.prompt(text)
|
||||
}
|
||||
}
|
||||
|
||||
async steer(text: string): Promise<void> {
|
||||
const { session } = await this.getSession()
|
||||
await session.steer(text)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.result && !this.initPromise) return
|
||||
const { session } = await this.getSession()
|
||||
await session.abort()
|
||||
}
|
||||
}
|
||||
|
||||
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
|
||||
function toCloneable(value: unknown): unknown {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value ?? null))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readRole(message: unknown): string {
|
||||
if (message && typeof message === 'object' && 'role' in message) {
|
||||
return String((message as { role: unknown }).role)
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
Reference in New Issue
Block a user