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
+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 }