feat: add secure multi-runtime controls
Make agent backends configurable and governable with encrypted credentials, explicit context sharing, and tool approval boundaries. 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>
parent
1b6178b841
commit
698a15ad14
@@ -1,5 +1,12 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentEvent, DesktopApi } from '../../shared/contracts'
|
||||
import App from './App'
|
||||
|
||||
@@ -28,25 +35,57 @@ const api: DesktopApi = {
|
||||
})),
|
||||
run,
|
||||
cancel: vi.fn(async () => {}),
|
||||
respondApproval: vi.fn(async () => {}),
|
||||
onEvent: vi.fn((listener) => {
|
||||
agentListener = listener
|
||||
return () => {
|
||||
agentListener = undefined
|
||||
}
|
||||
})
|
||||
},
|
||||
settings: {
|
||||
getRuntime: vi.fn<DesktopApi['settings']['getRuntime']>(async () => ({
|
||||
provider: 'auto',
|
||||
bigtokenBaseUrl: 'https://bigtoken.ai',
|
||||
bigtokenModel: 'sonnet-5',
|
||||
apiKeyConfigured: false,
|
||||
credentialSource: 'none',
|
||||
secureStorageAvailable: true,
|
||||
toolApproval: 'always'
|
||||
})),
|
||||
updateRuntime: vi.fn<DesktopApi['settings']['updateRuntime']>(
|
||||
async (input) => ({
|
||||
provider: input.provider,
|
||||
bigtokenBaseUrl: input.bigtokenBaseUrl,
|
||||
bigtokenModel: input.bigtokenModel,
|
||||
apiKeyConfigured: input.apiKey.action === 'replace',
|
||||
credentialSource:
|
||||
input.apiKey.action === 'replace' ? 'encrypted' : 'none',
|
||||
secureStorageAvailable: true,
|
||||
toolApproval: input.toolApproval
|
||||
})
|
||||
)
|
||||
},
|
||||
context: {
|
||||
selectFiles: vi.fn(async () => []),
|
||||
remove: vi.fn(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
run.mockReset()
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: api
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('sends a prompt and renders streamed agent content', async () => {
|
||||
render(<App />)
|
||||
|
||||
@@ -76,4 +115,34 @@ describe('App', () => {
|
||||
|
||||
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('configures a runtime without reading an existing API key', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(await screen.findByText('本地工作区'))
|
||||
expect(
|
||||
await screen.findByRole('heading', {
|
||||
name: '模型与 Agent Runtime'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
|
||||
const apiKeyInput = screen.getByLabelText('API Key')
|
||||
expect(apiKeyInput).toHaveValue('')
|
||||
fireEvent.change(apiKeyInput, {
|
||||
target: { value: 'test-api-key' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.settings.updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: {
|
||||
action: 'replace',
|
||||
value: 'test-api-key'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(apiKeyInput).toHaveValue(''))
|
||||
})
|
||||
})
|
||||
|
||||
+182
-9
@@ -21,8 +21,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentRuntimeStatus,
|
||||
AppInfo
|
||||
AppInfo,
|
||||
ContextAttachment
|
||||
} from '../../shared/contracts'
|
||||
import { SettingsPanel } from './SettingsPanel'
|
||||
|
||||
type ToolActivity = {
|
||||
name: string
|
||||
@@ -38,6 +40,11 @@ type Message = {
|
||||
state: 'streaming' | 'complete' | 'error'
|
||||
status?: string
|
||||
tools?: ToolActivity[]
|
||||
approval?: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
}
|
||||
|
||||
type Conversation = {
|
||||
@@ -120,6 +127,9 @@ function App(): React.JSX.Element {
|
||||
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [attachments, setAttachments] = useState<ContextAttachment[]>([])
|
||||
const [contextError, setContextError] = useState<string>()
|
||||
const activeRuns = useRef(new Map<string, ActiveRun>())
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@@ -186,11 +196,22 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
return { ...message, tools }
|
||||
})
|
||||
} else if (event.type === 'approval') {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
status: undefined,
|
||||
approval: {
|
||||
id: event.approvalId,
|
||||
title: event.title,
|
||||
description: event.description
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
state: event.type === 'error' ? 'error' : 'complete',
|
||||
status: event.type === 'error' ? event.message : undefined,
|
||||
approval: undefined,
|
||||
content:
|
||||
event.type === 'error' && !message.content
|
||||
? event.message
|
||||
@@ -203,7 +224,10 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey, JSON.stringify(conversations))
|
||||
const timeout = setTimeout(() => {
|
||||
localStorage.setItem(storageKey, JSON.stringify(conversations))
|
||||
}, 200)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [conversations])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -216,6 +240,12 @@ function App(): React.JSX.Element {
|
||||
const conversation = createConversation()
|
||||
setConversations((current) => [conversation, ...current])
|
||||
setActiveId(conversation.id)
|
||||
setAttachments((current) => {
|
||||
for (const attachment of current) {
|
||||
void window.goodbuddy.context.remove(attachment.id)
|
||||
}
|
||||
return []
|
||||
})
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
return () => {
|
||||
@@ -225,10 +255,13 @@ function App(): React.JSX.Element {
|
||||
}, [handleAgentEvent])
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
const frame = requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: 'auto'
|
||||
})
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [activeConversation?.messages])
|
||||
|
||||
const newConversation = (): void => {
|
||||
@@ -236,6 +269,10 @@ function App(): React.JSX.Element {
|
||||
setConversations((current) => [conversation, ...current])
|
||||
setActiveId(conversation.id)
|
||||
setInput('')
|
||||
for (const attachment of attachments) {
|
||||
void window.goodbuddy.context.remove(attachment.id)
|
||||
}
|
||||
setAttachments([])
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
@@ -292,8 +329,13 @@ function App(): React.JSX.Element {
|
||||
await window.goodbuddy.agent.run({
|
||||
requestId,
|
||||
conversationId,
|
||||
prompt
|
||||
prompt,
|
||||
contextIds: attachments.map((attachment) => attachment.id)
|
||||
})
|
||||
for (const attachment of attachments) {
|
||||
void window.goodbuddy.context.remove(attachment.id)
|
||||
}
|
||||
setAttachments([])
|
||||
} catch (error) {
|
||||
handleAgentEvent({
|
||||
requestId,
|
||||
@@ -312,6 +354,29 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const respondToApproval = async (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
approvalId: string,
|
||||
approved: boolean
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await window.goodbuddy.agent.respondApproval(approvalId, approved)
|
||||
updateMessage(conversationId, messageId, (message) => ({
|
||||
...message,
|
||||
approval: undefined,
|
||||
status: approved
|
||||
? '已授权,Agent 正在执行'
|
||||
: '已拒绝工具执行'
|
||||
}))
|
||||
} catch {
|
||||
updateMessage(conversationId, messageId, (message) => ({
|
||||
...message,
|
||||
status: '审批响应失败,请重试'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const isRunning =
|
||||
activeConversation?.messages.some(
|
||||
(message) => message.state === 'streaming'
|
||||
@@ -378,7 +443,11 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button className="user-card" type="button">
|
||||
<button
|
||||
className="user-card"
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<span className="avatar">GB</span>
|
||||
<span className="user-card__copy">
|
||||
<strong>本地工作区</strong>
|
||||
@@ -486,6 +555,43 @@ function App(): React.JSX.Element {
|
||||
<small>{tool.state}</small>
|
||||
</div>
|
||||
))}
|
||||
{message.approval && (
|
||||
<div className="approval-card">
|
||||
<ShieldCheck size={18} />
|
||||
<div>
|
||||
<strong>{message.approval.title}</strong>
|
||||
<p>{message.approval.description}</p>
|
||||
</div>
|
||||
<button
|
||||
className="approval-card__deny"
|
||||
onClick={() =>
|
||||
void respondToApproval(
|
||||
activeConversation.id,
|
||||
message.id,
|
||||
message.approval!.id,
|
||||
false
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
className="approval-card__allow"
|
||||
onClick={() =>
|
||||
void respondToApproval(
|
||||
activeConversation.id,
|
||||
message.id,
|
||||
message.approval!.id,
|
||||
true
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
允许
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message.status && (
|
||||
<div
|
||||
className={
|
||||
@@ -506,6 +612,39 @@ function App(): React.JSX.Element {
|
||||
|
||||
<footer className="composer-wrap">
|
||||
<div className="composer">
|
||||
{attachments.length > 0 && (
|
||||
<div className="context-list">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
className="context-chip"
|
||||
key={attachment.id}
|
||||
title={attachment.preview}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>
|
||||
<strong>{attachment.name}</strong>
|
||||
<small>
|
||||
{Math.max(1, Math.ceil(attachment.size / 1024))} KB
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
aria-label={`移除 ${attachment.name}`}
|
||||
onClick={() => {
|
||||
void window.goodbuddy.context.remove(attachment.id)
|
||||
setAttachments((current) =>
|
||||
current.filter(
|
||||
(item) => item.id !== attachment.id
|
||||
)
|
||||
)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
aria-label="向 GoodBuddy 提问"
|
||||
placeholder="给 GoodBuddy 发消息…"
|
||||
@@ -522,7 +661,33 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
<div className="composer__toolbar">
|
||||
<div className="composer__attachments">
|
||||
<button type="button" aria-label="添加附件" title="下一阶段开放">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="添加附件"
|
||||
onClick={() => {
|
||||
setContextError(undefined)
|
||||
void window.goodbuddy.context
|
||||
.selectFiles()
|
||||
.then((selected) => {
|
||||
setAttachments((current) => [
|
||||
...current,
|
||||
...selected.filter(
|
||||
(item) =>
|
||||
!current.some(
|
||||
(existing) => existing.id === item.id
|
||||
)
|
||||
)
|
||||
])
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setContextError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: '添加文件失败'
|
||||
)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
<span className="divider" />
|
||||
@@ -555,11 +720,19 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
<p className="composer-hint">
|
||||
AI 可能会犯错。工具执行前请检查参数和权限。
|
||||
{contextError ??
|
||||
'AI 可能会犯错。工具执行前请检查参数和权限。'}
|
||||
{appInfo?.shortcut && ` 快捷唤起:${appInfo.shortcut}`}
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
<SettingsPanel
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={() => {
|
||||
void window.goodbuddy.agent.getStatus().then(setRuntime)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { Check, KeyRound, LockKeyhole, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type {
|
||||
RuntimeSettings,
|
||||
RuntimeSettingsInput
|
||||
} from '../../shared/contracts'
|
||||
import { defaultRuntimeSettings } from '../../shared/contracts'
|
||||
|
||||
type SettingsPanelProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: (settings: RuntimeSettings) => void
|
||||
}
|
||||
|
||||
const credentialLabels: Record<
|
||||
RuntimeSettings['credentialSource'],
|
||||
string
|
||||
> = {
|
||||
none: '尚未配置',
|
||||
encrypted: '已由系统安全存储加密',
|
||||
environment: '由环境变量提供'
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
open,
|
||||
onClose,
|
||||
onSaved
|
||||
}: SettingsPanelProps): React.JSX.Element | null {
|
||||
const [settings, setSettings] = useState<RuntimeSettings>()
|
||||
const [provider, setProvider] =
|
||||
useState<RuntimeSettingsInput['provider']>(
|
||||
defaultRuntimeSettings.provider
|
||||
)
|
||||
const [baseUrl, setBaseUrl] = useState<string>(
|
||||
defaultRuntimeSettings.bigtokenBaseUrl
|
||||
)
|
||||
const [model, setModel] = useState<string>(
|
||||
defaultRuntimeSettings.bigtokenModel
|
||||
)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [clearApiKey, setClearApiKey] = useState(false)
|
||||
const [toolApproval, setToolApproval] =
|
||||
useState<RuntimeSettingsInput['toolApproval']>(
|
||||
defaultRuntimeSettings.toolApproval
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
void window.goodbuddy.settings
|
||||
.getRuntime()
|
||||
.then((value) => {
|
||||
setError(undefined)
|
||||
setSaved(false)
|
||||
setApiKey('')
|
||||
setClearApiKey(false)
|
||||
setSettings(value)
|
||||
setProvider(value.provider)
|
||||
setBaseUrl(value.bigtokenBaseUrl)
|
||||
setModel(value.bigtokenModel)
|
||||
setToolApproval(value.toolApproval)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setError(reason instanceof Error ? reason.message : '读取设置失败')
|
||||
})
|
||||
}, [open])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
const environmentManaged = settings?.credentialSource === 'environment'
|
||||
|
||||
const close = (): void => {
|
||||
setApiKey('')
|
||||
setClearApiKey(false)
|
||||
setError(undefined)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
setSaved(false)
|
||||
try {
|
||||
const apiKeyUpdate: RuntimeSettingsInput['apiKey'] = clearApiKey
|
||||
? { action: 'clear' }
|
||||
: apiKey.trim()
|
||||
? { action: 'replace', value: apiKey.trim() }
|
||||
: { action: 'keep' }
|
||||
const value = await window.goodbuddy.settings.updateRuntime({
|
||||
provider,
|
||||
bigtokenBaseUrl: baseUrl,
|
||||
bigtokenModel: model,
|
||||
apiKey: apiKeyUpdate,
|
||||
toolApproval
|
||||
})
|
||||
setSettings(value)
|
||||
setApiKey('')
|
||||
setClearApiKey(false)
|
||||
setSaved(true)
|
||||
onSaved(value)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '保存设置失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-backdrop" role="presentation">
|
||||
<section
|
||||
aria-labelledby="settings-title"
|
||||
aria-modal="true"
|
||||
className="settings-panel"
|
||||
role="dialog"
|
||||
>
|
||||
<header className="settings-panel__header">
|
||||
<div>
|
||||
<p className="eyebrow">RUNTIME CONTROL</p>
|
||||
<h2 id="settings-title">模型与 Agent Runtime</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭设置"
|
||||
className="icon-button"
|
||||
onClick={close}
|
||||
type="button"
|
||||
>
|
||||
<X size={19} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="settings-panel__body">
|
||||
<label className="field">
|
||||
<span>默认 Runtime</span>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(event) =>
|
||||
setProvider(
|
||||
event.target.value as RuntimeSettingsInput['provider']
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="auto">自动选择</option>
|
||||
<option value="bigtoken">Bigtoken 直连模型</option>
|
||||
<option value="opencode">OpenCode Agent</option>
|
||||
<option value="continue">Continue CLI Agent</option>
|
||||
</select>
|
||||
<small>
|
||||
自动模式优先使用已配置的 OpenCode,其次使用 Bigtoken。
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<div className="settings-section">
|
||||
<div className="settings-section__title">
|
||||
<KeyRound size={17} />
|
||||
<div>
|
||||
<strong>Bigtoken</strong>
|
||||
<small>Anthropic Messages API</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>服务地址</span>
|
||||
<input
|
||||
disabled={environmentManaged}
|
||||
inputMode="url"
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
value={baseUrl}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>模型</span>
|
||||
<input
|
||||
disabled={environmentManaged}
|
||||
onChange={(event) => setModel(event.target.value)}
|
||||
value={model}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>API Key</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
disabled={
|
||||
environmentManaged || !settings?.secureStorageAvailable
|
||||
}
|
||||
onChange={(event) => {
|
||||
setApiKey(event.target.value)
|
||||
setClearApiKey(false)
|
||||
}}
|
||||
placeholder={
|
||||
settings?.apiKeyConfigured
|
||||
? '已配置,留空保持不变'
|
||||
: '输入 API Key'
|
||||
}
|
||||
type="password"
|
||||
value={apiKey}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="credential-state">
|
||||
<LockKeyhole size={15} />
|
||||
<span>
|
||||
{settings
|
||||
? credentialLabels[settings.credentialSource]
|
||||
: '正在读取凭据状态'}
|
||||
</span>
|
||||
{settings?.credentialSource === 'encrypted' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setApiKey('')
|
||||
setClearApiKey(true)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{clearApiKey ? '保存后清除' : '清除凭据'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settings && !settings.secureStorageAvailable && (
|
||||
<p className="settings-warning">
|
||||
当前系统密钥服务不可用。为了避免明文落盘,请使用环境变量提供
|
||||
API Key。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>高风险工具默认授权</span>
|
||||
<select
|
||||
value={toolApproval}
|
||||
onChange={(event) =>
|
||||
setToolApproval(
|
||||
event.target.value as RuntimeSettingsInput['toolApproval']
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="always">每次执行都确认</option>
|
||||
<option value="session">当前会话授权</option>
|
||||
<option value="workspace">当前工作区授权</option>
|
||||
<option value="policy">由企业策略决定</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<footer className="settings-panel__footer">
|
||||
<div className="settings-feedback">
|
||||
{error && <span className="settings-error">{error}</span>}
|
||||
{saved && (
|
||||
<span className="settings-success">
|
||||
<Check size={14} />
|
||||
已保存并切换 Runtime
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="secondary-button" onClick={close} type="button">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
type="button"
|
||||
>
|
||||
{saving ? '保存中…' : '保存设置'}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -603,6 +603,56 @@ textarea:focus-visible {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.approval-card {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid #dfc38f;
|
||||
border-radius: 11px;
|
||||
margin-top: 10px;
|
||||
background: #fbf1dc;
|
||||
color: #74552d;
|
||||
gap: 10px;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.approval-card > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.approval-card strong {
|
||||
display: block;
|
||||
color: #644822;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.approval-card p {
|
||||
margin: 3px 0 0;
|
||||
color: #8a704c;
|
||||
font-size: 9px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.approval-card button {
|
||||
min-height: 29px;
|
||||
padding: 0 10px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.approval-card__deny {
|
||||
border: 1px solid #dbc7a5;
|
||||
background: #fffaf0;
|
||||
color: #815e34;
|
||||
}
|
||||
|
||||
.approval-card__allow {
|
||||
background: #285943;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.composer-wrap {
|
||||
padding: 8px max(28px, calc((100% - 820px) / 2)) 15px;
|
||||
background: linear-gradient(transparent, #f7f5f0 22%);
|
||||
@@ -618,6 +668,59 @@ textarea:focus-visible {
|
||||
0 2px 5px rgb(59 48 34 / 4%);
|
||||
}
|
||||
|
||||
.context-list {
|
||||
display: flex;
|
||||
padding: 0 1px 9px;
|
||||
overflow-x: auto;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.context-chip {
|
||||
display: flex;
|
||||
min-width: 150px;
|
||||
max-width: 220px;
|
||||
align-items: center;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #ddd8ce;
|
||||
border-radius: 9px;
|
||||
background: #f4f2ec;
|
||||
color: #4a6055;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.context-chip > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.context-chip strong {
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-chip small {
|
||||
color: #92958f;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.context-chip button {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #92958f;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-chip button:hover {
|
||||
background: #e6e2d9;
|
||||
color: #9b5148;
|
||||
}
|
||||
|
||||
.composer:focus-within {
|
||||
border-color: #c9af82;
|
||||
box-shadow:
|
||||
@@ -720,6 +823,226 @@ textarea:focus-visible {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-backdrop {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
background: rgb(12 29 22 / 42%);
|
||||
inset: 0;
|
||||
place-items: center;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
display: grid;
|
||||
width: min(620px, calc(100vw - 40px));
|
||||
max-height: calc(100vh - 48px);
|
||||
border: 1px solid #d9d2c6;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: #fbfaf6;
|
||||
box-shadow: 0 30px 90px rgb(15 35 26 / 28%);
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.settings-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 22px 24px 18px;
|
||||
border-bottom: 1px solid #e6e1d8;
|
||||
}
|
||||
|
||||
.settings-panel__header > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-panel__header .eyebrow {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.settings-panel__header h2 {
|
||||
margin: 0;
|
||||
color: #223d31;
|
||||
font-family: Georgia, "Songti SC", serif;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-panel__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 24px 26px;
|
||||
overflow-y: auto;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
border: 1px solid #e2ddd4;
|
||||
border-radius: 13px;
|
||||
background: #f5f3ed;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.settings-section__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #375a48;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.settings-section__title > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-section__title strong {
|
||||
color: #2b4438;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-section__title small {
|
||||
color: #90918c;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #3e4d45;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #d8d2c7;
|
||||
border-radius: 9px;
|
||||
outline: 0;
|
||||
background: #fffefa;
|
||||
color: #34433b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
border-color: #b9955e;
|
||||
box-shadow: 0 0 0 3px rgb(185 149 94 / 12%);
|
||||
}
|
||||
|
||||
.field input:disabled,
|
||||
.field select:disabled {
|
||||
background: #ebe9e3;
|
||||
color: #92938e;
|
||||
}
|
||||
|
||||
.field small {
|
||||
color: #92938e;
|
||||
font-size: 9px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.credential-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #6b756f;
|
||||
font-size: 10px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.credential-state span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.credential-state button {
|
||||
padding: 4px 7px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #9b554d;
|
||||
cursor: pointer;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.credential-state button:hover {
|
||||
background: #eee4df;
|
||||
}
|
||||
|
||||
.settings-warning {
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #e6cda5;
|
||||
border-radius: 8px;
|
||||
margin: 0;
|
||||
background: #fbf0dc;
|
||||
color: #856536;
|
||||
font-size: 10px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.settings-panel__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid #e6e1d8;
|
||||
background: #f7f5ef;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-feedback {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.settings-error {
|
||||
color: #a14940;
|
||||
}
|
||||
|
||||
.settings-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #397157;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
min-height: 35px;
|
||||
padding: 0 13px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: #1d4a37;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
background: #286047;
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border: 1px solid #dad5cb;
|
||||
background: #fffefa;
|
||||
color: #59655f;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
Reference in New Issue
Block a user