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
+13
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.vite/
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*-smoke.png
|
||||||
+1085
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
import { resolve } from 'node:path'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()]
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: '[name].cjs',
|
||||||
|
format: 'cjs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@renderer': resolve('src/renderer/src'),
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [react()]
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import globals from 'globals'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**', 'out/**', 'coverage/**']
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['src/main/**/*.ts', 'src/preload/**/*.ts', 'src/shared/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/renderer/src/**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.test.{ts,tsx}', 'vitest.config.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.browser,
|
||||||
|
...globals.vitest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
Generated
+8153
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"name": "goodbuddy",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Cross-platform AI desktop assistant",
|
||||||
|
"main": "./out/main/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"start": "electron-vite preview",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"build": "npm run typecheck && electron-vite build",
|
||||||
|
"dist": "npm run build && electron-builder"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "live.digiman.goodbuddy",
|
||||||
|
"productName": "GoodBuddy",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"out/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dmg"
|
||||||
|
],
|
||||||
|
"category": "public.app-category.productivity"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
],
|
||||||
|
"category": "Utility"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/sdk": "^1.18.9",
|
||||||
|
"lucide-react": "^1.27.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@testing-library/jest-dom": "^7.0.0",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/node": "^26.1.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"electron": "^43.2.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
|
"electron-vite": "^5.0.0",
|
||||||
|
"eslint": "^10.8.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"globals": "^17.8.0",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.65.0",
|
||||||
|
"vite": "^7.3.6",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import type { DesktopApi } from '../../shared/contracts'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
goodbuddy: DesktopApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
Element.prototype.scrollTo = vi.fn()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.web.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node", "electron-vite/node"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"electron.vite.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"src/main/**/*.ts",
|
||||||
|
"src/preload/**/*.ts",
|
||||||
|
"src/shared/**/*.ts",
|
||||||
|
"tests/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/renderer/src/**/*.ts",
|
||||||
|
"src/renderer/src/**/*.tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/renderer/src/test-setup.ts'],
|
||||||
|
coverage: {
|
||||||
|
reporter: ['text', 'html']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user