commit 17110ee5ab9028c626e3a649b41f1d595dc7eaa4 Author: Rick Ye Date: Sat Sep 5 09:28:33 2026 +0800 v0.1.0: Electron 桌面端骨架 + pi SDK 聊天链路 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdb2433 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +out/ +*.log +.DS_Store +.env +.env.local +release/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..8084498 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +save-exact=true +engine-strict=false diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8a06b8 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# agent-store-operations + +店铺运营 Agent 桌面工具 —— Electron for [pi agent](https://pi.dev/)。 + +## 仓库结构 + +``` +apps/desktop/ Electron 客户端(React + Tailwind + GSAP,主进程内嵌 pi SDK) +docs/ 架构决策、pi 集成笔记、参考项目取舍、路线图与版本自省 +``` + +> 后端(NestJS)、后台(Ant Design Pro)、数据库尚未设计,按约定不创建。见 `docs/architecture.md`。 + +## 开发 + +```bash +pnpm install +pnpm dev # 启动 desktop(electron-vite HMR) +pnpm typecheck +``` + +## 文档 + +- [architecture.md](docs/architecture.md) — 架构决策记录(ADR) +- [pi-sdk-notes.md](docs/pi-sdk-notes.md) — pi SDK 集成笔记 +- [references.md](docs/references.md) — 参考项目取舍(pi-web / Proma / beautifului) +- [tech-stack.md](docs/tech-stack.md) — 技术栈 +- [roadmap.md](docs/roadmap.md) — 版本路线图与自省日志 diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts new file mode 100644 index 0000000..3bbdeca --- /dev/null +++ b/apps/desktop/electron.vite.config.ts @@ -0,0 +1,56 @@ +import { resolve } from 'node:path' +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + main: { + // pi SDK 为纯 ESM 包,主进程输出 ESM(Electron ≥28 支持) + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + // electron 保持内置模块导入;pi 全家桶运行时从 node_modules 以 ESM 加载, + // 同时避开 pi 实验性导出引用的未声明子包问题 + external: ['electron', /^@earendil-works\//], + output: { + format: 'es', + entryFileNames: '[name].mjs' + } + } + }, + resolve: { + alias: { + '@main': resolve(__dirname, 'src/main'), + '@shared': resolve(__dirname, 'src/shared') + } + } + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + // electron 解析为内置模块,避免把 npm 包打进 preload + external: ['electron'], + output: { + format: 'cjs', + // "type":"module" 下 .js 会被当 ESM 加载,preload 必须用 .cjs + entryFileNames: '[name].cjs' + } + } + }, + resolve: { + alias: { + '@shared': resolve(__dirname, 'src/shared') + } + } + }, + renderer: { + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@renderer': resolve(__dirname, 'src/renderer/src'), + '@shared': resolve(__dirname, 'src/shared') + } + } + } +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..78ba462 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,35 @@ +{ + "name": "@aso/desktop", + "private": true, + "version": "0.1.0", + "description": "店铺运营 Agent 桌面端(Electron for pi agent)", + "type": "module", + "main": "out/main/index.mjs", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "preview": "electron-vite preview", + "start": "electron-vite preview", + "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json" + }, + "dependencies": { + "@earendil-works/pi-coding-agent": "0.85.0", + "@earendil-works/pi-server": "0.85.0", + "gsap": "3.15.0", + "lucide-react": "1.41.0", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.3", + "@types/node": "26.4.1", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.7", + "@vitejs/plugin-react": "6.1.1", + "electron": "44.2.0", + "electron-vite": "5.0.0", + "tailwindcss": "4.3.3", + "typescript": "7.0.2", + "vite": "8.2.2" + } +} diff --git a/apps/desktop/scripts/smoke.mjs b/apps/desktop/scripts/smoke.mjs new file mode 100644 index 0000000..cb6442a --- /dev/null +++ b/apps/desktop/scripts/smoke.mjs @@ -0,0 +1,39 @@ +/** + * pi SDK 集成冒烟测试(与 src/main/pi/agent-service.ts 相同的调用路径) + * 用法:node scripts/smoke.mjs + * 验证点:会话创建、事件订阅、prompt 在无凭据时的报错传播。 + */ +import { createAgentSession } from '@earendil-works/pi-coding-agent' +import { homedir } from 'node:os' + +const events = [] +let settled = false + +console.log('[smoke] creating session (cwd=%s)...', homedir()) +const { session } = await createAgentSession({ cwd: homedir() }) +console.log('[smoke] session created: id=%s model=%s/%s', session.sessionId, session.model?.provider ?? '-', session.model?.id ?? '-') + +session.subscribe((event) => { + events.push(event.type) + if (event.type === 'message_update' && event.assistantMessageEvent?.type?.endsWith('_delta')) { + const d = event.assistantMessageEvent.delta ?? '' + process.stdout.write(d) + } +}) + +try { + await session.prompt('回复"链路正常"两个字即可,不要调用任何工具。') + console.log('\n[smoke] prompt resolved; event sequence:', [...new Set(events)].join(' -> ')) + const last = session.getLastAssistantText() + console.log('[smoke] last assistant text:', last ? JSON.stringify(last.slice(0, 120)) : '(none)') + settled = true +} catch (err) { + console.log('\n[smoke] prompt rejected as expected (no credentials):', err instanceof Error ? err.message : err) +} + +const stats = session.getSessionStats() +console.log('[smoke] stats:', JSON.stringify({ messages: session.messages.length, cost: stats.cost })) +console.log('[smoke] active tools:', session.getActiveToolNames().join(', ')) +session.dispose() +console.log('[smoke] done, settled=%s', String(settled)) +process.exit(0) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 0000000..000300d --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -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 { + 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() + }) +} diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts new file mode 100644 index 0000000..1f2d4de --- /dev/null +++ b/apps/desktop/src/main/ipc.ts @@ -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 +} diff --git a/apps/desktop/src/main/pi/agent-service.ts b/apps/desktop/src/main/pi/agent-service.ts new file mode 100644 index 0000000..d352cf4 --- /dev/null +++ b/apps/desktop/src/main/pi/agent-service.ts @@ -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 | 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 { + if (!this.initPromise) { + this.initPromise = this.create().catch((err) => { + this.initPromise = null + throw err + }) + } + return this.initPromise + } + + private async create(): Promise { + const result = await createAgentSession({ + cwd: app.getPath('home') + }) + result.session.subscribe((event) => this.handleEvent(event)) + return result + } + + /** 初始化完成事件由调用方(ipc 层)在 ready 后主动拉取状态 */ + async ensureReady(): Promise { + 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 { + 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 { + const { session } = await this.getSession() + return this.buildState(session) + } + + async send(text: string): Promise { + const { session } = await this.getSession() + if (session.isStreaming) { + await session.prompt(text, { streamingBehavior: 'followUp' }) + } else { + await session.prompt(text) + } + } + + async steer(text: string): Promise { + const { session } = await this.getSession() + await session.steer(text) + } + + async stop(): Promise { + 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' +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts new file mode 100644 index 0000000..c657293 --- /dev/null +++ b/apps/desktop/src/preload/index.ts @@ -0,0 +1,28 @@ +import { contextBridge, ipcRenderer } from 'electron' +import type { AgentUiEvent, SessionState } from '@shared/types' + +export interface DesktopApi { + send: (text: string) => Promise + steer: (text: string) => Promise + stop: () => Promise + getState: () => Promise + versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }> + onEvent: (callback: (event: AgentUiEvent) => void) => () => void +} + +const api: DesktopApi = { + send: (text) => ipcRenderer.invoke('agent:send', text), + steer: (text) => ipcRenderer.invoke('agent:steer', text), + stop: () => ipcRenderer.invoke('agent:stop'), + getState: () => ipcRenderer.invoke('agent:state'), + versions: () => ipcRenderer.invoke('app:versions'), + onEvent: (callback) => { + const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event) + ipcRenderer.on('agent:event', listener) + return () => { + ipcRenderer.removeListener('agent:event', listener) + } + } +} + +contextBridge.exposeInMainWorld('api', api) diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html new file mode 100644 index 0000000..fed5409 --- /dev/null +++ b/apps/desktop/src/renderer/index.html @@ -0,0 +1,16 @@ + + + + + + + 店铺运营 Agent + + +
+ + + diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx new file mode 100644 index 0000000..43e764e --- /dev/null +++ b/apps/desktop/src/renderer/src/App.tsx @@ -0,0 +1,43 @@ +import { useRef } from 'react' +import type { JSX } from 'react' +import { useAgent } from './hooks/useAgent' +import { Sidebar } from './components/Sidebar' +import { ChatView } from './components/ChatView' +import { Composer } from './components/Composer' + +export default function App(): JSX.Element { + const { state, send, stop, clearError } = useAgent() + const prefillRef = useRef(null) + + const handlePickSuggestion = (text: string): void => { + prefillRef.current = text + } + + const queuedCount = state.queue.followUp.length + state.queue.steering.length + + return ( +
+ +
+ + +
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/ChatView.tsx b/apps/desktop/src/renderer/src/components/ChatView.tsx new file mode 100644 index 0000000..58525a0 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ChatView.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef } from 'react' +import type { JSX } from 'react' +import { AlertTriangle, RotateCw, Store } from 'lucide-react' +import type { ToolState, UiMessage } from '../hooks/useAgent' +import { MessageItem } from './MessageItem' + +const SUGGESTIONS = [ + '帮我分析一下今天该关注哪些品类热点', + '根据热点词起草一个商品标题方案', + '查询最近一次上架任务的执行结果' +] + +function EmptyState({ onPick }: { onPick: (text: string) => void }): JSX.Element { + return ( +
+
+ +
+

店铺运营 Agent

+

+ 流程固定、编排灵活。描述你的目标,Agent 会按 SOP 调用工具完成;也可以随时切为手动逐步执行。 +

+
+ {SUGGESTIONS.map((s) => ( + + ))} +
+
+ ) +} + +export function ChatView({ + messages, + tools, + streaming, + error, + retry, + sessionError, + onDismissError, + onPickSuggestion +}: { + messages: UiMessage[] + tools: Record + streaming: boolean + error: string | null + retry: { attempt: number; maxAttempts: number; errorMessage: string } | null + sessionError: string | null + onDismissError: () => void + onPickSuggestion: (text: string) => void +}): JSX.Element { + const scrollRef = useRef(null) + const pinnedRef = useRef(true) + + // 用户上滚时停止自动吸底,回到底部附近恢复 + const handleScroll = (): void => { + const el = scrollRef.current + if (!el) return + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + pinnedRef.current = distance < 90 + } + + useEffect(() => { + const el = scrollRef.current + if (el && pinnedRef.current) { + el.scrollTop = el.scrollHeight + } + }, [messages, tools, streaming]) + + const hasContent = messages.length > 0 + + return ( +
+ {(error || sessionError) && ( +
+ +
+ {sessionError ? ( + <> +
Agent 会话初始化失败
+
{sessionError}
+ + ) : ( +
{error}
+ )} +
+ +
+ )} + + {retry && ( +
+ + + 请求失败,自动重试 {retry.attempt}/{retry.maxAttempts} + + {retry.errorMessage} +
+ )} + +
+ {hasContent ? ( +
+ {messages.map((message) => ( + + ))} +
+ ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/Composer.tsx b/apps/desktop/src/renderer/src/components/Composer.tsx new file mode 100644 index 0000000..e795dcb --- /dev/null +++ b/apps/desktop/src/renderer/src/components/Composer.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from 'react' +import type { JSX, RefObject } from 'react' +import { ArrowUp, CornerDownLeft, Square } from 'lucide-react' + +export function Composer({ + streaming, + queuedCount, + disabled, + onSend, + onStop, + prefillRef +}: { + streaming: boolean + queuedCount: number + disabled: boolean + onSend: (text: string) => void + onStop: () => void + /** 供空状态建议词点击后填入 */ + prefillRef: RefObject +}): JSX.Element { + const [value, setValue] = useState('') + const textareaRef = useRef(null) + + // 空状态建议词点击 → 填入输入框并聚焦 + useEffect(() => { + if (prefillRef.current) { + setValue(prefillRef.current) + prefillRef.current = null + textareaRef.current?.focus() + } + }, [prefillRef]) + + // 自适应高度 + useEffect(() => { + const el = textareaRef.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, 200)}px` + }, [value]) + + const submit = (): void => { + const text = value.trim() + if (!text || disabled) return + onSend(text) + setValue('') + } + + return ( +
+
+
+