feat: bootstrap secure cross-platform assistant

Establish the Electron foundation and pluggable agent runtime so desktop workflows can evolve safely across supported platforms.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-29 22:42:30 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 1b6178b841
30 changed files with 12017 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest'
import { BigtokenAgentRuntime } from './bigtoken-runtime'
function createEventStream(text: string): string {
return [
'event: message_start',
'data: {"type":"message_start","message":{"id":"message-1"}}',
'',
'event: content_block_delta',
`data: ${JSON.stringify({
type: 'content_block_delta',
delta: { type: 'text_delta', text }
})}`,
'',
'event: message_stop',
'data: {"type":"message_stop"}',
'',
''
].join('\n')
}
describe('BigtokenAgentRuntime', () => {
it('uses the Anthropic messages endpoint and streams text deltas', async () => {
const fetcher = vi.fn<typeof fetch>(async () => {
return new Response(createEventStream('真实模型回答'), {
status: 200,
headers: { 'content-type': 'text/event-stream' }
})
})
const runtime = new BigtokenAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
fetcher
})
const events = []
for await (const event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed125',
conversationId: 'conversation-1',
prompt: '你好'
},
new AbortController().signal
)) {
events.push(event)
}
expect(fetcher).toHaveBeenCalledOnce()
const [input, init] = fetcher.mock.calls[0] ?? []
expect(input?.toString()).toBe('https://bigtoken.ai/v1/messages')
expect(init?.method).toBe('POST')
const body = JSON.parse(init?.body as string) as {
model: string
stream: boolean
}
expect(body).toMatchObject({
model: 'sonnet-5',
stream: true
})
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: '真实模型回答'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
})
+218
View File
@@ -0,0 +1,218 @@
import type {
AgentEvent,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
import type { AgentRuntime } from './runtime'
type ConversationMessage = {
role: 'user' | 'assistant'
content: string
}
export type BigtokenRuntimeOptions = {
apiKey: string
baseUrl: string
model: string
fetcher?: typeof fetch
}
function getErrorMessage(value: unknown): string | undefined {
if (!value || typeof value !== 'object') {
return undefined
}
const error = 'error' in value ? value.error : undefined
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string'
) {
return error.message
}
return undefined
}
function getTextDelta(value: unknown): string | undefined {
if (
!value ||
typeof value !== 'object' ||
!('type' in value) ||
value.type !== 'content_block_delta' ||
!('delta' in value) ||
!value.delta ||
typeof value.delta !== 'object'
) {
return undefined
}
if (
'type' in value.delta &&
value.delta.type === 'text_delta' &&
'text' in value.delta &&
typeof value.delta.text === 'string'
) {
return value.delta.text
}
return undefined
}
export class BigtokenAgentRuntime implements AgentRuntime {
private readonly conversations = new Map<string, ConversationMessage[]>()
private readonly fetcher: typeof fetch
constructor(private readonly options: BigtokenRuntimeOptions) {
this.fetcher = options.fetcher ?? fetch
}
async getStatus(): Promise<AgentRuntimeStatus> {
return {
id: 'bigtoken',
label: this.options.model,
available: Boolean(this.options.apiKey),
detail: `Bigtoken Anthropic API · ${this.options.baseUrl}`
}
}
private getMessages(request: AgentRequest): ConversationMessage[] {
const history = this.conversations.get(request.conversationId) ?? []
return [
...history.slice(-20),
{
role: 'user',
content: request.prompt
} satisfies ConversationMessage
]
}
async *run(
request: AgentRequest,
signal: AbortSignal
): AsyncGenerator<AgentEvent, void, void> {
yield {
requestId: request.requestId,
type: 'status',
message: `${this.options.model} 正在思考`
}
const messages = this.getMessages(request)
const response = await this.fetcher(
new URL('/v1/messages', this.options.baseUrl),
{
method: 'POST',
headers: {
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
'x-api-key': this.options.apiKey
},
body: JSON.stringify({
model: this.options.model,
max_tokens: 4096,
stream: true,
system:
'You are GoodBuddy, a secure desktop assistant. Answer clearly in the language used by the user. Never claim to have used desktop tools unless a tool result was provided.',
messages
}),
signal
}
)
if (!response.ok) {
let detail: string | undefined
try {
detail = getErrorMessage(await response.json())
} catch {
detail = undefined
}
throw new Error(
detail ?? `Bigtoken 请求失败(HTTP ${response.status}`
)
}
if (!response.body) {
throw new Error('Bigtoken 未返回流式响应')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let answer = ''
let completed = false
while (!completed) {
const { done, value } = await reader.read()
buffer += decoder.decode(value, { stream: !done }).replaceAll(
'\r\n',
'\n'
)
const blocks = buffer.split('\n\n')
buffer = blocks.pop() ?? ''
for (const block of blocks) {
const data = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
if (!data || data === '[DONE]') {
continue
}
let event: unknown
try {
event = JSON.parse(data)
} catch {
continue
}
const error = getErrorMessage(event)
if (error) {
throw new Error(error)
}
const delta = getTextDelta(event)
if (delta) {
answer += delta
yield {
requestId: request.requestId,
type: 'text',
delta
}
}
if (
event &&
typeof event === 'object' &&
'type' in event &&
event.type === 'message_stop'
) {
completed = true
break
}
}
if (done) {
completed = true
}
}
if (!answer) {
throw new Error('Bigtoken 返回了空内容')
}
this.conversations.set(request.conversationId, [
...messages,
{ role: 'assistant', content: answer }
])
yield {
requestId: request.requestId,
type: 'done'
}
}
async dispose(): Promise<void> {
this.conversations.clear()
}
}
+29
View File
@@ -0,0 +1,29 @@
import { BigtokenAgentRuntime } from './bigtoken-runtime'
import { DemoAgentRuntime } from './demo-runtime'
import { OpenCodeRuntime } from './opencode-runtime'
import type { AgentRuntime } from './runtime'
export function createAgentRuntime(defaultWorkspace: string): AgentRuntime {
const baseUrl = process.env.GOODBUDDY_OPENCODE_URL
const embedded = process.env.GOODBUDDY_OPENCODE_EMBEDDED === 'true'
if (baseUrl || embedded) {
return new OpenCodeRuntime({
baseUrl,
embedded,
defaultWorkspace
})
}
const bigtokenApiKey = process.env.GOODBUDDY_BIGTOKEN_API_KEY?.trim()
if (bigtokenApiKey) {
return new BigtokenAgentRuntime({
apiKey: bigtokenApiKey,
baseUrl:
process.env.GOODBUDDY_BIGTOKEN_BASE_URL ?? 'https://bigtoken.ai',
model: process.env.GOODBUDDY_BIGTOKEN_MODEL ?? 'sonnet-5'
})
}
return new DemoAgentRuntime()
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { DemoAgentRuntime } from './demo-runtime'
describe('DemoAgentRuntime', () => {
it('streams a complete response with the original prompt', async () => {
const runtime = new DemoAgentRuntime()
const events = []
for await (const event of runtime.run(
{
requestId: '95dd315d-9616-43b4-8929-e84643d063c4',
conversationId: 'conversation-1',
prompt: '测试问题'
},
new AbortController().signal
)) {
events.push(event)
}
const content = events
.filter((event) => event.type === 'text')
.map((event) => (event.type === 'text' ? event.delta : ''))
.join('')
expect(events[0]).toMatchObject({ type: 'status' })
expect(content).toContain('测试问题')
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
})
+72
View File
@@ -0,0 +1,72 @@
import type {
AgentEvent,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
import type { AgentRuntime } from './runtime'
function wait(milliseconds: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason)
return
}
function onAbort(): void {
clearTimeout(timeout)
reject(signal.reason)
}
const timeout = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, milliseconds)
signal.addEventListener('abort', onAbort, { once: true })
})
}
export class DemoAgentRuntime implements AgentRuntime {
async getStatus(): Promise<AgentRuntimeStatus> {
return {
id: 'demo',
label: '演示模式',
available: true,
detail: '配置 OpenCode 后将启用文件、搜索和受控工具能力'
}
}
async *run(
request: AgentRequest,
signal: AbortSignal
): AsyncGenerator<AgentEvent, void, void> {
yield {
requestId: request.requestId,
type: 'status',
message: '正在准备回答'
}
const response = [
'GoodBuddy 的桌面外壳已经运行。',
'',
`你刚才输入了:“${request.prompt.slice(0, 160)}${request.prompt.length > 160 ? '…' : ''}`,
'',
'当前使用演示运行时。设置 `GOODBUDDY_OPENCODE_URL` 连接已有 OpenCode Server',
'或设置 `GOODBUDDY_OPENCODE_EMBEDDED=true` 由 GoodBuddy 启动本机 OpenCode。'
].join('\n')
for (const chunk of response.match(/.{1,12}/gs) ?? []) {
await wait(16, signal)
yield {
requestId: request.requestId,
type: 'text',
delta: chunk
}
}
yield {
requestId: request.requestId,
type: 'done'
}
}
async dispose(): Promise<void> {}
}
+210
View File
@@ -0,0 +1,210 @@
import {
createOpencodeClient,
createOpencodeServer,
type OpencodeClient
} from '@opencode-ai/sdk'
import type {
AgentEvent,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
import type { AgentRuntime } from './runtime'
type OpenCodeServer = Awaited<ReturnType<typeof createOpencodeServer>>
export type OpenCodeRuntimeOptions = {
baseUrl?: string
embedded: boolean
defaultWorkspace: string
}
export class OpenCodeRuntime implements AgentRuntime {
private client?: OpencodeClient
private server?: OpenCodeServer
private readonly sessions = new Map<string, string>()
constructor(private readonly options: OpenCodeRuntimeOptions) {}
private async getClient(): Promise<OpencodeClient> {
if (this.client) {
return this.client
}
let baseUrl = this.options.baseUrl
if (!baseUrl && this.options.embedded) {
this.server = await createOpencodeServer({
hostname: '127.0.0.1',
port: 0,
timeout: 10_000
})
baseUrl = this.server.url
}
if (!baseUrl) {
throw new Error('未配置 OpenCode Server')
}
this.client = createOpencodeClient({
baseUrl,
directory: this.options.defaultWorkspace
})
return this.client
}
async getStatus(): Promise<AgentRuntimeStatus> {
try {
const client = await this.getClient()
const response = await client.session.list({
query: { directory: this.options.defaultWorkspace }
})
if (response.error) {
throw new Error('OpenCode Server 返回错误')
}
return {
id: 'opencode',
label: 'OpenCode',
available: true,
detail: this.server
? '由 GoodBuddy 管理本机 OpenCode 进程'
: `已连接 ${this.options.baseUrl}`
}
} catch (error) {
return {
id: 'opencode',
label: 'OpenCode',
available: false,
detail: error instanceof Error ? error.message : 'OpenCode 不可用'
}
}
}
private async getSessionId(
client: OpencodeClient,
request: AgentRequest,
directory: string
): Promise<string> {
const current = this.sessions.get(request.conversationId)
if (current) {
return current
}
const response = await client.session.create({
body: { title: 'GoodBuddy 对话' },
query: { directory }
})
if (!response.data) {
throw new Error('OpenCode 会话创建失败')
}
this.sessions.set(request.conversationId, response.data.id)
return response.data.id
}
async *run(
request: AgentRequest,
signal: AbortSignal
): AsyncGenerator<AgentEvent, void, void> {
const client = await this.getClient()
const directory = request.workspace ?? this.options.defaultWorkspace
const sessionId = await this.getSessionId(client, request, directory)
yield {
requestId: request.requestId,
type: 'status',
message: 'OpenCode 正在处理请求'
}
const subscription = await client.event.subscribe({
query: { directory },
signal
})
const abortSession = (): void => {
void client.session.abort({
path: { id: sessionId },
query: { directory }
})
}
signal.addEventListener('abort', abortSession, { once: true })
try {
const prompt = client.session.promptAsync({
body: {
parts: [{ type: 'text', text: request.prompt }]
},
path: { id: sessionId },
query: { directory },
signal
})
for await (const event of subscription.stream) {
if (
event.type === 'message.part.updated' &&
event.properties.part.sessionID === sessionId
) {
const { part, delta } = event.properties
if (part.type === 'text' && delta) {
yield {
requestId: request.requestId,
type: 'text',
delta
}
} else if (part.type === 'tool') {
const state =
part.state.status === 'error' ? 'failed' : part.state.status
yield {
requestId: request.requestId,
type: 'tool',
name: part.tool,
state,
summary: `OpenCode 工具:${part.tool}`
}
}
}
if (
event.type === 'session.error' &&
event.properties.sessionID === sessionId
) {
const error = event.properties.error
const message =
error &&
typeof error.data === 'object' &&
error.data &&
'message' in error.data &&
typeof error.data.message === 'string'
? error.data.message
: 'OpenCode 执行失败'
throw new Error(message)
}
if (
event.type === 'session.idle' &&
event.properties.sessionID === sessionId
) {
await prompt
yield {
requestId: request.requestId,
type: 'done',
sessionId
}
return
}
}
await prompt
throw new Error('OpenCode 事件流意外结束')
} finally {
signal.removeEventListener('abort', abortSession)
}
}
async dispose(): Promise<void> {
this.server?.close()
this.server = undefined
this.client = undefined
}
}
+14
View File
@@ -0,0 +1,14 @@
import type {
AgentEvent,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
export interface AgentRuntime {
getStatus(): Promise<AgentRuntimeStatus>
run(
request: AgentRequest,
signal: AbortSignal
): AsyncGenerator<AgentEvent, void, void>
dispose(): Promise<void>
}
+127
View File
@@ -0,0 +1,127 @@
import {
app,
BrowserWindow,
globalShortcut,
Menu,
nativeImage,
session,
Tray
} from 'electron'
import { homedir } from 'node:os'
import { createAgentRuntime } from './agent/create-runtime'
import { registerIpcHandlers } from './ipc'
import { createMainWindow, showWindow, toggleWindow } from './window'
const shortcut = 'CommandOrControl+Shift+Space'
const hasSingleInstanceLock = app.requestSingleInstanceLock()
if (!hasSingleInstanceLock) {
app.quit()
}
let mainWindow: BrowserWindow | undefined
let tray: Tray | undefined
let isQuitting = false
let removeIpcHandlers: (() => void) | undefined
const runtime = createAgentRuntime(
process.env.GOODBUDDY_WORKSPACE ?? homedir()
)
function createTrayIcon(): Electron.NativeImage {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">',
'<rect width="32" height="32" rx="10" fill="#18392b"/>',
'<path d="M9 10.5h14v9a5 5 0 0 1-5 5h-4a5 5 0 0 1-5-5z" fill="#f3bb60"/>',
'<circle cx="13" cy="16" r="1.5" fill="#18392b"/>',
'<circle cx="19" cy="16" r="1.5" fill="#18392b"/>',
'<path d="M13 20h6" stroke="#18392b" stroke-width="1.8" stroke-linecap="round"/>',
'</svg>'
].join('')
const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
return nativeImage.createFromDataURL(dataUrl)
}
function buildTray(): Tray {
const nextTray = new Tray(createTrayIcon())
nextTray.setToolTip('GoodBuddy')
nextTray.setContextMenu(
Menu.buildFromTemplate([
{
label: '打开 GoodBuddy',
click: () => mainWindow && showWindow(mainWindow)
},
{
label: '新建对话',
click: () => {
if (mainWindow) {
showWindow(mainWindow)
mainWindow.webContents.send('conversation:new')
}
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
}
}
])
)
nextTray.on('click', () => {
if (mainWindow) {
toggleWindow(mainWindow)
}
})
return nextTray
}
if (hasSingleInstanceLock) {
app.on('second-instance', () => {
if (mainWindow) {
showWindow(mainWindow)
}
})
void app.whenReady().then(() => {
app.setAppUserModelId('live.digiman.goodbuddy')
session.defaultSession.setPermissionRequestHandler(
(_webContents, _permission, callback) => callback(false)
)
session.defaultSession.setPermissionCheckHandler(() => false)
mainWindow = createMainWindow(() => isQuitting)
tray = buildTray()
const shortcutRegistered = globalShortcut.register(shortcut, () => {
if (mainWindow) {
toggleWindow(mainWindow)
}
})
removeIpcHandlers = registerIpcHandlers(
mainWindow,
runtime,
shortcutRegistered ? shortcut : '未注册'
)
app.on('activate', () => {
if (mainWindow) {
showWindow(mainWindow)
}
})
})
}
app.on('before-quit', () => {
isQuitting = true
})
app.on('will-quit', () => {
removeIpcHandlers?.()
globalShortcut.unregisterAll()
tray?.destroy()
void runtime.dispose()
})
+116
View File
@@ -0,0 +1,116 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { z } from 'zod'
import {
agentRequestSchema,
type AgentEvent,
type AppInfo
} from '../shared/contracts'
import { ipcChannels } from '../shared/ipc-channels'
import type { AgentRuntime } from './agent/runtime'
import { showWindow } from './window'
const requestIdSchema = z.string().uuid()
function assertTrustedSender(event: Electron.IpcMainInvokeEvent, window: BrowserWindow): void {
if (event.sender !== window.webContents) {
throw new Error('拒绝来自未知窗口的 IPC 请求')
}
}
export function registerIpcHandlers(
window: BrowserWindow,
runtime: AgentRuntime,
shortcut: string
): () => void {
const activeRequests = new Map<string, AbortController>()
const channels = Object.values(ipcChannels).filter(
(channel) =>
channel !== ipcChannels.agentEvent &&
channel !== ipcChannels.conversationNew
)
for (const channel of channels) {
ipcMain.removeHandler(channel)
}
ipcMain.handle(ipcChannels.appInfo, (event): AppInfo => {
assertTrustedSender(event, window)
return {
name: app.getName(),
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
shortcut
}
})
ipcMain.handle(ipcChannels.appShow, (event) => {
assertTrustedSender(event, window)
showWindow(window)
})
ipcMain.handle(ipcChannels.appHide, (event) => {
assertTrustedSender(event, window)
window.hide()
})
ipcMain.handle(ipcChannels.agentStatus, (event) => {
assertTrustedSender(event, window)
return runtime.getStatus()
})
ipcMain.handle(ipcChannels.agentRun, (event, input: unknown) => {
assertTrustedSender(event, window)
const request = agentRequestSchema.parse(input)
if (activeRequests.has(request.requestId)) {
throw new Error('请求正在执行')
}
const controller = new AbortController()
activeRequests.set(request.requestId, controller)
void (async () => {
try {
for await (const agentEvent of runtime.run(
request,
controller.signal
)) {
if (!window.isDestroyed()) {
window.webContents.send(ipcChannels.agentEvent, agentEvent)
}
}
} catch (error) {
if (!window.isDestroyed()) {
const agentEvent: AgentEvent = {
requestId: request.requestId,
type: 'error',
message: controller.signal.aborted
? '请求已取消'
: error instanceof Error
? error.message
: 'Agent Runtime 执行失败'
}
window.webContents.send(ipcChannels.agentEvent, agentEvent)
}
} finally {
activeRequests.delete(request.requestId)
}
})()
})
ipcMain.handle(ipcChannels.agentCancel, (event, input: unknown) => {
assertTrustedSender(event, window)
const requestId = requestIdSchema.parse(input)
activeRequests.get(requestId)?.abort(new Error('用户取消了请求'))
})
return () => {
for (const controller of activeRequests.values()) {
controller.abort(new Error('应用正在退出'))
}
activeRequests.clear()
for (const channel of channels) {
ipcMain.removeHandler(channel)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import { BrowserWindow, shell } from 'electron'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDirectory = dirname(fileURLToPath(import.meta.url))
function isAllowedExternalUrl(url: string): boolean {
try {
return new URL(url).protocol === 'https:'
} catch {
return false
}
}
export function createMainWindow(shouldQuit: () => boolean): BrowserWindow {
const window = new BrowserWindow({
width: 1180,
height: 760,
minWidth: 920,
minHeight: 620,
show: false,
backgroundColor: '#f4f1ea',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
webPreferences: {
preload: join(currentDirectory, '../preload/index.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
window.once('ready-to-show', () => {
window.show()
})
window.on('close', (event) => {
if (!shouldQuit()) {
event.preventDefault()
window.hide()
}
})
window.webContents.setWindowOpenHandler(({ url }) => {
if (isAllowedExternalUrl(url)) {
void shell.openExternal(url)
}
return { action: 'deny' }
})
window.webContents.on('will-navigate', (event, url) => {
const developmentUrl = process.env.ELECTRON_RENDERER_URL
if (!developmentUrl || !url.startsWith(developmentUrl)) {
event.preventDefault()
}
})
if (process.env.ELECTRON_RENDERER_URL) {
void window.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
void window.loadFile(join(currentDirectory, '../renderer/index.html'))
}
return window
}
export function showWindow(window: BrowserWindow): void {
if (window.isMinimized()) {
window.restore()
}
window.show()
window.focus()
}
export function toggleWindow(window: BrowserWindow): void {
if (window.isVisible() && window.isFocused()) {
window.hide()
return
}
showWindow(window)
}
+46
View File
@@ -0,0 +1,46 @@
import { contextBridge, ipcRenderer } from 'electron'
import {
type AgentEvent,
type AgentRequest,
type AgentRuntimeStatus,
type AppInfo,
type DesktopApi
} from '../shared/contracts'
import { ipcChannels } from '../shared/ipc-channels'
const desktopApi: DesktopApi = {
app: {
getInfo: () => ipcRenderer.invoke(ipcChannels.appInfo) as Promise<AppInfo>,
show: async () => {
await ipcRenderer.invoke(ipcChannels.appShow)
},
hide: async () => {
await ipcRenderer.invoke(ipcChannels.appHide)
},
onNewConversation: (listener) => {
const handler = (): void => listener()
ipcRenderer.on(ipcChannels.conversationNew, handler)
return () => ipcRenderer.removeListener(ipcChannels.conversationNew, handler)
}
},
agent: {
getStatus: () =>
ipcRenderer.invoke(
ipcChannels.agentStatus
) as Promise<AgentRuntimeStatus>,
run: async (request: AgentRequest) => {
await ipcRenderer.invoke(ipcChannels.agentRun, request)
},
cancel: async (requestId: string) => {
await ipcRenderer.invoke(ipcChannels.agentCancel, requestId)
},
onEvent: (listener) => {
const handler = (_event: Electron.IpcRendererEvent, payload: AgentEvent): void =>
listener(payload)
ipcRenderer.on(ipcChannels.agentEvent, handler)
return () => ipcRenderer.removeListener(ipcChannels.agentEvent, handler)
}
}
}
contextBridge.exposeInMainWorld('goodbuddy', desktopApi)
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>GoodBuddy</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentEvent, DesktopApi } from '../../shared/contracts'
import App from './App'
let agentListener: ((event: AgentEvent) => void) | undefined
const run = vi.fn<DesktopApi['agent']['run']>()
const api: DesktopApi = {
app: {
getInfo: vi.fn(async () => ({
name: 'GoodBuddy',
version: '0.1.0',
platform: 'win32',
arch: 'x64',
shortcut: 'CommandOrControl+Shift+Space'
})),
show: vi.fn(async () => {}),
hide: vi.fn(async () => {}),
onNewConversation: vi.fn(() => () => {})
},
agent: {
getStatus: vi.fn<DesktopApi['agent']['getStatus']>(async () => ({
id: 'demo' as const,
label: '演示模式',
available: true,
detail: 'Ready'
})),
run,
cancel: vi.fn(async () => {}),
onEvent: vi.fn((listener) => {
agentListener = listener
return () => {
agentListener = undefined
}
})
}
}
describe('App', () => {
beforeEach(() => {
localStorage.clear()
run.mockReset()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: api
})
})
it('sends a prompt and renders streamed agent content', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '帮我分析项目' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
expect(request?.prompt).toBe('帮我分析项目')
act(() => {
if (!request) {
throw new Error('Missing request')
}
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '这是回答内容'
})
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
})
})
+567
View File
@@ -0,0 +1,567 @@
import {
Bot,
ChevronDown,
CircleHelp,
FileText,
History,
Library,
MessageSquarePlus,
MoreHorizontal,
Paperclip,
Search,
Send,
Settings,
ShieldCheck,
Sparkles,
Square,
TerminalSquare,
UserRound
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
AgentEvent,
AgentRuntimeStatus,
AppInfo
} from '../../shared/contracts'
type ToolActivity = {
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
summary: string
}
type Message = {
id: string
role: 'user' | 'assistant'
content: string
createdAt: number
state: 'streaming' | 'complete' | 'error'
status?: string
tools?: ToolActivity[]
}
type Conversation = {
id: string
title: string
updatedAt: number
messages: Message[]
}
type ActiveRun = {
conversationId: string
messageId: string
}
const storageKey = 'goodbuddy.conversations.v1'
const quickActions = [
{
title: '总结一段内容',
description: '提炼重点并输出行动项',
prompt: '请帮我总结下面的内容,并列出重点和行动项:\n'
},
{
title: '分析错误信息',
description: '定位原因并给出排查步骤',
prompt: '请分析下面的错误信息,给出可能原因和排查步骤:\n'
},
{
title: '编写工作内容',
description: '起草邮件、周报或方案',
prompt: '请帮我起草一份清晰、专业的工作内容:\n'
}
]
function createConversation(): Conversation {
const now = Date.now()
return {
id: crypto.randomUUID(),
title: '新对话',
updatedAt: now,
messages: [
{
id: crypto.randomUUID(),
role: 'assistant',
content:
'你好,我是 GoodBuddy。你可以直接向我提问,后续还可以让我读取经过授权的文件、搜索项目并调用工具。',
createdAt: now,
state: 'complete'
}
]
}
}
function loadConversations(): Conversation[] {
try {
const value = localStorage.getItem(storageKey)
if (!value) {
return [createConversation()]
}
const parsed: unknown = JSON.parse(value)
return Array.isArray(parsed) && parsed.length > 0
? (parsed as Conversation[])
: [createConversation()]
} catch {
return [createConversation()]
}
}
function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit'
}).format(timestamp)
}
function App(): React.JSX.Element {
const [conversations, setConversations] = useState(loadConversations)
const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
const [input, setInput] = useState('')
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
const [appInfo, setAppInfo] = useState<AppInfo>()
const [sidebarOpen, setSidebarOpen] = useState(true)
const activeRuns = useRef(new Map<string, ActiveRun>())
const inputRef = useRef<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const activeConversation = useMemo(
() => conversations.find((conversation) => conversation.id === activeId),
[activeId, conversations]
)
const updateMessage = useCallback(
(
conversationId: string,
messageId: string,
update: (message: Message) => Message
): void => {
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
updatedAt: Date.now(),
messages: conversation.messages.map((message) =>
message.id === messageId ? update(message) : message
)
}
: conversation
)
)
},
[]
)
const handleAgentEvent = useCallback(
(event: AgentEvent): void => {
const run = activeRuns.current.get(event.requestId)
if (!run) {
return
}
if (event.type === 'text') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
content: message.content + event.delta,
status: undefined
}))
} else if (event.type === 'status') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
status: event.message
}))
} else if (event.type === 'tool') {
updateMessage(run.conversationId, run.messageId, (message) => {
const tools = [...(message.tools ?? [])]
const index = tools.findIndex((tool) => tool.name === event.name)
const tool = {
name: event.name,
state: event.state,
summary: event.summary
}
if (index >= 0) {
tools[index] = tool
} else {
tools.push(tool)
}
return { ...message, tools }
})
} else {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
state: event.type === 'error' ? 'error' : 'complete',
status: event.type === 'error' ? event.message : undefined,
content:
event.type === 'error' && !message.content
? event.message
: message.content
}))
activeRuns.current.delete(event.requestId)
}
},
[updateMessage]
)
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(conversations))
}, [conversations])
useEffect(() => {
void window.goodbuddy.agent.getStatus().then(setRuntime)
void window.goodbuddy.app.getInfo().then(setAppInfo)
const removeAgentListener =
window.goodbuddy.agent.onEvent(handleAgentEvent)
const removeNewConversationListener =
window.goodbuddy.app.onNewConversation(() => {
const conversation = createConversation()
setConversations((current) => [conversation, ...current])
setActiveId(conversation.id)
inputRef.current?.focus()
})
return () => {
removeAgentListener()
removeNewConversationListener()
}
}, [handleAgentEvent])
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth'
})
}, [activeConversation?.messages])
const newConversation = (): void => {
const conversation = createConversation()
setConversations((current) => [conversation, ...current])
setActiveId(conversation.id)
setInput('')
inputRef.current?.focus()
}
const submit = async (): Promise<void> => {
const prompt = input.trim()
if (!prompt || !activeConversation) {
return
}
const requestId = crypto.randomUUID()
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: prompt,
createdAt: Date.now(),
state: 'complete'
}
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
createdAt: Date.now(),
state: 'streaming',
status: '正在连接 Agent Runtime'
}
const conversationId = activeConversation.id
activeRuns.current.set(requestId, {
conversationId,
messageId: assistantMessage.id
})
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
title:
conversation.title === '新对话'
? prompt.slice(0, 24)
: conversation.title,
updatedAt: Date.now(),
messages: [
...conversation.messages,
userMessage,
assistantMessage
]
}
: conversation
)
)
setInput('')
try {
await window.goodbuddy.agent.run({
requestId,
conversationId,
prompt
})
} catch (error) {
handleAgentEvent({
requestId,
type: 'error',
message: error instanceof Error ? error.message : '发送失败'
})
}
}
const stop = async (): Promise<void> => {
const requestId = [...activeRuns.current.entries()].find(
([, run]) => run.conversationId === activeId
)?.[0]
if (requestId) {
await window.goodbuddy.agent.cancel(requestId)
}
}
const isRunning =
activeConversation?.messages.some(
(message) => message.state === 'streaming'
) ?? false
return (
<div className="app-shell">
<aside className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}>
<div className="brand">
<div className="brand__mark">
<Bot size={20} strokeWidth={2.4} />
</div>
<div className="brand__copy">
<strong>GoodBuddy</strong>
<span>AI desktop companion</span>
</div>
</div>
<button className="new-chat" type="button" onClick={newConversation}>
<MessageSquarePlus size={17} />
<span></span>
<kbd>Ctrl N</kbd>
</button>
<div className="sidebar-search">
<Search size={15} />
<input aria-label="搜索对话" placeholder="搜索对话" />
</div>
<nav className="primary-nav" aria-label="主导航">
<button className="nav-item nav-item--active" type="button">
<History size={17} />
<span></span>
</button>
<button className="nav-item" type="button">
<Library size={17} />
<span></span>
<span className="nav-item__hint"></span>
</button>
<button className="nav-item" type="button">
<TerminalSquare size={17} />
<span></span>
<span className="nav-item__hint"></span>
</button>
</nav>
<div className="conversation-list">
<p className="section-label"></p>
{conversations.map((conversation) => (
<button
className={
conversation.id === activeId
? 'conversation-item conversation-item--active'
: 'conversation-item'
}
key={conversation.id}
type="button"
onClick={() => setActiveId(conversation.id)}
>
<span>{conversation.title}</span>
<small>{formatTime(conversation.updatedAt)}</small>
</button>
))}
</div>
<div className="sidebar-footer">
<button className="user-card" type="button">
<span className="avatar">GB</span>
<span className="user-card__copy">
<strong></strong>
<small>{appInfo ? `${appInfo.platform} · ${appInfo.arch}` : '加载中'}</small>
</span>
<Settings size={16} />
</button>
</div>
</aside>
<main className="workspace">
<header className="topbar">
<button
className="icon-button sidebar-toggle"
type="button"
aria-label="切换侧栏"
onClick={() => setSidebarOpen((open) => !open)}
>
<MoreHorizontal size={19} />
</button>
<button className="conversation-title" type="button">
<span>{activeConversation?.title ?? '新对话'}</span>
<ChevronDown size={15} />
</button>
<div className="topbar__actions">
<span
className={
runtime?.available
? 'runtime-status runtime-status--online'
: 'runtime-status'
}
title={runtime?.detail}
>
<span className="runtime-status__dot" />
{runtime?.label ?? '正在检测运行时'}
</span>
<button className="icon-button" type="button" aria-label="安全状态">
<ShieldCheck size={18} />
</button>
<button className="icon-button" type="button" aria-label="帮助">
<CircleHelp size={18} />
</button>
</div>
</header>
<section className="chat" ref={scrollRef}>
{activeConversation?.messages.length === 1 && (
<div className="welcome">
<div className="welcome__badge">
<Sparkles size={18} />
</div>
<p className="eyebrow">GOODBUDDY WORKSPACE</p>
<h1></h1>
<p className="welcome__description">
OpenCode 使
</p>
<div className="quick-actions">
{quickActions.map((action) => (
<button
key={action.title}
type="button"
onClick={() => {
setInput(action.prompt)
inputRef.current?.focus()
}}
>
<span className="quick-actions__icon">
<FileText size={17} />
</span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</button>
))}
</div>
</div>
)}
<div className="message-list">
{activeConversation?.messages.map((message) => (
<article
className={`message message--${message.role}`}
key={message.id}
>
<div className="message__avatar">
{message.role === 'assistant' ? (
<Bot size={18} />
) : (
<UserRound size={18} />
)}
</div>
<div className="message__body">
<div className="message__meta">
<strong>
{message.role === 'assistant' ? 'GoodBuddy' : '你'}
</strong>
<span>{formatTime(message.createdAt)}</span>
</div>
{message.content && (
<div className="message__content">{message.content}</div>
)}
{message.tools?.map((tool) => (
<div className="tool-activity" key={tool.name}>
<TerminalSquare size={15} />
<span>{tool.summary}</span>
<small>{tool.state}</small>
</div>
))}
{message.status && (
<div
className={
message.state === 'error'
? 'message__status message__status--error'
: 'message__status'
}
>
<span className="thinking-dot" />
{message.status}
</div>
)}
</div>
</article>
))}
</div>
</section>
<footer className="composer-wrap">
<div className="composer">
<textarea
aria-label="向 GoodBuddy 提问"
placeholder="给 GoodBuddy 发消息…"
ref={inputRef}
rows={1}
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
void submit()
}
}}
/>
<div className="composer__toolbar">
<div className="composer__attachments">
<button type="button" aria-label="添加附件" title="下一阶段开放">
<Paperclip size={18} />
</button>
<span className="divider" />
<button className="model-button" type="button">
<Sparkles size={15} />
{runtime?.label ?? 'Runtime'}
<ChevronDown size={14} />
</button>
</div>
{isRunning ? (
<button
className="send-button send-button--stop"
type="button"
aria-label="停止生成"
onClick={() => void stop()}
>
<Square size={15} fill="currentColor" />
</button>
) : (
<button
className="send-button"
type="button"
aria-label="发送"
disabled={!input.trim()}
onClick={() => void submit()}
>
<Send size={17} />
</button>
)}
</div>
</div>
<p className="composer-hint">
AI
{appInfo?.shortcut && ` 快捷唤起:${appInfo.shortcut}`}
</p>
</footer>
</main>
</div>
)
}
export default App
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
import type { DesktopApi } from '../../shared/contracts'
declare global {
interface Window {
goodbuddy: DesktopApi
}
}
export {}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
const root = document.getElementById('root')
if (!root) {
throw new Error('Root element not found')
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
)
+768
View File
@@ -0,0 +1,768 @@
:root {
color: #1a2a23;
background: #f3f0e9;
font-family:
Inter, "SF Pro Display", "Segoe UI", "PingFang SC", "Microsoft YaHei",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
button,
input,
textarea {
color: inherit;
font: inherit;
}
button {
border: 0;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 2px solid #d8993f;
outline-offset: 2px;
}
.app-shell {
display: flex;
width: 100%;
height: 100%;
background:
radial-gradient(circle at 70% -20%, rgb(255 255 255 / 90%), transparent 36%),
#f7f5f0;
}
.sidebar {
display: flex;
flex: 0 0 278px;
flex-direction: column;
min-width: 0;
padding: 20px 14px 14px;
overflow: hidden;
background: #173a2c;
color: #f4f5ed;
transition:
flex-basis 180ms ease,
padding 180ms ease;
}
.sidebar--closed {
flex-basis: 0;
padding-right: 0;
padding-left: 0;
}
.brand {
display: flex;
align-items: center;
min-width: 248px;
padding: 0 8px 20px;
gap: 11px;
}
.brand__mark {
display: grid;
width: 38px;
height: 38px;
flex: 0 0 38px;
place-items: center;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 12px;
background: #f1bb65;
color: #173a2c;
box-shadow: 0 7px 24px rgb(8 25 18 / 25%);
}
.brand__copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.brand__copy strong {
font-size: 15px;
letter-spacing: 0.01em;
}
.brand__copy span {
color: #9bb4a8;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.new-chat {
display: flex;
min-width: 248px;
align-items: center;
padding: 11px 12px;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 10px;
margin-bottom: 11px;
background: #29513f;
color: #fffdf7;
cursor: pointer;
gap: 9px;
text-align: left;
}
.new-chat:hover {
background: #315c49;
}
.new-chat span {
flex: 1;
font-size: 13px;
font-weight: 600;
}
.new-chat kbd {
padding: 2px 5px;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 5px;
background: rgb(0 0 0 / 10%);
color: #a9c0b5;
font-size: 9px;
}
.sidebar-search {
display: flex;
min-width: 248px;
align-items: center;
padding: 9px 11px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 9px;
margin-bottom: 15px;
background: rgb(8 29 20 / 25%);
color: #91aa9f;
gap: 8px;
}
.sidebar-search input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: #f5f6ef;
font-size: 12px;
}
.sidebar-search input::placeholder {
color: #8aa297;
}
.primary-nav {
display: flex;
min-width: 248px;
flex-direction: column;
padding-bottom: 13px;
border-bottom: 1px solid rgb(255 255 255 / 8%);
gap: 3px;
}
.nav-item {
display: flex;
align-items: center;
padding: 9px 10px;
border-radius: 8px;
background: transparent;
color: #adbbb4;
cursor: pointer;
gap: 10px;
text-align: left;
}
.nav-item:hover,
.nav-item--active {
background: rgb(255 255 255 / 7%);
color: #fffdf8;
}
.nav-item span:nth-child(2) {
flex: 1;
font-size: 12px;
font-weight: 550;
}
.nav-item__hint {
color: #6f8e7f;
font-size: 9px;
}
.conversation-list {
min-width: 248px;
flex: 1;
padding-top: 14px;
overflow: auto;
}
.section-label {
padding: 0 9px;
margin: 0 0 7px;
color: #779387;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.conversation-item {
display: flex;
width: 100%;
align-items: center;
padding: 9px 10px;
border-radius: 8px;
background: transparent;
color: #aabbb3;
cursor: pointer;
gap: 8px;
text-align: left;
}
.conversation-item:hover,
.conversation-item--active {
background: rgb(255 255 255 / 7%);
color: #fff;
}
.conversation-item span {
flex: 1;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-item small {
color: #6f8c7e;
font-size: 9px;
}
.sidebar-footer {
min-width: 248px;
padding-top: 12px;
border-top: 1px solid rgb(255 255 255 / 8%);
}
.user-card {
display: flex;
width: 100%;
align-items: center;
padding: 7px 8px;
border-radius: 8px;
background: transparent;
color: #bdcbc4;
cursor: pointer;
gap: 9px;
text-align: left;
}
.user-card:hover {
background: rgb(255 255 255 / 6%);
}
.avatar {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border-radius: 10px;
background: #e8b45f;
color: #173a2c;
font-size: 10px;
font-weight: 800;
}
.user-card__copy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.user-card__copy strong {
color: #eff2eb;
font-size: 11px;
}
.user-card__copy small {
color: #789488;
font-size: 9px;
}
.workspace {
display: grid;
min-width: 0;
flex: 1;
grid-template-rows: 58px minmax(0, 1fr) auto;
}
.topbar {
display: flex;
align-items: center;
padding: 0 21px;
border-bottom: 1px solid #e4e0d7;
background: rgb(250 249 245 / 76%);
backdrop-filter: blur(16px);
}
.icon-button {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 9px;
background: transparent;
color: #718078;
cursor: pointer;
}
.icon-button:hover {
background: #eeebe4;
color: #264537;
}
.conversation-title {
display: flex;
align-items: center;
padding: 7px 9px;
border-radius: 8px;
margin-left: 4px;
background: transparent;
color: #293b33;
cursor: pointer;
gap: 6px;
font-size: 12px;
font-weight: 650;
}
.conversation-title:hover {
background: #eeebe4;
}
.topbar__actions {
display: flex;
align-items: center;
margin-left: auto;
gap: 3px;
}
.runtime-status {
display: flex;
align-items: center;
padding: 6px 10px;
border: 1px solid #ddd8cc;
border-radius: 999px;
margin-right: 8px;
color: #796d5c;
font-size: 10px;
font-weight: 650;
gap: 6px;
}
.runtime-status__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #b79059;
box-shadow: 0 0 0 3px rgb(183 144 89 / 12%);
}
.runtime-status--online {
color: #315d49;
}
.runtime-status--online .runtime-status__dot {
background: #45a272;
box-shadow: 0 0 0 3px rgb(69 162 114 / 13%);
}
.chat {
min-height: 0;
padding: 28px max(28px, calc((100% - 820px) / 2)) 10px;
overflow-y: auto;
scrollbar-color: #d0cbc0 transparent;
scrollbar-width: thin;
}
.welcome {
padding: 26px 0 30px;
text-align: center;
}
.welcome__badge {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid #e0b46f;
border-radius: 14px;
margin: 0 auto 14px;
background: #f5c879;
color: #244536;
box-shadow: 0 10px 26px rgb(169 117 39 / 16%);
}
.eyebrow {
margin: 0 0 8px;
color: #a06f2e;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.16em;
}
.welcome h1 {
margin: 0;
color: #1d382c;
font-family: Georgia, "Songti SC", serif;
font-size: clamp(26px, 3vw, 36px);
font-weight: 500;
letter-spacing: -0.025em;
}
.welcome__description {
margin: 12px 0 22px;
color: #7c827d;
font-size: 12px;
}
.quick-actions {
display: grid;
max-width: 720px;
margin: 0 auto;
gap: 10px;
grid-template-columns: repeat(3, 1fr);
}
.quick-actions button {
display: grid;
min-height: 118px;
padding: 15px;
border: 1px solid #e3ded3;
border-radius: 13px;
background: rgb(255 255 255 / 56%);
cursor: pointer;
gap: 5px;
grid-template-rows: auto auto 1fr;
text-align: left;
transition:
transform 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease;
}
.quick-actions button:hover {
border-color: #cfbc99;
box-shadow: 0 11px 30px rgb(54 44 28 / 7%);
transform: translateY(-2px);
}
.quick-actions__icon {
display: grid;
width: 31px;
height: 31px;
place-items: center;
border-radius: 9px;
margin-bottom: 4px;
background: #e9eee9;
color: #416b56;
}
.quick-actions strong {
color: #31423a;
font-size: 11px;
}
.quick-actions small {
color: #8a8e88;
font-size: 10px;
line-height: 1.45;
}
.message-list {
display: flex;
flex-direction: column;
padding-bottom: 20px;
gap: 4px;
}
.message {
display: grid;
padding: 17px 8px;
border-top: 1px solid transparent;
gap: 12px;
grid-template-columns: 30px 1fr;
}
.message + .message {
border-top-color: #ebe7df;
}
.message__avatar {
display: grid;
width: 29px;
height: 29px;
place-items: center;
border: 1px solid #d9d4ca;
border-radius: 9px;
background: #fffefa;
color: #416451;
}
.message--user .message__avatar {
border-color: #d5c29f;
background: #f1c97f;
color: #3a4c42;
}
.message__body {
min-width: 0;
}
.message__meta {
display: flex;
align-items: center;
margin: 1px 0 8px;
gap: 8px;
}
.message__meta strong {
color: #304239;
font-size: 11px;
}
.message__meta span {
color: #a0a29d;
font-size: 9px;
}
.message__content {
color: #3e4943;
font-size: 13px;
line-height: 1.75;
white-space: pre-wrap;
word-break: break-word;
}
.message__status {
display: flex;
align-items: center;
margin-top: 8px;
color: #858983;
font-size: 10px;
gap: 7px;
}
.message__status--error {
color: #a24d43;
}
.thinking-dot {
width: 6px;
height: 6px;
border-radius: 50%;
animation: pulse 1.1s ease-in-out infinite;
background: #d39b4f;
}
.tool-activity {
display: flex;
align-items: center;
padding: 9px 11px;
border: 1px solid #dedbd2;
border-radius: 9px;
margin-top: 8px;
background: #f1efe9;
color: #506158;
font-size: 10px;
gap: 8px;
}
.tool-activity span {
flex: 1;
}
.tool-activity small {
color: #8a8e88;
text-transform: uppercase;
}
.composer-wrap {
padding: 8px max(28px, calc((100% - 820px) / 2)) 15px;
background: linear-gradient(transparent, #f7f5f0 22%);
}
.composer {
padding: 12px 13px 10px;
border: 1px solid #d7d1c6;
border-radius: 15px;
background: #fffefa;
box-shadow:
0 12px 36px rgb(59 48 34 / 9%),
0 2px 5px rgb(59 48 34 / 4%);
}
.composer:focus-within {
border-color: #c9af82;
box-shadow:
0 12px 36px rgb(59 48 34 / 10%),
0 0 0 3px rgb(211 164 89 / 10%);
}
.composer textarea {
display: block;
width: 100%;
min-height: 38px;
max-height: 160px;
padding: 2px 3px;
border: 0;
outline: 0;
resize: none;
background: transparent;
color: #2f3d36;
font-size: 13px;
line-height: 1.55;
}
.composer textarea::placeholder {
color: #a2a099;
}
.composer__toolbar {
display: flex;
align-items: center;
min-height: 31px;
}
.composer__attachments {
display: flex;
align-items: center;
gap: 4px;
}
.composer__attachments button {
display: flex;
height: 29px;
align-items: center;
padding: 0 7px;
border-radius: 7px;
background: transparent;
color: #778079;
cursor: pointer;
gap: 5px;
}
.composer__attachments button:hover {
background: #f0eee8;
color: #345443;
}
.divider {
width: 1px;
height: 17px;
margin: 0 4px;
background: #e1ddd5;
}
.model-button {
font-size: 10px;
font-weight: 600;
}
.send-button {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border-radius: 9px;
margin-left: auto;
background: #1d4a37;
color: #fff;
cursor: pointer;
box-shadow: 0 5px 12px rgb(29 74 55 / 18%);
}
.send-button:hover {
background: #276046;
}
.send-button:disabled {
background: #d7d4cc;
color: #9d9a93;
cursor: default;
box-shadow: none;
}
.send-button--stop {
background: #9a5147;
}
.composer-hint {
margin: 7px 0 0;
color: #9b9c97;
font-size: 9px;
text-align: center;
}
@keyframes pulse {
0%,
100% {
opacity: 0.35;
transform: scale(0.85);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
@media (max-width: 1020px) {
.sidebar {
flex-basis: 236px;
}
.brand,
.new-chat,
.sidebar-search,
.primary-nav,
.conversation-list,
.sidebar-footer {
min-width: 206px;
}
.quick-actions {
grid-template-columns: 1fr;
}
.quick-actions button {
min-height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+4
View File
@@ -0,0 +1,4 @@
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
Element.prototype.scrollTo = vi.fn()
+69
View File
@@ -0,0 +1,69 @@
import { z } from 'zod'
export const agentRequestSchema = z.object({
requestId: z.string().uuid(),
conversationId: z.string().min(1).max(128),
prompt: z.string().trim().min(1).max(100_000),
workspace: z.string().max(2_048).optional()
})
export type AgentRequest = z.infer<typeof agentRequestSchema>
export type AgentRuntimeStatus = {
id: 'demo' | 'bigtoken' | 'opencode'
label: string
available: boolean
detail: string
}
export type AgentEvent =
| {
requestId: string
type: 'status'
message: string
}
| {
requestId: string
type: 'text'
delta: string
}
| {
requestId: string
type: 'tool'
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
summary: string
}
| {
requestId: string
type: 'done'
sessionId?: string
}
| {
requestId: string
type: 'error'
message: string
}
export type AppInfo = {
name: string
version: string
platform: string
arch: string
shortcut: string
}
export type DesktopApi = {
app: {
getInfo: () => Promise<AppInfo>
show: () => Promise<void>
hide: () => Promise<void>
onNewConversation: (listener: () => void) => () => void
}
agent: {
getStatus: () => Promise<AgentRuntimeStatus>
run: (request: AgentRequest) => Promise<void>
cancel: (requestId: string) => Promise<void>
onEvent: (listener: (event: AgentEvent) => void) => () => void
}
}
+10
View File
@@ -0,0 +1,10 @@
export const ipcChannels = {
appInfo: 'app:get-info',
appShow: 'app:show',
appHide: 'app:hide',
conversationNew: 'conversation:new',
agentStatus: 'agent:get-status',
agentRun: 'agent:run',
agentCancel: 'agent:cancel',
agentEvent: 'agent:event'
} as const