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

This commit is contained in:
Rick Ye
2026-09-05 09:28:33 +08:00
commit 17110ee5ab
35 changed files with 5350 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
out/
*.log
.DS_Store
.env
.env.local
release/
+2
View File
@@ -0,0 +1,2 @@
save-exact=true
engine-strict=false
+28
View File
@@ -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) — 版本路线图与自省日志
+56
View File
@@ -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')
}
}
}
})
+35
View File
@@ -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"
}
}
+39
View File
@@ -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)
+87
View File
@@ -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()
})
}
+49
View File
@@ -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
}
+231
View File
@@ -0,0 +1,231 @@
import { app } from 'electron'
import {
createAgentSession,
type AgentSession,
type AgentSessionEvent,
type CreateAgentSessionResult
} from '@earendil-works/pi-coding-agent'
import type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo } from '@shared/types'
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
const DELTA_FLUSH_MS = 40
/**
* pi AgentSession 的主进程封装:
* - 懒初始化会话(默认复用 ~/.pi 的配置、凭据与会话持久化)
* - 订阅 pi 事件流,归一化为 AgentUiEvent 转发给渲染层
* - 提供 prompt / abort / 状态与统计查询
*/
export class AgentService {
private result: CreateAgentSessionResult | null = null
private initPromise: Promise<CreateAgentSessionResult> | null = null
private listeners = new Set<(event: AgentUiEvent) => void>()
private deltaQueue: AgentUiEvent[] = []
private flushTimer: NodeJS.Timeout | null = null
onEvent(fn: (event: AgentUiEvent) => void): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
private emit(event: AgentUiEvent): void {
for (const fn of this.listeners) {
try {
fn(event)
} catch (err) {
console.error('[agent-service] listener error:', err)
}
}
}
/** 增量类事件合批下发,避免高频 delta 打爆 IPC */
private emitDelta(event: AgentUiEvent): void {
this.deltaQueue.push(event)
if (this.flushTimer) return
this.flushTimer = setTimeout(() => {
this.flushTimer = null
const batch = this.deltaQueue
this.deltaQueue = []
for (const e of batch) this.emit(e)
}, DELTA_FLUSH_MS)
}
private getSession(): Promise<CreateAgentSessionResult> {
if (!this.initPromise) {
this.initPromise = this.create().catch((err) => {
this.initPromise = null
throw err
})
}
return this.initPromise
}
private async create(): Promise<CreateAgentSessionResult> {
const result = await createAgentSession({
cwd: app.getPath('home')
})
result.session.subscribe((event) => this.handleEvent(event))
return result
}
/** 初始化完成事件由调用方(ipc 层)在 ready 后主动拉取状态 */
async ensureReady(): Promise<SessionState> {
try {
const { session } = await this.getSession()
const state = this.buildState(session)
this.emit({ type: 'session_ready', state })
return state
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
this.emit({ type: 'session_error', message })
throw err
}
}
private handleEvent(event: AgentSessionEvent): void {
switch (event.type) {
case 'agent_start':
this.emit({ type: 'agent_start' })
break
case 'agent_end':
this.emit({ type: 'agent_end' })
break
case 'agent_settled':
this.emit({ type: 'agent_settled' })
void this.emitStats()
break
case 'message_start':
this.emit({ type: 'message_start', role: readRole(event.message) })
break
case 'message_update': {
const inner = event.assistantMessageEvent
if (inner?.type === 'text_delta' && inner.delta) {
this.emitDelta({ type: 'delta', kind: 'text', delta: inner.delta })
} else if (inner?.type === 'thinking_delta' && inner.delta) {
this.emitDelta({ type: 'delta', kind: 'thinking', delta: inner.delta })
}
break
}
case 'message_end':
this.emit({ type: 'message_end', message: toCloneable(event.message) })
break
case 'tool_execution_start':
this.emit({
type: 'tool_start',
toolCallId: event.toolCallId,
toolName: event.toolName,
args: toCloneable(event.args)
})
break
case 'tool_execution_update':
this.emit({
type: 'tool_update',
toolCallId: event.toolCallId,
toolName: event.toolName,
partialResult: toCloneable(event.partialResult)
})
break
case 'tool_execution_end':
this.emit({
type: 'tool_end',
toolCallId: event.toolCallId,
toolName: event.toolName,
result: toCloneable(event.result),
isError: event.isError
})
break
case 'queue_update':
this.emit({ type: 'queue_update', steering: [...event.steering], followUp: [...event.followUp] })
break
case 'auto_retry_start':
this.emit({
type: 'retry',
attempt: event.attempt,
maxAttempts: event.maxAttempts,
errorMessage: event.errorMessage
})
break
case 'session_info_changed':
this.emit({ type: 'session_info', name: event.name })
break
default:
break
}
}
private buildState(session: AgentSession): SessionState {
const model = session.model
const modelInfo: ModelInfo | undefined = model
? {
id: String(model.id ?? ''),
provider: String(model.provider ?? ''),
name: String(model.name ?? model.id ?? '')
}
: undefined
return {
sessionId: session.sessionId,
sessionName: session.sessionName,
model: modelInfo,
streaming: session.isStreaming,
activeTools: session.getActiveToolNames(),
messageCount: session.messages.length
}
}
private async emitStats(): Promise<void> {
try {
const { session } = await this.getSession()
const stats = session.getSessionStats()
const usage = session.getContextUsage()
const info: SessionStatsInfo = {
tokens: { ...stats.tokens },
cost: stats.cost,
contextPercent: usage && typeof usage.percent === 'number' ? Math.round(usage.percent) : undefined
}
this.emit({ type: 'stats', stats: info })
} catch {
// 统计失败不打断主流程
}
}
async getState(): Promise<SessionState> {
const { session } = await this.getSession()
return this.buildState(session)
}
async send(text: string): Promise<void> {
const { session } = await this.getSession()
if (session.isStreaming) {
await session.prompt(text, { streamingBehavior: 'followUp' })
} else {
await session.prompt(text)
}
}
async steer(text: string): Promise<void> {
const { session } = await this.getSession()
await session.steer(text)
}
async stop(): Promise<void> {
if (!this.result && !this.initPromise) return
const { session } = await this.getSession()
await session.abort()
}
}
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
function toCloneable(value: unknown): unknown {
try {
return JSON.parse(JSON.stringify(value ?? null))
} catch {
return null
}
}
function readRole(message: unknown): string {
if (message && typeof message === 'object' && 'role' in message) {
return String((message as { role: unknown }).role)
}
return 'unknown'
}
+28
View File
@@ -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)
+16
View File
@@ -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>
+43
View File
@@ -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
View File
@@ -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])
}
+18
View File
@@ -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
+10
View File
@@ -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;
}
+47
View File
@@ -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 }
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}
+22
View File
@@ -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"]
}
+23
View File
@@ -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"]
}
+69
View File
@@ -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 的部分,先留接口占位(不依赖后端即可运行的本地能力可先行)
+70
View File
@@ -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(当前会话内分支)
+35
View File
@@ -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 用于消息入场、卡片展开等动效
+36
View File
@@ -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 冒烟双层验证替代,后续版本需人工过一遍键盘操作。
+38
View File
@@ -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 交互
+22
View File
@@ -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"
]
}
}
+3281
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- 'apps/*'
- 'packages/*'