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