v0.4.0: 工具与审批(业务工具骨架 + Approval Card)
This commit is contained in:
@@ -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
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user