feat(phase24-b3): NemotronProvider 抽取 + incident-card 重構
Some checks failed
E2E Health Check / e2e-health (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled

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:
OG T
2026-04-02 23:12:42 +08:00
parent 5a8aae89c4
commit 58002e6bf4
3 changed files with 372 additions and 74 deletions

View File

@@ -72,6 +72,62 @@ function formatDuration(createdAt: string | undefined): string {
}
}
// =============================================================================
// 2026-04-02 Claude Code: Phase R-UI2 handleApprove/Reject 重複邏輯抽取
// useApprovalAction — 統一 setup/teardownloading 狀態、timeout、error 處理
// action callback 負責差異邏輯API call並回傳最終 ButtonState
// 若 action 未回傳voidhook 維持 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)
// ── 解析 proposalIdapprove/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')}