feat: bootstrap secure cross-platform assistant
Establish the Electron foundation and pluggable agent runtime so desktop workflows can evolve safely across supported platforms. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
1b6178b841
@@ -0,0 +1,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()
|
||||
Reference in New Issue
Block a user