@townco/debugger 0.1.28 → 0.1.29
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/package.json +7 -4
- package/src/App.tsx +6 -0
- package/src/analysis/analyzer.ts +235 -0
- package/src/analysis/embeddings.ts +97 -0
- package/src/analysis/schema.ts +67 -0
- package/src/analysis/types.ts +132 -0
- package/src/analysis-db.ts +238 -0
- package/src/comparison-db.test.ts +28 -5
- package/src/comparison-db.ts +57 -9
- package/src/components/AnalyzeAllButton.tsx +81 -0
- package/src/components/DebuggerHeader.tsx +12 -0
- package/src/components/SessionAnalysisButton.tsx +109 -0
- package/src/components/SessionAnalysisDialog.tsx +155 -0
- package/src/components/UnifiedTimeline.tsx +3 -3
- package/src/components/ui/dialog.tsx +120 -0
- package/src/db.ts +3 -2
- package/src/lib/metrics.ts +101 -5
- package/src/pages/ComparisonView.tsx +258 -135
- package/src/pages/FindSessions.tsx +230 -0
- package/src/pages/SessionList.tsx +76 -10
- package/src/pages/SessionView.tsx +33 -1
- package/src/pages/TownHall.tsx +345 -187
- package/src/schemas.ts +27 -8
- package/src/server.ts +337 -3
- package/src/types.ts +11 -2
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { Connection, Table } from "@lancedb/lancedb";
|
|
3
|
+
import { connect } from "@lancedb/lancedb";
|
|
4
|
+
import type { SessionAnalysis } from "./analysis/types";
|
|
5
|
+
|
|
6
|
+
export class AnalysisDb {
|
|
7
|
+
private db: Database;
|
|
8
|
+
private lanceDb: Connection | null = null;
|
|
9
|
+
private embeddingTable: Table | null = null;
|
|
10
|
+
private lanceDbInitialized: Promise<void>;
|
|
11
|
+
|
|
12
|
+
constructor(dbPath: string) {
|
|
13
|
+
// Open the main debugger database (not readonly, since we need to write)
|
|
14
|
+
this.db = new Database(dbPath);
|
|
15
|
+
console.log("AnalysisDb: Opening database at", dbPath);
|
|
16
|
+
|
|
17
|
+
this.initTables();
|
|
18
|
+
this.lanceDbInitialized = this.initLanceDb(dbPath);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private async initLanceDb(dbPath: string): Promise<void> {
|
|
22
|
+
try {
|
|
23
|
+
// LanceDB stores in a directory, use same location as SQLite db
|
|
24
|
+
const lanceDbPath = dbPath.replace(/\.db$/, ".lancedb");
|
|
25
|
+
console.log("AnalysisDb: Initializing LanceDB at", lanceDbPath);
|
|
26
|
+
|
|
27
|
+
this.lanceDb = await connect(lanceDbPath);
|
|
28
|
+
|
|
29
|
+
// Check if table exists, create if not
|
|
30
|
+
const tableNames = await this.lanceDb.tableNames();
|
|
31
|
+
if (tableNames.includes("session_embeddings")) {
|
|
32
|
+
this.embeddingTable =
|
|
33
|
+
await this.lanceDb.openTable("session_embeddings");
|
|
34
|
+
} else {
|
|
35
|
+
// Create table with schema
|
|
36
|
+
this.embeddingTable = await this.lanceDb.createTable(
|
|
37
|
+
"session_embeddings",
|
|
38
|
+
[{ session_id: "placeholder", embedding: new Array(1536).fill(0) }],
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log("AnalysisDb: LanceDB initialized successfully");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error("Failed to initialize LanceDB:", error);
|
|
45
|
+
console.warn("Embedding functionality will be disabled");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private initTables() {
|
|
50
|
+
console.log("AnalysisDb: Initializing SQLite tables...");
|
|
51
|
+
|
|
52
|
+
// Create session_analyses table
|
|
53
|
+
this.db.run(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS session_analyses (
|
|
55
|
+
session_id TEXT PRIMARY KEY,
|
|
56
|
+
analysis_json TEXT NOT NULL,
|
|
57
|
+
analyzed_at TEXT NOT NULL,
|
|
58
|
+
updated_at TEXT NOT NULL
|
|
59
|
+
)
|
|
60
|
+
`);
|
|
61
|
+
|
|
62
|
+
// Create index on analyzed_at for sorting
|
|
63
|
+
this.db.run(`
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_session_analyses_analyzed_at
|
|
65
|
+
ON session_analyses(analyzed_at)
|
|
66
|
+
`);
|
|
67
|
+
|
|
68
|
+
console.log("AnalysisDb: SQLite tables initialized");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
saveAnalysis(analysis: SessionAnalysis): void {
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
|
|
74
|
+
this.db.run(
|
|
75
|
+
`INSERT INTO session_analyses (
|
|
76
|
+
session_id, analysis_json, analyzed_at, updated_at
|
|
77
|
+
) VALUES (?, ?, ?, ?)
|
|
78
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
79
|
+
analysis_json = excluded.analysis_json,
|
|
80
|
+
updated_at = excluded.updated_at
|
|
81
|
+
`,
|
|
82
|
+
[analysis.session_id, JSON.stringify(analysis), now, now],
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getAnalysis(sessionId: string): SessionAnalysis | null {
|
|
87
|
+
const row = this.db
|
|
88
|
+
.query<{ analysis_json: string }, [string]>(
|
|
89
|
+
`SELECT analysis_json FROM session_analyses WHERE session_id = ?`,
|
|
90
|
+
)
|
|
91
|
+
.get(sessionId);
|
|
92
|
+
|
|
93
|
+
if (!row) return null;
|
|
94
|
+
|
|
95
|
+
return JSON.parse(row.analysis_json) as SessionAnalysis;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
listAnalyses(limit = 50, offset = 0): SessionAnalysis[] {
|
|
99
|
+
const rows = this.db
|
|
100
|
+
.query<{ analysis_json: string }, [number, number]>(
|
|
101
|
+
`SELECT analysis_json FROM session_analyses
|
|
102
|
+
ORDER BY analyzed_at DESC
|
|
103
|
+
LIMIT ? OFFSET ?`,
|
|
104
|
+
)
|
|
105
|
+
.all(limit, offset);
|
|
106
|
+
|
|
107
|
+
return rows.map((row) => JSON.parse(row.analysis_json) as SessionAnalysis);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Saves an embedding for a session analysis.
|
|
112
|
+
* If an embedding already exists for this session, it will be replaced.
|
|
113
|
+
*/
|
|
114
|
+
async saveEmbedding(
|
|
115
|
+
sessionId: string,
|
|
116
|
+
embedding: Float32Array,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
// Wait for LanceDB to initialize
|
|
119
|
+
await this.lanceDbInitialized;
|
|
120
|
+
|
|
121
|
+
if (!this.embeddingTable) {
|
|
122
|
+
console.warn("LanceDB not initialized, skipping embedding save");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// LanceDB uses add() to insert/upsert records
|
|
128
|
+
// Convert Float32Array to regular array for LanceDB
|
|
129
|
+
const embeddingArray = Array.from(embedding);
|
|
130
|
+
|
|
131
|
+
await this.embeddingTable.add([
|
|
132
|
+
{
|
|
133
|
+
session_id: sessionId,
|
|
134
|
+
embedding: embeddingArray,
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
console.log(`AnalysisDb: Saved embedding for session ${sessionId}`);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error(`Failed to save embedding for ${sessionId}:`, error);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Retrieves the embedding for a session.
|
|
147
|
+
* Returns null if no embedding exists.
|
|
148
|
+
*/
|
|
149
|
+
async getEmbedding(sessionId: string): Promise<Float32Array | null> {
|
|
150
|
+
// Wait for LanceDB to initialize
|
|
151
|
+
await this.lanceDbInitialized;
|
|
152
|
+
|
|
153
|
+
if (!this.embeddingTable) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// We need to do a vector search to query LanceDB
|
|
159
|
+
// Use a dummy vector and large limit to get all records, then filter
|
|
160
|
+
const dummyVector = new Array(1536).fill(0);
|
|
161
|
+
const allRecords = await this.embeddingTable
|
|
162
|
+
.search(dummyVector)
|
|
163
|
+
.limit(1000) // Should be enough for most use cases
|
|
164
|
+
.toArray();
|
|
165
|
+
|
|
166
|
+
const record = allRecords.find((r: any) => r.session_id === sessionId);
|
|
167
|
+
|
|
168
|
+
if (!record) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const embedding = record.embedding;
|
|
173
|
+
return new Float32Array(embedding);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error("Error retrieving embedding:", error);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Searches for sessions with similar embeddings using KNN search.
|
|
182
|
+
* Returns session IDs with their distance scores (lower = more similar).
|
|
183
|
+
*/
|
|
184
|
+
async searchSimilarSessions(
|
|
185
|
+
embedding: Float32Array,
|
|
186
|
+
limit = 10,
|
|
187
|
+
): Promise<Array<{ session_id: string; distance: number }>> {
|
|
188
|
+
// Wait for LanceDB to initialize
|
|
189
|
+
await this.lanceDbInitialized;
|
|
190
|
+
|
|
191
|
+
if (!this.embeddingTable) {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
// LanceDB vector search
|
|
197
|
+
const embeddingArray = Array.from(embedding);
|
|
198
|
+
const results = await this.embeddingTable
|
|
199
|
+
.search(embeddingArray)
|
|
200
|
+
.limit(limit)
|
|
201
|
+
.toArray();
|
|
202
|
+
|
|
203
|
+
return results.map((result: any) => ({
|
|
204
|
+
session_id: result.session_id,
|
|
205
|
+
distance: result._distance,
|
|
206
|
+
}));
|
|
207
|
+
} catch (error) {
|
|
208
|
+
console.error("Error searching similar sessions:", error);
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Checks if an embedding exists for a session.
|
|
215
|
+
*/
|
|
216
|
+
async hasEmbedding(sessionId: string): Promise<boolean> {
|
|
217
|
+
// Wait for LanceDB to initialize
|
|
218
|
+
await this.lanceDbInitialized;
|
|
219
|
+
|
|
220
|
+
if (!this.embeddingTable) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
// Use a dummy vector search to get all records, then check if session exists
|
|
226
|
+
const dummyVector = new Array(1536).fill(0);
|
|
227
|
+
const allRecords = await this.embeddingTable
|
|
228
|
+
.search(dummyVector)
|
|
229
|
+
.limit(1000)
|
|
230
|
+
.toArray();
|
|
231
|
+
|
|
232
|
+
return allRecords.some((r: any) => r.session_id === sessionId);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.error("Error checking embedding existence:", error);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import { ComparisonDb } from "./comparison-db";
|
|
4
|
+
import type { ComparisonDimension } from "./types";
|
|
4
5
|
|
|
5
6
|
const TEST_DB = "test-comparison.db";
|
|
6
7
|
|
|
@@ -27,7 +28,7 @@ describe("ComparisonDb", () => {
|
|
|
27
28
|
const now = new Date().toISOString();
|
|
28
29
|
const config = {
|
|
29
30
|
id: "test-config",
|
|
30
|
-
|
|
31
|
+
dimensions: ["model"] as ComparisonDimension[],
|
|
31
32
|
controlModel: "model-a",
|
|
32
33
|
variantModel: "model-b",
|
|
33
34
|
createdAt: now,
|
|
@@ -39,7 +40,7 @@ describe("ComparisonDb", () => {
|
|
|
39
40
|
|
|
40
41
|
// Compare non-timestamp fields and verify timestamps exist
|
|
41
42
|
expect(saved?.id).toBe(config.id);
|
|
42
|
-
expect(saved?.
|
|
43
|
+
expect(saved?.dimensions).toEqual(config.dimensions);
|
|
43
44
|
expect(saved?.controlModel).toBe(config.controlModel);
|
|
44
45
|
expect(saved?.variantModel).toBe(config.variantModel);
|
|
45
46
|
expect(saved?.createdAt).toBeDefined();
|
|
@@ -52,7 +53,7 @@ describe("ComparisonDb", () => {
|
|
|
52
53
|
test("saves and retrieves config with tools", () => {
|
|
53
54
|
const config = {
|
|
54
55
|
id: "test-config-tools",
|
|
55
|
-
|
|
56
|
+
dimensions: ["tools"] as ComparisonDimension[],
|
|
56
57
|
variantTools: ["tool-a", "tool-b"],
|
|
57
58
|
createdAt: new Date().toISOString(),
|
|
58
59
|
updatedAt: new Date().toISOString(),
|
|
@@ -64,11 +65,32 @@ describe("ComparisonDb", () => {
|
|
|
64
65
|
expect(saved?.variantTools).toEqual(["tool-a", "tool-b"]);
|
|
65
66
|
});
|
|
66
67
|
|
|
68
|
+
test("saves and retrieves config with multiple dimensions", () => {
|
|
69
|
+
const config = {
|
|
70
|
+
id: "test-config-multi",
|
|
71
|
+
dimensions: ["model", "system_prompt", "tools"] as ComparisonDimension[],
|
|
72
|
+
controlModel: "model-a",
|
|
73
|
+
variantModel: "model-b",
|
|
74
|
+
variantSystemPrompt: "You are a test assistant",
|
|
75
|
+
variantTools: ["tool-a"],
|
|
76
|
+
createdAt: new Date().toISOString(),
|
|
77
|
+
updatedAt: new Date().toISOString(),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
db.saveConfig(config);
|
|
81
|
+
const saved = db.getConfig("test-config-multi");
|
|
82
|
+
|
|
83
|
+
expect(saved?.dimensions).toEqual(["model", "system_prompt", "tools"]);
|
|
84
|
+
expect(saved?.variantModel).toBe("model-b");
|
|
85
|
+
expect(saved?.variantSystemPrompt).toBe("You are a test assistant");
|
|
86
|
+
expect(saved?.variantTools).toEqual(["tool-a"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
67
89
|
test("creates and lists runs", () => {
|
|
68
90
|
const configId = "config-1";
|
|
69
91
|
db.saveConfig({
|
|
70
92
|
id: configId,
|
|
71
|
-
|
|
93
|
+
dimensions: ["model"] as ComparisonDimension[],
|
|
72
94
|
createdAt: new Date().toISOString(),
|
|
73
95
|
updatedAt: new Date().toISOString(),
|
|
74
96
|
});
|
|
@@ -86,7 +108,7 @@ describe("ComparisonDb", () => {
|
|
|
86
108
|
const configId = "config-1";
|
|
87
109
|
db.saveConfig({
|
|
88
110
|
id: configId,
|
|
89
|
-
|
|
111
|
+
dimensions: ["model"] as ComparisonDimension[],
|
|
90
112
|
createdAt: new Date().toISOString(),
|
|
91
113
|
updatedAt: new Date().toISOString(),
|
|
92
114
|
});
|
|
@@ -100,6 +122,7 @@ describe("ComparisonDb", () => {
|
|
|
100
122
|
totalTokens: 30,
|
|
101
123
|
estimatedCost: 0.01,
|
|
102
124
|
toolCallCount: 1,
|
|
125
|
+
toolCalls: [],
|
|
103
126
|
};
|
|
104
127
|
|
|
105
128
|
db.updateRunStatus(run.id, "completed", {
|
package/src/comparison-db.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
SessionMetrics,
|
|
9
9
|
} from "./types";
|
|
10
10
|
|
|
11
|
-
const SCHEMA_VERSION =
|
|
11
|
+
const SCHEMA_VERSION = 2;
|
|
12
12
|
|
|
13
13
|
export class ComparisonDb {
|
|
14
14
|
private db: Database;
|
|
@@ -34,7 +34,7 @@ export class ComparisonDb {
|
|
|
34
34
|
this.db.run(`
|
|
35
35
|
CREATE TABLE IF NOT EXISTS comparison_configs (
|
|
36
36
|
id TEXT PRIMARY KEY,
|
|
37
|
-
|
|
37
|
+
dimensions TEXT NOT NULL,
|
|
38
38
|
control_model TEXT,
|
|
39
39
|
variant_model TEXT,
|
|
40
40
|
variant_system_prompt TEXT,
|
|
@@ -71,8 +71,32 @@ export class ComparisonDb {
|
|
|
71
71
|
`);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
//
|
|
75
|
-
|
|
74
|
+
// Migration: rename 'dimension' column to 'dimensions' and convert to JSON array
|
|
75
|
+
if (currentVersion < 2) {
|
|
76
|
+
// Check if old 'dimension' column exists
|
|
77
|
+
const tableInfo = this.db
|
|
78
|
+
.query<{ name: string }, []>("PRAGMA table_info(comparison_configs)")
|
|
79
|
+
.all();
|
|
80
|
+
const hasDimensionColumn = tableInfo.some(
|
|
81
|
+
(col) => col.name === "dimension",
|
|
82
|
+
);
|
|
83
|
+
const hasDimensionsColumn = tableInfo.some(
|
|
84
|
+
(col) => col.name === "dimensions",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (hasDimensionColumn && !hasDimensionsColumn) {
|
|
88
|
+
// Rename column and migrate data
|
|
89
|
+
this.db.run(
|
|
90
|
+
`ALTER TABLE comparison_configs RENAME COLUMN dimension TO dimensions`,
|
|
91
|
+
);
|
|
92
|
+
// Convert single dimension values to JSON arrays
|
|
93
|
+
this.db.run(`
|
|
94
|
+
UPDATE comparison_configs
|
|
95
|
+
SET dimensions = '["' || dimensions || '"]'
|
|
96
|
+
WHERE dimensions NOT LIKE '[%'
|
|
97
|
+
`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
76
100
|
|
|
77
101
|
// Update schema version
|
|
78
102
|
this.db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
@@ -84,10 +108,10 @@ export class ComparisonDb {
|
|
|
84
108
|
const now = new Date().toISOString();
|
|
85
109
|
this.db.run(
|
|
86
110
|
`
|
|
87
|
-
INSERT INTO comparison_configs (id,
|
|
111
|
+
INSERT INTO comparison_configs (id, dimensions, control_model, variant_model, variant_system_prompt, variant_tools, created_at, updated_at)
|
|
88
112
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
89
113
|
ON CONFLICT(id) DO UPDATE SET
|
|
90
|
-
|
|
114
|
+
dimensions = excluded.dimensions,
|
|
91
115
|
control_model = excluded.control_model,
|
|
92
116
|
variant_model = excluded.variant_model,
|
|
93
117
|
variant_system_prompt = excluded.variant_system_prompt,
|
|
@@ -96,7 +120,7 @@ export class ComparisonDb {
|
|
|
96
120
|
`,
|
|
97
121
|
[
|
|
98
122
|
config.id,
|
|
99
|
-
config.
|
|
123
|
+
JSON.stringify(config.dimensions),
|
|
100
124
|
config.controlModel ?? null,
|
|
101
125
|
config.variantModel ?? null,
|
|
102
126
|
config.variantSystemPrompt ?? null,
|
|
@@ -145,9 +169,32 @@ export class ComparisonDb {
|
|
|
145
169
|
}
|
|
146
170
|
}
|
|
147
171
|
|
|
172
|
+
// Parse dimensions from JSON array
|
|
173
|
+
let dimensions: ComparisonConfig["dimensions"] = [];
|
|
174
|
+
if (row.dimensions) {
|
|
175
|
+
try {
|
|
176
|
+
const parsed = JSON.parse(row.dimensions);
|
|
177
|
+
if (Array.isArray(parsed)) {
|
|
178
|
+
dimensions = parsed as ComparisonConfig["dimensions"];
|
|
179
|
+
} else if (typeof parsed === "string") {
|
|
180
|
+
// Handle case where it's a single dimension string (legacy format)
|
|
181
|
+
dimensions = [parsed as ComparisonConfig["dimensions"][number]];
|
|
182
|
+
}
|
|
183
|
+
} catch (e) {
|
|
184
|
+
// If JSON parsing fails, it might be a plain string (legacy format before migration)
|
|
185
|
+
if (typeof row.dimensions === "string" && row.dimensions.length > 0) {
|
|
186
|
+
dimensions = [
|
|
187
|
+
row.dimensions as ComparisonConfig["dimensions"][number],
|
|
188
|
+
];
|
|
189
|
+
} else {
|
|
190
|
+
console.error("Failed to parse dimensions JSON:", e);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
148
195
|
return {
|
|
149
196
|
id: row.id,
|
|
150
|
-
|
|
197
|
+
dimensions,
|
|
151
198
|
controlModel: row.control_model ?? undefined,
|
|
152
199
|
variantModel: row.variant_model ?? undefined,
|
|
153
200
|
variantSystemPrompt: row.variant_system_prompt ?? undefined,
|
|
@@ -303,7 +350,8 @@ export class ComparisonDb {
|
|
|
303
350
|
return result.data;
|
|
304
351
|
}
|
|
305
352
|
console.error("Invalid metrics schema:", result.error);
|
|
306
|
-
|
|
353
|
+
const fallback = SessionMetricsSchema.safeParse({});
|
|
354
|
+
return fallback.success ? fallback.data : null;
|
|
307
355
|
} catch (e) {
|
|
308
356
|
console.error("Failed to parse metrics JSON:", e);
|
|
309
357
|
return null;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Sparkles } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Button } from "./ui/button";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
sessionIds: string[];
|
|
7
|
+
onComplete?: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function AnalyzeAllButton({ sessionIds, onComplete }: Props) {
|
|
11
|
+
const [loading, setLoading] = useState(false);
|
|
12
|
+
const [progress, setProgress] = useState<{
|
|
13
|
+
completed: number;
|
|
14
|
+
total: number;
|
|
15
|
+
errors: number;
|
|
16
|
+
} | null>(null);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
const handleAnalyzeAll = async () => {
|
|
20
|
+
setLoading(true);
|
|
21
|
+
setError(null);
|
|
22
|
+
setProgress({ completed: 0, total: sessionIds.length, errors: 0 });
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch("/api/analyze-all-sessions", {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ sessionIds }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const errorData = await response.json();
|
|
33
|
+
throw new Error(errorData.error || response.statusText);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { results } = await response.json();
|
|
37
|
+
|
|
38
|
+
// Count successes and errors
|
|
39
|
+
const completed = results.filter((r: any) => r.success).length;
|
|
40
|
+
const errors = results.filter((r: any) => !r.success).length;
|
|
41
|
+
|
|
42
|
+
setProgress({ completed, total: sessionIds.length, errors });
|
|
43
|
+
|
|
44
|
+
if (onComplete) {
|
|
45
|
+
onComplete();
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const message = err instanceof Error ? err.message : "Analysis failed";
|
|
49
|
+
setError(message);
|
|
50
|
+
console.error("Batch analysis error:", err);
|
|
51
|
+
} finally {
|
|
52
|
+
setLoading(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex flex-col gap-2">
|
|
58
|
+
<Button
|
|
59
|
+
variant="default"
|
|
60
|
+
onClick={handleAnalyzeAll}
|
|
61
|
+
disabled={loading || sessionIds.length === 0}
|
|
62
|
+
>
|
|
63
|
+
<Sparkles className="size-4 mr-2" />
|
|
64
|
+
{loading
|
|
65
|
+
? `Analyzing ${progress?.completed}/${progress?.total}...`
|
|
66
|
+
: `Analyze All (${sessionIds.length})`}
|
|
67
|
+
</Button>
|
|
68
|
+
|
|
69
|
+
{progress && !loading && (
|
|
70
|
+
<div className="text-sm text-muted-foreground">
|
|
71
|
+
Completed: {progress.completed}/{progress.total}
|
|
72
|
+
{progress.errors > 0 && ` (${progress.errors} errors)`}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{error && (
|
|
77
|
+
<div className="text-sm text-destructive max-w-md">{error}</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -29,6 +29,7 @@ export function DebuggerHeader({
|
|
|
29
29
|
typeof window !== "undefined" ? window.location.pathname : "/";
|
|
30
30
|
const isSessionsActive = pathname === "/";
|
|
31
31
|
const isTracesActive = pathname === "/traces";
|
|
32
|
+
const isFindSessionsActive = pathname === "/find-sessions";
|
|
32
33
|
const isTownHallActive = pathname.startsWith("/town-hall");
|
|
33
34
|
const [agentName, setAgentName] = useState<string>("Agent");
|
|
34
35
|
|
|
@@ -107,6 +108,17 @@ export function DebuggerHeader({
|
|
|
107
108
|
>
|
|
108
109
|
Traces
|
|
109
110
|
</a>
|
|
111
|
+
<a
|
|
112
|
+
href="/find-sessions"
|
|
113
|
+
className={cn(
|
|
114
|
+
"px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
|
|
115
|
+
isFindSessionsActive
|
|
116
|
+
? "bg-muted text-foreground"
|
|
117
|
+
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
Find Sessions
|
|
121
|
+
</a>
|
|
110
122
|
<a
|
|
111
123
|
href="/town-hall"
|
|
112
124
|
className={cn(
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Eye, Sparkles } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { SessionAnalysis } from "../analysis/types";
|
|
4
|
+
import { SessionAnalysisDialog } from "./SessionAnalysisDialog";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
existingAnalysis?: SessionAnalysis | null;
|
|
10
|
+
onAnalysisComplete?: (analysis: SessionAnalysis) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SessionAnalysisButton({
|
|
14
|
+
sessionId,
|
|
15
|
+
existingAnalysis,
|
|
16
|
+
onAnalysisComplete,
|
|
17
|
+
}: Props) {
|
|
18
|
+
const [showDialog, setShowDialog] = useState(false);
|
|
19
|
+
const [loading, setLoading] = useState(false);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
const [analysis, setAnalysis] = useState<SessionAnalysis | null>(
|
|
22
|
+
existingAnalysis || null,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// Show existing analysis without re-analyzing
|
|
26
|
+
const handleShowAnalysis = () => {
|
|
27
|
+
if (existingAnalysis) {
|
|
28
|
+
setAnalysis(existingAnalysis);
|
|
29
|
+
setShowDialog(true);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Analyze (or re-analyze) the session
|
|
34
|
+
const handleAnalyze = async () => {
|
|
35
|
+
setLoading(true);
|
|
36
|
+
setError(null);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(`/api/analyze-session/${sessionId}`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const errorData = await response.json();
|
|
45
|
+
throw new Error(errorData.error || response.statusText);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = await response.json();
|
|
49
|
+
setAnalysis(result);
|
|
50
|
+
setShowDialog(true);
|
|
51
|
+
|
|
52
|
+
// Notify parent component
|
|
53
|
+
if (onAnalysisComplete) {
|
|
54
|
+
onAnalysisComplete(result);
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
const message = err instanceof Error ? err.message : "Analysis failed";
|
|
58
|
+
setError(message);
|
|
59
|
+
console.error("Session analysis error:", err);
|
|
60
|
+
} finally {
|
|
61
|
+
setLoading(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<div className="flex gap-2">
|
|
68
|
+
{existingAnalysis ? (
|
|
69
|
+
// Show both buttons when analysis exists
|
|
70
|
+
<>
|
|
71
|
+
<Button size="sm" onClick={handleShowAnalysis}>
|
|
72
|
+
<Eye className="size-4 mr-2" />
|
|
73
|
+
Show Analysis
|
|
74
|
+
</Button>
|
|
75
|
+
<Button
|
|
76
|
+
size="sm"
|
|
77
|
+
variant="outline"
|
|
78
|
+
onClick={handleAnalyze}
|
|
79
|
+
disabled={loading}
|
|
80
|
+
>
|
|
81
|
+
<Sparkles className="size-4 mr-2" />
|
|
82
|
+
{loading ? "Re-analyzing..." : "Re-analyze"}
|
|
83
|
+
</Button>
|
|
84
|
+
</>
|
|
85
|
+
) : (
|
|
86
|
+
// Show only "Analyze Session" button if no analysis exists
|
|
87
|
+
<Button size="sm" onClick={handleAnalyze} disabled={loading}>
|
|
88
|
+
<Sparkles className="size-4 mr-2" />
|
|
89
|
+
{loading ? "Analyzing..." : "Analyze Session"}
|
|
90
|
+
</Button>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{showDialog && analysis && (
|
|
95
|
+
<SessionAnalysisDialog
|
|
96
|
+
open={showDialog}
|
|
97
|
+
onClose={() => setShowDialog(false)}
|
|
98
|
+
analysis={analysis}
|
|
99
|
+
/>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{error && (
|
|
103
|
+
<div className="absolute top-14 right-4 bg-destructive text-white text-sm px-3 py-2 rounded shadow-lg max-w-md">
|
|
104
|
+
{error}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
}
|