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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
1b6178b841
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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> {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user