| import { useState, useEffect } from 'react' |
|
|
| const ACTION_META = { |
| fetch_user_history: { tag: 'INV', color: '#60a5fa', border: 'rgba(96,165,250,0.3)', bg: 'rgba(59,130,246,0.06)' }, |
| fetch_thread_context: { tag: 'INV', color: '#60a5fa', border: 'rgba(96,165,250,0.3)', bg: 'rgba(59,130,246,0.06)' }, |
| check_policy_clause: { tag: 'INV', color: '#60a5fa', border: 'rgba(96,165,250,0.3)', bg: 'rgba(59,130,246,0.06)' }, |
| mark_violation_type: { tag: 'CLS', color: '#fbbf24', border: 'rgba(251,191,36,0.3)', bg: 'rgba(251,191,36,0.06)' }, |
| allow: { tag: 'END', color: '#4ade80', border: 'rgba(74,222,128,0.4)', bg: 'rgba(74,222,128,0.07)' }, |
| flag: { tag: 'END', color: '#fb923c', border: 'rgba(251,146,60,0.4)', bg: 'rgba(251,146,60,0.07)' }, |
| remove: { tag: 'END', color: '#f87171', border: 'rgba(248,113,113,0.4)', bg: 'rgba(248,113,113,0.07)' }, |
| escalate: { tag: 'END', color: '#c084fc', border: 'rgba(192,132,252,0.4)', bg: 'rgba(192,132,252,0.07)' }, |
| } |
|
|
| const INV_LABELS = { |
| fetch_user_history: 'Fetched user violation history', |
| fetch_thread_context: 'Retrieved conversation thread context', |
| check_policy_clause: 'Loaded applicable policy clause', |
| mark_violation_type: 'Classified violation type', |
| allow: 'Decision: Allow content', |
| flag: 'Decision: Flag for review', |
| remove: 'Decision: Remove content', |
| escalate: 'Decision: Escalate to senior moderator', |
| } |
|
|
| function RewardSign({ value }) { |
| const isPos = value >= 0 |
| return ( |
| <span className="tabular-nums" style={{ color: isPos ? '#4ade80' : '#f87171' }}> |
| {isPos ? '+' : ''}{value.toFixed(2)} |
| </span> |
| ) |
| } |
|
|
| export default function StepTimeline({ trajectory, animKey }) { |
| const [visibleCount, setVisibleCount] = useState(0) |
|
|
| |
| useEffect(() => { |
| setVisibleCount(0) |
| if (!trajectory?.length) return |
| let count = 0 |
| const id = setInterval(() => { |
| count += 1 |
| setVisibleCount(count) |
| if (count >= trajectory.length) clearInterval(id) |
| }, 380) |
| return () => clearInterval(id) |
| }, [animKey, trajectory]) |
|
|
| if (!trajectory?.length) return null |
|
|
| const visible = trajectory.slice(0, visibleCount) |
|
|
| return ( |
| <div className="space-y-2"> |
| {visible.map((item, i) => { |
| const at = item.action.action_type |
| const meta = ACTION_META[at] || { tag: '?', color: '#5a5f7a', border: 'rgba(90,95,122,0.3)', bg: 'transparent' } |
| const isTerminal = meta.tag === 'END' |
| const params = item.action.parameters |
| |
| return ( |
| <div |
| key={`${animKey}-${i}`} |
| className="rounded overflow-hidden" |
| style={{ |
| border: `1px solid ${meta.border}`, |
| background: meta.bg, |
| animation: 'stepReveal 0.32s ease forwards', |
| }} |
| > |
| <div className="flex items-start gap-3 px-3 py-2.5"> |
| {/* Step index + type tag */} |
| <div className="flex-shrink-0 flex items-center gap-2 pt-px"> |
| <span className="text-[#3a3f5a] text-[10px] tabular-nums w-4"> |
| {String(item.step).padStart(2, '0')} |
| </span> |
| <span |
| className="text-[9px] font-semibold px-1.5 py-px rounded-sm tracking-widest" |
| style={{ |
| color: meta.color, |
| background: `${meta.color}18`, |
| border: `1px solid ${meta.border}`, |
| }} |
| > |
| {meta.tag} |
| </span> |
| </div> |
| |
| {/* Content */} |
| <div className="flex-1 min-w-0"> |
| <div className="flex items-baseline justify-between gap-2"> |
| <span |
| className="font-medium tracking-tight" |
| style={{ color: meta.color, fontSize: '12px' }} |
| > |
| {at} |
| </span> |
| <span className="text-xs flex-shrink-0"> |
| <RewardSign value={item.reward} /> |
| </span> |
| </div> |
| |
| {/* Human-readable label */} |
| <div className="mt-0.5 text-[10px] text-[#5a5f7a]"> |
| {INV_LABELS[at] || at} |
| </div> |
| |
| {/* Parameters for mark_violation_type */} |
| {at === 'mark_violation_type' && params?.violation_type && ( |
| <div className="mt-1 text-[10px] text-[#3a3f5a]"> |
| violation_type = <span style={{ color: '#fbbf24' }}>"{params.violation_type}"</span> |
| </div> |
| )} |
| |
| {/* Reward reason (dimmed, compact) */} |
| <div className="mt-0.5 text-[10px] text-[#2a2a45] truncate"> |
| {item.reward_reason} |
| </div> |
| </div> |
| </div> |
| |
| {/* Terminal bottom accent */} |
| {isTerminal && ( |
| <div |
| className="h-px w-full" |
| style={{ background: `linear-gradient(90deg, ${meta.color}70, transparent)` }} |
| /> |
| )} |
| </div> |
| ) |
| })} |
| |
| {/* Pending steps placeholder — shows how many are still to animate */} |
| {visibleCount < trajectory.length && ( |
| <div className="flex items-center gap-2 px-3 py-1.5"> |
| <span className="animate-scanPulse text-[#3a3f5a] text-xs">◎</span> |
| <span className="text-[10px] text-[#3a3f5a]"> |
| {trajectory.length - visibleCount} more step{trajectory.length - visibleCount !== 1 ? 's' : ''}... |
| </span> |
| </div> |
| )} |
| </div> |
| ) |
| } |
|
|