@raingor/pi-web-switch 0.4.0 → 0.4.1
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 +5 -1
- package/public/sw.js +28 -5
- package/server/agent-session-manager.ts +827 -0
- package/server/chat-api-plugin.ts +488 -0
- package/server/pi-reader.ts +242 -1
- package/src/App.tsx +2 -0
- package/src/components/chat/ChatInput.tsx +863 -0
- package/src/components/chat/ChatPage.tsx +617 -0
- package/src/components/chat/ChatWindow.tsx +338 -0
- package/src/components/chat/MessageView.tsx +595 -0
- package/src/components/dashboard/DashboardPage.tsx +45 -5
- package/src/components/layout/AppShell.tsx +12 -5
- package/src/components/layout/Sidebar.tsx +2 -0
- package/src/components/providers/ProvidersModelsPage.tsx +28 -10
- package/src/components/sessions/SessionsPage.tsx +466 -148
- package/src/hooks/useAgentSession.ts +1104 -0
- package/src/index.css +24 -0
- package/src/lib/translations/en.ts +69 -0
- package/src/lib/translations/ja.ts +69 -0
- package/src/lib/translations/zh-CN.ts +69 -0
- package/src/lib/translations/zh-TW.ts +69 -0
- package/src/main.tsx +4 -2
- package/src/store/config-store.ts +41 -0
- package/src/types/chat.ts +217 -0
- package/vite.config.ts +141 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
// MessageView — renders a single chat message (user, assistant, tool result, bash).
|
|
2
|
+
// Ported and simplified from pi-web's components/MessageView.tsx.
|
|
3
|
+
|
|
4
|
+
import { memo, useState, type ReactNode } from "react";
|
|
5
|
+
import type {
|
|
6
|
+
AgentMessage,
|
|
7
|
+
AssistantMessage,
|
|
8
|
+
ToolResultMessage,
|
|
9
|
+
ToolCallContent,
|
|
10
|
+
TextContent,
|
|
11
|
+
ThinkingContent,
|
|
12
|
+
ImageContent,
|
|
13
|
+
BashExecutionMessage,
|
|
14
|
+
CustomMessage,
|
|
15
|
+
} from "@/types/chat";
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
message: AgentMessage;
|
|
19
|
+
isStreaming?: boolean;
|
|
20
|
+
modelNames?: Record<string, string>;
|
|
21
|
+
cwd?: string;
|
|
22
|
+
onOpenFile?: (filePath: string) => void;
|
|
23
|
+
entryId?: string;
|
|
24
|
+
onFork?: (entryId: string) => void;
|
|
25
|
+
forking?: boolean;
|
|
26
|
+
onEditContent?: (content: string) => void;
|
|
27
|
+
showTimestamp?: boolean;
|
|
28
|
+
sessionId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatTimestamp(ts?: number): string {
|
|
32
|
+
if (!ts) return "";
|
|
33
|
+
const d = new Date(ts);
|
|
34
|
+
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extractText(content: string | (TextContent | ImageContent)[]): string {
|
|
38
|
+
if (typeof content === "string") return content;
|
|
39
|
+
return content
|
|
40
|
+
.filter((b): b is TextContent => b.type === "text")
|
|
41
|
+
.map((b) => b.text)
|
|
42
|
+
.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Tool Call Block ─────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function ToolCallBlock({ block }: { block: ToolCallContent }) {
|
|
48
|
+
const [expanded, setExpanded] = useState(false);
|
|
49
|
+
const inputStr = JSON.stringify(block.input, null, 2);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div style={{
|
|
53
|
+
border: "1px solid var(--border, #e5e7eb)",
|
|
54
|
+
borderRadius: 8,
|
|
55
|
+
background: "var(--bg-panel, #f9fafb)",
|
|
56
|
+
overflow: "hidden",
|
|
57
|
+
margin: "8px 0",
|
|
58
|
+
}}>
|
|
59
|
+
<button
|
|
60
|
+
onClick={() => setExpanded(!expanded)}
|
|
61
|
+
style={{
|
|
62
|
+
display: "flex",
|
|
63
|
+
alignItems: "center",
|
|
64
|
+
gap: 8,
|
|
65
|
+
width: "100%",
|
|
66
|
+
padding: "8px 12px",
|
|
67
|
+
background: "none",
|
|
68
|
+
border: "none",
|
|
69
|
+
cursor: "pointer",
|
|
70
|
+
textAlign: "left",
|
|
71
|
+
color: "var(--text-muted, #6b7280)",
|
|
72
|
+
fontSize: 13,
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<svg
|
|
76
|
+
width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6"
|
|
77
|
+
strokeLinecap="round" strokeLinejoin="round"
|
|
78
|
+
style={{ transform: expanded ? "rotate(90deg)" : "none", transition: "transform 0.15s", flexShrink: 0 }}
|
|
79
|
+
>
|
|
80
|
+
<polyline points="4 2.5 7.5 6 4 9.5" />
|
|
81
|
+
</svg>
|
|
82
|
+
<span style={{ fontWeight: 600, color: "var(--accent, #2563eb)" }}>{block.toolName}</span>
|
|
83
|
+
<span style={{ fontSize: 11, opacity: 0.6 }}>tool call</span>
|
|
84
|
+
</button>
|
|
85
|
+
{expanded && (
|
|
86
|
+
<pre style={{
|
|
87
|
+
margin: 0,
|
|
88
|
+
padding: "8px 12px",
|
|
89
|
+
borderTop: "1px solid var(--border, #e5e7eb)",
|
|
90
|
+
fontSize: 12,
|
|
91
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
92
|
+
color: "var(--text-muted, #6b7280)",
|
|
93
|
+
whiteSpace: "pre-wrap",
|
|
94
|
+
wordBreak: "break-word",
|
|
95
|
+
overflow: "auto",
|
|
96
|
+
maxHeight: 300,
|
|
97
|
+
}}>
|
|
98
|
+
{inputStr}
|
|
99
|
+
</pre>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Thinking Block ──────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
function ThinkingBlock({ block }: { block: ThinkingContent }) {
|
|
108
|
+
const [expanded, setExpanded] = useState(false);
|
|
109
|
+
if (!block.thinking && block.deferred) {
|
|
110
|
+
return (
|
|
111
|
+
<div style={{ margin: "6px 0", fontSize: 12, color: "var(--text-dim)", fontStyle: "italic" }}>
|
|
112
|
+
Thinking (collapsed)
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div style={{ margin: "8px 0" }}>
|
|
119
|
+
<button
|
|
120
|
+
onClick={() => setExpanded(!expanded)}
|
|
121
|
+
style={{
|
|
122
|
+
display: "flex",
|
|
123
|
+
alignItems: "center",
|
|
124
|
+
gap: 6,
|
|
125
|
+
background: "none",
|
|
126
|
+
border: "none",
|
|
127
|
+
cursor: "pointer",
|
|
128
|
+
fontSize: 12,
|
|
129
|
+
color: "var(--text-muted, #6b7280)",
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<svg
|
|
133
|
+
width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6"
|
|
134
|
+
strokeLinecap="round" strokeLinejoin="round"
|
|
135
|
+
style={{ transform: expanded ? "rotate(90deg)" : "none", transition: "transform 0.15s" }}
|
|
136
|
+
>
|
|
137
|
+
<polyline points="4 2.5 7.5 6 4 9.5" />
|
|
138
|
+
</svg>
|
|
139
|
+
Thinking
|
|
140
|
+
</button>
|
|
141
|
+
{expanded && (
|
|
142
|
+
<div style={{
|
|
143
|
+
marginTop: 6,
|
|
144
|
+
padding: "8px 12px",
|
|
145
|
+
borderLeft: "2px solid var(--border, #e5e7eb)",
|
|
146
|
+
fontSize: 13,
|
|
147
|
+
color: "var(--text-muted, #6b7280)",
|
|
148
|
+
whiteSpace: "pre-wrap",
|
|
149
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
150
|
+
lineHeight: 1.6,
|
|
151
|
+
}}>
|
|
152
|
+
{block.thinking}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Image Block ─────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function ImageBlock({ block }: { block: ImageContent }) {
|
|
162
|
+
const src = block.source.type === "url"
|
|
163
|
+
? block.source.url
|
|
164
|
+
: `data:${block.source.media_type ?? "image/png"};base64,${block.source.data}`;
|
|
165
|
+
return (
|
|
166
|
+
<img
|
|
167
|
+
src={src}
|
|
168
|
+
alt="attachment"
|
|
169
|
+
style={{
|
|
170
|
+
maxWidth: "100%",
|
|
171
|
+
maxHeight: 400,
|
|
172
|
+
borderRadius: 8,
|
|
173
|
+
margin: "8px 0",
|
|
174
|
+
}}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── Tool Result Block ───────────────────────────────────
|
|
180
|
+
|
|
181
|
+
function ToolResultView({ message }: { message: ToolResultMessage }) {
|
|
182
|
+
const [expanded, setExpanded] = useState(false);
|
|
183
|
+
const textContent = message.content
|
|
184
|
+
.filter((b): b is TextContent => b.type === "text")
|
|
185
|
+
.map((b) => b.text)
|
|
186
|
+
.join("\n");
|
|
187
|
+
|
|
188
|
+
const truncated = textContent.length > 500;
|
|
189
|
+
const displayText = expanded || !truncated ? textContent : textContent.slice(0, 500) + "...";
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div style={{
|
|
193
|
+
margin: "4px 0 8px 0",
|
|
194
|
+
padding: "8px 12px",
|
|
195
|
+
borderRadius: 8,
|
|
196
|
+
background: message.isError ? "rgba(239,68,68,0.06)" : "var(--bg-panel, #f9fafb)",
|
|
197
|
+
border: `1px solid ${message.isError ? "rgba(239,68,68,0.2)" : "var(--border, #e5e7eb)"}`,
|
|
198
|
+
fontSize: 13,
|
|
199
|
+
}}>
|
|
200
|
+
{message.toolName && (
|
|
201
|
+
<div style={{
|
|
202
|
+
fontSize: 11,
|
|
203
|
+
fontWeight: 600,
|
|
204
|
+
color: message.isError ? "#dc2626" : "var(--text-muted, #6b7280)",
|
|
205
|
+
marginBottom: 4,
|
|
206
|
+
}}>
|
|
207
|
+
{message.toolName} result {message.isError ? "(error)" : ""}
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
<pre style={{
|
|
211
|
+
margin: 0,
|
|
212
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
213
|
+
color: message.isError ? "#dc2626" : "var(--text-muted, #6b7280)",
|
|
214
|
+
whiteSpace: "pre-wrap",
|
|
215
|
+
wordBreak: "break-word",
|
|
216
|
+
lineHeight: 1.5,
|
|
217
|
+
}}>
|
|
218
|
+
{displayText}
|
|
219
|
+
</pre>
|
|
220
|
+
{truncated && (
|
|
221
|
+
<button
|
|
222
|
+
onClick={() => setExpanded(!expanded)}
|
|
223
|
+
style={{
|
|
224
|
+
marginTop: 4,
|
|
225
|
+
background: "none",
|
|
226
|
+
border: "none",
|
|
227
|
+
color: "var(--accent, #2563eb)",
|
|
228
|
+
cursor: "pointer",
|
|
229
|
+
fontSize: 12,
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
{expanded ? "Show less" : `Show ${textContent.length - 500} more characters`}
|
|
233
|
+
</button>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── Bash Execution View ─────────────────────────────────
|
|
240
|
+
|
|
241
|
+
function BashExecutionView({ message }: { message: BashExecutionMessage }) {
|
|
242
|
+
return (
|
|
243
|
+
<div style={{
|
|
244
|
+
margin: "8px 0",
|
|
245
|
+
borderRadius: 8,
|
|
246
|
+
overflow: "hidden",
|
|
247
|
+
border: "1px solid var(--border, #e5e7eb)",
|
|
248
|
+
}}>
|
|
249
|
+
<div style={{
|
|
250
|
+
padding: "6px 12px",
|
|
251
|
+
background: "var(--bg-panel, #f9fafb)",
|
|
252
|
+
fontSize: 12,
|
|
253
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
254
|
+
color: "var(--text-muted, #6b7280)",
|
|
255
|
+
display: "flex",
|
|
256
|
+
alignItems: "center",
|
|
257
|
+
gap: 6,
|
|
258
|
+
}}>
|
|
259
|
+
<span style={{ color: "var(--accent, #2563eb)" }}>$</span>
|
|
260
|
+
<span>{message.command}</span>
|
|
261
|
+
{message.exitCode !== undefined && (
|
|
262
|
+
<span style={{ marginLeft: "auto", color: message.exitCode === 0 ? "#10b981" : "#dc2626" }}>
|
|
263
|
+
exit {message.exitCode}
|
|
264
|
+
</span>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
{message.output && (
|
|
268
|
+
<pre style={{
|
|
269
|
+
margin: 0,
|
|
270
|
+
padding: "8px 12px",
|
|
271
|
+
fontSize: 12,
|
|
272
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
273
|
+
color: "var(--text-muted, #6b7280)",
|
|
274
|
+
whiteSpace: "pre-wrap",
|
|
275
|
+
wordBreak: "break-word",
|
|
276
|
+
maxHeight: 300,
|
|
277
|
+
overflow: "auto",
|
|
278
|
+
}}>
|
|
279
|
+
{message.output}
|
|
280
|
+
</pre>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Markdown Renderer (simplified) ──────────────────────
|
|
287
|
+
|
|
288
|
+
function renderMarkdown(text: string): ReactNode {
|
|
289
|
+
// Simple markdown rendering: code blocks, inline code, bold, links, line breaks
|
|
290
|
+
const parts: ReactNode[] = [];
|
|
291
|
+
const codeBlockRegex = /```(\w+)?\n?([\s\S]*?)```/g;
|
|
292
|
+
let lastIndex = 0;
|
|
293
|
+
let match;
|
|
294
|
+
let key = 0;
|
|
295
|
+
|
|
296
|
+
while ((match = codeBlockRegex.exec(text)) !== null) {
|
|
297
|
+
if (match.index > lastIndex) {
|
|
298
|
+
parts.push(renderInlineMarkdown(text.slice(lastIndex, match.index), `md-${key++}`));
|
|
299
|
+
}
|
|
300
|
+
const lang = match[1] || "text";
|
|
301
|
+
const code = match[2];
|
|
302
|
+
parts.push(
|
|
303
|
+
<div key={`code-${key++}`} style={{
|
|
304
|
+
margin: "8px 0",
|
|
305
|
+
borderRadius: 8,
|
|
306
|
+
overflow: "hidden",
|
|
307
|
+
border: "1px solid var(--border, #e5e7eb)",
|
|
308
|
+
}}>
|
|
309
|
+
<div style={{
|
|
310
|
+
padding: "4px 12px",
|
|
311
|
+
background: "var(--bg-panel, #f9fafb)",
|
|
312
|
+
fontSize: 11,
|
|
313
|
+
color: "var(--text-dim)",
|
|
314
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
315
|
+
borderBottom: "1px solid var(--border, #e5e7eb)",
|
|
316
|
+
}}>
|
|
317
|
+
{lang}
|
|
318
|
+
</div>
|
|
319
|
+
<pre style={{
|
|
320
|
+
margin: 0,
|
|
321
|
+
padding: "10px 12px",
|
|
322
|
+
fontSize: 13,
|
|
323
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
324
|
+
color: "var(--text, #1f2937)",
|
|
325
|
+
whiteSpace: "pre-wrap",
|
|
326
|
+
wordBreak: "break-word",
|
|
327
|
+
overflow: "auto",
|
|
328
|
+
maxHeight: 500,
|
|
329
|
+
}}>
|
|
330
|
+
<code>{code}</code>
|
|
331
|
+
</pre>
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
lastIndex = match.index + match[0].length;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (lastIndex < text.length) {
|
|
338
|
+
parts.push(renderInlineMarkdown(text.slice(lastIndex), `md-${key++}`));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return <>{parts}</>;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function renderInlineMarkdown(text: string, keyPrefix: string): ReactNode {
|
|
345
|
+
// Split by lines for paragraph rendering
|
|
346
|
+
const lines = text.split("\n");
|
|
347
|
+
return (
|
|
348
|
+
<div key={keyPrefix}>
|
|
349
|
+
{lines.map((line, i) => (
|
|
350
|
+
<div key={`${keyPrefix}-${i}`} style={{ minHeight: "1.5em" }}>
|
|
351
|
+
{renderLine(line, `${keyPrefix}-${i}`)}
|
|
352
|
+
</div>
|
|
353
|
+
))}
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function renderLine(line: string, keyPrefix: string): ReactNode {
|
|
359
|
+
// Handle inline code, bold, links
|
|
360
|
+
const parts: ReactNode[] = [];
|
|
361
|
+
const regex = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\[[^\]]+\]\([^)]+\))/g;
|
|
362
|
+
let lastIndex = 0;
|
|
363
|
+
let match;
|
|
364
|
+
let key = 0;
|
|
365
|
+
|
|
366
|
+
while ((match = regex.exec(line)) !== null) {
|
|
367
|
+
if (match.index > lastIndex) {
|
|
368
|
+
parts.push(line.slice(lastIndex, match.index));
|
|
369
|
+
}
|
|
370
|
+
const token = match[0];
|
|
371
|
+
if (token.startsWith("`")) {
|
|
372
|
+
parts.push(
|
|
373
|
+
<code key={`${keyPrefix}-${key++}`} style={{
|
|
374
|
+
padding: "1px 5px",
|
|
375
|
+
borderRadius: 4,
|
|
376
|
+
background: "var(--bg-panel, #f3f4f6)",
|
|
377
|
+
fontSize: "0.9em",
|
|
378
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
379
|
+
}}>
|
|
380
|
+
{token.slice(1, -1)}
|
|
381
|
+
</code>
|
|
382
|
+
);
|
|
383
|
+
} else if (token.startsWith("**")) {
|
|
384
|
+
parts.push(<strong key={`${keyPrefix}-${key++}`}>{token.slice(2, -2)}</strong>);
|
|
385
|
+
} else if (token.startsWith("[")) {
|
|
386
|
+
const linkMatch = token.match(/\[([^\]]+)\]\(([^)]+)\)/);
|
|
387
|
+
if (linkMatch) {
|
|
388
|
+
parts.push(
|
|
389
|
+
<a key={`${keyPrefix}-${key++}`} href={linkMatch[2]} target="_blank" rel="noopener noreferrer"
|
|
390
|
+
style={{ color: "var(--accent, #2563eb)", textDecoration: "underline" }}>
|
|
391
|
+
{linkMatch[1]}
|
|
392
|
+
</a>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
lastIndex = match.index + token.length;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (lastIndex < line.length) {
|
|
400
|
+
parts.push(line.slice(lastIndex));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return parts.length > 0 ? parts : (line === "" ? "\u200B" : line);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── Main MessageView ────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
function MessageViewImpl({
|
|
409
|
+
message,
|
|
410
|
+
isStreaming,
|
|
411
|
+
modelNames,
|
|
412
|
+
cwd,
|
|
413
|
+
onOpenFile,
|
|
414
|
+
entryId,
|
|
415
|
+
onFork,
|
|
416
|
+
forking,
|
|
417
|
+
onEditContent,
|
|
418
|
+
showTimestamp,
|
|
419
|
+
sessionId,
|
|
420
|
+
}: Props) {
|
|
421
|
+
if (message.role === "user") {
|
|
422
|
+
const text = typeof message.content === "string"
|
|
423
|
+
? message.content
|
|
424
|
+
: extractText(message.content);
|
|
425
|
+
const images = Array.isArray(message.content)
|
|
426
|
+
? message.content.filter((b): b is ImageContent => b.type === "image")
|
|
427
|
+
: [];
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
<div style={{
|
|
431
|
+
display: "flex",
|
|
432
|
+
justifyContent: "flex-end",
|
|
433
|
+
marginBottom: 16,
|
|
434
|
+
}}>
|
|
435
|
+
<div style={{
|
|
436
|
+
maxWidth: "80%",
|
|
437
|
+
padding: "10px 14px",
|
|
438
|
+
borderRadius: "16px 16px 4px 16px",
|
|
439
|
+
background: "var(--accent, #2563eb)",
|
|
440
|
+
color: "#fff",
|
|
441
|
+
fontSize: 14,
|
|
442
|
+
lineHeight: 1.6,
|
|
443
|
+
wordBreak: "break-word",
|
|
444
|
+
}}>
|
|
445
|
+
{text && <div>{renderMarkdown(text)}</div>}
|
|
446
|
+
{images.map((img, i) => (
|
|
447
|
+
<ImageBlock key={i} block={img} />
|
|
448
|
+
))}
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (message.role === "assistant") {
|
|
455
|
+
const am = message as AssistantMessage;
|
|
456
|
+
const blocks = am.content;
|
|
457
|
+
const modelLabel = am.provider && am.model
|
|
458
|
+
? modelNames?.[`${am.provider}:${am.model}`] ?? am.model
|
|
459
|
+
: am.model;
|
|
460
|
+
|
|
461
|
+
return (
|
|
462
|
+
<div style={{ marginBottom: 16 }}>
|
|
463
|
+
<div style={{
|
|
464
|
+
display: "flex",
|
|
465
|
+
alignItems: "center",
|
|
466
|
+
gap: 8,
|
|
467
|
+
marginBottom: 4,
|
|
468
|
+
}}>
|
|
469
|
+
<span style={{
|
|
470
|
+
fontSize: 13,
|
|
471
|
+
fontWeight: 600,
|
|
472
|
+
color: "var(--text, #1f2937)",
|
|
473
|
+
}}>
|
|
474
|
+
π {modelLabel && <span style={{ fontWeight: 400, color: "var(--text-muted, #6b7280)", fontSize: 12 }}>· {modelLabel}</span>}
|
|
475
|
+
</span>
|
|
476
|
+
{showTimestamp && message.timestamp && (
|
|
477
|
+
<span style={{ fontSize: 11, color: "var(--text-dim)" }}>
|
|
478
|
+
{formatTimestamp(message.timestamp)}
|
|
479
|
+
</span>
|
|
480
|
+
)}
|
|
481
|
+
{onFork && entryId && !isStreaming && (
|
|
482
|
+
<button
|
|
483
|
+
onClick={() => onFork(entryId)}
|
|
484
|
+
disabled={forking}
|
|
485
|
+
title="Fork from here"
|
|
486
|
+
style={{
|
|
487
|
+
background: "none",
|
|
488
|
+
border: "none",
|
|
489
|
+
cursor: forking ? "wait" : "pointer",
|
|
490
|
+
color: "var(--text-dim)",
|
|
491
|
+
padding: 0,
|
|
492
|
+
fontSize: 11,
|
|
493
|
+
}}
|
|
494
|
+
>
|
|
495
|
+
{forking ? "⏳" : "⎇"}
|
|
496
|
+
</button>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
<div style={{
|
|
500
|
+
padding: "0 2px",
|
|
501
|
+
fontSize: 14,
|
|
502
|
+
lineHeight: 1.7,
|
|
503
|
+
color: "var(--text, #1f2937)",
|
|
504
|
+
}}>
|
|
505
|
+
{blocks.map((block, i) => {
|
|
506
|
+
switch (block.type) {
|
|
507
|
+
case "text":
|
|
508
|
+
return <div key={i}>{renderMarkdown(block.text)}</div>;
|
|
509
|
+
case "thinking":
|
|
510
|
+
return <ThinkingBlock key={i} block={block} />;
|
|
511
|
+
case "toolCall":
|
|
512
|
+
return <ToolCallBlock key={i} block={block} />;
|
|
513
|
+
case "image":
|
|
514
|
+
return <ImageBlock key={i} block={block} />;
|
|
515
|
+
default:
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
})}
|
|
519
|
+
{am.errorMessage && (
|
|
520
|
+
<div style={{
|
|
521
|
+
marginTop: 8,
|
|
522
|
+
padding: "8px 12px",
|
|
523
|
+
borderRadius: 8,
|
|
524
|
+
background: "rgba(239,68,68,0.06)",
|
|
525
|
+
color: "#dc2626",
|
|
526
|
+
fontSize: 13,
|
|
527
|
+
}}>
|
|
528
|
+
{am.errorMessage}
|
|
529
|
+
</div>
|
|
530
|
+
)}
|
|
531
|
+
{am.usage && (
|
|
532
|
+
<div style={{
|
|
533
|
+
marginTop: 6,
|
|
534
|
+
fontSize: 11,
|
|
535
|
+
color: "var(--text-dim)",
|
|
536
|
+
display: "flex",
|
|
537
|
+
gap: 12,
|
|
538
|
+
}}>
|
|
539
|
+
<span>in: {am.usage.input.toLocaleString()}</span>
|
|
540
|
+
<span>out: {am.usage.output.toLocaleString()}</span>
|
|
541
|
+
{am.usage.cacheRead > 0 && <span>cache: {am.usage.cacheRead.toLocaleString()}</span>}
|
|
542
|
+
{am.usage.cost?.total ? <span>${am.usage.cost.total.toFixed(4)}</span> : null}
|
|
543
|
+
</div>
|
|
544
|
+
)}
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (message.role === "toolResult") {
|
|
551
|
+
return <ToolResultView message={message as ToolResultMessage} />;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (message.role === "bashExecution") {
|
|
555
|
+
return <BashExecutionView message={message as BashExecutionMessage} />;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (message.role === "custom") {
|
|
559
|
+
const cm = message as CustomMessage;
|
|
560
|
+
if (!cm.display) return null;
|
|
561
|
+
const text = typeof cm.content === "string" ? cm.content : extractText(cm.content);
|
|
562
|
+
if (cm.customType === "compaction") {
|
|
563
|
+
return (
|
|
564
|
+
<div style={{
|
|
565
|
+
margin: "12px 0",
|
|
566
|
+
padding: "8px 14px",
|
|
567
|
+
borderRadius: 8,
|
|
568
|
+
background: "var(--bg-panel, #f9fafb)",
|
|
569
|
+
border: "1px solid var(--border, #e5e7eb)",
|
|
570
|
+
fontSize: 13,
|
|
571
|
+
color: "var(--text-muted, #6b7280)",
|
|
572
|
+
fontStyle: "italic",
|
|
573
|
+
}}>
|
|
574
|
+
{text}
|
|
575
|
+
</div>
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
return (
|
|
579
|
+
<div style={{
|
|
580
|
+
margin: "8px 0",
|
|
581
|
+
padding: "8px 14px",
|
|
582
|
+
borderRadius: 8,
|
|
583
|
+
background: "var(--bg-panel, #f9fafb)",
|
|
584
|
+
fontSize: 13,
|
|
585
|
+
color: "var(--text-muted, #6b7280)",
|
|
586
|
+
}}>
|
|
587
|
+
{text}
|
|
588
|
+
</div>
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export const MessageView = memo(MessageViewImpl);
|
|
@@ -69,6 +69,7 @@ interface UsageRangeData {
|
|
|
69
69
|
}[];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
type SourceKey = "all" | "pi" | "cindy-pi" | "claude" | "codex" | "opencode" | "gemini" | "grok";
|
|
72
73
|
type RangeKey = "today" | "7d" | "30d" | "custom";
|
|
73
74
|
type TabKey = "log" | "provider" | "model";
|
|
74
75
|
type SortDir = "asc" | "desc";
|
|
@@ -270,6 +271,7 @@ export function DashboardPage() {
|
|
|
270
271
|
const { t, lang } = useTranslation();
|
|
271
272
|
const { currency, toggle: toggleCurrency } = useCurrency();
|
|
272
273
|
const { initialized } = useConfigStore();
|
|
274
|
+
const [source, setSource] = useState<SourceKey>("pi");
|
|
273
275
|
const [range, setRange] = useState<RangeKey>("today");
|
|
274
276
|
const [customFrom, setCustomFrom] = useState("");
|
|
275
277
|
const [customTo, setCustomTo] = useState("");
|
|
@@ -290,7 +292,15 @@ export function DashboardPage() {
|
|
|
290
292
|
|
|
291
293
|
const fetchData = useCallback(() => {
|
|
292
294
|
if (!initialized || customInvalid) return;
|
|
293
|
-
let
|
|
295
|
+
let baseUrl = "/api/pi/usage-range";
|
|
296
|
+
if (source === "all") baseUrl = "/api/pi/all-usage-range";
|
|
297
|
+
else if (source === "cindy-pi") baseUrl = "/api/pi/cindy-usage-range";
|
|
298
|
+
else if (source === "claude") baseUrl = "/api/pi/claude-usage-range";
|
|
299
|
+
else if (source === "codex") baseUrl = "/api/pi/codex-usage-range";
|
|
300
|
+
else if (source === "opencode") baseUrl = "/api/pi/opencode-usage-range";
|
|
301
|
+
else if (source === "gemini") baseUrl = "/api/pi/gemini-usage-range";
|
|
302
|
+
else if (source === "grok") baseUrl = "/api/pi/grok-usage-range";
|
|
303
|
+
let url = `${baseUrl}?range=${range}`;
|
|
294
304
|
if (range === "custom" && customFrom) {
|
|
295
305
|
url += `&from=${customFrom}&to=${customTo || customFrom}`;
|
|
296
306
|
}
|
|
@@ -308,19 +318,22 @@ export function DashboardPage() {
|
|
|
308
318
|
// Previous period of equal length → period-over-period trend on stat cards
|
|
309
319
|
const prev = getPrevRange(range);
|
|
310
320
|
if (prev) {
|
|
311
|
-
fetch(
|
|
321
|
+
fetch(`${baseUrl}?range=custom&from=${prev.from}&to=${prev.to}`)
|
|
312
322
|
.then((r) => r.json())
|
|
313
323
|
.then((p) => setPrevTotals({ tokens: p.totalTokens ?? 0, cost: p.totalCost ?? 0 }))
|
|
314
324
|
.catch(() => setPrevTotals(null));
|
|
315
325
|
} else {
|
|
316
326
|
setPrevTotals(null);
|
|
317
327
|
}
|
|
318
|
-
}, [initialized, range, customFrom, customTo, customInvalid]);
|
|
328
|
+
}, [initialized, source, range, customFrom, customTo, customInvalid]);
|
|
319
329
|
|
|
320
330
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
321
331
|
|
|
322
|
-
// Reset request-log pagination when the queried range changes
|
|
323
|
-
useEffect(() => { setLogPage(1); }, [range, customFrom, customTo]);
|
|
332
|
+
// Reset request-log pagination when the queried range or source changes
|
|
333
|
+
useEffect(() => { setLogPage(1); }, [range, customFrom, customTo, source]);
|
|
334
|
+
|
|
335
|
+
// Reset loading state when source changes
|
|
336
|
+
useEffect(() => { setLoading(true); }, [source]);
|
|
324
337
|
|
|
325
338
|
// Auto-refresh with configurable interval (seconds)
|
|
326
339
|
useEffect(() => {
|
|
@@ -384,6 +397,33 @@ export function DashboardPage() {
|
|
|
384
397
|
|
|
385
398
|
return (
|
|
386
399
|
<div className="space-y-5">
|
|
400
|
+
{/* Source Selector: Pi / Cindy-Pi */}
|
|
401
|
+
<div className="flex items-center gap-1 rounded-lg border p-0.5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--page-bg)" }}>
|
|
402
|
+
{([
|
|
403
|
+
{ key: "all" as SourceKey, label: "dashboard.source_all", icon: "📊" },
|
|
404
|
+
{ key: "pi" as SourceKey, label: "dashboard.source_pi", icon: "🖥" },
|
|
405
|
+
{ key: "cindy-pi" as SourceKey, label: "dashboard.source_cindy_pi", icon: "🤖" },
|
|
406
|
+
{ key: "claude" as SourceKey, label: "dashboard.source_claude", icon: "🧠" },
|
|
407
|
+
{ key: "codex" as SourceKey, label: "dashboard.source_codex", icon: "⚡" },
|
|
408
|
+
{ key: "opencode" as SourceKey, label: "dashboard.source_opencode", icon: "🔷" },
|
|
409
|
+
{ key: "gemini" as SourceKey, label: "dashboard.source_gemini", icon: "✨" },
|
|
410
|
+
{ key: "grok" as SourceKey, label: "dashboard.source_grok", icon: "🌀" },
|
|
411
|
+
]).map((s) => (
|
|
412
|
+
<button
|
|
413
|
+
key={s.key}
|
|
414
|
+
onClick={() => setSource(s.key)}
|
|
415
|
+
className={cn(
|
|
416
|
+
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
|
|
417
|
+
source === s.key ? "text-white" : "hover:bg-gray-800/30"
|
|
418
|
+
)}
|
|
419
|
+
style={source === s.key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
|
|
420
|
+
>
|
|
421
|
+
<span>{s.icon}</span>
|
|
422
|
+
<span>{t(s.label)}</span>
|
|
423
|
+
</button>
|
|
424
|
+
))}
|
|
425
|
+
</div>
|
|
426
|
+
|
|
387
427
|
{/* Title + Time Range Selector + Currency Toggle */}
|
|
388
428
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
389
429
|
<div>
|