principles-disciple 1.13.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 +4 -4
- package/package.json +1 -1
- package/scripts/sync-plugin.mjs +1 -156
- package/src/commands/nocturnal-train.ts +12 -11
- package/src/core/evolution-reducer.ts +4 -31
- package/src/core/nocturnal-trinity.ts +4 -19
- package/src/core/principle-tree-ledger.ts +7 -27
- package/src/core/thinking-os-parser.ts +44 -36
- package/src/index.ts +3 -7
- package/src/service/nocturnal-service.ts +7 -11
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +3 -18
- package/templates/langs/en/principles/THINKING_OS.md +0 -13
- package/templates/langs/zh/principles/THINKING_OS.md +0 -13
- package/ui/src/i18n/ui.ts +52 -0
- package/ui/src/pages/EvolutionPage.tsx +38 -57
- 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 +444 -95
- package/ui/src/styles.css +316 -0
- package/src/core/principle-tree-migration.ts +0 -195
|
@@ -1,12 +1,33 @@
|
|
|
1
|
-
import React, { useEffect, useState, useMemo } from 'react';
|
|
2
|
-
import { ChevronLeft, Search, ArrowUpDown,
|
|
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, LineChart, StatusBadge } 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
|
+
|
|
10
31
|
// ---------------------------------------------------------------------------
|
|
11
32
|
// Recommendation badge helper
|
|
12
33
|
// ---------------------------------------------------------------------------
|
|
@@ -28,13 +49,43 @@ export function ThinkingModelsPage() {
|
|
|
28
49
|
const [data, setData] = useState<ThinkingOverviewResponse | null>(null);
|
|
29
50
|
const [detail, setDetail] = useState<ThinkingModelDetailResponse | null>(null);
|
|
30
51
|
const [selectedModel, setSelectedModel] = useState('');
|
|
52
|
+
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
|
31
53
|
const [error, setError] = useState('');
|
|
32
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
|
+
|
|
33
61
|
// Filters
|
|
34
62
|
const [recFilter, setRecFilter] = useState('all');
|
|
35
63
|
const [search, setSearch] = useState('');
|
|
36
64
|
const [sortBy, setSortBy] = useState<'hits' | 'successRate' | 'name'>('hits');
|
|
37
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
|
+
|
|
38
89
|
useEffect(() => {
|
|
39
90
|
api.getThinkingOverview().then((value) => {
|
|
40
91
|
setData(value);
|
|
@@ -44,9 +95,57 @@ export function ThinkingModelsPage() {
|
|
|
44
95
|
|
|
45
96
|
useEffect(() => {
|
|
46
97
|
if (!selectedModel) return;
|
|
47
|
-
|
|
98
|
+
setIsLoadingDetail(true);
|
|
99
|
+
api.getThinkingModelDetail(selectedModel)
|
|
100
|
+
.then((d) => { setDetail(d); setIsLoadingDetail(false); })
|
|
101
|
+
.catch((err) => { setError(String(err)); setIsLoadingDetail(false); });
|
|
48
102
|
}, [selectedModel]);
|
|
49
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
|
+
|
|
50
149
|
// Filtered + sorted model list
|
|
51
150
|
const filteredModels = useMemo(() => {
|
|
52
151
|
if (!data) return [];
|
|
@@ -54,8 +153,8 @@ export function ThinkingModelsPage() {
|
|
|
54
153
|
if (recFilter !== 'all') {
|
|
55
154
|
models = models.filter(m => m.recommendation === recFilter);
|
|
56
155
|
}
|
|
57
|
-
if (
|
|
58
|
-
const q =
|
|
156
|
+
if (debouncedSearch) {
|
|
157
|
+
const q = debouncedSearch.toLowerCase();
|
|
59
158
|
models = models.filter(m =>
|
|
60
159
|
m.name.toLowerCase().includes(q) ||
|
|
61
160
|
(m.commonScenarios ?? []).some(s => s.toLowerCase().includes(q))
|
|
@@ -67,7 +166,20 @@ export function ThinkingModelsPage() {
|
|
|
67
166
|
return a.name.localeCompare(b.name);
|
|
68
167
|
});
|
|
69
168
|
return models;
|
|
70
|
-
}, [data, recFilter,
|
|
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]);
|
|
71
183
|
|
|
72
184
|
if (error) return <ErrorState error={error} />;
|
|
73
185
|
if (!data) return <Loading />;
|
|
@@ -81,6 +193,11 @@ export function ThinkingModelsPage() {
|
|
|
81
193
|
<header className="page-header">
|
|
82
194
|
<div>
|
|
83
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
|
+
)}
|
|
84
201
|
</div>
|
|
85
202
|
<div className="pill-row">
|
|
86
203
|
<span className="badge">{t('thinkingModels.coverage')} {formatPercent(data.summary.coverageRate)}</span>
|
|
@@ -92,20 +209,23 @@ export function ThinkingModelsPage() {
|
|
|
92
209
|
|
|
93
210
|
{!hasData ? (
|
|
94
211
|
/* ── No data yet: show model definitions grid ── */
|
|
95
|
-
<section className="panel" style={{ marginBottom:
|
|
96
|
-
<div style={{ textAlign: 'center', padding:
|
|
97
|
-
<div style={{ fontSize: '2rem', marginBottom:
|
|
98
|
-
<h3 style={{ marginBottom:
|
|
99
|
-
<p style={{ fontSize:
|
|
100
|
-
{t('thinkingModels.noDataDesc')
|
|
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')}
|
|
101
218
|
</p>
|
|
102
219
|
</div>
|
|
103
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap:
|
|
220
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: SPACE[3] }}>
|
|
104
221
|
{data.topModels.map(model => (
|
|
105
222
|
<div
|
|
106
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); } }}
|
|
107
227
|
style={{
|
|
108
|
-
padding:
|
|
228
|
+
padding: SPACE[3],
|
|
109
229
|
border: '1px solid var(--border)',
|
|
110
230
|
borderRadius: 8,
|
|
111
231
|
background: 'var(--bg-sunken)',
|
|
@@ -113,16 +233,16 @@ export function ThinkingModelsPage() {
|
|
|
113
233
|
}}
|
|
114
234
|
onClick={() => { setSelectedModel(model.modelId); setDetail(null); }}
|
|
115
235
|
>
|
|
116
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom:
|
|
117
|
-
<strong style={{ fontSize:
|
|
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>
|
|
118
238
|
</div>
|
|
119
|
-
<p style={{ fontSize:
|
|
239
|
+
<p style={{ fontSize: TEXT.base, color: 'var(--text-secondary)', margin: `0 0 ${SPACE[2]}`, lineHeight: 1.4 }}>
|
|
120
240
|
{model.description}
|
|
121
241
|
</p>
|
|
122
242
|
{model.commonScenarios.length > 0 && (
|
|
123
|
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap:
|
|
243
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: SPACE[1] }}>
|
|
124
244
|
{model.commonScenarios.slice(0, 3).map(s => (
|
|
125
|
-
<span key={s} style={{ fontSize:
|
|
245
|
+
<span key={s} style={{ fontSize: TEXT.xs, padding: '1px 6px', background: 'rgba(91,139,160,0.1)', borderRadius: 3, color: 'var(--info)' }}>
|
|
126
246
|
{s}
|
|
127
247
|
</span>
|
|
128
248
|
))}
|
|
@@ -137,8 +257,8 @@ export function ThinkingModelsPage() {
|
|
|
137
257
|
<>
|
|
138
258
|
{/* Coverage Trend */}
|
|
139
259
|
{data.coverageTrend.length >= 1 && (
|
|
140
|
-
<section className="panel" style={{ marginBottom:
|
|
141
|
-
<h3
|
|
260
|
+
<section className="panel" style={{ marginBottom: SPACE[4] }}>
|
|
261
|
+
<h3 className="section-title">
|
|
142
262
|
{t('thinkingModels.coverageTrend')}
|
|
143
263
|
</h3>
|
|
144
264
|
<LineChart
|
|
@@ -154,40 +274,32 @@ export function ThinkingModelsPage() {
|
|
|
154
274
|
)}
|
|
155
275
|
|
|
156
276
|
{/* Search + Sort + Filter */}
|
|
157
|
-
<div style={{ display: 'flex', gap:
|
|
277
|
+
<div style={{ display: 'flex', gap: SPACE[2], marginBottom: SPACE[3], alignItems: 'center', flexWrap: 'wrap' }}>
|
|
158
278
|
<div style={{ position: 'relative', flex: '1 1 200px' }}>
|
|
159
|
-
<Search size={14} style={{ position: 'absolute', left:
|
|
279
|
+
<Search size={14} style={{ position: 'absolute', left: SPACE[2], top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)', pointerEvents: 'none' }} />
|
|
160
280
|
<input
|
|
161
281
|
type="text"
|
|
162
|
-
placeholder={t('
|
|
282
|
+
placeholder={t('thinkingModels.searchPlaceholder')}
|
|
163
283
|
value={search}
|
|
164
284
|
onChange={e => setSearch(e.target.value)}
|
|
165
|
-
style={{
|
|
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 }}
|
|
166
286
|
/>
|
|
167
287
|
</div>
|
|
168
288
|
<button
|
|
169
289
|
onClick={() => setSortBy(prev => prev === 'hits' ? 'successRate' : prev === 'successRate' ? 'name' : 'hits')}
|
|
170
|
-
|
|
290
|
+
className="sort-button"
|
|
171
291
|
>
|
|
172
292
|
<ArrowUpDown size={14} />
|
|
173
|
-
{sortBy === 'hits' ? '
|
|
293
|
+
{sortBy === 'hits' ? t('thinkingModels.sortByHits') : sortBy === 'successRate' ? t('thinkingModels.sortBySuccessRate') : t('thinkingModels.sortByName')}
|
|
174
294
|
</button>
|
|
175
|
-
<div style={{ display: 'flex', gap:
|
|
295
|
+
<div style={{ display: 'flex', gap: SPACE[1] }}>
|
|
176
296
|
{['all', 'reinforce', 'rework', 'archive'].map(key => (
|
|
177
297
|
<button
|
|
178
298
|
key={key}
|
|
179
299
|
onClick={() => setRecFilter(key)}
|
|
180
|
-
|
|
181
|
-
padding: '3px 8px',
|
|
182
|
-
border: `1px solid ${recFilter === key ? 'var(--accent)' : 'var(--border)'}`,
|
|
183
|
-
borderRadius: 4,
|
|
184
|
-
background: recFilter === key ? 'rgba(91, 139, 160, 0.15)' : 'transparent',
|
|
185
|
-
color: recFilter === key ? 'var(--accent)' : 'var(--text-secondary)',
|
|
186
|
-
cursor: 'pointer',
|
|
187
|
-
fontSize: '0.7rem',
|
|
188
|
-
}}
|
|
300
|
+
className={`filter-button ${recFilter === key ? 'active' : ''}`}
|
|
189
301
|
>
|
|
190
|
-
{key === 'all' ? '
|
|
302
|
+
{key === 'all' ? t('thinkingModels.filterAll') : REC_BADGE[key]?.label(t)}
|
|
191
303
|
</button>
|
|
192
304
|
))}
|
|
193
305
|
</div>
|
|
@@ -197,49 +309,166 @@ export function ThinkingModelsPage() {
|
|
|
197
309
|
<div className="grid two-columns wide-right">
|
|
198
310
|
{/* Left: Model List */}
|
|
199
311
|
<section className="panel">
|
|
200
|
-
|
|
201
|
-
|
|
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>
|
|
202
318
|
<button
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
onClick={() => { setSelectedModel(item.modelId); setDetail(null); }}
|
|
319
|
+
onClick={startComparison}
|
|
320
|
+
className="compare-button"
|
|
206
321
|
>
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180, display: 'block' }}>
|
|
210
|
-
{item.commonScenarios.join(', ') || '—'}
|
|
211
|
-
</span>
|
|
212
|
-
</div>
|
|
213
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
214
|
-
<span style={{ fontWeight: 600, fontSize: '0.85rem' }}>{item.hits}</span>
|
|
215
|
-
{REC_BADGE[item.recommendation] && (
|
|
216
|
-
<StatusBadge variant={REC_BADGE[item.recommendation].variant}>
|
|
217
|
-
{REC_BADGE[item.recommendation].label(t)}
|
|
218
|
-
</StatusBadge>
|
|
219
|
-
)}
|
|
220
|
-
</div>
|
|
322
|
+
<Columns size={14} />
|
|
323
|
+
{t('thinkingModels.compare')}
|
|
221
324
|
</button>
|
|
222
|
-
|
|
325
|
+
</div>
|
|
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
|
+
})}
|
|
223
366
|
{filteredModels.length === 0 && (
|
|
224
|
-
<
|
|
225
|
-
No models match your filters.
|
|
226
|
-
|
|
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
|
+
/>
|
|
227
371
|
)}
|
|
228
372
|
</div>
|
|
229
373
|
</section>
|
|
230
374
|
|
|
231
|
-
{/* Right: Detail Panel */}
|
|
375
|
+
{/* Right: Detail Panel / Comparison View */}
|
|
232
376
|
<section className="panel">
|
|
233
|
-
{
|
|
234
|
-
|
|
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>
|
|
394
|
+
</div>
|
|
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>
|
|
453
|
+
</div>
|
|
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 && (
|
|
235
464
|
<div className="detail-stack">
|
|
236
465
|
<div className="detail-header">
|
|
237
|
-
<button className="back-button" onClick={() => setDetail(null)} title=
|
|
466
|
+
<button className="back-button" onClick={() => { setDetail(null); setSelectedModel(''); setIsLoadingDetail(true); }} title={t('common.back')}>
|
|
238
467
|
<ChevronLeft strokeWidth={1.75} size={18} />
|
|
239
468
|
</button>
|
|
240
469
|
<div>
|
|
241
470
|
<h3>{detail.modelMeta.name}</h3>
|
|
242
|
-
<p style={{ fontSize:
|
|
471
|
+
<p style={{ fontSize: TEXT.lg, color: 'var(--text-secondary)' }}>{detail.modelMeta.description}</p>
|
|
243
472
|
</div>
|
|
244
473
|
{REC_BADGE[detail.modelMeta.recommendation] && (
|
|
245
474
|
<StatusBadge variant={REC_BADGE[detail.modelMeta.recommendation].variant}>
|
|
@@ -248,11 +477,31 @@ export function ThinkingModelsPage() {
|
|
|
248
477
|
)}
|
|
249
478
|
</div>
|
|
250
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
|
+
|
|
251
500
|
{/* Usage Trend */}
|
|
252
|
-
{detail.usageTrend.length >= 1
|
|
501
|
+
{detail.usageTrend.length >= 1 ? (
|
|
253
502
|
<article>
|
|
254
|
-
<h4 style={{
|
|
255
|
-
{t('thinkingModels.usageTrend')
|
|
503
|
+
<h4 className="text-lg text-semibold" style={{ marginBottom: SPACE[2] }}>
|
|
504
|
+
{t('thinkingModels.usageTrend')}
|
|
256
505
|
</h4>
|
|
257
506
|
<LineChart
|
|
258
507
|
data={detail.usageTrend.map(d => ({ label: d.day.slice(5), value: d.hits }))}
|
|
@@ -264,11 +513,16 @@ export function ThinkingModelsPage() {
|
|
|
264
513
|
showArea
|
|
265
514
|
/>
|
|
266
515
|
</article>
|
|
516
|
+
) : (
|
|
517
|
+
<EmptyState
|
|
518
|
+
title={t('thinkingModels.emptyUsageTrend')}
|
|
519
|
+
description={t('thinkingModels.emptyUsageTrendDesc')}
|
|
520
|
+
/>
|
|
267
521
|
)}
|
|
268
522
|
|
|
269
523
|
{/* Outcome Stats */}
|
|
270
524
|
<article>
|
|
271
|
-
<h4>{t('thinkingModels.outcomeStats')}</h4>
|
|
525
|
+
<h4 className="text-base text-semibold">{t('thinkingModels.outcomeStats')}</h4>
|
|
272
526
|
<div className="pill-row">
|
|
273
527
|
<span className="badge">{t('thinkingModels.success')} {formatPercent(detail.outcomeStats.successRate)}</span>
|
|
274
528
|
<span className="badge">{t('thinkingModels.failure')} {formatPercent(detail.outcomeStats.failureRate)}</span>
|
|
@@ -280,11 +534,11 @@ export function ThinkingModelsPage() {
|
|
|
280
534
|
{/* Scenario Distribution */}
|
|
281
535
|
{detail.scenarioDistribution.length > 0 && (
|
|
282
536
|
<article>
|
|
283
|
-
<h4>{t('thinkingModels.scenarioDistribution')}</h4>
|
|
537
|
+
<h4 className="text-base text-semibold">{t('thinkingModels.scenarioDistribution')}</h4>
|
|
284
538
|
<div className="stack">
|
|
285
539
|
{detail.scenarioDistribution.map((item) => (
|
|
286
540
|
<div className="row-card" key={item.scenario}>
|
|
287
|
-
<strong>{item.scenario}</strong>
|
|
541
|
+
<strong className="text-base">{item.scenario}</strong>
|
|
288
542
|
<span>{item.hits}</span>
|
|
289
543
|
</div>
|
|
290
544
|
))}
|
|
@@ -295,30 +549,39 @@ export function ThinkingModelsPage() {
|
|
|
295
549
|
{/* Recent Events */}
|
|
296
550
|
{detail.recentEvents.length > 0 && (
|
|
297
551
|
<article>
|
|
298
|
-
<h4>{t('thinkingModels.recentEvents')}</h4>
|
|
552
|
+
<h4 className="text-base text-semibold">{t('thinkingModels.recentEvents')}</h4>
|
|
299
553
|
<div className="stack">
|
|
300
554
|
{detail.recentEvents.map((event) => (
|
|
301
555
|
<div className="row-card vertical" key={event.id}>
|
|
302
556
|
<div>
|
|
303
|
-
<strong>{formatDate(event.createdAt)}</strong>
|
|
304
|
-
<span>{event.scenarios.join(', ') || '—'}</span>
|
|
557
|
+
<strong className="text-base">{formatDate(event.createdAt)}</strong>
|
|
558
|
+
<span className="text-sm">{event.scenarios.join(', ') || '—'}</span>
|
|
305
559
|
</div>
|
|
306
|
-
{
|
|
307
|
-
<div
|
|
308
|
-
|
|
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(', ')}
|
|
309
565
|
</div>
|
|
310
566
|
)}
|
|
311
|
-
{
|
|
312
|
-
<div
|
|
313
|
-
|
|
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(', ')}
|
|
314
570
|
</div>
|
|
315
571
|
)}
|
|
316
|
-
{
|
|
317
|
-
<div
|
|
318
|
-
|
|
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(', ')}
|
|
319
577
|
</div>
|
|
320
578
|
)}
|
|
321
|
-
|
|
579
|
+
{event.matchedPattern && (
|
|
580
|
+
<code className="matched-pattern">
|
|
581
|
+
/{event.matchedPattern}/
|
|
582
|
+
</code>
|
|
583
|
+
)}
|
|
584
|
+
<pre className="event-trigger-excerpt">
|
|
322
585
|
{event.triggerExcerpt}
|
|
323
586
|
</pre>
|
|
324
587
|
</div>
|
|
@@ -326,18 +589,104 @@ export function ThinkingModelsPage() {
|
|
|
326
589
|
</div>
|
|
327
590
|
</article>
|
|
328
591
|
)}
|
|
329
|
-
|
|
330
|
-
{/* No data message for detail */}
|
|
331
|
-
{detail.usageTrend.length === 0 && detail.recentEvents.length === 0 && (
|
|
332
|
-
<div style={{ textAlign: 'center', padding: 'var(--space-4)', color: 'var(--text-secondary)' }}>
|
|
333
|
-
<Info size={20} style={{ marginBottom: 8 }} />
|
|
334
|
-
<p style={{ fontSize: '0.8rem' }}>No usage data for this model yet.</p>
|
|
335
|
-
</div>
|
|
336
|
-
)}
|
|
337
592
|
</div>
|
|
338
593
|
)}
|
|
594
|
+
</>
|
|
595
|
+
)}
|
|
339
596
|
</section>
|
|
340
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>
|
|
635
|
+
)}
|
|
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
|
+
)}
|
|
341
690
|
</>
|
|
342
691
|
)}
|
|
343
692
|
</div>
|