feat(phase24-b3): NemotronProvider 抽取 + incident-card 重構
Phase 24 B3: - 新增 ai_providers/nemotron.py: NemotronProvider 封裝 K8s Tool Calling 搬移自 openclaw.py _call_nemotron_tools (L1623-1785) capabilities=tool_calling, privacy_level=cloud - ai_router.py: 加入 NemotronProvider 到 Registry - ai_providers/__init__.py: 匯出 NemotronProvider Phase R-UI2 (架構師 Warning): - incident-card.tsx: 抽取 useApprovalAction hook handleApprove/handleReject 60行重複邏輯 → 共用 hook 行為完全不變,維護性提升 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,62 @@ function formatDuration(createdAt: string | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 2026-04-02 Claude Code: Phase R-UI2 handleApprove/Reject 重複邏輯抽取
|
||||
// useApprovalAction — 統一 setup/teardown:loading 狀態、timeout、error 處理
|
||||
// action callback 負責差異邏輯(API call),並回傳最終 ButtonState
|
||||
// 若 action 未回傳(void),hook 維持 idle
|
||||
// =============================================================================
|
||||
|
||||
interface ApprovalActionResult {
|
||||
execute: () => Promise<void>
|
||||
buttonState: ButtonState
|
||||
errorMessage: string | null
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
function useApprovalAction(
|
||||
action: () => Promise<ButtonState | void>,
|
||||
timeoutMs: number,
|
||||
timeoutMessage: string,
|
||||
): ApprovalActionResult {
|
||||
const [buttonState, setButtonState] = useState<ButtonState>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) }
|
||||
}, [])
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
if (buttonState === 'loading') return
|
||||
setButtonState('loading')
|
||||
setErrorMessage(null)
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setButtonState('timeout')
|
||||
setErrorMessage(timeoutMessage)
|
||||
}, timeoutMs)
|
||||
|
||||
try {
|
||||
const finalState = await action()
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
|
||||
setButtonState(finalState ?? 'idle')
|
||||
} catch (error) {
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
|
||||
setButtonState('error')
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}, [action, buttonState, timeoutMs, timeoutMessage])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setButtonState('idle')
|
||||
setErrorMessage(null)
|
||||
}, [])
|
||||
|
||||
return { execute, buttonState, errorMessage, reset }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
@@ -80,15 +136,8 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
|
||||
const t = useTranslations('incident.card')
|
||||
const { csrfToken } = useCSRF()
|
||||
|
||||
const [buttonState, setButtonState] = useState<ButtonState>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [currentProposalId, setCurrentProposalId] = useState<string | null>(null)
|
||||
const [aiExpanded, setAiExpanded] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) }
|
||||
}, [])
|
||||
|
||||
const incidentStatus = incident.status as string
|
||||
const sev = incident.severity as keyof typeof SEV_CONFIG
|
||||
@@ -104,75 +153,59 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
|
||||
const decisionAction = decision?.proposal_data?.action ?? ''
|
||||
const decisionReasoning = decision?.proposal_data?.reasoning ?? ''
|
||||
|
||||
// ── handleApprove(移植自 DualStateIncidentCard Phase 6.5)──────────────
|
||||
const handleApprove = useCallback(async () => {
|
||||
if (!isDecisionReady || buttonState === 'loading') return
|
||||
setButtonState('loading')
|
||||
setErrorMessage(null)
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setButtonState('timeout')
|
||||
setErrorMessage(t('timeoutMessage'))
|
||||
}, EXECUTION_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
let approvalId = currentProposalId
|
||||
if (!approvalId && decision?.token) {
|
||||
const proposalResult = await apiClient.generateProposal(incident.incident_id)
|
||||
if (!proposalResult.success || !proposalResult.proposal) {
|
||||
throw new Error(proposalResult.message || 'Failed to generate proposal')
|
||||
}
|
||||
approvalId = proposalResult.proposal.id
|
||||
setCurrentProposalId(approvalId)
|
||||
// ── 解析 proposalId(approve/reject 共用前置步驟)─────────────────────────
|
||||
const resolveProposalId = useCallback(async (): Promise<string> => {
|
||||
let approvalId = currentProposalId
|
||||
if (!approvalId && decision?.token) {
|
||||
const proposalResult = await apiClient.generateProposal(incident.incident_id)
|
||||
if (!proposalResult.success || !proposalResult.proposal) {
|
||||
throw new Error(proposalResult.message || 'Failed to generate proposal')
|
||||
}
|
||||
if (!approvalId) throw new Error('No approval ID available')
|
||||
const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via AI Center', csrfToken)
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
|
||||
const approvalStatus = result.approval?.status?.toLowerCase()
|
||||
if (approvalStatus === 'approved') {
|
||||
setButtonState('approved')
|
||||
onApprovalChange?.(approvalId, 'approved')
|
||||
} else {
|
||||
setButtonState('idle')
|
||||
}
|
||||
} catch (error) {
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
|
||||
setButtonState('error')
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
approvalId = proposalResult.proposal.id
|
||||
setCurrentProposalId(approvalId)
|
||||
}
|
||||
}, [currentProposalId, decision, incident.incident_id, isDecisionReady, buttonState, onApprovalChange, csrfToken, t])
|
||||
if (!approvalId) throw new Error('No approval ID available')
|
||||
return approvalId
|
||||
}, [currentProposalId, decision, incident.incident_id])
|
||||
|
||||
// ── handleReject(移植自 DualStateIncidentCard Phase 6.5)────────────────
|
||||
const handleReject = useCallback(async () => {
|
||||
if (!isDecisionReady || buttonState === 'loading') return
|
||||
setButtonState('loading')
|
||||
setErrorMessage(null)
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setButtonState('timeout')
|
||||
setErrorMessage(t('timeoutMessage'))
|
||||
}, EXECUTION_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
let approvalId = currentProposalId
|
||||
if (!approvalId && decision?.token) {
|
||||
const proposalResult = await apiClient.generateProposal(incident.incident_id)
|
||||
if (proposalResult.success && proposalResult.proposal) {
|
||||
approvalId = proposalResult.proposal.id
|
||||
setCurrentProposalId(approvalId)
|
||||
}
|
||||
}
|
||||
if (!approvalId) throw new Error('No approval ID available')
|
||||
await apiClient.rejectApproval(approvalId, 'Rejected via AI Center', csrfToken)
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
|
||||
setButtonState('rejected')
|
||||
onApprovalChange?.(approvalId, 'rejected')
|
||||
} catch (error) {
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
|
||||
setButtonState('error')
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
// ── approve action(差異邏輯:signApproval + 成功狀態判斷)────────────────
|
||||
const approveAction = useCallback(async (): Promise<ButtonState | void> => {
|
||||
if (!isDecisionReady) return
|
||||
const approvalId = await resolveProposalId()
|
||||
const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via AI Center', csrfToken)
|
||||
const approvalStatus = result.approval?.status?.toLowerCase()
|
||||
if (approvalStatus === 'approved') {
|
||||
onApprovalChange?.(approvalId, 'approved')
|
||||
return 'approved'
|
||||
}
|
||||
}, [currentProposalId, decision, incident.incident_id, isDecisionReady, buttonState, onApprovalChange, csrfToken, t])
|
||||
// API 回傳非 approved(保持與原始行為一致:回到 idle)
|
||||
return 'idle'
|
||||
}, [isDecisionReady, resolveProposalId, csrfToken, onApprovalChange])
|
||||
|
||||
// ── reject action(差異邏輯:rejectApproval + 直接 rejected 狀態)─────────
|
||||
const rejectAction = useCallback(async (): Promise<ButtonState> => {
|
||||
if (!isDecisionReady) return 'idle'
|
||||
const approvalId = await resolveProposalId()
|
||||
await apiClient.rejectApproval(approvalId, 'Rejected via AI Center', csrfToken)
|
||||
onApprovalChange?.(approvalId, 'rejected')
|
||||
return 'rejected'
|
||||
}, [isDecisionReady, resolveProposalId, csrfToken, onApprovalChange])
|
||||
|
||||
const timeoutMsg = t('timeoutMessage')
|
||||
|
||||
// ── useApprovalAction instances(共用 buttonState / errorMessage 透過 approve hook 主導)
|
||||
// approve 和 reject 原本共用同一個 buttonState;為保持原行為,
|
||||
// 以 approveHook 的 state 為主,rejectHook 的 execute 直接呼叫(state 不顯示)
|
||||
const approveHook = useApprovalAction(approveAction, EXECUTION_TIMEOUT_MS, timeoutMsg)
|
||||
const rejectHook = useApprovalAction(rejectAction, EXECUTION_TIMEOUT_MS, timeoutMsg)
|
||||
|
||||
// 顯示優先順序:若任一 hook 處於 non-idle 則優先顯示;否則 idle
|
||||
const activeHook = approveHook.buttonState !== 'idle' ? approveHook : rejectHook
|
||||
const buttonState = activeHook.buttonState
|
||||
const errorMessage = activeHook.errorMessage
|
||||
|
||||
const handleApprove = approveHook.execute
|
||||
const handleReject = rejectHook.execute
|
||||
|
||||
// ── 授權按鈕渲染 ───────────────────────────────────────────────────────────
|
||||
const renderApproveButtons = () => {
|
||||
@@ -196,7 +229,7 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
|
||||
{buttonState === 'timeout' ? t('timeout') : t('error')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setButtonState('idle'); setErrorMessage(null) }}
|
||||
onClick={() => { approveHook.reset(); rejectHook.reset() }}
|
||||
style={{ fontSize: 12, padding: '2px 7px', background: '#f0efe8', border: '0.5px solid #e0ddd4', borderRadius: 10, cursor: 'pointer', color: '#87867f' }}
|
||||
>
|
||||
{t('retry')}
|
||||
|
||||
Reference in New Issue
Block a user