v0.1.0: Electron 桌面端骨架 + pi SDK 聊天链路
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
out/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
release/
|
||||||
@@ -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) — 版本路线图与自省日志
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import type { AgentUiEvent, SessionState } from '@shared/types'
|
||||||
|
|
||||||
|
export interface DesktopApi {
|
||||||
|
send: (text: string) => Promise<void>
|
||||||
|
steer: (text: string) => Promise<void>
|
||||||
|
stop: () => Promise<void>
|
||||||
|
getState: () => Promise<SessionState>
|
||||||
|
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)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'"
|
||||||
|
/>
|
||||||
|
<title>店铺运营 Agent</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-zinc-950 text-zinc-100">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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<string | null>(null)
|
||||||
|
|
||||||
|
const handlePickSuggestion = (text: string): void => {
|
||||||
|
prefillRef.current = text
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedCount = state.queue.followUp.length + state.queue.steering.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
|
||||||
|
<Sidebar sessionState={state.sessionState} stats={state.stats} connected={state.ready} />
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<ChatView
|
||||||
|
messages={state.messages}
|
||||||
|
tools={state.tools}
|
||||||
|
streaming={state.streaming}
|
||||||
|
error={state.error}
|
||||||
|
retry={state.retry}
|
||||||
|
sessionError={state.sessionError}
|
||||||
|
onDismissError={clearError}
|
||||||
|
onPickSuggestion={handlePickSuggestion}
|
||||||
|
/>
|
||||||
|
<Composer
|
||||||
|
streaming={state.streaming}
|
||||||
|
queuedCount={queuedCount}
|
||||||
|
disabled={!state.ready}
|
||||||
|
onSend={send}
|
||||||
|
onStop={stop}
|
||||||
|
prefillRef={prefillRef}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center px-8 text-center">
|
||||||
|
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-emerald-950/60 ring-1 ring-emerald-900/60">
|
||||||
|
<Store size={26} className="text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-1.5 text-lg font-medium text-zinc-100">店铺运营 Agent</h2>
|
||||||
|
<p className="mb-8 max-w-sm text-[13px] leading-6 text-zinc-500">
|
||||||
|
流程固定、编排灵活。描述你的目标,Agent 会按 SOP 调用工具完成;也可以随时切为手动逐步执行。
|
||||||
|
</p>
|
||||||
|
<div className="grid w-full max-w-md gap-2">
|
||||||
|
{SUGGESTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(s)}
|
||||||
|
className="rounded-xl border border-zinc-800 bg-zinc-900/40 px-4 py-2.5 text-left text-[13px] text-zinc-400 transition-all hover:border-zinc-700 hover:bg-zinc-800/60 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatView({
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
streaming,
|
||||||
|
error,
|
||||||
|
retry,
|
||||||
|
sessionError,
|
||||||
|
onDismissError,
|
||||||
|
onPickSuggestion
|
||||||
|
}: {
|
||||||
|
messages: UiMessage[]
|
||||||
|
tools: Record<string, ToolState>
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
|
{(error || sessionError) && (
|
||||||
|
<div className="absolute inset-x-4 top-3 z-10 flex items-start gap-2.5 rounded-xl border border-red-900/60 bg-red-950/80 px-4 py-3 text-[13px] text-red-200 shadow-lg backdrop-blur">
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-red-400" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{sessionError ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-0.5 font-medium">Agent 会话初始化失败</div>
|
||||||
|
<div className="break-all text-red-300/80">{sessionError}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="break-all">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismissError}
|
||||||
|
className="shrink-0 rounded px-1.5 text-red-300/70 transition-colors hover:bg-red-900/40 hover:text-red-200"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{retry && (
|
||||||
|
<div className="absolute inset-x-4 top-3 z-10 flex items-center gap-2.5 rounded-xl border border-amber-900/60 bg-amber-950/80 px-4 py-2.5 text-[13px] text-amber-200 shadow-lg backdrop-blur">
|
||||||
|
<RotateCw size={14} className="animate-spin text-amber-400" />
|
||||||
|
<span>
|
||||||
|
请求失败,自动重试 {retry.attempt}/{retry.maxAttempts}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-amber-300/70">{retry.errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{hasContent ? (
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<MessageItem key={message.key} message={message} tools={tools} streaming={streaming} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full">
|
||||||
|
<EmptyState onPick={onPickSuggestion} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string | null>
|
||||||
|
}): JSX.Element {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(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 (
|
||||||
|
<div className="shrink-0 px-6 pb-5 pt-1">
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/70 shadow-xl shadow-black/30 transition-colors focus-within:border-zinc-700">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault()
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={disabled ? 'Agent 会话未就绪…' : '描述你的目标,或直接下达指令(Enter 发送,Shift+Enter 换行)'}
|
||||||
|
className="block max-h-[200px] w-full bg-transparent px-4 pt-3.5 pb-1 text-[14px] leading-6 text-zinc-100 caret-emerald-400 outline-none placeholder:text-zinc-600 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between px-3 pb-2.5 pt-1">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-zinc-600">
|
||||||
|
{streaming ? (
|
||||||
|
queuedCount > 0 ? (
|
||||||
|
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-zinc-400">队列中 {queuedCount} 条</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CornerDownLeft size={11} />
|
||||||
|
回复中,Enter 可排队追加
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CornerDownLeft size={11} />
|
||||||
|
Enter 发送 · Shift+Enter 换行
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{streaming && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onStop}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-800 text-zinc-300 transition-colors hover:border-red-800 hover:bg-red-950/60 hover:text-red-300"
|
||||||
|
title="停止生成"
|
||||||
|
>
|
||||||
|
<Square size={12} className="fill-current" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={disabled || !value.trim()}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-600 text-white transition-all hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-zinc-800 disabled:text-zinc-600"
|
||||||
|
title="发送"
|
||||||
|
>
|
||||||
|
<ArrowUp size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 极简 Markdown 渲染(v0.1):
|
||||||
|
* 支持 fenced code block、行内 code、**加粗** 与换行。
|
||||||
|
* 不使用 dangerouslySetInnerHTML,全部构建 React 节点。
|
||||||
|
* 完整 Markdown(标题/列表/链接/表格)排入 v0.5。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CodeBlock {
|
||||||
|
lang: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFences(text: string): Array<{ type: 'text'; text: string } | { type: 'code'; block: CodeBlock }> {
|
||||||
|
const parts: Array<{ type: 'text'; text: string } | { type: 'code'; block: CodeBlock }> = []
|
||||||
|
const regex = /```(\w*)\n?([\s\S]*?)(?:```|$)/g
|
||||||
|
let last = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = regex.exec(text))) {
|
||||||
|
if (match.index > last) parts.push({ type: 'text', text: text.slice(last, match.index) })
|
||||||
|
parts.push({ type: 'code', block: { lang: match[1] ?? '', code: match[2] ?? '' } })
|
||||||
|
last = regex.lastIndex
|
||||||
|
}
|
||||||
|
if (last < text.length) parts.push({ type: 'text', text: text.slice(last) })
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(text: string, keyPrefix: string): ReactNode[] {
|
||||||
|
const nodes: ReactNode[] = []
|
||||||
|
const regex = /(\*\*[^*]+\*\*|`[^`\n]+`)/g
|
||||||
|
let last = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
let i = 0
|
||||||
|
while ((match = regex.exec(text))) {
|
||||||
|
if (match.index > last) nodes.push(text.slice(last, match.index))
|
||||||
|
const token = match[0]
|
||||||
|
if (token.startsWith('**')) {
|
||||||
|
nodes.push(
|
||||||
|
<strong key={`${keyPrefix}-b${i}`} className="font-semibold text-zinc-50">
|
||||||
|
{token.slice(2, -2)}
|
||||||
|
</strong>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
nodes.push(
|
||||||
|
<code key={`${keyPrefix}-c${i}`} className="rounded bg-zinc-800/80 px-1.5 py-0.5 font-mono text-[0.85em] text-emerald-300">
|
||||||
|
{token.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
last = regex.lastIndex
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
if (last < text.length) nodes.push(text.slice(last))
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Markdown({ text }: { text: string }): ReactNode {
|
||||||
|
const parts = splitFences(text)
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
if (part.type === 'code') {
|
||||||
|
return (
|
||||||
|
<div key={i} className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
|
||||||
|
{part.block.lang && (
|
||||||
|
<div className="border-b border-zinc-800 px-3 py-1 font-mono text-[11px] text-zinc-500">{part.block.lang}</div>
|
||||||
|
)}
|
||||||
|
<pre className="overflow-x-auto p-3 font-mono text-[12.5px] leading-relaxed text-zinc-200">
|
||||||
|
<code>{part.block.code.replace(/\n$/, '')}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const trimmed = part.text.replace(/^\n+|\n+$/g, '')
|
||||||
|
if (!trimmed) return null
|
||||||
|
return (
|
||||||
|
<p key={i} className="whitespace-pre-wrap text-[14.5px] leading-7 text-zinc-200">
|
||||||
|
{renderInline(trimmed, `p${i}`)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { JSX } from 'react'
|
||||||
|
import { Brain, ChevronRight } from 'lucide-react'
|
||||||
|
import type { ToolState, UiMessage } from '../hooks/useAgent'
|
||||||
|
import { Markdown } from './Markdown'
|
||||||
|
import { ToolCard, useEntrance } from './ToolCard'
|
||||||
|
|
||||||
|
interface ContentBlock {
|
||||||
|
type?: string
|
||||||
|
text?: string
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
arguments?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 防御性读取消息内容块 */
|
||||||
|
function readBlocks(raw: unknown): ContentBlock[] {
|
||||||
|
if (!raw || typeof raw !== 'object') return []
|
||||||
|
const content = (raw as { content?: unknown }).content
|
||||||
|
if (typeof content === 'string') return [{ type: 'text', text: content }]
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content.filter((c): c is ContentBlock => typeof c === 'object' && c !== null)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean }): JSX.Element | null {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
if (!text.trim()) return null
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[12px] text-zinc-500 transition-colors hover:bg-zinc-800/60 hover:text-zinc-400"
|
||||||
|
>
|
||||||
|
<Brain size={13} className={streaming ? 'text-emerald-400/80' : ''} />
|
||||||
|
<span>{streaming ? '思考中…' : '思考过程'}</span>
|
||||||
|
<ChevronRight size={12} className={`transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||||
|
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<pre className="mt-1 max-h-72 overflow-y-auto whitespace-pre-wrap rounded-lg border border-zinc-800/70 bg-zinc-900/40 p-3 font-mono text-[12px] leading-relaxed text-zinc-500">
|
||||||
|
{text}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantBody({
|
||||||
|
message,
|
||||||
|
tools,
|
||||||
|
streaming
|
||||||
|
}: {
|
||||||
|
message: UiMessage
|
||||||
|
tools: Record<string, ToolState>
|
||||||
|
streaming: boolean
|
||||||
|
}): JSX.Element {
|
||||||
|
if (message.stream) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ThinkingBlock text={message.stream.thinking} streaming />
|
||||||
|
{message.stream.text ? (
|
||||||
|
<div className="text-[14.5px] leading-7 text-zinc-200">
|
||||||
|
<Markdown text={message.stream.text} />
|
||||||
|
{streaming && <span className="stream-caret" />}
|
||||||
|
</div>
|
||||||
|
) : streaming && !message.stream.thinking ? (
|
||||||
|
<div className="flex items-center gap-1.5 py-1 text-zinc-500">
|
||||||
|
<span className="thinking-dot h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||||
|
<span className="thinking-dot h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||||
|
<span className="thinking-dot h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = readBlocks(message.raw)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{blocks.map((block, i) => {
|
||||||
|
if (block.type === 'thinking' && typeof block.text === 'string') {
|
||||||
|
return <ThinkingBlock key={`t${i}`} text={block.text} streaming={false} />
|
||||||
|
}
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
return (
|
||||||
|
<div key={`x${i}`} className="text-[14.5px] leading-7 text-zinc-200">
|
||||||
|
<Markdown text={block.text} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (block.type === 'toolCall' && block.id) {
|
||||||
|
const tool = tools[block.id]
|
||||||
|
if (tool) return <ToolCard key={`c${i}`} toolCallId={block.id} tool={tool} />
|
||||||
|
return (
|
||||||
|
<div key={`c${i}`} className="rounded-lg border border-zinc-800 bg-zinc-900/60 px-3 py-2 font-mono text-[12.5px] text-zinc-400">
|
||||||
|
{block.name ?? 'tool'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageItem({
|
||||||
|
message,
|
||||||
|
tools,
|
||||||
|
streaming
|
||||||
|
}: {
|
||||||
|
message: UiMessage
|
||||||
|
tools: Record<string, ToolState>
|
||||||
|
streaming: boolean
|
||||||
|
}): JSX.Element | null {
|
||||||
|
const ref = useEntrance<HTMLDivElement>()
|
||||||
|
|
||||||
|
if (message.role === 'user') {
|
||||||
|
const blocks = readBlocks(message.raw)
|
||||||
|
const text = blocks.map((b) => b.text ?? '').join('') || ''
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="flex justify-end">
|
||||||
|
<div className="max-w-[78%] whitespace-pre-wrap rounded-2xl rounded-br-md bg-emerald-950/50 px-4 py-2.5 text-[14.5px] leading-7 text-emerald-50 ring-1 ring-emerald-900/60">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === 'assistant') {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="max-w-full">
|
||||||
|
<AssistantBody message={message} tools={tools} streaming={streaming} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他角色(custom / notification 等)暂不渲染
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { JSX } from 'react'
|
||||||
|
import { Cpu, MessageSquarePlus, Store } from 'lucide-react'
|
||||||
|
import type { SessionState, SessionStatsInfo } from '@shared/types'
|
||||||
|
|
||||||
|
interface VersionInfo {
|
||||||
|
app: string
|
||||||
|
electron: string
|
||||||
|
node: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(cost: number): string {
|
||||||
|
if (cost >= 1) return `$${cost.toFixed(2)}`
|
||||||
|
if (cost > 0) return `$${cost.toFixed(4)}`
|
||||||
|
return '$0'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
sessionState,
|
||||||
|
stats,
|
||||||
|
connected
|
||||||
|
}: {
|
||||||
|
sessionState: SessionState | null
|
||||||
|
stats: SessionStatsInfo | null
|
||||||
|
connected: boolean
|
||||||
|
}): JSX.Element {
|
||||||
|
const [versions, setVersions] = useState<VersionInfo | null>(null)
|
||||||
|
const entranceRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void window.api.versions().then(setVersions).catch(() => setVersions(null))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = entranceRef.current
|
||||||
|
if (!el) return
|
||||||
|
let tween: { kill(): void } | null = null
|
||||||
|
let cancelled = false
|
||||||
|
void import('gsap').then((gsapModule) => {
|
||||||
|
if (cancelled) return
|
||||||
|
tween = gsapModule.gsap.fromTo(
|
||||||
|
el,
|
||||||
|
{ opacity: 0, x: -12 },
|
||||||
|
{ opacity: 1, x: 0, duration: 0.35, ease: 'power2.out' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
tween?.kill()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside ref={entranceRef} className="flex w-[260px] shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-950">
|
||||||
|
{/* 品牌 */}
|
||||||
|
<div className="flex items-center gap-2.5 px-4 pb-4 pt-5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-950/60 ring-1 ring-emerald-900/60">
|
||||||
|
<Store size={16} className="text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-medium text-zinc-100">店铺运营 Agent</div>
|
||||||
|
<div className="text-[11px] text-zinc-600">powered by pi</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新会话(v0.2 提供) */}
|
||||||
|
<div className="px-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
title="会话管理将在 v0.2 提供"
|
||||||
|
className="flex w-full items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-[13px] text-zinc-400 transition-colors hover:border-zinc-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<MessageSquarePlus size={15} />
|
||||||
|
新会话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 会话列表占位 */}
|
||||||
|
<div className="mt-4 flex-1 overflow-y-auto px-3">
|
||||||
|
<div className="px-2 pb-2 text-[11px] font-medium uppercase tracking-wider text-zinc-600">会话</div>
|
||||||
|
<div className="rounded-xl border border-dashed border-zinc-800/80 px-3 py-4 text-center text-[12px] leading-5 text-zinc-600">
|
||||||
|
会话列表与历史恢复
|
||||||
|
<br />
|
||||||
|
<span className="text-zinc-700">v0.2 上线</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部状态 */}
|
||||||
|
<div className="border-t border-zinc-800/80 px-4 py-3.5">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
title={sessionState?.model ? `${sessionState.model.provider}/${sessionState.model.id}` : '未配置模型'}
|
||||||
|
>
|
||||||
|
<Cpu size={13} className="shrink-0 text-zinc-500" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[12px] text-zinc-300">
|
||||||
|
{sessionState?.model?.name ?? (connected ? '模型加载中…' : '未连接')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10.5px] text-zinc-600">{sessionState?.model ? `${sessionState.model.provider}` : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${connected ? 'bg-emerald-400' : 'bg-zinc-600'}`} />
|
||||||
|
</div>
|
||||||
|
{stats && (
|
||||||
|
<div className="mt-2.5 flex items-center justify-between text-[10.5px] text-zinc-600">
|
||||||
|
<span>上下文 {stats.contextPercent !== undefined ? `${stats.contextPercent}%` : '—'}</span>
|
||||||
|
<span>{formatTokens(stats.tokens.total)} tokens</span>
|
||||||
|
<span>{formatCost(stats.cost)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{versions && (
|
||||||
|
<div className="mt-2 text-[10px] text-zinc-700">
|
||||||
|
v{versions.app} · Electron {versions.electron} · Node {versions.node}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { JSX, RefObject } from 'react'
|
||||||
|
import { Check, ChevronDown, Loader2, X } from 'lucide-react'
|
||||||
|
import type { ToolState } from '../hooks/useAgent'
|
||||||
|
|
||||||
|
/** 从工具结果中提取可读文本(防御性读取,结构未知时回退 JSON) */
|
||||||
|
function extractResultText(result: unknown): string {
|
||||||
|
if (result == null) return ''
|
||||||
|
if (typeof result === 'string') return result
|
||||||
|
if (typeof result === 'object') {
|
||||||
|
const r = result as Record<string, unknown>
|
||||||
|
if (Array.isArray(r.content)) {
|
||||||
|
const texts = r.content
|
||||||
|
.filter((c): c is { type: string; text?: string } => typeof c === 'object' && c !== null)
|
||||||
|
.map((c) => (c.type === 'text' && typeof c.text === 'string' ? c.text : ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
if (texts.length) return texts.join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(result, null, 2)
|
||||||
|
} catch {
|
||||||
|
return String(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pretty(value: unknown): string {
|
||||||
|
if (value == null) return ''
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// 尝试美化 JSON 字符串参数
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(value), null, 2)
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max = 4000): string {
|
||||||
|
if (text.length <= max) return text
|
||||||
|
return `${text.slice(0, max)}\n…(已截断,共 ${text.length} 字符)`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCard({ toolCallId, tool }: { toolCallId: string; tool: ToolState }): JSX.Element {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const resultText = extractResultText(tool.result)
|
||||||
|
const argsText = pretty(tool.args)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/60">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-zinc-800/50"
|
||||||
|
>
|
||||||
|
{tool.status === 'running' ? (
|
||||||
|
<Loader2 size={14} className="shrink-0 animate-spin text-emerald-400" />
|
||||||
|
) : tool.status === 'error' ? (
|
||||||
|
<X size={14} className="shrink-0 text-red-400" />
|
||||||
|
) : (
|
||||||
|
<Check size={14} className="shrink-0 text-emerald-400" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-[12.5px] text-zinc-300">{tool.toolName}</span>
|
||||||
|
{tool.status === 'running' && <span className="text-[11px] text-zinc-500">执行中…</span>}
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`ml-auto shrink-0 text-zinc-500 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||||
|
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="space-y-2 border-t border-zinc-800/80 px-3 py-2">
|
||||||
|
{argsText && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[11px] font-medium text-zinc-500">参数</div>
|
||||||
|
<pre className="overflow-x-auto rounded bg-zinc-950/60 p-2 font-mono text-[11.5px] leading-relaxed text-zinc-400">
|
||||||
|
{truncate(argsText, 1500)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(resultText || tool.status !== 'running') && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[11px] font-medium text-zinc-500">
|
||||||
|
{tool.status === 'error' ? '错误' : '结果'} · {toolCallId.slice(0, 8)}
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
className={`overflow-x-auto rounded bg-zinc-950/60 p-2 font-mono text-[11.5px] leading-relaxed ${
|
||||||
|
tool.status === 'error' ? 'text-red-300/90' : 'text-zinc-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{truncate(resultText)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
|
||||||
|
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
|
||||||
|
const ref = useRef<T | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
let tween: { kill(): void } | null = null
|
||||||
|
let cancelled = false
|
||||||
|
void import('gsap').then((gsapModule) => {
|
||||||
|
if (cancelled) return
|
||||||
|
tween = gsapModule.gsap.fromTo(
|
||||||
|
el,
|
||||||
|
{ opacity: 0, y: 10 },
|
||||||
|
{ opacity: 1, y: 0, duration: 0.28, ease: 'power2.out', clearProps: 'transform' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
tween?.kill()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
return ref
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
|
||||||
|
import type { AgentUiEvent, SessionState, SessionStatsInfo } from '@shared/types'
|
||||||
|
|
||||||
|
export interface ToolState {
|
||||||
|
toolName: string
|
||||||
|
args?: unknown
|
||||||
|
partialResult?: unknown
|
||||||
|
result?: unknown
|
||||||
|
status: 'running' | 'done' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiMessage {
|
||||||
|
key: string
|
||||||
|
role: string
|
||||||
|
/** 已完成的消息原文(AgentMessage,防御性读取) */
|
||||||
|
raw?: unknown
|
||||||
|
/** 流式中的增量缓冲 */
|
||||||
|
stream?: { text: string; thinking: string }
|
||||||
|
/** 本地乐观插入(发送即显示,等待 message_end 回填) */
|
||||||
|
local?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentUiState {
|
||||||
|
messages: UiMessage[]
|
||||||
|
tools: Record<string, ToolState>
|
||||||
|
streaming: boolean
|
||||||
|
queue: { steering: string[]; followUp: string[] }
|
||||||
|
error: string | null
|
||||||
|
sessionError: string | null
|
||||||
|
sessionState: SessionState | null
|
||||||
|
stats: SessionStatsInfo | null
|
||||||
|
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
|
||||||
|
ready: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: 'event'; event: AgentUiEvent }
|
||||||
|
| { type: 'optimistic-user'; key: string; text: string }
|
||||||
|
| { type: 'clear-error' }
|
||||||
|
|
||||||
|
const initialState: AgentUiState = {
|
||||||
|
messages: [],
|
||||||
|
tools: {},
|
||||||
|
streaming: false,
|
||||||
|
queue: { steering: [], followUp: [] },
|
||||||
|
error: null,
|
||||||
|
sessionError: null,
|
||||||
|
sessionState: null,
|
||||||
|
stats: null,
|
||||||
|
retry: null,
|
||||||
|
ready: false
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyCounter = 0
|
||||||
|
function nextKey(prefix: string): string {
|
||||||
|
keyCounter += 1
|
||||||
|
return `${prefix}-${Date.now().toString(36)}-${keyCounter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRole(message: unknown): string {
|
||||||
|
if (message && typeof message === 'object' && 'role' in message) {
|
||||||
|
return String((message as { role: unknown }).role)
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||||
|
if (action.type === 'optimistic-user') {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
messages: [
|
||||||
|
...state.messages,
|
||||||
|
{ key: action.key, role: 'user', local: true, raw: { role: 'user', content: action.text } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.type === 'clear-error') {
|
||||||
|
return { ...state, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = action.event
|
||||||
|
switch (event.type) {
|
||||||
|
case 'session_ready':
|
||||||
|
return { ...state, ready: true, sessionState: event.state }
|
||||||
|
case 'session_error':
|
||||||
|
return { ...state, sessionError: event.message }
|
||||||
|
case 'agent_start':
|
||||||
|
return { ...state, streaming: true, error: null, retry: null }
|
||||||
|
case 'agent_end':
|
||||||
|
return { ...state, streaming: false }
|
||||||
|
case 'agent_settled':
|
||||||
|
return { ...state, streaming: false, retry: null }
|
||||||
|
case 'message_start': {
|
||||||
|
// 助手消息开流式占位;用户消息在发送时已乐观插入,此处不重复
|
||||||
|
if (event.role === 'assistant') {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
messages: [...state.messages, { key: nextKey('a'), role: 'assistant', stream: { text: '', thinking: '' } }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
case 'delta': {
|
||||||
|
const messages = [...state.messages]
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const m = messages[i]
|
||||||
|
if (m.stream) {
|
||||||
|
const stream = { ...m.stream }
|
||||||
|
if (event.kind === 'text') stream.text += event.delta
|
||||||
|
else stream.thinking += event.delta
|
||||||
|
messages[i] = { ...m, stream }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...state, messages }
|
||||||
|
}
|
||||||
|
case 'message_end': {
|
||||||
|
const role = readRole(event.message)
|
||||||
|
const messages = [...state.messages]
|
||||||
|
// 回填最近的流式占位(assistant)
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].stream) {
|
||||||
|
messages[i] = { key: messages[i].key, role, raw: event.message }
|
||||||
|
return { ...state, messages }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (role === 'user') {
|
||||||
|
// 若最后一条是本地乐观插入的用户消息,用服务端版本回填
|
||||||
|
const last = messages[messages.length - 1]
|
||||||
|
if (last?.local && last.role === 'user') {
|
||||||
|
messages[messages.length - 1] = { key: last.key, role: 'user', raw: event.message }
|
||||||
|
return { ...state, messages }
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
if (role === 'toolResult') {
|
||||||
|
// 工具结果已在 ToolCard 中通过 tool_end 展示,跳过独立消息
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
if (role === 'assistant') {
|
||||||
|
messages.push({ key: nextKey('a'), role: 'assistant', raw: event.message })
|
||||||
|
return { ...state, messages }
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
case 'tool_start': {
|
||||||
|
const tools = { ...state.tools }
|
||||||
|
tools[event.toolCallId] = { toolName: event.toolName, args: event.args, status: 'running' }
|
||||||
|
return { ...state, tools }
|
||||||
|
}
|
||||||
|
case 'tool_update': {
|
||||||
|
const tools = { ...state.tools }
|
||||||
|
const prev = tools[event.toolCallId]
|
||||||
|
tools[event.toolCallId] = { ...(prev ?? { toolName: event.toolName, status: 'running' as const }), partialResult: event.partialResult }
|
||||||
|
return { ...state, tools }
|
||||||
|
}
|
||||||
|
case 'tool_end': {
|
||||||
|
const tools = { ...state.tools }
|
||||||
|
const prev = tools[event.toolCallId]
|
||||||
|
tools[event.toolCallId] = {
|
||||||
|
...(prev ?? { toolName: event.toolName }),
|
||||||
|
result: event.result,
|
||||||
|
status: event.isError ? 'error' : 'done'
|
||||||
|
}
|
||||||
|
return { ...state, tools }
|
||||||
|
}
|
||||||
|
case 'queue_update':
|
||||||
|
return { ...state, queue: { steering: event.steering, followUp: event.followUp } }
|
||||||
|
case 'retry':
|
||||||
|
return { ...state, retry: { attempt: event.attempt, maxAttempts: event.maxAttempts, errorMessage: event.errorMessage }, streaming: true }
|
||||||
|
case 'error':
|
||||||
|
return { ...state, error: event.message }
|
||||||
|
case 'session_info':
|
||||||
|
return state.sessionState
|
||||||
|
? { ...state, sessionState: { ...state.sessionState, sessionName: event.name } }
|
||||||
|
: state
|
||||||
|
case 'stats':
|
||||||
|
return { ...state, stats: event.stats }
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAgent {
|
||||||
|
state: AgentUiState
|
||||||
|
send: (text: string) => void
|
||||||
|
stop: () => void
|
||||||
|
clearError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgent(): UseAgent {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState)
|
||||||
|
const stateRef = useRef(state)
|
||||||
|
stateRef.current = state
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.api.onEvent((event) => {
|
||||||
|
dispatch({ type: 'event', event })
|
||||||
|
})
|
||||||
|
return unsubscribe
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const send = useCallback((text: string) => {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
dispatch({ type: 'optimistic-user', key: nextKey('u'), text: trimmed })
|
||||||
|
window.api.send(trimmed).catch((err: unknown) => {
|
||||||
|
dispatch({
|
||||||
|
type: 'event',
|
||||||
|
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
window.api.stop().catch((err: unknown) => {
|
||||||
|
dispatch({
|
||||||
|
type: 'event',
|
||||||
|
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearError = useCallback(() => dispatch({ type: 'clear-error' }), [])
|
||||||
|
|
||||||
|
return useMemo(() => ({ state, send, stop, clearError }), [state, send, stop, clearError])
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo } from '@shared/types'
|
||||||
|
|
||||||
|
export type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo }
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
api: {
|
||||||
|
send: (text: string) => Promise<void>
|
||||||
|
steer: (text: string) => Promise<void>
|
||||||
|
stop: () => Promise<void>
|
||||||
|
getState: () => Promise<SessionState>
|
||||||
|
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }>
|
||||||
|
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = window.api
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './styles/globals.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||||
|
--font-mono: ui-monospace, "Cascadia Code", Consolas, "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(113, 113, 122, 0.35) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(113, 113, 122, 0.35);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(113, 113, 122, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(16, 185, 129, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流式输出光标 */
|
||||||
|
@keyframes caret-blink {
|
||||||
|
0%,
|
||||||
|
60% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
61%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-caret {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.5em;
|
||||||
|
height: 1.05em;
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
background: rgb(16 185 129);
|
||||||
|
border-radius: 1px;
|
||||||
|
animation: caret-blink 0.9s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 思考中指示 */
|
||||||
|
@keyframes thinking-dot {
|
||||||
|
0%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: scale(0.6);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-dot {
|
||||||
|
animation: thinking-dot 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-dot:nth-child(2) {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-dot:nth-child(3) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 禁用 textarea 的默认 resize 与滚动条闪烁 */
|
||||||
|
textarea {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 主进程 <-> 渲染进程 的 IPC 契约类型(两端共用,不引入 Node 依赖)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
id: string
|
||||||
|
provider: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionState {
|
||||||
|
sessionId: string
|
||||||
|
sessionName?: string
|
||||||
|
model?: ModelInfo
|
||||||
|
streaming: boolean
|
||||||
|
activeTools: string[]
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStatsInfo {
|
||||||
|
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }
|
||||||
|
cost: number
|
||||||
|
/** 上下文占用百分比 0-100,取不到时省略 */
|
||||||
|
contextPercent?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转发到渲染进程的 agent 事件。
|
||||||
|
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
|
||||||
|
*/
|
||||||
|
export type AgentUiEvent =
|
||||||
|
| { type: 'session_ready'; state: SessionState }
|
||||||
|
| { type: 'session_error'; message: string }
|
||||||
|
| { type: 'agent_start' }
|
||||||
|
| { type: 'agent_end' }
|
||||||
|
| { type: 'agent_settled' }
|
||||||
|
| { type: 'message_start'; role: string }
|
||||||
|
| { type: 'delta'; kind: 'text' | 'thinking'; delta: string }
|
||||||
|
| { type: 'message_end'; message: unknown }
|
||||||
|
| { type: 'tool_start'; toolCallId: string; toolName: string; args: unknown }
|
||||||
|
| { type: 'tool_update'; toolCallId: string; toolName: string; partialResult: unknown }
|
||||||
|
| { type: 'tool_end'; toolCallId: string; toolName: string; result: unknown; isError: boolean }
|
||||||
|
| { type: 'queue_update'; steering: string[]; followUp: string[] }
|
||||||
|
| { type: 'retry'; attempt: number; maxAttempts: number; errorMessage: string }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
| { type: 'session_info'; name?: string }
|
||||||
|
| { type: 'stats'; stats: SessionStatsInfo }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "out/tsconfig.node",
|
||||||
|
"paths": {
|
||||||
|
"@main/*": ["./src/main/*"],
|
||||||
|
"@shared/*": ["./src/shared/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["electron.vite.config.ts", "src/main/**/*.ts", "src/preload/**/*.ts", "src/shared/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"outDir": "out/tsconfig.web",
|
||||||
|
"paths": {
|
||||||
|
"@renderer/*": ["./src/renderer/src/*"],
|
||||||
|
"@shared/*": ["./src/shared/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/renderer/src/**/*.ts", "src/renderer/src/**/*.tsx", "src/shared/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 架构决策记录(ADR)
|
||||||
|
|
||||||
|
> 本文档沉淀截至 2026-09-05 的架构讨论结论,作为实现的唯一依据。后端/后台/数据库**尚未设计,本期不动**。
|
||||||
|
|
||||||
|
## 1. 系统形态
|
||||||
|
|
||||||
|
店铺运营桌面工具:**固定业务流程 + Agent 灵活编排 + 可切换纯手动**。
|
||||||
|
|
||||||
|
```
|
||||||
|
本地机器(每用户) 服务器(未设计,本期不动)
|
||||||
|
┌─ Electron App ────────────────┐ ┌─ NestJS ─────────────┐
|
||||||
|
│ 渲染进程:React UI │ │ 账号/鉴权 │
|
||||||
|
│ (聊天流、工具卡片、审批卡、 │ │ LLM 代理(key/用量) │
|
||||||
|
│ 业务面板、GSAP 动效) │ │ 共享资产(可选) │
|
||||||
|
│ ▲ IPC(contextBridge) │ └──────────┬───────────┘
|
||||||
|
│ 主进程: │ │
|
||||||
|
│ ├─ pi SDK(createAgentSession)│──── HTTPS ───────┘
|
||||||
|
│ ├─ pi 插件(extensions/tools) │ 业务工具=服务器API薄封装
|
||||||
|
│ └─ sqlite(会话/任务/业务数据) │
|
||||||
|
│ 共享 ~/.pi(auth/models/sessions)│
|
||||||
|
└────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 关键决策
|
||||||
|
|
||||||
|
### D1 pi 跑本地,嵌 Electron 主进程
|
||||||
|
- pi SDK 同进程嵌入(类型安全、`extensionFactories` 官方文档路径)。
|
||||||
|
- Electron 37.6+ 内置 Node ≥22.19,满足 pi 运行时要求(本机系统 Node 22.15 仅用于构建)。
|
||||||
|
- 备选降级路径:主进程 spawn `pi --mode rpc` 子进程(JSONL stdio),用于将来崩溃隔离;工具定义代码可复用。
|
||||||
|
- 共享 `~/.pi/agent`(auth.json、models-store.json、sessions/),与用户已有的 pi CLI 配置互通(本机已确认存在)。
|
||||||
|
|
||||||
|
### D2 能力层唯一:Agent 与手动模式共用同一份实现
|
||||||
|
- 每个业务功能(热点词采集、素材生成、模板生成、CDP 操作、数据分析)= `packages/agent` 中的独立模块。
|
||||||
|
- 同时向两个方向暴露:① pi 的 `defineTool()`/extension 注册(Agent 模式,LLM 编排);② Electron IPC 直接调用(纯手动模式,UI 按钮触发)。
|
||||||
|
- 两条路径行为一致、审计一致。这是"可切换纯手动"低成本实现的前提。
|
||||||
|
|
||||||
|
### D3 固定流程用 pi skills 承载 SOP,审批卡做闸门
|
||||||
|
- SOP(完整上架流程、报活动流程等)写成 pi skill / 提示模板,agent 按剧本执行、剧本内灵活处理异常。
|
||||||
|
- 涉及花钱/不可逆的操作(核价、报活动):extension 拦截 `tool_call` → 渲染进程弹审批卡 → 人工确认后才放行。
|
||||||
|
- 任务状态落本地 sqlite,支持断点续跑。
|
||||||
|
|
||||||
|
### D4 CDP 操作本地执行
|
||||||
|
- 店铺后台登录态在本地浏览器 cookie,CDP 控制必须发生在用户机器(这也是 D1 的根本原因之一)。
|
||||||
|
- 浏览器操作工具按"幂等、可重试、每步截图回传确认"设计;既保流程可靠,也缓冲平台风控。
|
||||||
|
|
||||||
|
### D5 数据主权:本地优先,服务器极薄
|
||||||
|
| 存哪 | 存什么 |
|
||||||
|
|---|---|
|
||||||
|
| 本地 sqlite | 商品数据、分析结果、热点词采集结果、生成记录、任务/断点状态、pi 会话(大文件落磁盘,库存元数据与路径) |
|
||||||
|
| 服务器 PG | 仅账号/鉴权、LLM 代理配置与用量、(可选)团队共享词库/模板。**明确不存商品数据、分析结果等用户业务数据** |
|
||||||
|
|
||||||
|
后果与对策:
|
||||||
|
- 单机即真相:无跨设备同步、无全团队聚合报表(用户已确认接受)。
|
||||||
|
- 本地备份:sqlite 单文件 + 资产目录做"一键打包备份/恢复",排早期功能。
|
||||||
|
- 本地 sqlite schema 必须自带版本号与迁移机制(migrations 表 + 启动时自动升级)。
|
||||||
|
|
||||||
|
## 3. 业务流程链(目标态,全部以 pi 插件形式内置)
|
||||||
|
|
||||||
|
1. 热点词采集(AI/工具采集)或手动定义
|
||||||
|
2. 依据热点词 + 参考素材生成商品图、标题等信息
|
||||||
|
3. 生成商家模板
|
||||||
|
4. CDP 驱动浏览器操作店铺后台:上架、核价、流量加速、下架、报活动
|
||||||
|
5. 数据分析
|
||||||
|
|
||||||
|
## 4. 范围裁剪(本期)
|
||||||
|
|
||||||
|
- ✅ Electron 桌面端(本仓库 `apps/desktop`)
|
||||||
|
- ⛔ NestJS 后端、Ant Design Pro 后台、PostgreSQL —— 未设计,不创建、不实现
|
||||||
|
- 业务插件中依赖服务器 API 的部分,先留接口占位(不依赖后端即可运行的本地能力可先行)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# pi SDK 集成笔记
|
||||||
|
|
||||||
|
> 来源:pi.dev 官方文档 + 仓库调研(2026-09-05)。
|
||||||
|
> ⚠️ 本文是二手摘要,**写代码前必须对照已安装包的 `.d.ts` 类型定义核对 API 签名**。
|
||||||
|
|
||||||
|
## 1. 包与运行时
|
||||||
|
|
||||||
|
- 包名:`@earendil-works/pi-coding-agent`(主包自带 SDK,无需单独安装)
|
||||||
|
- Node ≥ 22.19(Electron 37.6+ 主进程满足;系统 Node 仅跑构建)
|
||||||
|
- MIT 协议;monorepo 结构:`pi-agent-core`(agent 运行时)/ `pi-ai`(多 Provider LLM 统一接口)/ `pi-tui` / `chord` / `telemetry`
|
||||||
|
|
||||||
|
## 2. SDK 三段式(嵌入 Node 应用的标准路径)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { session } = await createAgentSession({ /* options */ });
|
||||||
|
session.subscribe((event) => { /* 处理 text_delta 等 */ });
|
||||||
|
await session.prompt("...");
|
||||||
|
```
|
||||||
|
|
||||||
|
- `prompt()` 返回 Promise,完成后 resolve;流式期间可用 `steer()` / `followUp()` 排队插话。
|
||||||
|
- 事件流:`agent_start/end`、`message_update`(含 `text_delta` / `thinking_delta`)、`tool_execution_start/update/end`、`turn_end`、`queue_update`、压缩与自动重试事件。
|
||||||
|
|
||||||
|
## 3. 会话管理
|
||||||
|
|
||||||
|
- `SessionManager.inMemory()`(无持久化)/ `.create()` / `.continueRecent()` / `.open()`
|
||||||
|
- 会话是树结构,支持分支(`fork()`);多会话动态替换用 `AgentSessionRuntime`(`newSession()` / `switchSession()` / `fork()` / `importFromJsonl()`,替换后需重新订阅)
|
||||||
|
- 持久化后端可插拔:官方 `session-backends/sqlite-node`(与我们本地 sqlite 方案契合)
|
||||||
|
- 本地文件布局(与 pi CLI / pi-web 共享):`~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl`
|
||||||
|
|
||||||
|
## 4. 模型与凭据
|
||||||
|
|
||||||
|
- 认证解析优先级:运行时 API key → `auth.json`(API key 或 OAuth)→ 环境变量 → 自定义 provider fallback resolver
|
||||||
|
- `setRuntimeApiKey()`(不落盘)、`InMemoryCredentialStore`、自定义 auth/models 路径、`PI_OFFLINE` 离线模式
|
||||||
|
- 内置 Provider:OpenAI、Anthropic、Google 等;自定义模型用 `models.json`,自定义 provider 支持实现自有 API 与 OAuth(为将来"服务器 LLM 代理端点"预留)
|
||||||
|
- 本机现状:`~/.pi/agent/` 已有 `auth.json` 与 `models-store.json`,应用零配置可直接复用
|
||||||
|
|
||||||
|
## 5. 定制点(SDK options)
|
||||||
|
|
||||||
|
| 维度 | 方式 |
|
||||||
|
|---|---|
|
||||||
|
| 系统提示 | `DefaultResourceLoader({ systemPromptOverride: () => "..." })` |
|
||||||
|
| 工具 | `tools` 白名单内置工具;`excludeTools` 排除;`noTools: "all"` 全关;`defineTool()` 经 `customTools` 注入 |
|
||||||
|
| 扩展 | `extensionFactories` 内联扩展(`pi.registerTool()` / `pi.on()` / 命令),支持命名与共享 eventBus |
|
||||||
|
| 模型 | `getModel()` 取内置模型;`models.json` 自定义;`thinkingLevel`、`scopedModels` |
|
||||||
|
| Skills/上下文/提示模板 | ResourceLoader 的 `skillsOverride`、`agentsFilesOverride`、`promptsOverride` |
|
||||||
|
| 设置 | `SettingsManager.inMemory()` / `.create()`,可覆盖压缩、重试等 |
|
||||||
|
|
||||||
|
## 6. 安全要点
|
||||||
|
|
||||||
|
- pi **无内置权限系统**,工具以进程用户权限执行。业务 agent 必须 `noTools` 或白名单收窄,能力全部走 `defineTool()` 自定义工具;本地文件类操作按需开启并配 `tool_call` 拦截。
|
||||||
|
|
||||||
|
## 7. 扩展(Extension)API 摘要
|
||||||
|
|
||||||
|
- 加载:`~/.pi/agent/extensions/*.ts`(全局)、`.pi/extensions/*.ts`(项目级,需 trust)、`pi -e ./x.ts`(临时);jiti 加载 TS 免编译,`/reload` 热重载(SDK 路径用 `extensionFactories` 内联)
|
||||||
|
- 能力:`pi.on(event, handler)`(拦截 `tool_call`、改写 `input`、`before_agent_start` 注入上下文/改提示词)、`pi.registerTool()`(typebox 参数、流式进度 `onUpdate`)、`pi.registerCommand()`、`ctx.ui` 自定义交互、`pi.appendEntry()` 会话持久化、`pi.sendMessage()`
|
||||||
|
- 注意:自定义工具需手动截断输出(默认 50KB/2000 行)、`withFileMutationQueue()` 防并行写冲突
|
||||||
|
|
||||||
|
## 8. 备选集成模式
|
||||||
|
|
||||||
|
| 模式 | 用途 | 取舍 |
|
||||||
|
|---|---|---|
|
||||||
|
| SDK 同进程(主选) | 嵌入 Electron 主进程 | 类型安全、直接访问 agent state |
|
||||||
|
| RPC 模式(`pi --mode rpc`) | 子进程隔离 | 崩溃隔离好,但失去类型安全,自管 JSONL 协议 |
|
||||||
|
| JSON event stream | 结构化事件打印 | 观测/调试用 |
|
||||||
|
|
||||||
|
## 9. pi-web 可借鉴的实现细节
|
||||||
|
|
||||||
|
- WebSocket 事件以快照推送、60ms 节流(我们的 IPC 转发同样适用)
|
||||||
|
- 会话列表 UI 数据:运行状态、上下文用量、成本、压缩详情
|
||||||
|
- 双分支设计:New session(从历史消息建独立会话)/ Edit from here(当前会话内分支)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 参考项目取舍
|
||||||
|
|
||||||
|
原则:**功能设计抄 pi-web,工程结构抄 Proma,技术选型和业务形态按我们自己的来。**
|
||||||
|
|
||||||
|
## agegr/pi-web(5.9k★,Next.js 本地 Web UI)
|
||||||
|
|
||||||
|
### 采纳
|
||||||
|
- **与 pi 共享本地数据的集成模式**:直接读 `~/.pi/agent` 的配置/凭据/会话(`sessions/<encoded-cwd>/<时间戳>_<uuid>.jsonl`),支持 `PI_CODING_AGENT_DIR` 重定向 → 我们的应用零配置复用用户已有 pi 环境
|
||||||
|
- **会话工作区功能清单**:按项目分组、运行状态、上下文用量/成本/压缩详情、重命名/导出/删除
|
||||||
|
- **双分支设计**:New session(从历史消息建独立会话)/ Edit from here(会话内分支)
|
||||||
|
- **模型配置面板**:models.json 编辑、API key、连通性测试
|
||||||
|
- **文件访问边界**:只允许访问选定工作目录与已知项目根(安全约束)
|
||||||
|
- 事件推送做快照 + 60ms 节流的工程细节
|
||||||
|
|
||||||
|
### 丢弃
|
||||||
|
- Next.js/Web 技术栈(我们是 Electron)
|
||||||
|
- Git worktree、通用文件浏览器等重编码功能(垂直业务场景不需要,最多降级为可选模块)
|
||||||
|
|
||||||
|
## proma-ai/Proma(Electron + React,本地优先 AI 桌面应用)
|
||||||
|
|
||||||
|
### 采纳
|
||||||
|
- **工程结构**:`apps/electron + packages/*` 的 Electron monorepo 组织
|
||||||
|
- **产品形态**:workspace 按业务域隔离、Skills 技能系统的组织方式、多模型 chat 的配置体验
|
||||||
|
- **本地优先**理念(与我们 D5 数据主权决策一致)
|
||||||
|
|
||||||
|
### 丢弃
|
||||||
|
- Bun monorepo → 我们用 **pnpm**(pi 生态是 npm 系;Windows 上 pnpm + electron-builder 兼容性更稳)
|
||||||
|
- 远程机器人、通用 agent 客户端的复杂度
|
||||||
|
- 照抄代码(结构参考为主,代码质量未逐一验证)
|
||||||
|
|
||||||
|
## beautifului.dev
|
||||||
|
|
||||||
|
- AI 原语组件的**设计参考**(Approval Card、Tool Chips、Task Rows、Streaming Text、Thinking、Prompt Bar 等 21 个)
|
||||||
|
- copy-paste 式分发(类 shadcn),我们按此风格在 `apps/desktop` 内自实现同款组件(React + Tailwind),不引入其付费订阅
|
||||||
|
- GSAP 用于消息入场、卡片展开等动效
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 路线图与版本自省
|
||||||
|
|
||||||
|
> 每个版本完成后在此追加"自省":缺什么、哪里不好、下一版怎么补。
|
||||||
|
|
||||||
|
## 版本计划
|
||||||
|
|
||||||
|
| 版本 | 主题 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| v0.1.0 | 骨架 + 聊天链路 | monorepo、electron-vite 三进程、pi SDK 嵌主进程、IPC 事件转发、聊天 UI(流式文本 + thinking 折叠)、停止/排队基础 |
|
||||||
|
| v0.2.0 | 会话管理 | 会话列表/新建/恢复/重命名/删除、分支(New session / Edit from here)、运行状态与用量展示 |
|
||||||
|
| v0.3.0 | 模型与设置 | 模型选择、Provider/API key 管理(写入 ~/.pi auth)、系统提示词自定义、连通性测试 |
|
||||||
|
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
|
||||||
|
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
|
||||||
|
| 未来 | | electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、CDP 插件、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.1.0 — 骨架 + 聊天链路
|
||||||
|
|
||||||
|
**交付**:
|
||||||
|
- pnpm monorepo(`apps/desktop` + `docs/`;后端/后台/数据库按约定未创建)
|
||||||
|
- Electron 44 + electron-vite 5 + React 19 + Tailwind v4 + GSAP + lucide-react
|
||||||
|
- 主进程 ESM(`.mjs`)内嵌 pi SDK 0.85.0(`@earendil-works/*` 全部 external,运行时从 node_modules 加载);preload 输出 `.cjs`
|
||||||
|
- IPC:`agent:send/steer/stop/state` + `agent:event` 事件流(delta 40ms 合批,参考 pi-web 节流经验)
|
||||||
|
- UI:侧栏(品牌/模型状态/上下文用量/成本/版本)、聊天区(用户气泡、流式助手文本 + 光标、thinking 折叠、工具卡片含参数/结果)、输入区(自适应高度、Enter 发送/Shift+Enter 换行、流式时排队提示 + 停止按钮)、空状态建议词、错误条 + 自动重试条
|
||||||
|
- 会话复用 `~/.pi/agent`(与用户已装的 pi CLI 互通)
|
||||||
|
- 验证:typecheck 通过、build 通过、应用启动 UI 截图核验通过、pi SDK 冒烟脚本通过(无凭据时错误干净传播)
|
||||||
|
|
||||||
|
**自省**(v0.1.0 → 指导 v0.2+):
|
||||||
|
1. **真实 LLM 往返未验证**——本机 `~/.pi/agent/auth.json` 为空对象,无任何 API key;模型解析为 `unknown/unknown`(pi 的 fallback)。需要用户配 key(`pi /login` 或等 v0.3 设置界面)后复测流式/工具/思考链路。
|
||||||
|
2. **会话管理完全缺失**——重启后不恢复历史;侧栏列表是占位框。v0.2 用 `SessionManager`(list/open/create/rename/delete + 分支)补齐,pi 会话文件在 `~/.pi/agent/sessions/<encoded-cwd>/` 下按 cwd=home 分组,要注意 cwd 策略(固定 home 还是工作区选择)。
|
||||||
|
3. **工具面过宽**——当前默认 `read/bash/edit/write` 全开(冒烟已证实)。按架构 D2/D6,v0.4 收窄为业务工具白名单。
|
||||||
|
4. **Markdown 渲染极简**——只有 fenced code/行内 code/加粗;标题、列表、链接、表格都没有,长回复可读性差。排 v0.5。
|
||||||
|
5. **无 SSE 之外的流控**——超长回复没有虚拟化,消息多后可能卡;先记录,v0.5 评估虚拟滚动。
|
||||||
|
6. **坑位记录**:pi-coding-agent 0.85.0 漏声明 `@earendil-works/pi-server` 依赖(已手动补装);electron-vite 5 需显式 `external: ['electron', /^@earendil-works\//]`;`"type":"module"` 下 preload 必须 `.cjs`;PowerShell 改写含中文的源文件会毁编码(统一用写入工具)。
|
||||||
|
7. **交互验证受限**——computer-use 无法激活该窗口(虚拟显示),键盘链路未实测;已用截图 + Node 冒烟双层验证替代,后续版本需人工过一遍键盘操作。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 技术栈
|
||||||
|
|
||||||
|
## 桌面端(本期实现范围)
|
||||||
|
|
||||||
|
| 层 | 选型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 框架 | Electron + electron-vite | 三进程构建(main/preload/renderer),HMR;Electron ≥37.6(内置 Node ≥22.19,满足 pi 运行时) |
|
||||||
|
| 渲染进程 UI | React + TypeScript | |
|
||||||
|
| 样式 | Tailwind CSS v4 | |
|
||||||
|
| 组件 | shadcn/ui 风格自实现 + beautifului.dev 原语风格(Approval Card、Tool Chips、Task Rows、Streaming Text、Thinking、Prompt Bar) | copy-paste 式自有组件,不引入外部付费 |
|
||||||
|
| 动效 | GSAP | 消息入场、卡片展开、状态过渡 |
|
||||||
|
| 图标 | lucide-react | |
|
||||||
|
| Agent 运行时 | pi SDK(`@earendil-works/pi-coding-agent`,版本精确锁定) | 嵌主进程;`save-exact` 纪律 |
|
||||||
|
| 本地库 | sqlite3(better-sqlite3 候选,带 migrations 表 + 启动自动迁移) | v0.x 后期引入,先跑通链路 |
|
||||||
|
|
||||||
|
## Monorepo 布局(目标态,按版本逐步填充)
|
||||||
|
|
||||||
|
```
|
||||||
|
agent-store-operations/
|
||||||
|
├─ pnpm-workspace.yaml # apps/*, packages/*
|
||||||
|
├─ apps/
|
||||||
|
│ ├─ desktop/ # ✅ 本期:Electron 客户端
|
||||||
|
│ ├─ server/ # ⛔ 未设计,不创建(NestJS)
|
||||||
|
│ └─ admin/ # ⛔ 未设计,不创建(Ant Design Pro)
|
||||||
|
├─ packages/
|
||||||
|
│ ├─ shared/ # ⏳ 后端设计定稿后再建(DTO 契约)
|
||||||
|
│ └─ agent/ # ⏳ pi 插件:业务工具 + skills + 提示词
|
||||||
|
└─ docs/ # ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
> 约束:`apps/server`、`apps/admin`、数据库相关一律不创建不设计(用户明确要求)。业务插件包 `packages/agent` 中依赖服务器 API 的工具只留占位接口。
|
||||||
|
|
||||||
|
## 工程纪律
|
||||||
|
|
||||||
|
- 依赖 `save-exact=true`(.npmrc),pi 包版本浮动 = 行为漂移
|
||||||
|
- 主进程依赖 externalize(electron-vite 默认),pi 保持 `dependencies`,打包时随 app 分发
|
||||||
|
- 每个版本 git tag(v0.1.0 起),自省记录追加到 `docs/roadmap.md`
|
||||||
|
- 渲染进程零敏感信息:API key、pi 凭据只存在主进程可达的 `~/.pi`,渲染进程经 IPC 交互
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "agent-store-operations",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "店铺运营 Agent 桌面工具(Electron for pi agent)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --filter @aso/desktop dev",
|
||||||
|
"build": "pnpm --filter @aso/desktop build",
|
||||||
|
"typecheck": "pnpm --filter @aso/desktop typecheck"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.15.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"electron",
|
||||||
|
"esbuild",
|
||||||
|
"@google/genai",
|
||||||
|
"protobufjs"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3281
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- 'apps/*'
|
||||||
|
- 'packages/*'
|
||||||
Reference in New Issue
Block a user