principles-disciple 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/ui/src/i18n/ui.ts +86 -9
- package/ui/src/pages/EvolutionPage.tsx +39 -58
- package/ui/src/pages/FeedbackPage.tsx +0 -2
- package/ui/src/pages/GateMonitorPage.tsx +3 -3
- package/ui/src/pages/LoginPage.tsx +2 -1
- package/ui/src/pages/OverviewPage.tsx +10 -9
- package/ui/src/pages/SamplesPage.tsx +3 -3
- package/ui/src/pages/ThinkingModelsPage.tsx +636 -69
- package/ui/src/styles.css +316 -0
|
@@ -1,19 +1,91 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { ChevronLeft } from 'lucide-react';
|
|
1
|
+
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
|
2
|
+
import { ChevronLeft, Search, ArrowUpDown, X, Columns, Wrench, Zap, ClipboardList, Loader2 } from 'lucide-react';
|
|
3
3
|
import { api } from '../api';
|
|
4
|
-
import type { ThinkingOverviewResponse, ThinkingModelDetailResponse } from '../types';
|
|
5
|
-
import { EmptyState } from '../charts';
|
|
4
|
+
import type { ThinkingOverviewResponse, ThinkingModelDetailResponse, ThinkingModelSummary } from '../types';
|
|
5
|
+
import { EmptyState, LineChart, StatusBadge, CollapsiblePanel, Sparkline, MiniBarChart } from '../charts';
|
|
6
6
|
import { useI18n } from '../i18n/ui';
|
|
7
7
|
import { formatPercent, formatDate } from '../utils/format';
|
|
8
8
|
import { Loading, ErrorState } from '../components';
|
|
9
9
|
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Design tokens (mirrors CSS custom properties for inline fallback)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const TEXT = {
|
|
15
|
+
xs: '0.65rem',
|
|
16
|
+
sm: '0.7rem',
|
|
17
|
+
base: '0.75rem',
|
|
18
|
+
lg: '0.8rem',
|
|
19
|
+
xl: '0.85rem',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
const SPACE = {
|
|
23
|
+
1: 'var(--space-1, 4px)',
|
|
24
|
+
2: 'var(--space-2, 8px)',
|
|
25
|
+
3: 'var(--space-3, 12px)',
|
|
26
|
+
4: 'var(--space-4, 16px)',
|
|
27
|
+
5: 'var(--space-5, 24px)',
|
|
28
|
+
6: 'var(--space-6, 32px)',
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Recommendation badge helper
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
type BadgeVariant = 'success' | 'warning' | 'neutral';
|
|
36
|
+
|
|
37
|
+
const REC_BADGE: Record<string, { variant: BadgeVariant; label: (t: (k: string) => string) => string }> = {
|
|
38
|
+
reinforce: { variant: 'success', label: (t) => t('thinkingModels.reinforce') },
|
|
39
|
+
rework: { variant: 'warning', label: (t) => t('thinkingModels.rework') },
|
|
40
|
+
archive: { variant: 'neutral', label: (t) => t('thinkingModels.archive') },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// ThinkingModelsPage — Redesigned Layout
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
10
47
|
export function ThinkingModelsPage() {
|
|
11
48
|
const { t } = useI18n();
|
|
12
49
|
const [data, setData] = useState<ThinkingOverviewResponse | null>(null);
|
|
13
50
|
const [detail, setDetail] = useState<ThinkingModelDetailResponse | null>(null);
|
|
14
51
|
const [selectedModel, setSelectedModel] = useState('');
|
|
52
|
+
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
|
15
53
|
const [error, setError] = useState('');
|
|
16
54
|
|
|
55
|
+
// Comparison mode state
|
|
56
|
+
const [selectedForCompare, setSelectedForCompare] = useState<string[]>([]);
|
|
57
|
+
const [comparisonDetails, setComparisonDetails] = useState<Map<string, ThinkingModelDetailResponse>>(new Map());
|
|
58
|
+
const [isComparing, setIsComparing] = useState(false);
|
|
59
|
+
const [comparisonLoadingModels, setComparisonLoadingModels] = useState<Set<string>>(new Set());
|
|
60
|
+
|
|
61
|
+
// Filters
|
|
62
|
+
const [recFilter, setRecFilter] = useState('all');
|
|
63
|
+
const [search, setSearch] = useState('');
|
|
64
|
+
const [sortBy, setSortBy] = useState<'hits' | 'successRate' | 'name'>('hits');
|
|
65
|
+
|
|
66
|
+
// Debounced search
|
|
67
|
+
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
68
|
+
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (searchTimer.current) clearTimeout(searchTimer.current);
|
|
72
|
+
searchTimer.current = setTimeout(() => setDebouncedSearch(search), 200);
|
|
73
|
+
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
|
|
74
|
+
}, [search]);
|
|
75
|
+
|
|
76
|
+
// Cleanup timer on unmount
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
// Cache for detail lookups during comparison
|
|
82
|
+
const detailCache = useMemo(() => {
|
|
83
|
+
const cache = new Map<string, ThinkingModelDetailResponse>();
|
|
84
|
+
if (detail) cache.set(selectedModel, detail);
|
|
85
|
+
comparisonDetails.forEach((d, id) => cache.set(id, d));
|
|
86
|
+
return cache;
|
|
87
|
+
}, [detail, selectedModel, comparisonDetails]);
|
|
88
|
+
|
|
17
89
|
useEffect(() => {
|
|
18
90
|
api.getThinkingOverview().then((value) => {
|
|
19
91
|
setData(value);
|
|
@@ -23,17 +95,109 @@ export function ThinkingModelsPage() {
|
|
|
23
95
|
|
|
24
96
|
useEffect(() => {
|
|
25
97
|
if (!selectedModel) return;
|
|
26
|
-
|
|
98
|
+
setIsLoadingDetail(true);
|
|
99
|
+
api.getThinkingModelDetail(selectedModel)
|
|
100
|
+
.then((d) => { setDetail(d); setIsLoadingDetail(false); })
|
|
101
|
+
.catch((err) => { setError(String(err)); setIsLoadingDetail(false); });
|
|
27
102
|
}, [selectedModel]);
|
|
28
103
|
|
|
104
|
+
// Comparison mode handlers
|
|
105
|
+
const toggleCompareSelection = useCallback((modelId: string) => {
|
|
106
|
+
setSelectedForCompare(prev => {
|
|
107
|
+
if (prev.includes(modelId)) {
|
|
108
|
+
return prev.filter(id => id !== modelId);
|
|
109
|
+
}
|
|
110
|
+
if (prev.length >= 4) return prev; // Max 4 for layout
|
|
111
|
+
return [...prev, modelId];
|
|
112
|
+
});
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
const startComparison = useCallback(async () => {
|
|
116
|
+
if (selectedForCompare.length < 2) return;
|
|
117
|
+
setIsComparing(true);
|
|
118
|
+
const newDetails = new Map(comparisonDetails);
|
|
119
|
+
const pending = selectedForCompare.filter(id => !newDetails.has(id));
|
|
120
|
+
|
|
121
|
+
// Track per-model loading state
|
|
122
|
+
setComparisonLoadingModels(new Set(pending));
|
|
123
|
+
|
|
124
|
+
const fetches = pending.map(async (id) => {
|
|
125
|
+
try {
|
|
126
|
+
const d = await api.getThinkingModelDetail(id);
|
|
127
|
+
newDetails.set(id, d);
|
|
128
|
+
} catch {
|
|
129
|
+
// Skip failed fetches
|
|
130
|
+
} finally {
|
|
131
|
+
setComparisonLoadingModels(prev => {
|
|
132
|
+
const next = new Set(prev);
|
|
133
|
+
next.delete(id);
|
|
134
|
+
return next;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
await Promise.all(fetches);
|
|
139
|
+
setComparisonDetails(newDetails);
|
|
140
|
+
}, [selectedForCompare, comparisonDetails]);
|
|
141
|
+
|
|
142
|
+
const exitComparison = useCallback(() => {
|
|
143
|
+
setIsComparing(false);
|
|
144
|
+
setSelectedForCompare([]);
|
|
145
|
+
setComparisonDetails(new Map());
|
|
146
|
+
setComparisonLoadingModels(new Set());
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
// Filtered + sorted model list
|
|
150
|
+
const filteredModels = useMemo(() => {
|
|
151
|
+
if (!data) return [];
|
|
152
|
+
let models = [...data.topModels];
|
|
153
|
+
if (recFilter !== 'all') {
|
|
154
|
+
models = models.filter(m => m.recommendation === recFilter);
|
|
155
|
+
}
|
|
156
|
+
if (debouncedSearch) {
|
|
157
|
+
const q = debouncedSearch.toLowerCase();
|
|
158
|
+
models = models.filter(m =>
|
|
159
|
+
m.name.toLowerCase().includes(q) ||
|
|
160
|
+
(m.commonScenarios ?? []).some(s => s.toLowerCase().includes(q))
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
models.sort((a, b) => {
|
|
164
|
+
if (sortBy === 'hits') return b.hits - a.hits;
|
|
165
|
+
if (sortBy === 'successRate') return b.successRate - a.successRate;
|
|
166
|
+
return a.name.localeCompare(b.name);
|
|
167
|
+
});
|
|
168
|
+
return models;
|
|
169
|
+
}, [data, recFilter, debouncedSearch, sortBy]);
|
|
170
|
+
|
|
171
|
+
// Scenario heatmap data
|
|
172
|
+
const heatmapData = useMemo(() => {
|
|
173
|
+
if (!data || data.scenarioMatrix.length === 0) return null;
|
|
174
|
+
const allScenarios = [...new Set(data.scenarioMatrix.map(m => m.scenario))].sort();
|
|
175
|
+
const models = [...data.topModels].sort((a, b) => b.hits - a.hits);
|
|
176
|
+
const hitMap = new Map<string, number>();
|
|
177
|
+
for (const entry of data.scenarioMatrix) {
|
|
178
|
+
hitMap.set(`${entry.modelId}::${entry.scenario}`, entry.hits);
|
|
179
|
+
}
|
|
180
|
+
const maxHits = Math.max(...data.scenarioMatrix.map(m => m.hits), 1);
|
|
181
|
+
return { allScenarios, models, hitMap, maxHits };
|
|
182
|
+
}, [data]);
|
|
183
|
+
|
|
29
184
|
if (error) return <ErrorState error={error} />;
|
|
30
185
|
if (!data) return <Loading />;
|
|
31
186
|
|
|
187
|
+
const totalHits = data.topModels.reduce((sum, m) => sum + m.hits, 0);
|
|
188
|
+
const hasData = totalHits > 0;
|
|
189
|
+
|
|
32
190
|
return (
|
|
33
191
|
<div className="page">
|
|
192
|
+
{/* ── Header ── */}
|
|
34
193
|
<header className="page-header">
|
|
35
194
|
<div>
|
|
36
195
|
<h2>{t('thinkingModels.pageTitle')}</h2>
|
|
196
|
+
{data.thinkingSummary?.modelDefinitions && data.thinkingSummary.modelDefinitions.length > 0 && (
|
|
197
|
+
<p style={{ fontSize: TEXT.sm, color: 'var(--text-secondary)', margin: `${SPACE[1]} 0 0` }}>
|
|
198
|
+
{t('thinkingModels.thinkingOsSource')}: <code style={{ fontSize: TEXT.xs, background: 'var(--bg-sunken)', padding: '1px 4px', borderRadius: 3 }}>THINKING_OS.md</code>
|
|
199
|
+
</p>
|
|
200
|
+
)}
|
|
37
201
|
</div>
|
|
38
202
|
<div className="pill-row">
|
|
39
203
|
<span className="badge">{t('thinkingModels.coverage')} {formatPercent(data.summary.coverageRate)}</span>
|
|
@@ -43,85 +207,488 @@ export function ThinkingModelsPage() {
|
|
|
43
207
|
</div>
|
|
44
208
|
</header>
|
|
45
209
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
210
|
+
{!hasData ? (
|
|
211
|
+
/* ── No data yet: show model definitions grid ── */
|
|
212
|
+
<section className="panel" style={{ marginBottom: SPACE[4] }}>
|
|
213
|
+
<div style={{ textAlign: 'center', padding: SPACE[5], color: 'var(--text-secondary)' }}>
|
|
214
|
+
<div style={{ fontSize: '2rem', marginBottom: SPACE[2] }}>🧠</div>
|
|
215
|
+
<h3 style={{ marginBottom: SPACE[1] }}>{t('thinkingModels.noDataTitle')}</h3>
|
|
216
|
+
<p style={{ fontSize: TEXT.lg, maxWidth: 500, margin: `0 auto ${SPACE[5]}` }}>
|
|
217
|
+
{t('thinkingModels.noDataDesc')}
|
|
218
|
+
</p>
|
|
219
|
+
</div>
|
|
220
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: SPACE[3] }}>
|
|
221
|
+
{data.topModels.map(model => (
|
|
222
|
+
<div
|
|
223
|
+
key={model.modelId}
|
|
224
|
+
role="button"
|
|
225
|
+
tabIndex={0}
|
|
226
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedModel(model.modelId); setDetail(null); } }}
|
|
227
|
+
style={{
|
|
228
|
+
padding: SPACE[3],
|
|
229
|
+
border: '1px solid var(--border)',
|
|
230
|
+
borderRadius: 8,
|
|
231
|
+
background: 'var(--bg-sunken)',
|
|
232
|
+
cursor: 'pointer',
|
|
233
|
+
}}
|
|
234
|
+
onClick={() => { setSelectedModel(model.modelId); setDetail(null); }}
|
|
235
|
+
>
|
|
236
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: SPACE[2] }}>
|
|
237
|
+
<strong style={{ fontSize: TEXT.xl }}>{model.modelId}: {model.name}</strong>
|
|
62
238
|
</div>
|
|
63
|
-
|
|
239
|
+
<p style={{ fontSize: TEXT.base, color: 'var(--text-secondary)', margin: `0 0 ${SPACE[2]}`, lineHeight: 1.4 }}>
|
|
240
|
+
{model.description}
|
|
241
|
+
</p>
|
|
242
|
+
{model.commonScenarios.length > 0 && (
|
|
243
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: SPACE[1] }}>
|
|
244
|
+
{model.commonScenarios.slice(0, 3).map(s => (
|
|
245
|
+
<span key={s} style={{ fontSize: TEXT.xs, padding: '1px 6px', background: 'rgba(91,139,160,0.1)', borderRadius: 3, color: 'var(--info)' }}>
|
|
246
|
+
{s}
|
|
247
|
+
</span>
|
|
248
|
+
))}
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
64
252
|
))}
|
|
65
253
|
</div>
|
|
66
254
|
</section>
|
|
255
|
+
) : (
|
|
256
|
+
/* ── Has data: full dashboard ── */
|
|
257
|
+
<>
|
|
258
|
+
{/* Coverage Trend */}
|
|
259
|
+
{data.coverageTrend.length >= 1 && (
|
|
260
|
+
<section className="panel" style={{ marginBottom: SPACE[4] }}>
|
|
261
|
+
<h3 className="section-title">
|
|
262
|
+
{t('thinkingModels.coverageTrend')}
|
|
263
|
+
</h3>
|
|
264
|
+
<LineChart
|
|
265
|
+
data={data.coverageTrend.map(d => ({ label: d.day.slice(5), value: Math.round(d.coverageRate * 100) }))}
|
|
266
|
+
width={560}
|
|
267
|
+
height={140}
|
|
268
|
+
color="var(--accent)"
|
|
269
|
+
showGrid
|
|
270
|
+
showDots
|
|
271
|
+
showArea
|
|
272
|
+
/>
|
|
273
|
+
</section>
|
|
274
|
+
)}
|
|
67
275
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
276
|
+
{/* Search + Sort + Filter */}
|
|
277
|
+
<div style={{ display: 'flex', gap: SPACE[2], marginBottom: SPACE[3], alignItems: 'center', flexWrap: 'wrap' }}>
|
|
278
|
+
<div style={{ position: 'relative', flex: '1 1 200px' }}>
|
|
279
|
+
<Search size={14} style={{ position: 'absolute', left: SPACE[2], top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)', pointerEvents: 'none' }} />
|
|
280
|
+
<input
|
|
281
|
+
type="text"
|
|
282
|
+
placeholder={t('thinkingModels.searchPlaceholder')}
|
|
283
|
+
value={search}
|
|
284
|
+
onChange={e => setSearch(e.target.value)}
|
|
285
|
+
style={{ width: '100%', padding: `6px ${SPACE[2]} 6px 28px`, border: '1px solid var(--border)', borderRadius: 6, background: 'var(--bg-panel)', color: 'var(--text-primary)', fontSize: TEXT.lg }}
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
<button
|
|
289
|
+
onClick={() => setSortBy(prev => prev === 'hits' ? 'successRate' : prev === 'successRate' ? 'name' : 'hits')}
|
|
290
|
+
className="sort-button"
|
|
291
|
+
>
|
|
292
|
+
<ArrowUpDown size={14} />
|
|
293
|
+
{sortBy === 'hits' ? t('thinkingModels.sortByHits') : sortBy === 'successRate' ? t('thinkingModels.sortBySuccessRate') : t('thinkingModels.sortByName')}
|
|
294
|
+
</button>
|
|
295
|
+
<div style={{ display: 'flex', gap: SPACE[1] }}>
|
|
296
|
+
{['all', 'reinforce', 'rework', 'archive'].map(key => (
|
|
73
297
|
<button
|
|
74
|
-
|
|
75
|
-
onClick={() =>
|
|
76
|
-
|
|
298
|
+
key={key}
|
|
299
|
+
onClick={() => setRecFilter(key)}
|
|
300
|
+
className={`filter-button ${recFilter === key ? 'active' : ''}`}
|
|
77
301
|
>
|
|
78
|
-
|
|
302
|
+
{key === 'all' ? t('thinkingModels.filterAll') : REC_BADGE[key]?.label(t)}
|
|
79
303
|
</button>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
{/* Two-column layout: Model List + Detail */}
|
|
309
|
+
<div className="grid two-columns wide-right">
|
|
310
|
+
{/* Left: Model List */}
|
|
311
|
+
<section className="panel">
|
|
312
|
+
{/* Compare button bar */}
|
|
313
|
+
{selectedForCompare.length >= 2 && !isComparing && (
|
|
314
|
+
<div className="compare-bar">
|
|
315
|
+
<span style={{ fontSize: TEXT.base, color: 'var(--text-secondary)' }}>
|
|
316
|
+
{selectedForCompare.length} {t('thinkingModels.compareSelected')}
|
|
317
|
+
</span>
|
|
318
|
+
<button
|
|
319
|
+
onClick={startComparison}
|
|
320
|
+
className="compare-button"
|
|
321
|
+
>
|
|
322
|
+
<Columns size={14} />
|
|
323
|
+
{t('thinkingModels.compare')}
|
|
324
|
+
</button>
|
|
83
325
|
</div>
|
|
84
|
-
|
|
326
|
+
)}
|
|
327
|
+
<div className="list-table">
|
|
328
|
+
{filteredModels.map((item) => {
|
|
329
|
+
const isChecked = selectedForCompare.includes(item.modelId);
|
|
330
|
+
return (
|
|
331
|
+
<div
|
|
332
|
+
key={item.modelId}
|
|
333
|
+
className={`table-row ${selectedModel === item.modelId && !isComparing ? 'active' : ''}`}
|
|
334
|
+
style={{ display: 'flex', alignItems: 'center', gap: SPACE[2], padding: `${SPACE[2]} ${SPACE[3]}`, ...(item.recommendation === 'reinforce' ? { borderLeft: '3px solid var(--success)', paddingLeft: `calc(${SPACE[2]} - 3px)` } : {}) }}
|
|
335
|
+
>
|
|
336
|
+
<input
|
|
337
|
+
type="checkbox"
|
|
338
|
+
checked={isChecked}
|
|
339
|
+
onChange={() => toggleCompareSelection(item.modelId)}
|
|
340
|
+
onClick={e => e.stopPropagation()}
|
|
341
|
+
style={{ accentColor: 'var(--accent)', cursor: 'pointer', flexShrink: 0 }}
|
|
342
|
+
aria-label={`${t('thinkingModels.compare')}: ${item.name}`}
|
|
343
|
+
/>
|
|
344
|
+
<button
|
|
345
|
+
onClick={() => { setSelectedModel(item.modelId); setDetail(null); setIsComparing(false); }}
|
|
346
|
+
className="model-list-button"
|
|
347
|
+
>
|
|
348
|
+
<div>
|
|
349
|
+
<strong className="text-base">{item.name}</strong>
|
|
350
|
+
<span className="scenario-ellipsis">
|
|
351
|
+
{item.commonScenarios.join(', ') || '—'}
|
|
352
|
+
</span>
|
|
353
|
+
</div>
|
|
354
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: SPACE[2] }}>
|
|
355
|
+
<span className="hits-count">{item.hits}</span>
|
|
356
|
+
{REC_BADGE[item.recommendation] && (
|
|
357
|
+
<StatusBadge variant={REC_BADGE[item.recommendation].variant}>
|
|
358
|
+
{REC_BADGE[item.recommendation].label(t)}
|
|
359
|
+
</StatusBadge>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
</button>
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
})}
|
|
366
|
+
{filteredModels.length === 0 && (
|
|
367
|
+
<EmptyState
|
|
368
|
+
title={data.topModels.length > 0 ? (t('thinkingModels.noMatches') || 'No models match your filters.') : t('thinkingModels.noModelsYet')}
|
|
369
|
+
description={data.topModels.length > 0 ? '' : t('thinkingModels.noModelsYetDesc')}
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
85
372
|
</div>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
373
|
+
</section>
|
|
374
|
+
|
|
375
|
+
{/* Right: Detail Panel / Comparison View */}
|
|
376
|
+
<section className="panel">
|
|
377
|
+
{isComparing ? (
|
|
378
|
+
/* ── Comparison View ── */
|
|
379
|
+
<div className="comparison-view">
|
|
380
|
+
<div className="detail-header" style={{ marginBottom: SPACE[4] }}>
|
|
381
|
+
<button className="back-button" onClick={exitComparison} title={t('thinkingModels.exitCompare')}>
|
|
382
|
+
<X strokeWidth={1.75} size={18} />
|
|
383
|
+
</button>
|
|
384
|
+
<div>
|
|
385
|
+
<h3>{t('thinkingModels.comparisonTitle')}</h3>
|
|
386
|
+
<p style={{ fontSize: TEXT.lg, color: 'var(--text-secondary)' }}>
|
|
387
|
+
{selectedForCompare.length} {t('thinkingModels.compareSelected')}
|
|
388
|
+
{comparisonLoadingModels.size > 0 && (
|
|
389
|
+
<span style={{ marginLeft: SPACE[2], color: 'var(--info)' }}>
|
|
390
|
+
<Loader2 size={12} className="spin" /> {t('thinkingModels.loadingComparison')}
|
|
391
|
+
</span>
|
|
392
|
+
)}
|
|
393
|
+
</p>
|
|
102
394
|
</div>
|
|
103
|
-
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
{/* Comparison grid: side-by-side metrics */}
|
|
398
|
+
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${selectedForCompare.length}, 1fr)`, gap: SPACE[3], marginBottom: SPACE[4] }}>
|
|
399
|
+
{selectedForCompare.map(modelId => {
|
|
400
|
+
const summary = data.topModels.find(m => m.modelId === modelId);
|
|
401
|
+
const det = comparisonDetails.get(modelId);
|
|
402
|
+
const isLoading = comparisonLoadingModels.has(modelId);
|
|
403
|
+
if (!summary) return null;
|
|
404
|
+
return (
|
|
405
|
+
<div key={modelId} style={{ padding: SPACE[3], border: '1px solid var(--border)', borderRadius: 8, background: 'var(--bg-sunken)', opacity: isLoading ? 0.6 : 1, position: 'relative' }}>
|
|
406
|
+
<strong style={{ fontSize: TEXT.xl, display: 'block', marginBottom: SPACE[2] }}>{summary.name}</strong>
|
|
407
|
+
{isLoading && (
|
|
408
|
+
<div style={{ position: 'absolute', top: SPACE[2], right: SPACE[2] }}>
|
|
409
|
+
<Loader2 size={14} className="spin" style={{ color: 'var(--info)' }} />
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
<div className="pill-row" style={{ flexWrap: 'wrap', marginBottom: SPACE[2] }}>
|
|
413
|
+
<span className="badge">{t('thinkingModels.hits')}: {summary.hits}</span>
|
|
414
|
+
<span className="badge">{t('thinkingModels.successRate')}: {formatPercent(summary.successRate)}</span>
|
|
415
|
+
<span className="badge">{t('thinkingModels.failureRate')}: {formatPercent(summary.failureRate)}</span>
|
|
416
|
+
<span className="badge">{t('thinkingModels.pain')}: {formatPercent(summary.painRate)}</span>
|
|
417
|
+
</div>
|
|
418
|
+
{det && det.outcomeStats && (
|
|
419
|
+
<div style={{ fontSize: TEXT.sm, color: 'var(--text-secondary)', marginTop: SPACE[1] }}>
|
|
420
|
+
<div>{t('thinkingModels.correction')}: {formatPercent(det.outcomeStats.correctionRate)}</div>
|
|
421
|
+
<div>{t('thinkingModels.coverage')}: {formatPercent(det.modelMeta.coverageRate)}</div>
|
|
422
|
+
</div>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
})}
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{/* Usage Trends for each model */}
|
|
430
|
+
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${selectedForCompare.length}, 1fr)`, gap: SPACE[3] }}>
|
|
431
|
+
{selectedForCompare.map(modelId => {
|
|
432
|
+
const summary = data.topModels.find(m => m.modelId === modelId);
|
|
433
|
+
const det = comparisonDetails.get(modelId);
|
|
434
|
+
if (!det || det.usageTrend.length < 2) return null;
|
|
435
|
+
return (
|
|
436
|
+
<article key={modelId} style={{ padding: SPACE[2], border: '1px solid var(--border)', borderRadius: 6, background: 'var(--bg-sunken)' }}>
|
|
437
|
+
<h4 className="text-sm text-semibold" style={{ marginBottom: SPACE[2] }}>
|
|
438
|
+
{summary?.name} — {t('thinkingModels.usageTrend')}
|
|
439
|
+
</h4>
|
|
440
|
+
<LineChart
|
|
441
|
+
data={det.usageTrend.map(d => ({ label: d.day.slice(5), value: d.hits }))}
|
|
442
|
+
width="100%"
|
|
443
|
+
height={80}
|
|
444
|
+
color="var(--accent)"
|
|
445
|
+
showGrid={false}
|
|
446
|
+
showDots
|
|
447
|
+
showArea
|
|
448
|
+
/>
|
|
449
|
+
</article>
|
|
450
|
+
);
|
|
451
|
+
})}
|
|
452
|
+
</div>
|
|
104
453
|
</div>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
454
|
+
) : (
|
|
455
|
+
<>
|
|
456
|
+
{isLoadingDetail && (
|
|
457
|
+
<div style={{ textAlign: 'center', padding: SPACE[6], color: 'var(--text-secondary)' }}>
|
|
458
|
+
<Loader2 size={24} className="spin" style={{ margin: `0 auto ${SPACE[2]}` }} />
|
|
459
|
+
<p style={{ fontSize: TEXT.lg }}>{t('thinkingModels.loadingDetail')}</p>
|
|
460
|
+
</div>
|
|
461
|
+
)}
|
|
462
|
+
{!isLoadingDetail && !detail && <EmptyState title={t('thinkingModels.emptyTitle')} description={t('thinkingModels.emptyDesc')} />}
|
|
463
|
+
{!isLoadingDetail && detail && (
|
|
464
|
+
<div className="detail-stack">
|
|
465
|
+
<div className="detail-header">
|
|
466
|
+
<button className="back-button" onClick={() => { setDetail(null); setSelectedModel(''); setIsLoadingDetail(true); }} title={t('common.back')}>
|
|
467
|
+
<ChevronLeft strokeWidth={1.75} size={18} />
|
|
468
|
+
</button>
|
|
469
|
+
<div>
|
|
470
|
+
<h3>{detail.modelMeta.name}</h3>
|
|
471
|
+
<p style={{ fontSize: TEXT.lg, color: 'var(--text-secondary)' }}>{detail.modelMeta.description}</p>
|
|
472
|
+
</div>
|
|
473
|
+
{REC_BADGE[detail.modelMeta.recommendation] && (
|
|
474
|
+
<StatusBadge variant={REC_BADGE[detail.modelMeta.recommendation].variant}>
|
|
475
|
+
{REC_BADGE[detail.modelMeta.recommendation].label(t)}
|
|
476
|
+
</StatusBadge>
|
|
477
|
+
)}
|
|
478
|
+
</div>
|
|
479
|
+
|
|
480
|
+
{/* Trigger Conditions */}
|
|
481
|
+
{detail.modelMeta.trigger && (
|
|
482
|
+
<article>
|
|
483
|
+
<h4 className="text-base text-semibold">{t('thinkingModels.trigger')}</h4>
|
|
484
|
+
<code className="code-block code-block-trigger">
|
|
485
|
+
{detail.modelMeta.trigger}
|
|
486
|
+
</code>
|
|
487
|
+
</article>
|
|
488
|
+
)}
|
|
489
|
+
|
|
490
|
+
{/* Anti-Patterns */}
|
|
491
|
+
{detail.modelMeta.antiPattern && (
|
|
492
|
+
<article>
|
|
493
|
+
<h4 className="text-base text-semibold text-error">{t('thinkingModels.antiPattern')}</h4>
|
|
494
|
+
<code className="code-block code-block-antipattern">
|
|
495
|
+
{detail.modelMeta.antiPattern}
|
|
496
|
+
</code>
|
|
497
|
+
</article>
|
|
498
|
+
)}
|
|
499
|
+
|
|
500
|
+
{/* Usage Trend */}
|
|
501
|
+
{detail.usageTrend.length >= 1 ? (
|
|
502
|
+
<article>
|
|
503
|
+
<h4 className="text-lg text-semibold" style={{ marginBottom: SPACE[2] }}>
|
|
504
|
+
{t('thinkingModels.usageTrend')}
|
|
505
|
+
</h4>
|
|
506
|
+
<LineChart
|
|
507
|
+
data={detail.usageTrend.map(d => ({ label: d.day.slice(5), value: d.hits }))}
|
|
508
|
+
width={500}
|
|
509
|
+
height={100}
|
|
510
|
+
color="var(--accent)"
|
|
511
|
+
showGrid
|
|
512
|
+
showDots
|
|
513
|
+
showArea
|
|
514
|
+
/>
|
|
515
|
+
</article>
|
|
516
|
+
) : (
|
|
517
|
+
<EmptyState
|
|
518
|
+
title={t('thinkingModels.emptyUsageTrend')}
|
|
519
|
+
description={t('thinkingModels.emptyUsageTrendDesc')}
|
|
520
|
+
/>
|
|
521
|
+
)}
|
|
522
|
+
|
|
523
|
+
{/* Outcome Stats */}
|
|
524
|
+
<article>
|
|
525
|
+
<h4 className="text-base text-semibold">{t('thinkingModels.outcomeStats')}</h4>
|
|
526
|
+
<div className="pill-row">
|
|
527
|
+
<span className="badge">{t('thinkingModels.success')} {formatPercent(detail.outcomeStats.successRate)}</span>
|
|
528
|
+
<span className="badge">{t('thinkingModels.failure')} {formatPercent(detail.outcomeStats.failureRate)}</span>
|
|
529
|
+
<span className="badge">{t('thinkingModels.pain')} {formatPercent(detail.outcomeStats.painRate)}</span>
|
|
530
|
+
<span className="badge">{t('thinkingModels.correction')} {formatPercent(detail.outcomeStats.correctionRate)}</span>
|
|
116
531
|
</div>
|
|
117
|
-
|
|
532
|
+
</article>
|
|
533
|
+
|
|
534
|
+
{/* Scenario Distribution */}
|
|
535
|
+
{detail.scenarioDistribution.length > 0 && (
|
|
536
|
+
<article>
|
|
537
|
+
<h4 className="text-base text-semibold">{t('thinkingModels.scenarioDistribution')}</h4>
|
|
538
|
+
<div className="stack">
|
|
539
|
+
{detail.scenarioDistribution.map((item) => (
|
|
540
|
+
<div className="row-card" key={item.scenario}>
|
|
541
|
+
<strong className="text-base">{item.scenario}</strong>
|
|
542
|
+
<span>{item.hits}</span>
|
|
543
|
+
</div>
|
|
544
|
+
))}
|
|
545
|
+
</div>
|
|
546
|
+
</article>
|
|
547
|
+
)}
|
|
548
|
+
|
|
549
|
+
{/* Recent Events */}
|
|
550
|
+
{detail.recentEvents.length > 0 && (
|
|
551
|
+
<article>
|
|
552
|
+
<h4 className="text-base text-semibold">{t('thinkingModels.recentEvents')}</h4>
|
|
553
|
+
<div className="stack">
|
|
554
|
+
{detail.recentEvents.map((event) => (
|
|
555
|
+
<div className="row-card vertical" key={event.id}>
|
|
556
|
+
<div>
|
|
557
|
+
<strong className="text-base">{formatDate(event.createdAt)}</strong>
|
|
558
|
+
<span className="text-sm">{event.scenarios.join(', ') || '—'}</span>
|
|
559
|
+
</div>
|
|
560
|
+
{event.toolContext?.length > 0 && (
|
|
561
|
+
<div className="event-context-tool" aria-label={t('thinkingModels.toolContext')}>
|
|
562
|
+
<Wrench size={12} aria-hidden /> {event.toolContext.map(tc => (
|
|
563
|
+
`${tc.toolName} (${tc.outcome}${tc.errorType ? `: ${tc.errorType}` : ''})`
|
|
564
|
+
)).join(', ')}
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
{event.painContext?.length > 0 && (
|
|
568
|
+
<div className="event-context-pain" aria-label={t('thinkingModels.painContext')}>
|
|
569
|
+
<Zap size={12} aria-hidden /> {event.painContext.map(pc => `${pc.source} (${pc.score})`).join(', ')}
|
|
570
|
+
</div>
|
|
571
|
+
)}
|
|
572
|
+
{event.principleContext?.length > 0 && (
|
|
573
|
+
<div className="event-context-principle" aria-label={t('thinkingModels.principleContext')}>
|
|
574
|
+
<ClipboardList size={12} aria-hidden /> {event.principleContext.map(pr => (
|
|
575
|
+
`${pr.principleId ?? '—'} ${pr.eventType ? `(${pr.eventType})` : ''}`
|
|
576
|
+
)).join(', ')}
|
|
577
|
+
</div>
|
|
578
|
+
)}
|
|
579
|
+
{event.matchedPattern && (
|
|
580
|
+
<code className="matched-pattern">
|
|
581
|
+
/{event.matchedPattern}/
|
|
582
|
+
</code>
|
|
583
|
+
)}
|
|
584
|
+
<pre className="event-trigger-excerpt">
|
|
585
|
+
{event.triggerExcerpt}
|
|
586
|
+
</pre>
|
|
587
|
+
</div>
|
|
588
|
+
))}
|
|
589
|
+
</div>
|
|
590
|
+
</article>
|
|
591
|
+
)}
|
|
118
592
|
</div>
|
|
119
|
-
|
|
120
|
-
|
|
593
|
+
)}
|
|
594
|
+
</>
|
|
595
|
+
)}
|
|
596
|
+
</section>
|
|
597
|
+
</div>
|
|
598
|
+
|
|
599
|
+
{/* Dormant Models Section */}
|
|
600
|
+
{data.dormantModels.length > 0 ? (
|
|
601
|
+
<CollapsiblePanel
|
|
602
|
+
title={t('thinkingModels.dormantModels')}
|
|
603
|
+
badge={`${data.dormantModels.length}`}
|
|
604
|
+
defaultCollapsed
|
|
605
|
+
>
|
|
606
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: SPACE[2], padding: `${SPACE[2]} 0` }}>
|
|
607
|
+
{data.dormantModels.map(model => (
|
|
608
|
+
<div
|
|
609
|
+
key={model.modelId}
|
|
610
|
+
style={{
|
|
611
|
+
padding: `${SPACE[2]} ${SPACE[3]}`,
|
|
612
|
+
border: '1px solid var(--border)',
|
|
613
|
+
borderRadius: 6,
|
|
614
|
+
background: 'var(--bg-sunken)',
|
|
615
|
+
}}
|
|
616
|
+
>
|
|
617
|
+
<strong style={{ fontSize: TEXT.lg }}>{model.name}</strong>
|
|
618
|
+
<p style={{ fontSize: TEXT.sm, color: 'var(--text-secondary)', margin: `${SPACE[1]} 0 0`, lineHeight: 1.3 }}>
|
|
619
|
+
{model.description}
|
|
620
|
+
</p>
|
|
621
|
+
</div>
|
|
622
|
+
))}
|
|
623
|
+
</div>
|
|
624
|
+
</CollapsiblePanel>
|
|
625
|
+
) : (
|
|
626
|
+
<CollapsiblePanel
|
|
627
|
+
title={t('thinkingModels.dormantModels')}
|
|
628
|
+
defaultCollapsed
|
|
629
|
+
>
|
|
630
|
+
<EmptyState
|
|
631
|
+
title={t('thinkingModels.emptyAllActive')}
|
|
632
|
+
description={t('thinkingModels.emptyAllActiveDesc')}
|
|
633
|
+
/>
|
|
634
|
+
</CollapsiblePanel>
|
|
121
635
|
)}
|
|
122
|
-
|
|
123
|
-
|
|
636
|
+
|
|
637
|
+
{/* Scenario Heatmap */}
|
|
638
|
+
{heatmapData ? (
|
|
639
|
+
<CollapsiblePanel title={t('thinkingModels.scenarioHeatmap')}>
|
|
640
|
+
<div style={{ overflowX: 'auto' }}>
|
|
641
|
+
<table className="heatmap-table">
|
|
642
|
+
<thead>
|
|
643
|
+
<tr>
|
|
644
|
+
<th className="heatmap-header heatmap-sticky">
|
|
645
|
+
Model
|
|
646
|
+
</th>
|
|
647
|
+
{heatmapData.allScenarios.map(sc => (
|
|
648
|
+
<th key={sc} className="heatmap-header heatmap-scenario">
|
|
649
|
+
{sc}
|
|
650
|
+
</th>
|
|
651
|
+
))}
|
|
652
|
+
</tr>
|
|
653
|
+
</thead>
|
|
654
|
+
<tbody>
|
|
655
|
+
{heatmapData.models.map(model => (
|
|
656
|
+
<tr key={model.modelId}>
|
|
657
|
+
<td className="heatmap-model heatmap-sticky">
|
|
658
|
+
{model.name}
|
|
659
|
+
</td>
|
|
660
|
+
{heatmapData.allScenarios.map(sc => {
|
|
661
|
+
const hits = heatmapData.hitMap.get(`${model.modelId}::${sc}`) ?? 0;
|
|
662
|
+
const intensity = hits / heatmapData.maxHits;
|
|
663
|
+
const bgColor = hits === 0
|
|
664
|
+
? 'var(--bg-sunken)'
|
|
665
|
+
: `rgba(91, 139, 160, ${Math.max(0.15, intensity * 0.55).toFixed(2)})`;
|
|
666
|
+
return (
|
|
667
|
+
<td
|
|
668
|
+
key={sc}
|
|
669
|
+
className="heatmap-cell"
|
|
670
|
+
style={{ backgroundColor: bgColor }}
|
|
671
|
+
>
|
|
672
|
+
{hits}
|
|
673
|
+
</td>
|
|
674
|
+
);
|
|
675
|
+
})}
|
|
676
|
+
</tr>
|
|
677
|
+
))}
|
|
678
|
+
</tbody>
|
|
679
|
+
</table>
|
|
680
|
+
</div>
|
|
681
|
+
</CollapsiblePanel>
|
|
682
|
+
) : (
|
|
683
|
+
<CollapsiblePanel title={t('thinkingModels.scenarioHeatmap')}>
|
|
684
|
+
<EmptyState
|
|
685
|
+
title={t('thinkingModels.emptyScenarioMatrix')}
|
|
686
|
+
description={t('thinkingModels.emptyScenarioMatrixDesc')}
|
|
687
|
+
/>
|
|
688
|
+
</CollapsiblePanel>
|
|
689
|
+
)}
|
|
690
|
+
</>
|
|
691
|
+
)}
|
|
124
692
|
</div>
|
|
125
693
|
);
|
|
126
694
|
}
|
|
127
|
-
|