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