v0.4.0: 工具与审批(业务工具骨架 + Approval Card)

This commit is contained in:
Rick Ye
2026-09-05 09:47:50 +08:00
parent fe70cb0f71
commit 807fab4f20
15 changed files with 472 additions and 16 deletions
+16 -3
View File
@@ -5,9 +5,21 @@ import { Sidebar } from './components/Sidebar'
import { ChatView } from './components/ChatView'
import { Composer } from './components/Composer'
import { SettingsModal } from './components/SettingsModal'
import { ApprovalStack } from './components/ApprovalCard'
export default function App(): JSX.Element {
const { state, sessions, send, stop, clearError, newSession, openSession, renameSession, deleteSession } = useAgent()
const {
state,
sessions,
send,
stop,
clearError,
newSession,
openSession,
renameSession,
deleteSession,
resolveApproval
} = useAgent()
const prefillRef = useRef<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
@@ -18,7 +30,7 @@ export default function App(): JSX.Element {
const queuedCount = state.queue.followUp.length + state.queue.steering.length
return (
<div className="flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
<div className="relative flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
<Sidebar
sessionState={state.sessionState}
stats={state.stats}
@@ -30,7 +42,7 @@ export default function App(): JSX.Element {
onDeleteSession={deleteSession}
onOpenSettings={() => setSettingsOpen(true)}
/>
<main className="flex min-w-0 flex-1 flex-col">
<main className="relative flex min-w-0 flex-1 flex-col">
<ChatView
messages={state.messages}
tools={state.tools}
@@ -49,6 +61,7 @@ export default function App(): JSX.Element {
onStop={stop}
prefillRef={prefillRef}
/>
<ApprovalStack requests={state.approvals} onResolve={resolveApproval} />
</main>
<SettingsModal open={settingsOpen} sessionState={state.sessionState} onClose={() => setSettingsOpen(false)} />
</div>
@@ -0,0 +1,102 @@
import { useEffect, useRef } from 'react'
import type { JSX } from 'react'
import { Check, ShieldAlert, X } from 'lucide-react'
import type { ApprovalRequest } from '@shared/types'
/**
* 工具调用审批卡(beautifului.dev Approval Card 风格)。
* 敏感工具(核价、写操作等)执行前弹出,允许/拒绝后 1.2s 收起。
*/
function ApprovalRow({
request,
onResolve
}: {
request: ApprovalRequest
onResolve: (approved: boolean) => void
}): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
let tween: { kill(): void } | null = null
let cancelled = false
void import('gsap').then((gsapModule) => {
if (cancelled) return
tween = gsapModule.gsap.fromTo(
el,
{ opacity: 0, y: 16, scale: 0.97 },
{ opacity: 1, y: 0, scale: 1, duration: 0.3, ease: 'back.out(1.6)' }
)
})
return () => {
cancelled = true
tween?.kill()
}
}, [])
const argsText = (() => {
try {
return JSON.stringify(request.args, null, 2)
} catch {
return String(request.args)
}
})()
return (
<div
ref={ref}
className="pointer-events-auto w-[440px] max-w-[90vw] rounded-2xl border border-amber-900/60 bg-zinc-900 p-4 shadow-2xl shadow-black/50"
>
<div className="mb-2.5 flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-amber-950/70 ring-1 ring-amber-900/60">
<ShieldAlert size={14} className="text-amber-400" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium text-zinc-100">敏感操作,需要你的确认</div>
<div className="font-mono text-[11px] text-amber-300/80">{request.toolName}</div>
</div>
</div>
{argsText && argsText !== 'null' && (
<pre className="mb-3 max-h-32 overflow-auto rounded-lg bg-zinc-950/70 p-2.5 font-mono text-[11px] leading-relaxed text-zinc-400">
{argsText.length > 600 ? `${argsText.slice(0, 600)}…` : argsText}
</pre>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => onResolve(false)}
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-[12.5px] text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
<X size={13} />
拒绝
</button>
<button
type="button"
onClick={() => onResolve(true)}
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors hover:bg-emerald-500"
>
<Check size={13} />
允许执行
</button>
</div>
</div>
)
}
export function ApprovalStack({
requests,
onResolve
}: {
requests: ApprovalRequest[]
onResolve: (id: string, approved: boolean) => void
}): JSX.Element | null {
if (requests.length === 0) return null
return (
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-30 flex flex-col items-center gap-2">
{requests.map((request) => (
<ApprovalRow key={request.id} request={request} onResolve={(approved) => onResolve(request.id, approved)} />
))}
</div>
)
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import type { AgentUiEvent, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
import type { AgentUiEvent, ApprovalRequest, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
export interface ToolState {
toolName: string
@@ -9,6 +9,13 @@ export interface ToolState {
status: 'running' | 'done' | 'error'
}
/** 已结算的审批(保留短暂时间用于展示结果反馈) */
export interface SettledApproval {
id: string
approved: boolean
timedOut: boolean
}
export interface UiMessage {
key: string
role: string
@@ -31,6 +38,8 @@ interface AgentUiState {
stats: SessionStatsInfo | null
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
ready: boolean
approvals: ApprovalRequest[]
settledApprovals: SettledApproval[]
}
type Action =
@@ -48,7 +57,9 @@ const initialState: AgentUiState = {
sessionState: null,
stats: null,
retry: null,
ready: false
ready: false,
approvals: [],
settledApprovals: []
}
let keyCounter = 0
@@ -240,6 +251,16 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
sessionState: { ...state.sessionState, sessionName: event.name }
}
}
case 'approval_request':
return { ...state, approvals: [...state.approvals, event.request] }
case 'approval_settled': {
const settled: SettledApproval = { id: event.id, approved: event.approved, timedOut: event.timedOut }
return {
...state,
approvals: state.approvals.filter((a) => a.id !== event.id),
settledApprovals: [...state.settledApprovals.slice(-2), settled]
}
}
case 'stats':
return { ...state, stats: event.stats }
default:
@@ -258,6 +279,7 @@ export interface UseAgent {
openSession: (path: string) => void
renameSession: (name: string) => void
deleteSession: (path: string) => void
resolveApproval: (id: string, approved: boolean) => void
}
export function useAgent(): UseAgent {
@@ -341,6 +363,13 @@ export function useAgent(): UseAgent {
[dispatchError]
)
const resolveApproval = useCallback(
(id: string, approved: boolean): void => {
window.api.resolveApproval(id, approved).catch(dispatchError)
},
[dispatchError]
)
return useMemo(
() => ({
state,
@@ -352,8 +381,21 @@ export function useAgent(): UseAgent {
newSession,
openSession,
renameSession,
deleteSession
deleteSession,
resolveApproval
}),
[state, sessions, send, stop, clearError, refreshSessions, newSession, openSession, renameSession, deleteSession]
[
state,
sessions,
send,
stop,
clearError,
refreshSessions,
newSession,
openSession,
renameSession,
deleteSession,
resolveApproval
]
)
}
+1
View File
@@ -29,6 +29,7 @@ declare global {
logoutProvider: (providerId: string) => Promise<void>
getSettings: () => Promise<AppSettings>
setSystemPrompt: (text: string) => Promise<void>
resolveApproval: (id: string, approved: boolean) => Promise<void>
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
}
}