v0.1.0: Electron 桌面端骨架 + pi SDK 聊天链路

This commit is contained in:
Rick Ye
2026-09-05 09:28:33 +08:00
commit 17110ee5ab
35 changed files with 5350 additions and 0 deletions
+231
View File
@@ -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'
}