@oh-my-pi/pi-web-ui 0.1.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +609 -0
  3. package/example/README.md +61 -0
  4. package/example/index.html +13 -0
  5. package/example/package.json +25 -0
  6. package/example/src/app.css +1 -0
  7. package/example/src/custom-messages.ts +99 -0
  8. package/example/src/main.ts +420 -0
  9. package/example/tsconfig.json +23 -0
  10. package/example/vite.config.ts +6 -0
  11. package/package.json +58 -0
  12. package/scripts/count-prompt-tokens.ts +88 -0
  13. package/src/ChatPanel.ts +218 -0
  14. package/src/app.css +68 -0
  15. package/src/components/AgentInterface.ts +390 -0
  16. package/src/components/AttachmentTile.ts +107 -0
  17. package/src/components/ConsoleBlock.ts +74 -0
  18. package/src/components/CustomProviderCard.ts +96 -0
  19. package/src/components/ExpandableSection.ts +46 -0
  20. package/src/components/Input.ts +113 -0
  21. package/src/components/MessageEditor.ts +404 -0
  22. package/src/components/MessageList.ts +97 -0
  23. package/src/components/Messages.ts +384 -0
  24. package/src/components/ProviderKeyInput.ts +152 -0
  25. package/src/components/SandboxedIframe.ts +626 -0
  26. package/src/components/StreamingMessageContainer.ts +107 -0
  27. package/src/components/ThinkingBlock.ts +45 -0
  28. package/src/components/message-renderer-registry.ts +28 -0
  29. package/src/components/sandbox/ArtifactsRuntimeProvider.ts +219 -0
  30. package/src/components/sandbox/AttachmentsRuntimeProvider.ts +66 -0
  31. package/src/components/sandbox/ConsoleRuntimeProvider.ts +186 -0
  32. package/src/components/sandbox/FileDownloadRuntimeProvider.ts +110 -0
  33. package/src/components/sandbox/RuntimeMessageBridge.ts +82 -0
  34. package/src/components/sandbox/RuntimeMessageRouter.ts +216 -0
  35. package/src/components/sandbox/SandboxRuntimeProvider.ts +52 -0
  36. package/src/dialogs/ApiKeyPromptDialog.ts +75 -0
  37. package/src/dialogs/AttachmentOverlay.ts +640 -0
  38. package/src/dialogs/CustomProviderDialog.ts +274 -0
  39. package/src/dialogs/ModelSelector.ts +314 -0
  40. package/src/dialogs/PersistentStorageDialog.ts +146 -0
  41. package/src/dialogs/ProvidersModelsTab.ts +212 -0
  42. package/src/dialogs/SessionListDialog.ts +157 -0
  43. package/src/dialogs/SettingsDialog.ts +216 -0
  44. package/src/index.ts +115 -0
  45. package/src/prompts/prompts.ts +282 -0
  46. package/src/storage/app-storage.ts +60 -0
  47. package/src/storage/backends/indexeddb-storage-backend.ts +193 -0
  48. package/src/storage/store.ts +33 -0
  49. package/src/storage/stores/custom-providers-store.ts +62 -0
  50. package/src/storage/stores/provider-keys-store.ts +33 -0
  51. package/src/storage/stores/sessions-store.ts +136 -0
  52. package/src/storage/stores/settings-store.ts +34 -0
  53. package/src/storage/types.ts +206 -0
  54. package/src/tools/artifacts/ArtifactElement.ts +14 -0
  55. package/src/tools/artifacts/ArtifactPill.ts +26 -0
  56. package/src/tools/artifacts/Console.ts +102 -0
  57. package/src/tools/artifacts/DocxArtifact.ts +213 -0
  58. package/src/tools/artifacts/ExcelArtifact.ts +231 -0
  59. package/src/tools/artifacts/GenericArtifact.ts +118 -0
  60. package/src/tools/artifacts/HtmlArtifact.ts +203 -0
  61. package/src/tools/artifacts/ImageArtifact.ts +116 -0
  62. package/src/tools/artifacts/MarkdownArtifact.ts +83 -0
  63. package/src/tools/artifacts/PdfArtifact.ts +201 -0
  64. package/src/tools/artifacts/SvgArtifact.ts +82 -0
  65. package/src/tools/artifacts/TextArtifact.ts +148 -0
  66. package/src/tools/artifacts/artifacts-tool-renderer.ts +371 -0
  67. package/src/tools/artifacts/artifacts.ts +713 -0
  68. package/src/tools/artifacts/index.ts +7 -0
  69. package/src/tools/extract-document.ts +271 -0
  70. package/src/tools/index.ts +46 -0
  71. package/src/tools/javascript-repl.ts +316 -0
  72. package/src/tools/renderer-registry.ts +127 -0
  73. package/src/tools/renderers/BashRenderer.ts +52 -0
  74. package/src/tools/renderers/CalculateRenderer.ts +58 -0
  75. package/src/tools/renderers/DefaultRenderer.ts +95 -0
  76. package/src/tools/renderers/GetCurrentTimeRenderer.ts +92 -0
  77. package/src/tools/types.ts +15 -0
  78. package/src/utils/attachment-utils.ts +606 -0
  79. package/src/utils/auth-token.ts +22 -0
  80. package/src/utils/format.ts +42 -0
  81. package/src/utils/i18n.ts +653 -0
  82. package/src/utils/model-discovery.ts +277 -0
  83. package/src/utils/proxy-utils.ts +134 -0
  84. package/src/utils/test-sessions.ts +2324 -0
  85. package/tsconfig.build.json +20 -0
  86. package/tsconfig.json +7 -0
@@ -0,0 +1,606 @@
1
+ import { parseAsync } from "docx-preview";
2
+ import JSZip from "jszip";
3
+ import type { PDFDocumentProxy } from "pdfjs-dist";
4
+ import * as pdfjsLib from "pdfjs-dist";
5
+ import * as XLSX from "xlsx";
6
+ import { i18n } from "./i18n";
7
+
8
+ // Configure PDF.js worker - we'll need to bundle this
9
+ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
10
+
11
+ export interface Attachment {
12
+ id: string;
13
+ type: "image" | "document";
14
+ fileName: string;
15
+ mimeType: string;
16
+ size: number;
17
+ content: string; // base64 encoded original data (without data URL prefix)
18
+ extractedText?: string; // For documents: <pdf filename="..."><page number="1">text</page></pdf>
19
+ preview?: string; // base64 image preview (first page for PDFs, or same as content for images)
20
+ }
21
+
22
+ const RESIZE_TRIGGER_MAX_DIMENSION = 2048;
23
+ const MAX_RESIZE_WIDTH = 1920;
24
+ const MAX_RESIZE_HEIGHT = 1080;
25
+ const JPEG_CONVERT_THRESHOLD_BYTES = 2 * 1024 * 1024;
26
+ const JPEG_QUALITY = 0.85;
27
+
28
+ function arrayBufferToBase64(arrayBuffer: ArrayBuffer): string {
29
+ const uint8Array = new Uint8Array(arrayBuffer);
30
+ let binary = "";
31
+ const chunkSize = 0x8000;
32
+ for (let i = 0; i < uint8Array.length; i += chunkSize) {
33
+ const chunk = uint8Array.slice(i, i + chunkSize);
34
+ binary += String.fromCharCode(...chunk);
35
+ }
36
+ return btoa(binary);
37
+ }
38
+
39
+ function getResizedDimensions(width: number, height: number): { width: number; height: number } {
40
+ const maxDim = Math.max(width, height);
41
+ if (maxDim <= RESIZE_TRIGGER_MAX_DIMENSION) {
42
+ return { width, height };
43
+ }
44
+ const scale = Math.min(MAX_RESIZE_WIDTH / width, MAX_RESIZE_HEIGHT / height);
45
+ return {
46
+ width: Math.max(1, Math.round(width * scale)),
47
+ height: Math.max(1, Math.round(height * scale)),
48
+ };
49
+ }
50
+
51
+ function updateFileNameForJpeg(fileName: string): string {
52
+ const lower = fileName.toLowerCase();
53
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
54
+ return fileName;
55
+ }
56
+ const dotIndex = fileName.lastIndexOf(".");
57
+ if (dotIndex > 0) {
58
+ return `${fileName.slice(0, dotIndex)}.jpg`;
59
+ }
60
+ return `${fileName}.jpg`;
61
+ }
62
+
63
+ async function decodeImage(arrayBuffer: ArrayBuffer, mimeType: string) {
64
+ const blob = new Blob([arrayBuffer], { type: mimeType });
65
+ if (typeof createImageBitmap === "function") {
66
+ const bitmap = await createImageBitmap(blob);
67
+ return {
68
+ width: bitmap.width,
69
+ height: bitmap.height,
70
+ draw: (ctx: CanvasRenderingContext2D, width: number, height: number) => {
71
+ ctx.drawImage(bitmap, 0, 0, width, height);
72
+ bitmap.close?.();
73
+ },
74
+ };
75
+ }
76
+
77
+ const url = URL.createObjectURL(blob);
78
+ try {
79
+ const img = new Image();
80
+ img.src = url;
81
+ await img.decode();
82
+ return {
83
+ width: img.naturalWidth,
84
+ height: img.naturalHeight,
85
+ draw: (ctx: CanvasRenderingContext2D, width: number, height: number) => {
86
+ ctx.drawImage(img, 0, 0, width, height);
87
+ },
88
+ };
89
+ } finally {
90
+ URL.revokeObjectURL(url);
91
+ }
92
+ }
93
+
94
+ async function processImageAttachment(
95
+ arrayBuffer: ArrayBuffer,
96
+ fileName: string,
97
+ mimeType: string,
98
+ ): Promise<{ base64: string; mimeType: string; size: number; fileName: string }> {
99
+ const shouldConvertToJpeg = arrayBuffer.byteLength > JPEG_CONVERT_THRESHOLD_BYTES;
100
+ const outputMimeType = shouldConvertToJpeg ? "image/jpeg" : mimeType;
101
+
102
+ const { width, height, draw } = await decodeImage(arrayBuffer, mimeType);
103
+ const resized = getResizedDimensions(width, height);
104
+ const shouldResize = resized.width !== width || resized.height !== height;
105
+
106
+ if (!shouldResize && !shouldConvertToJpeg) {
107
+ return {
108
+ base64: arrayBufferToBase64(arrayBuffer),
109
+ mimeType,
110
+ size: arrayBuffer.byteLength,
111
+ fileName,
112
+ };
113
+ }
114
+
115
+ const canvas = document.createElement("canvas");
116
+ canvas.width = resized.width;
117
+ canvas.height = resized.height;
118
+ const ctx = canvas.getContext("2d");
119
+ if (!ctx) {
120
+ throw new Error("Failed to create canvas context");
121
+ }
122
+
123
+ if (outputMimeType === "image/jpeg") {
124
+ ctx.fillStyle = "#ffffff";
125
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
126
+ }
127
+ draw(ctx, resized.width, resized.height);
128
+
129
+ const blob = await new Promise<Blob>((resolve, reject) => {
130
+ canvas.toBlob(
131
+ (result) => {
132
+ if (result) resolve(result);
133
+ else reject(new Error("Failed to encode image"));
134
+ },
135
+ outputMimeType,
136
+ outputMimeType === "image/jpeg" ? JPEG_QUALITY : undefined,
137
+ );
138
+ });
139
+
140
+ const outputBuffer = await blob.arrayBuffer();
141
+ return {
142
+ base64: arrayBufferToBase64(outputBuffer),
143
+ mimeType: outputMimeType,
144
+ size: blob.size,
145
+ fileName: shouldConvertToJpeg ? updateFileNameForJpeg(fileName) : fileName,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Load an attachment from various sources
151
+ * @param source - URL string, File, Blob, or ArrayBuffer
152
+ * @param fileName - Optional filename override
153
+ * @returns Promise<Attachment>
154
+ * @throws Error if loading fails
155
+ */
156
+ export async function loadAttachment(
157
+ source: string | File | Blob | ArrayBuffer,
158
+ fileName?: string,
159
+ ): Promise<Attachment> {
160
+ let arrayBuffer: ArrayBuffer;
161
+ let detectedFileName = fileName || "unnamed";
162
+ let mimeType = "application/octet-stream";
163
+ let size = 0;
164
+
165
+ // Convert source to ArrayBuffer
166
+ if (typeof source === "string") {
167
+ // It's a URL - fetch it
168
+ const response = await fetch(source);
169
+ if (!response.ok) {
170
+ throw new Error(i18n("Failed to fetch file"));
171
+ }
172
+ arrayBuffer = await response.arrayBuffer();
173
+ size = arrayBuffer.byteLength;
174
+ mimeType = response.headers.get("content-type") || mimeType;
175
+ if (!fileName) {
176
+ // Try to extract filename from URL
177
+ const urlParts = source.split("/");
178
+ detectedFileName = urlParts[urlParts.length - 1] || "document";
179
+ }
180
+ } else if (source instanceof File) {
181
+ arrayBuffer = await source.arrayBuffer();
182
+ size = source.size;
183
+ mimeType = source.type || mimeType;
184
+ detectedFileName = fileName || source.name;
185
+ } else if (source instanceof Blob) {
186
+ arrayBuffer = await source.arrayBuffer();
187
+ size = source.size;
188
+ mimeType = source.type || mimeType;
189
+ } else if (source instanceof ArrayBuffer) {
190
+ arrayBuffer = source;
191
+ size = source.byteLength;
192
+ } else {
193
+ throw new Error(i18n("Invalid source type"));
194
+ }
195
+
196
+ // Convert ArrayBuffer to base64 - handle large files properly
197
+ const base64Content = arrayBufferToBase64(arrayBuffer);
198
+
199
+ // Detect type and process accordingly
200
+ const id = `${detectedFileName}_${Date.now()}_${Math.random()}`;
201
+
202
+ // Check if it's a PDF
203
+ if (mimeType === "application/pdf" || detectedFileName.toLowerCase().endsWith(".pdf")) {
204
+ const { extractedText, preview } = await processPdf(arrayBuffer, detectedFileName);
205
+ return {
206
+ id,
207
+ type: "document",
208
+ fileName: detectedFileName,
209
+ mimeType: "application/pdf",
210
+ size,
211
+ content: base64Content,
212
+ extractedText,
213
+ preview,
214
+ };
215
+ }
216
+
217
+ // Check if it's a DOCX file
218
+ if (
219
+ mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
220
+ detectedFileName.toLowerCase().endsWith(".docx")
221
+ ) {
222
+ const { extractedText } = await processDocx(arrayBuffer, detectedFileName);
223
+ return {
224
+ id,
225
+ type: "document",
226
+ fileName: detectedFileName,
227
+ mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
228
+ size,
229
+ content: base64Content,
230
+ extractedText,
231
+ };
232
+ }
233
+
234
+ // Check if it's a PPTX file
235
+ if (
236
+ mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
237
+ detectedFileName.toLowerCase().endsWith(".pptx")
238
+ ) {
239
+ const { extractedText } = await processPptx(arrayBuffer, detectedFileName);
240
+ return {
241
+ id,
242
+ type: "document",
243
+ fileName: detectedFileName,
244
+ mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
245
+ size,
246
+ content: base64Content,
247
+ extractedText,
248
+ };
249
+ }
250
+
251
+ // Check if it's an Excel file (XLSX/XLS)
252
+ const excelMimeTypes = [
253
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
254
+ "application/vnd.ms-excel",
255
+ ];
256
+ if (
257
+ excelMimeTypes.includes(mimeType) ||
258
+ detectedFileName.toLowerCase().endsWith(".xlsx") ||
259
+ detectedFileName.toLowerCase().endsWith(".xls")
260
+ ) {
261
+ const { extractedText } = await processExcel(arrayBuffer, detectedFileName);
262
+ return {
263
+ id,
264
+ type: "document",
265
+ fileName: detectedFileName,
266
+ mimeType: mimeType.startsWith("application/vnd")
267
+ ? mimeType
268
+ : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
269
+ size,
270
+ content: base64Content,
271
+ extractedText,
272
+ };
273
+ }
274
+
275
+ // Check if it's an image
276
+ if (mimeType.startsWith("image/")) {
277
+ try {
278
+ const processed = await processImageAttachment(arrayBuffer, detectedFileName, mimeType);
279
+ return {
280
+ id,
281
+ type: "image",
282
+ fileName: processed.fileName,
283
+ mimeType: processed.mimeType,
284
+ size: processed.size,
285
+ content: processed.base64,
286
+ preview: processed.base64, // For images, preview is the same as content
287
+ };
288
+ } catch (error) {
289
+ console.error("Error processing image:", error);
290
+ return {
291
+ id,
292
+ type: "image",
293
+ fileName: detectedFileName,
294
+ mimeType,
295
+ size,
296
+ content: base64Content,
297
+ preview: base64Content, // For images, preview is the same as content
298
+ };
299
+ }
300
+ }
301
+
302
+ // Check if it's a text document
303
+ const textExtensions = [
304
+ ".txt",
305
+ ".md",
306
+ ".json",
307
+ ".xml",
308
+ ".html",
309
+ ".css",
310
+ ".js",
311
+ ".ts",
312
+ ".jsx",
313
+ ".tsx",
314
+ ".yml",
315
+ ".yaml",
316
+ ];
317
+ const isTextFile =
318
+ mimeType.startsWith("text/") || textExtensions.some((ext) => detectedFileName.toLowerCase().endsWith(ext));
319
+
320
+ if (isTextFile) {
321
+ const decoder = new TextDecoder();
322
+ const text = decoder.decode(arrayBuffer);
323
+ return {
324
+ id,
325
+ type: "document",
326
+ fileName: detectedFileName,
327
+ mimeType: mimeType.startsWith("text/") ? mimeType : "text/plain",
328
+ size,
329
+ content: base64Content,
330
+ extractedText: text,
331
+ };
332
+ }
333
+
334
+ throw new Error(`Unsupported file type: ${mimeType}`);
335
+ }
336
+
337
+ async function processPdf(
338
+ arrayBuffer: ArrayBuffer,
339
+ fileName: string,
340
+ ): Promise<{ extractedText: string; preview?: string }> {
341
+ let pdf: PDFDocumentProxy | null = null;
342
+ try {
343
+ pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
344
+
345
+ // Extract text with page structure
346
+ let extractedText = `<pdf filename="${fileName}">`;
347
+ for (let i = 1; i <= pdf.numPages; i++) {
348
+ const page = await pdf.getPage(i);
349
+ const textContent = await page.getTextContent();
350
+ const pageText = textContent.items
351
+ .map((item: any) => item.str)
352
+ .filter((str: string) => str.trim())
353
+ .join(" ");
354
+ extractedText += `\n<page number="${i}">\n${pageText}\n</page>`;
355
+ }
356
+ extractedText += "\n</pdf>";
357
+
358
+ // Generate preview from first page
359
+ const preview = await generatePdfPreview(pdf);
360
+
361
+ return { extractedText, preview };
362
+ } catch (error) {
363
+ console.error("Error processing PDF:", error);
364
+ throw new Error(`Failed to process PDF: ${String(error)}`);
365
+ } finally {
366
+ // Clean up PDF resources
367
+ if (pdf) {
368
+ pdf.destroy();
369
+ }
370
+ }
371
+ }
372
+
373
+ async function generatePdfPreview(pdf: PDFDocumentProxy): Promise<string | undefined> {
374
+ try {
375
+ const page = await pdf.getPage(1);
376
+ const viewport = page.getViewport({ scale: 1.0 });
377
+
378
+ // Create canvas with reasonable size for thumbnail (160x160 max)
379
+ const scale = Math.min(160 / viewport.width, 160 / viewport.height);
380
+ const scaledViewport = page.getViewport({ scale });
381
+
382
+ const canvas = document.createElement("canvas");
383
+ const context = canvas.getContext("2d");
384
+ if (!context) {
385
+ return undefined;
386
+ }
387
+
388
+ canvas.height = scaledViewport.height;
389
+ canvas.width = scaledViewport.width;
390
+
391
+ const renderContext = {
392
+ canvasContext: context,
393
+ viewport: scaledViewport,
394
+ canvas: canvas,
395
+ };
396
+ await page.render(renderContext).promise;
397
+
398
+ // Return base64 without data URL prefix
399
+ return canvas.toDataURL("image/png").split(",")[1];
400
+ } catch (error) {
401
+ console.error("Error generating PDF preview:", error);
402
+ return undefined;
403
+ }
404
+ }
405
+
406
+ async function processDocx(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> {
407
+ try {
408
+ // Parse document structure
409
+ const wordDoc = await parseAsync(arrayBuffer);
410
+
411
+ // Extract structured text from document body
412
+ let extractedText = `<docx filename="${fileName}">\n<page number="1">\n`;
413
+
414
+ const body = wordDoc.documentPart?.body;
415
+ if (body?.children) {
416
+ // Walk through document elements and extract text
417
+ const texts: string[] = [];
418
+ for (const element of body.children) {
419
+ const text = extractTextFromElement(element);
420
+ if (text) {
421
+ texts.push(text);
422
+ }
423
+ }
424
+ extractedText += texts.join("\n");
425
+ }
426
+
427
+ extractedText += `\n</page>\n</docx>`;
428
+ return { extractedText };
429
+ } catch (error) {
430
+ console.error("Error processing DOCX:", error);
431
+ throw new Error(`Failed to process DOCX: ${String(error)}`);
432
+ }
433
+ }
434
+
435
+ function extractTextFromElement(element: any): string {
436
+ let text = "";
437
+
438
+ // Check type with lowercase
439
+ const elementType = element.type?.toLowerCase() || "";
440
+
441
+ // Handle paragraphs
442
+ if (elementType === "paragraph" && element.children) {
443
+ for (const child of element.children) {
444
+ const childType = child.type?.toLowerCase() || "";
445
+ if (childType === "run" && child.children) {
446
+ for (const textChild of child.children) {
447
+ const textType = textChild.type?.toLowerCase() || "";
448
+ if (textType === "text") {
449
+ text += textChild.text || "";
450
+ }
451
+ }
452
+ } else if (childType === "text") {
453
+ text += child.text || "";
454
+ }
455
+ }
456
+ }
457
+ // Handle tables
458
+ else if (elementType === "table") {
459
+ if (element.children) {
460
+ const tableTexts: string[] = [];
461
+ for (const row of element.children) {
462
+ const rowType = row.type?.toLowerCase() || "";
463
+ if (rowType === "tablerow" && row.children) {
464
+ const rowTexts: string[] = [];
465
+ for (const cell of row.children) {
466
+ const cellType = cell.type?.toLowerCase() || "";
467
+ if (cellType === "tablecell" && cell.children) {
468
+ const cellTexts: string[] = [];
469
+ for (const cellElement of cell.children) {
470
+ const cellText = extractTextFromElement(cellElement);
471
+ if (cellText) cellTexts.push(cellText);
472
+ }
473
+ if (cellTexts.length > 0) rowTexts.push(cellTexts.join(" "));
474
+ }
475
+ }
476
+ if (rowTexts.length > 0) tableTexts.push(rowTexts.join(" | "));
477
+ }
478
+ }
479
+ if (tableTexts.length > 0) {
480
+ text = `\n[Table]\n${tableTexts.join("\n")}\n[/Table]\n`;
481
+ }
482
+ }
483
+ }
484
+ // Recursively handle other container elements
485
+ else if (element.children && Array.isArray(element.children)) {
486
+ const childTexts: string[] = [];
487
+ for (const child of element.children) {
488
+ const childText = extractTextFromElement(child);
489
+ if (childText) childTexts.push(childText);
490
+ }
491
+ text = childTexts.join(" ");
492
+ }
493
+
494
+ return text.trim();
495
+ }
496
+
497
+ async function processPptx(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> {
498
+ try {
499
+ // Load the PPTX file as a ZIP
500
+ const zip = await JSZip.loadAsync(arrayBuffer);
501
+
502
+ // PPTX slides are stored in ppt/slides/slide[n].xml
503
+ let extractedText = `<pptx filename="${fileName}">`;
504
+
505
+ // Get all slide files and sort them numerically
506
+ const slideFiles = Object.keys(zip.files)
507
+ .filter((name) => name.match(/ppt\/slides\/slide\d+\.xml$/))
508
+ .sort((a, b) => {
509
+ const numA = Number.parseInt(a.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
510
+ const numB = Number.parseInt(b.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
511
+ return numA - numB;
512
+ });
513
+
514
+ // Extract text from each slide
515
+ for (let i = 0; i < slideFiles.length; i++) {
516
+ const slideFile = zip.file(slideFiles[i]);
517
+ if (slideFile) {
518
+ const slideXml = await slideFile.async("text");
519
+
520
+ // Extract text from XML (simple regex approach)
521
+ // Looking for <a:t> tags which contain text in PPTX
522
+ const textMatches = slideXml.match(/<a:t[^>]*>([^<]+)<\/a:t>/g);
523
+
524
+ if (textMatches) {
525
+ extractedText += `\n<slide number="${i + 1}">`;
526
+ const slideTexts = textMatches
527
+ .map((match) => {
528
+ const textMatch = match.match(/<a:t[^>]*>([^<]+)<\/a:t>/);
529
+ return textMatch ? textMatch[1] : "";
530
+ })
531
+ .filter((t) => t.trim());
532
+
533
+ if (slideTexts.length > 0) {
534
+ extractedText += `\n${slideTexts.join("\n")}`;
535
+ }
536
+ extractedText += "\n</slide>";
537
+ }
538
+ }
539
+ }
540
+
541
+ // Also try to extract text from notes
542
+ const notesFiles = Object.keys(zip.files)
543
+ .filter((name) => name.match(/ppt\/notesSlides\/notesSlide\d+\.xml$/))
544
+ .sort((a, b) => {
545
+ const numA = Number.parseInt(a.match(/notesSlide(\d+)\.xml$/)?.[1] || "0", 10);
546
+ const numB = Number.parseInt(b.match(/notesSlide(\d+)\.xml$/)?.[1] || "0", 10);
547
+ return numA - numB;
548
+ });
549
+
550
+ if (notesFiles.length > 0) {
551
+ extractedText += "\n<notes>";
552
+ for (const noteFile of notesFiles) {
553
+ const file = zip.file(noteFile);
554
+ if (file) {
555
+ const noteXml = await file.async("text");
556
+ const textMatches = noteXml.match(/<a:t[^>]*>([^<]+)<\/a:t>/g);
557
+ if (textMatches) {
558
+ const noteTexts = textMatches
559
+ .map((match) => {
560
+ const textMatch = match.match(/<a:t[^>]*>([^<]+)<\/a:t>/);
561
+ return textMatch ? textMatch[1] : "";
562
+ })
563
+ .filter((t) => t.trim());
564
+
565
+ if (noteTexts.length > 0) {
566
+ const slideNum = noteFile.match(/notesSlide(\d+)\.xml$/)?.[1];
567
+ extractedText += `\n[Slide ${slideNum} notes]: ${noteTexts.join(" ")}`;
568
+ }
569
+ }
570
+ }
571
+ }
572
+ extractedText += "\n</notes>";
573
+ }
574
+
575
+ extractedText += "\n</pptx>";
576
+ return { extractedText };
577
+ } catch (error) {
578
+ console.error("Error processing PPTX:", error);
579
+ throw new Error(`Failed to process PPTX: ${String(error)}`);
580
+ }
581
+ }
582
+
583
+ async function processExcel(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> {
584
+ try {
585
+ // Read the workbook
586
+ const workbook = XLSX.read(arrayBuffer, { type: "array" });
587
+
588
+ let extractedText = `<excel filename="${fileName}">`;
589
+
590
+ // Process each sheet
591
+ for (const [index, sheetName] of workbook.SheetNames.entries()) {
592
+ const worksheet = workbook.Sheets[sheetName];
593
+
594
+ // Extract text as CSV for the extractedText field
595
+ const csvText = XLSX.utils.sheet_to_csv(worksheet);
596
+ extractedText += `\n<sheet name="${sheetName}" index="${index + 1}">\n${csvText}\n</sheet>`;
597
+ }
598
+
599
+ extractedText += "\n</excel>";
600
+
601
+ return { extractedText };
602
+ } catch (error) {
603
+ console.error("Error processing Excel:", error);
604
+ throw new Error(`Failed to process Excel: ${String(error)}`);
605
+ }
606
+ }
@@ -0,0 +1,22 @@
1
+ import PromptDialog from "@mariozechner/mini-lit/dist/PromptDialog.js";
2
+ import { i18n } from "./i18n";
3
+
4
+ export async function getAuthToken(): Promise<string | undefined> {
5
+ let authToken: string | undefined = localStorage.getItem(`auth-token`) || "";
6
+ if (authToken) return authToken;
7
+
8
+ while (true) {
9
+ authToken = (
10
+ await PromptDialog.ask(i18n("Enter Auth Token"), i18n("Please enter your auth token."), "", true)
11
+ )?.trim();
12
+ if (authToken) {
13
+ localStorage.setItem(`auth-token`, authToken);
14
+ break;
15
+ }
16
+ }
17
+ return authToken?.trim() || undefined;
18
+ }
19
+
20
+ export async function clearAuthToken() {
21
+ localStorage.removeItem(`auth-token`);
22
+ }
@@ -0,0 +1,42 @@
1
+ import { i18n } from "@mariozechner/mini-lit";
2
+ import type { Usage } from "@oh-my-pi/pi-ai";
3
+
4
+ export function formatCost(cost: number): string {
5
+ return `$${cost.toFixed(4)}`;
6
+ }
7
+
8
+ export function formatModelCost(cost: any): string {
9
+ if (!cost) return i18n("Free");
10
+ const input = cost.input || 0;
11
+ const output = cost.output || 0;
12
+ if (input === 0 && output === 0) return i18n("Free");
13
+
14
+ // Format numbers with appropriate precision
15
+ const formatNum = (num: number): string => {
16
+ if (num >= 100) return num.toFixed(0);
17
+ if (num >= 10) return num.toFixed(1).replace(/\.0$/, "");
18
+ if (num >= 1) return num.toFixed(2).replace(/\.?0+$/, "");
19
+ return num.toFixed(3).replace(/\.?0+$/, "");
20
+ };
21
+
22
+ return `$${formatNum(input)}/$${formatNum(output)}`;
23
+ }
24
+
25
+ export function formatUsage(usage: Usage) {
26
+ if (!usage) return "";
27
+
28
+ const parts = [];
29
+ if (usage.input) parts.push(`↑${formatTokenCount(usage.input)}`);
30
+ if (usage.output) parts.push(`↓${formatTokenCount(usage.output)}`);
31
+ if (usage.cacheRead) parts.push(`R${formatTokenCount(usage.cacheRead)}`);
32
+ if (usage.cacheWrite) parts.push(`W${formatTokenCount(usage.cacheWrite)}`);
33
+ if (usage.cost?.total) parts.push(formatCost(usage.cost.total));
34
+
35
+ return parts.join(" ");
36
+ }
37
+
38
+ export function formatTokenCount(count: number): string {
39
+ if (count < 1000) return count.toString();
40
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
41
+ return `${Math.round(count / 1000)}k`;
42
+ }