v0.1.0: Electron 桌面端骨架 + pi SDK 聊天链路
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { registerIpc } from './ipc'
|
||||
|
||||
process.env.APP_ROOT = join(__dirname, '..')
|
||||
|
||||
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL
|
||||
export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer')
|
||||
|
||||
const agentService = registerIpc()
|
||||
|
||||
async function createWindow(): Promise<void> {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 840,
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
show: false,
|
||||
backgroundColor: '#09090b',
|
||||
title: '店铺运营 Agent',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.cjs'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
win.on('ready-to-show', () => {
|
||||
win.show()
|
||||
// pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现
|
||||
agentService.ensureReady().catch((err) => {
|
||||
console.error('[main] pi session init failed:', err)
|
||||
})
|
||||
})
|
||||
|
||||
// 外部链接交给系统默认浏览器,不在应用内开新窗口
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// 渲染层日志/加载失败转发到主进程 stdout,便于排查白屏/黑屏问题
|
||||
win.webContents.on('console-message', (_e, level, message, line, sourceId) => {
|
||||
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
|
||||
})
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.log(`[renderer:load-fail] ${code} ${desc} ${url}`)
|
||||
})
|
||||
win.webContents.on('render-process-gone', (_e, details) => {
|
||||
console.log(`[renderer:gone] ${details.reason}`)
|
||||
})
|
||||
if (process.env.ASO_DEVTOOLS === '1') {
|
||||
win.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
await win.loadURL(VITE_DEV_SERVER_URL)
|
||||
} else {
|
||||
await win.loadFile(join(RENDERER_DIST, 'index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
// 确保应用单实例(第二实例激活已有窗口)
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.focus()
|
||||
}
|
||||
})
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
void createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) void createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { AgentService } from './pi/agent-service'
|
||||
|
||||
/**
|
||||
* 注册 IPC 通道并把 agent 事件转发到所有渲染窗口。
|
||||
* 返回 service 实例供入口进程使用。
|
||||
*/
|
||||
export function registerIpc(): AgentService {
|
||||
const service = new AgentService()
|
||||
|
||||
service.onEvent((event) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('agent:event', event)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:send', (_event, text: unknown) => {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error('empty prompt')
|
||||
}
|
||||
return service.send(text)
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:steer', (_event, text: unknown) => {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error('empty steer text')
|
||||
}
|
||||
return service.steer(text)
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:stop', () => service.stop())
|
||||
|
||||
ipcMain.handle('agent:state', () =>
|
||||
service.getState().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('app:versions', () => ({
|
||||
app: process.env.npm_package_version ?? '0.0.0',
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
chrome: process.versions.chrome
|
||||
}))
|
||||
|
||||
return service
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
createAgentSession,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
type CreateAgentSessionResult
|
||||
} from '@earendil-works/pi-coding-agent'
|
||||
import type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
|
||||
const DELTA_FLUSH_MS = 40
|
||||
|
||||
/**
|
||||
* pi AgentSession 的主进程封装:
|
||||
* - 懒初始化会话(默认复用 ~/.pi 的配置、凭据与会话持久化)
|
||||
* - 订阅 pi 事件流,归一化为 AgentUiEvent 转发给渲染层
|
||||
* - 提供 prompt / abort / 状态与统计查询
|
||||
*/
|
||||
export class AgentService {
|
||||
private result: CreateAgentSessionResult | null = null
|
||||
private initPromise: Promise<CreateAgentSessionResult> | null = null
|
||||
private listeners = new Set<(event: AgentUiEvent) => void>()
|
||||
private deltaQueue: AgentUiEvent[] = []
|
||||
private flushTimer: NodeJS.Timeout | null = null
|
||||
|
||||
onEvent(fn: (event: AgentUiEvent) => void): () => void {
|
||||
this.listeners.add(fn)
|
||||
return () => this.listeners.delete(fn)
|
||||
}
|
||||
|
||||
private emit(event: AgentUiEvent): void {
|
||||
for (const fn of this.listeners) {
|
||||
try {
|
||||
fn(event)
|
||||
} catch (err) {
|
||||
console.error('[agent-service] listener error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 增量类事件合批下发,避免高频 delta 打爆 IPC */
|
||||
private emitDelta(event: AgentUiEvent): void {
|
||||
this.deltaQueue.push(event)
|
||||
if (this.flushTimer) return
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null
|
||||
const batch = this.deltaQueue
|
||||
this.deltaQueue = []
|
||||
for (const e of batch) this.emit(e)
|
||||
}, DELTA_FLUSH_MS)
|
||||
}
|
||||
|
||||
private getSession(): Promise<CreateAgentSessionResult> {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this.create().catch((err) => {
|
||||
this.initPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
private async create(): Promise<CreateAgentSessionResult> {
|
||||
const result = await createAgentSession({
|
||||
cwd: app.getPath('home')
|
||||
})
|
||||
result.session.subscribe((event) => this.handleEvent(event))
|
||||
return result
|
||||
}
|
||||
|
||||
/** 初始化完成事件由调用方(ipc 层)在 ready 后主动拉取状态 */
|
||||
async ensureReady(): Promise<SessionState> {
|
||||
try {
|
||||
const { session } = await this.getSession()
|
||||
const state = this.buildState(session)
|
||||
this.emit({ type: 'session_ready', state })
|
||||
return state
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
this.emit({ type: 'session_error', message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: AgentSessionEvent): void {
|
||||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
this.emit({ type: 'agent_start' })
|
||||
break
|
||||
case 'agent_end':
|
||||
this.emit({ type: 'agent_end' })
|
||||
break
|
||||
case 'agent_settled':
|
||||
this.emit({ type: 'agent_settled' })
|
||||
void this.emitStats()
|
||||
break
|
||||
case 'message_start':
|
||||
this.emit({ type: 'message_start', role: readRole(event.message) })
|
||||
break
|
||||
case 'message_update': {
|
||||
const inner = event.assistantMessageEvent
|
||||
if (inner?.type === 'text_delta' && inner.delta) {
|
||||
this.emitDelta({ type: 'delta', kind: 'text', delta: inner.delta })
|
||||
} else if (inner?.type === 'thinking_delta' && inner.delta) {
|
||||
this.emitDelta({ type: 'delta', kind: 'thinking', delta: inner.delta })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'message_end':
|
||||
this.emit({ type: 'message_end', message: toCloneable(event.message) })
|
||||
break
|
||||
case 'tool_execution_start':
|
||||
this.emit({
|
||||
type: 'tool_start',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
args: toCloneable(event.args)
|
||||
})
|
||||
break
|
||||
case 'tool_execution_update':
|
||||
this.emit({
|
||||
type: 'tool_update',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
partialResult: toCloneable(event.partialResult)
|
||||
})
|
||||
break
|
||||
case 'tool_execution_end':
|
||||
this.emit({
|
||||
type: 'tool_end',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
result: toCloneable(event.result),
|
||||
isError: event.isError
|
||||
})
|
||||
break
|
||||
case 'queue_update':
|
||||
this.emit({ type: 'queue_update', steering: [...event.steering], followUp: [...event.followUp] })
|
||||
break
|
||||
case 'auto_retry_start':
|
||||
this.emit({
|
||||
type: 'retry',
|
||||
attempt: event.attempt,
|
||||
maxAttempts: event.maxAttempts,
|
||||
errorMessage: event.errorMessage
|
||||
})
|
||||
break
|
||||
case 'session_info_changed':
|
||||
this.emit({ type: 'session_info', name: event.name })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private buildState(session: AgentSession): SessionState {
|
||||
const model = session.model
|
||||
const modelInfo: ModelInfo | undefined = model
|
||||
? {
|
||||
id: String(model.id ?? ''),
|
||||
provider: String(model.provider ?? ''),
|
||||
name: String(model.name ?? model.id ?? '')
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
sessionName: session.sessionName,
|
||||
model: modelInfo,
|
||||
streaming: session.isStreaming,
|
||||
activeTools: session.getActiveToolNames(),
|
||||
messageCount: session.messages.length
|
||||
}
|
||||
}
|
||||
|
||||
private async emitStats(): Promise<void> {
|
||||
try {
|
||||
const { session } = await this.getSession()
|
||||
const stats = session.getSessionStats()
|
||||
const usage = session.getContextUsage()
|
||||
const info: SessionStatsInfo = {
|
||||
tokens: { ...stats.tokens },
|
||||
cost: stats.cost,
|
||||
contextPercent: usage && typeof usage.percent === 'number' ? Math.round(usage.percent) : undefined
|
||||
}
|
||||
this.emit({ type: 'stats', stats: info })
|
||||
} catch {
|
||||
// 统计失败不打断主流程
|
||||
}
|
||||
}
|
||||
|
||||
async getState(): Promise<SessionState> {
|
||||
const { session } = await this.getSession()
|
||||
return this.buildState(session)
|
||||
}
|
||||
|
||||
async send(text: string): Promise<void> {
|
||||
const { session } = await this.getSession()
|
||||
if (session.isStreaming) {
|
||||
await session.prompt(text, { streamingBehavior: 'followUp' })
|
||||
} else {
|
||||
await session.prompt(text)
|
||||
}
|
||||
}
|
||||
|
||||
async steer(text: string): Promise<void> {
|
||||
const { session } = await this.getSession()
|
||||
await session.steer(text)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.result && !this.initPromise) return
|
||||
const { session } = await this.getSession()
|
||||
await session.abort()
|
||||
}
|
||||
}
|
||||
|
||||
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
|
||||
function toCloneable(value: unknown): unknown {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value ?? null))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readRole(message: unknown): string {
|
||||
if (message && typeof message === 'object' && 'role' in message) {
|
||||
return String((message as { role: unknown }).role)
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { AgentUiEvent, SessionState } from '@shared/types'
|
||||
|
||||
export interface DesktopApi {
|
||||
send: (text: string) => Promise<void>
|
||||
steer: (text: string) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
getState: () => Promise<SessionState>
|
||||
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }>
|
||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
||||
}
|
||||
|
||||
const api: DesktopApi = {
|
||||
send: (text) => ipcRenderer.invoke('agent:send', text),
|
||||
steer: (text) => ipcRenderer.invoke('agent:steer', text),
|
||||
stop: () => ipcRenderer.invoke('agent:stop'),
|
||||
getState: () => ipcRenderer.invoke('agent:state'),
|
||||
versions: () => ipcRenderer.invoke('app:versions'),
|
||||
onEvent: (callback) => {
|
||||
const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event)
|
||||
ipcRenderer.on('agent:event', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('agent:event', listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'"
|
||||
/>
|
||||
<title>店铺运营 Agent</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useRef } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { useAgent } from './hooks/useAgent'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { ChatView } from './components/ChatView'
|
||||
import { Composer } from './components/Composer'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const { state, send, stop, clearError } = useAgent()
|
||||
const prefillRef = useRef<string | null>(null)
|
||||
|
||||
const handlePickSuggestion = (text: string): void => {
|
||||
prefillRef.current = text
|
||||
}
|
||||
|
||||
const queuedCount = state.queue.followUp.length + state.queue.steering.length
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
|
||||
<Sidebar sessionState={state.sessionState} stats={state.stats} connected={state.ready} />
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<ChatView
|
||||
messages={state.messages}
|
||||
tools={state.tools}
|
||||
streaming={state.streaming}
|
||||
error={state.error}
|
||||
retry={state.retry}
|
||||
sessionError={state.sessionError}
|
||||
onDismissError={clearError}
|
||||
onPickSuggestion={handlePickSuggestion}
|
||||
/>
|
||||
<Composer
|
||||
streaming={state.streaming}
|
||||
queuedCount={queuedCount}
|
||||
disabled={!state.ready}
|
||||
onSend={send}
|
||||
onStop={stop}
|
||||
prefillRef={prefillRef}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { AlertTriangle, RotateCw, Store } from 'lucide-react'
|
||||
import type { ToolState, UiMessage } from '../hooks/useAgent'
|
||||
import { MessageItem } from './MessageItem'
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'帮我分析一下今天该关注哪些品类热点',
|
||||
'根据热点词起草一个商品标题方案',
|
||||
'查询最近一次上架任务的执行结果'
|
||||
]
|
||||
|
||||
function EmptyState({ onPick }: { onPick: (text: string) => void }): JSX.Element {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-8 text-center">
|
||||
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-emerald-950/60 ring-1 ring-emerald-900/60">
|
||||
<Store size={26} className="text-emerald-400" />
|
||||
</div>
|
||||
<h2 className="mb-1.5 text-lg font-medium text-zinc-100">店铺运营 Agent</h2>
|
||||
<p className="mb-8 max-w-sm text-[13px] leading-6 text-zinc-500">
|
||||
流程固定、编排灵活。描述你的目标,Agent 会按 SOP 调用工具完成;也可以随时切为手动逐步执行。
|
||||
</p>
|
||||
<div className="grid w-full max-w-md gap-2">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onPick(s)}
|
||||
className="rounded-xl border border-zinc-800 bg-zinc-900/40 px-4 py-2.5 text-left text-[13px] text-zinc-400 transition-all hover:border-zinc-700 hover:bg-zinc-800/60 hover:text-zinc-200"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
messages,
|
||||
tools,
|
||||
streaming,
|
||||
error,
|
||||
retry,
|
||||
sessionError,
|
||||
onDismissError,
|
||||
onPickSuggestion
|
||||
}: {
|
||||
messages: UiMessage[]
|
||||
tools: Record<string, ToolState>
|
||||
streaming: boolean
|
||||
error: string | null
|
||||
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
|
||||
sessionError: string | null
|
||||
onDismissError: () => void
|
||||
onPickSuggestion: (text: string) => void
|
||||
}): JSX.Element {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const pinnedRef = useRef(true)
|
||||
|
||||
// 用户上滚时停止自动吸底,回到底部附近恢复
|
||||
const handleScroll = (): void => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
|
||||
pinnedRef.current = distance < 90
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && pinnedRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [messages, tools, streaming])
|
||||
|
||||
const hasContent = messages.length > 0
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{(error || sessionError) && (
|
||||
<div className="absolute inset-x-4 top-3 z-10 flex items-start gap-2.5 rounded-xl border border-red-900/60 bg-red-950/80 px-4 py-3 text-[13px] text-red-200 shadow-lg backdrop-blur">
|
||||
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-red-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{sessionError ? (
|
||||
<>
|
||||
<div className="mb-0.5 font-medium">Agent 会话初始化失败</div>
|
||||
<div className="break-all text-red-300/80">{sessionError}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="break-all">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismissError}
|
||||
className="shrink-0 rounded px-1.5 text-red-300/70 transition-colors hover:bg-red-900/40 hover:text-red-200"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{retry && (
|
||||
<div className="absolute inset-x-4 top-3 z-10 flex items-center gap-2.5 rounded-xl border border-amber-900/60 bg-amber-950/80 px-4 py-2.5 text-[13px] text-amber-200 shadow-lg backdrop-blur">
|
||||
<RotateCw size={14} className="animate-spin text-amber-400" />
|
||||
<span>
|
||||
请求失败,自动重试 {retry.attempt}/{retry.maxAttempts}
|
||||
</span>
|
||||
<span className="truncate text-amber-300/70">{retry.errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto">
|
||||
{hasContent ? (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
{messages.map((message) => (
|
||||
<MessageItem key={message.key} message={message} tools={tools} streaming={streaming} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
<EmptyState onPick={onPickSuggestion} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX, RefObject } from 'react'
|
||||
import { ArrowUp, CornerDownLeft, Square } from 'lucide-react'
|
||||
|
||||
export function Composer({
|
||||
streaming,
|
||||
queuedCount,
|
||||
disabled,
|
||||
onSend,
|
||||
onStop,
|
||||
prefillRef
|
||||
}: {
|
||||
streaming: boolean
|
||||
queuedCount: number
|
||||
disabled: boolean
|
||||
onSend: (text: string) => void
|
||||
onStop: () => void
|
||||
/** 供空状态建议词点击后填入 */
|
||||
prefillRef: RefObject<string | null>
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// 空状态建议词点击 → 填入输入框并聚焦
|
||||
useEffect(() => {
|
||||
if (prefillRef.current) {
|
||||
setValue(prefillRef.current)
|
||||
prefillRef.current = null
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
}, [prefillRef])
|
||||
|
||||
// 自适应高度
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 200)}px`
|
||||
}, [value])
|
||||
|
||||
const submit = (): void => {
|
||||
const text = value.trim()
|
||||
if (!text || disabled) return
|
||||
onSend(text)
|
||||
setValue('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-6 pb-5 pt-1">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/70 shadow-xl shadow-black/30 transition-colors focus-within:border-zinc-700">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
spellCheck={false}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? 'Agent 会话未就绪…' : '描述你的目标,或直接下达指令(Enter 发送,Shift+Enter 换行)'}
|
||||
className="block max-h-[200px] w-full bg-transparent px-4 pt-3.5 pb-1 text-[14px] leading-6 text-zinc-100 caret-emerald-400 outline-none placeholder:text-zinc-600 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-3 pb-2.5 pt-1">
|
||||
<div className="flex items-center gap-2 text-[11px] text-zinc-600">
|
||||
{streaming ? (
|
||||
queuedCount > 0 ? (
|
||||
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-zinc-400">队列中 {queuedCount} 条</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<CornerDownLeft size={11} />
|
||||
回复中,Enter 可排队追加
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<CornerDownLeft size={11} />
|
||||
Enter 发送 · Shift+Enter 换行
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{streaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-800 text-zinc-300 transition-colors hover:border-red-800 hover:bg-red-950/60 hover:text-red-300"
|
||||
title="停止生成"
|
||||
>
|
||||
<Square size={12} className="fill-current" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={disabled || !value.trim()}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-600 text-white transition-all hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-zinc-800 disabled:text-zinc-600"
|
||||
title="发送"
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 极简 Markdown 渲染(v0.1):
|
||||
* 支持 fenced code block、行内 code、**加粗** 与换行。
|
||||
* 不使用 dangerouslySetInnerHTML,全部构建 React 节点。
|
||||
* 完整 Markdown(标题/列表/链接/表格)排入 v0.5。
|
||||
*/
|
||||
|
||||
interface CodeBlock {
|
||||
lang: string
|
||||
code: string
|
||||
}
|
||||
|
||||
function splitFences(text: string): Array<{ type: 'text'; text: string } | { type: 'code'; block: CodeBlock }> {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'code'; block: CodeBlock }> = []
|
||||
const regex = /```(\w*)\n?([\s\S]*?)(?:```|$)/g
|
||||
let last = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(text))) {
|
||||
if (match.index > last) parts.push({ type: 'text', text: text.slice(last, match.index) })
|
||||
parts.push({ type: 'code', block: { lang: match[1] ?? '', code: match[2] ?? '' } })
|
||||
last = regex.lastIndex
|
||||
}
|
||||
if (last < text.length) parts.push({ type: 'text', text: text.slice(last) })
|
||||
return parts
|
||||
}
|
||||
|
||||
function renderInline(text: string, keyPrefix: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = []
|
||||
const regex = /(\*\*[^*]+\*\*|`[^`\n]+`)/g
|
||||
let last = 0
|
||||
let match: RegExpExecArray | null
|
||||
let i = 0
|
||||
while ((match = regex.exec(text))) {
|
||||
if (match.index > last) nodes.push(text.slice(last, match.index))
|
||||
const token = match[0]
|
||||
if (token.startsWith('**')) {
|
||||
nodes.push(
|
||||
<strong key={`${keyPrefix}-b${i}`} className="font-semibold text-zinc-50">
|
||||
{token.slice(2, -2)}
|
||||
</strong>
|
||||
)
|
||||
} else {
|
||||
nodes.push(
|
||||
<code key={`${keyPrefix}-c${i}`} className="rounded bg-zinc-800/80 px-1.5 py-0.5 font-mono text-[0.85em] text-emerald-300">
|
||||
{token.slice(1, -1)}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
last = regex.lastIndex
|
||||
i += 1
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last))
|
||||
return nodes
|
||||
}
|
||||
|
||||
export function Markdown({ text }: { text: string }): ReactNode {
|
||||
const parts = splitFences(text)
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{parts.map((part, i) => {
|
||||
if (part.type === 'code') {
|
||||
return (
|
||||
<div key={i} className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
{part.block.lang && (
|
||||
<div className="border-b border-zinc-800 px-3 py-1 font-mono text-[11px] text-zinc-500">{part.block.lang}</div>
|
||||
)}
|
||||
<pre className="overflow-x-auto p-3 font-mono text-[12.5px] leading-relaxed text-zinc-200">
|
||||
<code>{part.block.code.replace(/\n$/, '')}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const trimmed = part.text.replace(/^\n+|\n+$/g, '')
|
||||
if (!trimmed) return null
|
||||
return (
|
||||
<p key={i} className="whitespace-pre-wrap text-[14.5px] leading-7 text-zinc-200">
|
||||
{renderInline(trimmed, `p${i}`)}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { Brain, ChevronRight } from 'lucide-react'
|
||||
import type { ToolState, UiMessage } from '../hooks/useAgent'
|
||||
import { Markdown } from './Markdown'
|
||||
import { ToolCard, useEntrance } from './ToolCard'
|
||||
|
||||
interface ContentBlock {
|
||||
type?: string
|
||||
text?: string
|
||||
id?: string
|
||||
name?: string
|
||||
arguments?: unknown
|
||||
}
|
||||
|
||||
/** 防御性读取消息内容块 */
|
||||
function readBlocks(raw: unknown): ContentBlock[] {
|
||||
if (!raw || typeof raw !== 'object') return []
|
||||
const content = (raw as { content?: unknown }).content
|
||||
if (typeof content === 'string') return [{ type: 'text', text: content }]
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter((c): c is ContentBlock => typeof c === 'object' && c !== null)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean }): JSX.Element | null {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!text.trim()) return null
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[12px] text-zinc-500 transition-colors hover:bg-zinc-800/60 hover:text-zinc-400"
|
||||
>
|
||||
<Brain size={13} className={streaming ? 'text-emerald-400/80' : ''} />
|
||||
<span>{streaming ? '思考中…' : '思考过程'}</span>
|
||||
<ChevronRight size={12} className={`transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<pre className="mt-1 max-h-72 overflow-y-auto whitespace-pre-wrap rounded-lg border border-zinc-800/70 bg-zinc-900/40 p-3 font-mono text-[12px] leading-relaxed text-zinc-500">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantBody({
|
||||
message,
|
||||
tools,
|
||||
streaming
|
||||
}: {
|
||||
message: UiMessage
|
||||
tools: Record<string, ToolState>
|
||||
streaming: boolean
|
||||
}): JSX.Element {
|
||||
if (message.stream) {
|
||||
return (
|
||||
<div>
|
||||
<ThinkingBlock text={message.stream.thinking} streaming />
|
||||
{message.stream.text ? (
|
||||
<div className="text-[14.5px] leading-7 text-zinc-200">
|
||||
<Markdown text={message.stream.text} />
|
||||
{streaming && <span className="stream-caret" />}
|
||||
</div>
|
||||
) : streaming && !message.stream.thinking ? (
|
||||
<div className="flex items-center gap-1.5 py-1 text-zinc-500">
|
||||
<span className="thinking-dot h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
<span className="thinking-dot h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
<span className="thinking-dot h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const blocks = readBlocks(message.raw)
|
||||
return (
|
||||
<div>
|
||||
{blocks.map((block, i) => {
|
||||
if (block.type === 'thinking' && typeof block.text === 'string') {
|
||||
return <ThinkingBlock key={`t${i}`} text={block.text} streaming={false} />
|
||||
}
|
||||
if (block.type === 'text' && block.text) {
|
||||
return (
|
||||
<div key={`x${i}`} className="text-[14.5px] leading-7 text-zinc-200">
|
||||
<Markdown text={block.text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (block.type === 'toolCall' && block.id) {
|
||||
const tool = tools[block.id]
|
||||
if (tool) return <ToolCard key={`c${i}`} toolCallId={block.id} tool={tool} />
|
||||
return (
|
||||
<div key={`c${i}`} className="rounded-lg border border-zinc-800 bg-zinc-900/60 px-3 py-2 font-mono text-[12.5px] text-zinc-400">
|
||||
{block.name ?? 'tool'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageItem({
|
||||
message,
|
||||
tools,
|
||||
streaming
|
||||
}: {
|
||||
message: UiMessage
|
||||
tools: Record<string, ToolState>
|
||||
streaming: boolean
|
||||
}): JSX.Element | null {
|
||||
const ref = useEntrance<HTMLDivElement>()
|
||||
|
||||
if (message.role === 'user') {
|
||||
const blocks = readBlocks(message.raw)
|
||||
const text = blocks.map((b) => b.text ?? '').join('') || ''
|
||||
return (
|
||||
<div ref={ref} className="flex justify-end">
|
||||
<div className="max-w-[78%] whitespace-pre-wrap rounded-2xl rounded-br-md bg-emerald-950/50 px-4 py-2.5 text-[14.5px] leading-7 text-emerald-50 ring-1 ring-emerald-900/60">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
return (
|
||||
<div ref={ref} className="max-w-full">
|
||||
<AssistantBody message={message} tools={tools} streaming={streaming} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 其他角色(custom / notification 等)暂不渲染
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { Cpu, MessageSquarePlus, Store } from 'lucide-react'
|
||||
import type { SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
interface VersionInfo {
|
||||
app: string
|
||||
electron: string
|
||||
node: string
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
if (cost >= 1) return `$${cost.toFixed(2)}`
|
||||
if (cost > 0) return `$${cost.toFixed(4)}`
|
||||
return '$0'
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
sessionState,
|
||||
stats,
|
||||
connected
|
||||
}: {
|
||||
sessionState: SessionState | null
|
||||
stats: SessionStatsInfo | null
|
||||
connected: boolean
|
||||
}): JSX.Element {
|
||||
const [versions, setVersions] = useState<VersionInfo | null>(null)
|
||||
const entranceRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void window.api.versions().then(setVersions).catch(() => setVersions(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = entranceRef.current
|
||||
if (!el) return
|
||||
let tween: { kill(): void } | null = null
|
||||
let cancelled = false
|
||||
void import('gsap').then((gsapModule) => {
|
||||
if (cancelled) return
|
||||
tween = gsapModule.gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, x: -12 },
|
||||
{ opacity: 1, x: 0, duration: 0.35, ease: 'power2.out' }
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
tween?.kill()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<aside ref={entranceRef} className="flex w-[260px] shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-950">
|
||||
{/* 品牌 */}
|
||||
<div className="flex items-center gap-2.5 px-4 pb-4 pt-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-950/60 ring-1 ring-emerald-900/60">
|
||||
<Store size={16} className="text-emerald-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-medium text-zinc-100">店铺运营 Agent</div>
|
||||
<div className="text-[11px] text-zinc-600">powered by pi</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新会话(v0.2 提供) */}
|
||||
<div className="px-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="会话管理将在 v0.2 提供"
|
||||
className="flex w-full items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-[13px] text-zinc-400 transition-colors hover:border-zinc-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<MessageSquarePlus size={15} />
|
||||
新会话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 会话列表占位 */}
|
||||
<div className="mt-4 flex-1 overflow-y-auto px-3">
|
||||
<div className="px-2 pb-2 text-[11px] font-medium uppercase tracking-wider text-zinc-600">会话</div>
|
||||
<div className="rounded-xl border border-dashed border-zinc-800/80 px-3 py-4 text-center text-[12px] leading-5 text-zinc-600">
|
||||
会话列表与历史恢复
|
||||
<br />
|
||||
<span className="text-zinc-700">v0.2 上线</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部状态 */}
|
||||
<div className="border-t border-zinc-800/80 px-4 py-3.5">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
title={sessionState?.model ? `${sessionState.model.provider}/${sessionState.model.id}` : '未配置模型'}
|
||||
>
|
||||
<Cpu size={13} className="shrink-0 text-zinc-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] text-zinc-300">
|
||||
{sessionState?.model?.name ?? (connected ? '模型加载中…' : '未连接')}
|
||||
</div>
|
||||
<div className="text-[10.5px] text-zinc-600">{sessionState?.model ? `${sessionState.model.provider}` : '—'}</div>
|
||||
</div>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${connected ? 'bg-emerald-400' : 'bg-zinc-600'}`} />
|
||||
</div>
|
||||
{stats && (
|
||||
<div className="mt-2.5 flex items-center justify-between text-[10.5px] text-zinc-600">
|
||||
<span>上下文 {stats.contextPercent !== undefined ? `${stats.contextPercent}%` : '—'}</span>
|
||||
<span>{formatTokens(stats.tokens.total)} tokens</span>
|
||||
<span>{formatCost(stats.cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{versions && (
|
||||
<div className="mt-2 text-[10px] text-zinc-700">
|
||||
v{versions.app} · Electron {versions.electron} · Node {versions.node}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX, RefObject } from 'react'
|
||||
import { Check, ChevronDown, Loader2, X } from 'lucide-react'
|
||||
import type { ToolState } from '../hooks/useAgent'
|
||||
|
||||
/** 从工具结果中提取可读文本(防御性读取,结构未知时回退 JSON) */
|
||||
function extractResultText(result: unknown): string {
|
||||
if (result == null) return ''
|
||||
if (typeof result === 'string') return result
|
||||
if (typeof result === 'object') {
|
||||
const r = result as Record<string, unknown>
|
||||
if (Array.isArray(r.content)) {
|
||||
const texts = r.content
|
||||
.filter((c): c is { type: string; text?: string } => typeof c === 'object' && c !== null)
|
||||
.map((c) => (c.type === 'text' && typeof c.text === 'string' ? c.text : ''))
|
||||
.filter(Boolean)
|
||||
if (texts.length) return texts.join('\n')
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(result, null, 2)
|
||||
} catch {
|
||||
return String(result)
|
||||
}
|
||||
}
|
||||
|
||||
function pretty(value: unknown): string {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'string') {
|
||||
// 尝试美化 JSON 字符串参数
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(text: string, max = 4000): string {
|
||||
if (text.length <= max) return text
|
||||
return `${text.slice(0, max)}\n…(已截断,共 ${text.length} 字符)`
|
||||
}
|
||||
|
||||
export function ToolCard({ toolCallId, tool }: { toolCallId: string; tool: ToolState }): JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const resultText = extractResultText(tool.result)
|
||||
const argsText = pretty(tool.args)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-zinc-800/50"
|
||||
>
|
||||
{tool.status === 'running' ? (
|
||||
<Loader2 size={14} className="shrink-0 animate-spin text-emerald-400" />
|
||||
) : tool.status === 'error' ? (
|
||||
<X size={14} className="shrink-0 text-red-400" />
|
||||
) : (
|
||||
<Check size={14} className="shrink-0 text-emerald-400" />
|
||||
)}
|
||||
<span className="font-mono text-[12.5px] text-zinc-300">{tool.toolName}</span>
|
||||
{tool.status === 'running' && <span className="text-[11px] text-zinc-500">执行中…</span>}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`ml-auto shrink-0 text-zinc-500 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="space-y-2 border-t border-zinc-800/80 px-3 py-2">
|
||||
{argsText && (
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-medium text-zinc-500">参数</div>
|
||||
<pre className="overflow-x-auto rounded bg-zinc-950/60 p-2 font-mono text-[11.5px] leading-relaxed text-zinc-400">
|
||||
{truncate(argsText, 1500)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{(resultText || tool.status !== 'running') && (
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-medium text-zinc-500">
|
||||
{tool.status === 'error' ? '错误' : '结果'} · {toolCallId.slice(0, 8)}
|
||||
</div>
|
||||
<pre
|
||||
className={`overflow-x-auto rounded bg-zinc-950/60 p-2 font-mono text-[11.5px] leading-relaxed ${
|
||||
tool.status === 'error' ? 'text-red-300/90' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{truncate(resultText)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
|
||||
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
|
||||
const ref = useRef<T | null>(null)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
let tween: { kill(): void } | null = null
|
||||
let cancelled = false
|
||||
void import('gsap').then((gsapModule) => {
|
||||
if (cancelled) return
|
||||
tween = gsapModule.gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, y: 10 },
|
||||
{ opacity: 1, y: 0, duration: 0.28, ease: 'power2.out', clearProps: 'transform' }
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
tween?.kill()
|
||||
}
|
||||
}, [])
|
||||
return ref
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
|
||||
import type { AgentUiEvent, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
export interface ToolState {
|
||||
toolName: string
|
||||
args?: unknown
|
||||
partialResult?: unknown
|
||||
result?: unknown
|
||||
status: 'running' | 'done' | 'error'
|
||||
}
|
||||
|
||||
export interface UiMessage {
|
||||
key: string
|
||||
role: string
|
||||
/** 已完成的消息原文(AgentMessage,防御性读取) */
|
||||
raw?: unknown
|
||||
/** 流式中的增量缓冲 */
|
||||
stream?: { text: string; thinking: string }
|
||||
/** 本地乐观插入(发送即显示,等待 message_end 回填) */
|
||||
local?: boolean
|
||||
}
|
||||
|
||||
interface AgentUiState {
|
||||
messages: UiMessage[]
|
||||
tools: Record<string, ToolState>
|
||||
streaming: boolean
|
||||
queue: { steering: string[]; followUp: string[] }
|
||||
error: string | null
|
||||
sessionError: string | null
|
||||
sessionState: SessionState | null
|
||||
stats: SessionStatsInfo | null
|
||||
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: 'event'; event: AgentUiEvent }
|
||||
| { type: 'optimistic-user'; key: string; text: string }
|
||||
| { type: 'clear-error' }
|
||||
|
||||
const initialState: AgentUiState = {
|
||||
messages: [],
|
||||
tools: {},
|
||||
streaming: false,
|
||||
queue: { steering: [], followUp: [] },
|
||||
error: null,
|
||||
sessionError: null,
|
||||
sessionState: null,
|
||||
stats: null,
|
||||
retry: null,
|
||||
ready: false
|
||||
}
|
||||
|
||||
let keyCounter = 0
|
||||
function nextKey(prefix: string): string {
|
||||
keyCounter += 1
|
||||
return `${prefix}-${Date.now().toString(36)}-${keyCounter}`
|
||||
}
|
||||
|
||||
function readRole(message: unknown): string {
|
||||
if (message && typeof message === 'object' && 'role' in message) {
|
||||
return String((message as { role: unknown }).role)
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
if (action.type === 'optimistic-user') {
|
||||
return {
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{ key: action.key, role: 'user', local: true, raw: { role: 'user', content: action.text } }
|
||||
]
|
||||
}
|
||||
}
|
||||
if (action.type === 'clear-error') {
|
||||
return { ...state, error: null }
|
||||
}
|
||||
|
||||
const event = action.event
|
||||
switch (event.type) {
|
||||
case 'session_ready':
|
||||
return { ...state, ready: true, sessionState: event.state }
|
||||
case 'session_error':
|
||||
return { ...state, sessionError: event.message }
|
||||
case 'agent_start':
|
||||
return { ...state, streaming: true, error: null, retry: null }
|
||||
case 'agent_end':
|
||||
return { ...state, streaming: false }
|
||||
case 'agent_settled':
|
||||
return { ...state, streaming: false, retry: null }
|
||||
case 'message_start': {
|
||||
// 助手消息开流式占位;用户消息在发送时已乐观插入,此处不重复
|
||||
if (event.role === 'assistant') {
|
||||
return {
|
||||
...state,
|
||||
messages: [...state.messages, { key: nextKey('a'), role: 'assistant', stream: { text: '', thinking: '' } }]
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
case 'delta': {
|
||||
const messages = [...state.messages]
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (m.stream) {
|
||||
const stream = { ...m.stream }
|
||||
if (event.kind === 'text') stream.text += event.delta
|
||||
else stream.thinking += event.delta
|
||||
messages[i] = { ...m, stream }
|
||||
break
|
||||
}
|
||||
}
|
||||
return { ...state, messages }
|
||||
}
|
||||
case 'message_end': {
|
||||
const role = readRole(event.message)
|
||||
const messages = [...state.messages]
|
||||
// 回填最近的流式占位(assistant)
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].stream) {
|
||||
messages[i] = { key: messages[i].key, role, raw: event.message }
|
||||
return { ...state, messages }
|
||||
}
|
||||
}
|
||||
if (role === 'user') {
|
||||
// 若最后一条是本地乐观插入的用户消息,用服务端版本回填
|
||||
const last = messages[messages.length - 1]
|
||||
if (last?.local && last.role === 'user') {
|
||||
messages[messages.length - 1] = { key: last.key, role: 'user', raw: event.message }
|
||||
return { ...state, messages }
|
||||
}
|
||||
return state
|
||||
}
|
||||
if (role === 'toolResult') {
|
||||
// 工具结果已在 ToolCard 中通过 tool_end 展示,跳过独立消息
|
||||
return state
|
||||
}
|
||||
if (role === 'assistant') {
|
||||
messages.push({ key: nextKey('a'), role: 'assistant', raw: event.message })
|
||||
return { ...state, messages }
|
||||
}
|
||||
return state
|
||||
}
|
||||
case 'tool_start': {
|
||||
const tools = { ...state.tools }
|
||||
tools[event.toolCallId] = { toolName: event.toolName, args: event.args, status: 'running' }
|
||||
return { ...state, tools }
|
||||
}
|
||||
case 'tool_update': {
|
||||
const tools = { ...state.tools }
|
||||
const prev = tools[event.toolCallId]
|
||||
tools[event.toolCallId] = { ...(prev ?? { toolName: event.toolName, status: 'running' as const }), partialResult: event.partialResult }
|
||||
return { ...state, tools }
|
||||
}
|
||||
case 'tool_end': {
|
||||
const tools = { ...state.tools }
|
||||
const prev = tools[event.toolCallId]
|
||||
tools[event.toolCallId] = {
|
||||
...(prev ?? { toolName: event.toolName }),
|
||||
result: event.result,
|
||||
status: event.isError ? 'error' : 'done'
|
||||
}
|
||||
return { ...state, tools }
|
||||
}
|
||||
case 'queue_update':
|
||||
return { ...state, queue: { steering: event.steering, followUp: event.followUp } }
|
||||
case 'retry':
|
||||
return { ...state, retry: { attempt: event.attempt, maxAttempts: event.maxAttempts, errorMessage: event.errorMessage }, streaming: true }
|
||||
case 'error':
|
||||
return { ...state, error: event.message }
|
||||
case 'session_info':
|
||||
return state.sessionState
|
||||
? { ...state, sessionState: { ...state.sessionState, sessionName: event.name } }
|
||||
: state
|
||||
case 'stats':
|
||||
return { ...state, stats: event.stats }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseAgent {
|
||||
state: AgentUiState
|
||||
send: (text: string) => void
|
||||
stop: () => void
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export function useAgent(): UseAgent {
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.api.onEvent((event) => {
|
||||
dispatch({ type: 'event', event })
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
const send = useCallback((text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
dispatch({ type: 'optimistic-user', key: nextKey('u'), text: trimmed })
|
||||
window.api.send(trimmed).catch((err: unknown) => {
|
||||
dispatch({
|
||||
type: 'event',
|
||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
window.api.stop().catch((err: unknown) => {
|
||||
dispatch({
|
||||
type: 'event',
|
||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback(() => dispatch({ type: 'clear-error' }), [])
|
||||
|
||||
return useMemo(() => ({ state, send, stop, clearError }), [state, send, stop, clearError])
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
export type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: {
|
||||
send: (text: string) => Promise<void>
|
||||
steer: (text: string) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
getState: () => Promise<SessionState>
|
||||
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }>
|
||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const api = window.api
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/globals.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
--font-mono: ui-monospace, "Cascadia Code", Consolas, "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(113, 113, 122, 0.35) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(113, 113, 122, 0.35);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(113, 113, 122, 0.55);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
/* 流式输出光标 */
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
61%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stream-caret {
|
||||
display: inline-block;
|
||||
width: 0.5em;
|
||||
height: 1.05em;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
background: rgb(16 185 129);
|
||||
border-radius: 1px;
|
||||
animation: caret-blink 0.9s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* 思考中指示 */
|
||||
@keyframes thinking-dot {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-dot {
|
||||
animation: thinking-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.thinking-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.thinking-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
/* 禁用 textarea 的默认 resize 与滚动条闪烁 */
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 主进程 <-> 渲染进程 的 IPC 契约类型(两端共用,不引入 Node 依赖)。
|
||||
*/
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string
|
||||
provider: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
sessionId: string
|
||||
sessionName?: string
|
||||
model?: ModelInfo
|
||||
streaming: boolean
|
||||
activeTools: string[]
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export interface SessionStatsInfo {
|
||||
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }
|
||||
cost: number
|
||||
/** 上下文占用百分比 0-100,取不到时省略 */
|
||||
contextPercent?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发到渲染进程的 agent 事件。
|
||||
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
|
||||
*/
|
||||
export type AgentUiEvent =
|
||||
| { type: 'session_ready'; state: SessionState }
|
||||
| { type: 'session_error'; message: string }
|
||||
| { type: 'agent_start' }
|
||||
| { type: 'agent_end' }
|
||||
| { type: 'agent_settled' }
|
||||
| { type: 'message_start'; role: string }
|
||||
| { type: 'delta'; kind: 'text' | 'thinking'; delta: string }
|
||||
| { type: 'message_end'; message: unknown }
|
||||
| { type: 'tool_start'; toolCallId: string; toolName: string; args: unknown }
|
||||
| { type: 'tool_update'; toolCallId: string; toolName: string; partialResult: unknown }
|
||||
| { type: 'tool_end'; toolCallId: string; toolName: string; result: unknown; isError: boolean }
|
||||
| { type: 'queue_update'; steering: string[]; followUp: string[] }
|
||||
| { type: 'retry'; attempt: number; maxAttempts: number; errorMessage: string }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'session_info'; name?: string }
|
||||
| { type: 'stats'; stats: SessionStatsInfo }
|
||||
Reference in New Issue
Block a user