askimo 1.5.0 → 1.7.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/README.md CHANGED
@@ -36,6 +36,7 @@ OPENAI_MODEL=gpt-4o
36
36
  ANTHROPIC_MODEL=claude-sonnet-4-20250514
37
37
  XAI_MODEL=grok-4
38
38
  GEMINI_MODEL=gemini-3-pro-preview
39
+ IMAGE_MODEL=gemini-3-pro-image-preview
39
40
  ```
40
41
 
41
42
  ---
@@ -65,6 +66,16 @@ askimo "what's happening today?" -x # Use xAI Grok
65
66
  askimo "summarize this topic" -g # Use Google Gemini
66
67
  ```
67
68
 
69
+ ### Generate an image
70
+
71
+ ```bash
72
+ askimo --image "a cat wearing a top hat" # Save to current directory
73
+ askimo --image -d ./images "a sunset over ocean" # Save to ./images/
74
+ askimo --image --json "a dog on a skateboard" # JSON output with image paths
75
+ ```
76
+
77
+ Uses Google Gemini's image generation model. Requires `GOOGLE_GENERATIVE_AI_API_KEY` in your config.
78
+
68
79
  ### Continue a conversation
69
80
 
70
81
  ```bash
@@ -123,14 +134,15 @@ askimo conversations # Opens browser with all conversations
123
134
 
124
135
  ## ✨ Features
125
136
 
126
- | Feature | Description |
127
- |----------------|---------------------------------------------------|
128
- | Streaming | Real-time response output |
129
- | Piping | Pipe content via stdin |
130
- | File input | Read content from files with `-f` |
131
- | Citations | Source links with Perplexity |
132
- | History | Conversations saved to `~/.askimo/conversations/` |
133
- | Multi-provider | Switch between AI providers easily |
137
+ | Feature | Description |
138
+ |------------------|---------------------------------------------------|
139
+ | Streaming | Real-time response output |
140
+ | Image generation | Generate images with `-i` (uses Gemini) |
141
+ | Piping | Pipe content via stdin |
142
+ | File input | Read content from files with `-f` |
143
+ | Citations | Source links with Perplexity |
144
+ | History | Conversations saved to `~/.askimo/conversations/` |
145
+ | Multi-provider | Switch between AI providers easily |
134
146
 
135
147
  ---
136
148
 
package/index.mjs CHANGED
@@ -4,10 +4,18 @@ import { Command } from 'commander'
4
4
  import { startChat } from './lib/chat.mjs'
5
5
  import { ensureDirectories, loadConfig } from './lib/config.mjs'
6
6
  import { createConversation, loadConversation, loadConversationById, saveConversation } from './lib/conversation.mjs'
7
- import { showConversationsInBrowser } from './lib/conversations-ui.mjs'
7
+ import { showConversationsInBrowser } from './lib/conversationsUi.mjs'
8
+ import { saveImages } from './lib/image.mjs'
8
9
  import { buildMessage, readFile, readStdin } from './lib/input.mjs'
9
- import { DEFAULT_MODELS, determineProvider, getProvider, listModels } from './lib/providers.mjs'
10
- import { generateResponse, outputJson, streamResponse } from './lib/stream.mjs'
10
+ import { DEFAULT_MODELS, determineProvider, getImageProvider, getProvider, listModels } from './lib/providers.mjs'
11
+ import {
12
+ generateImageResponse,
13
+ generateResponse,
14
+ outputJson,
15
+ printImageResponse,
16
+ printResponse,
17
+ streamResponse
18
+ } from './lib/stream.mjs'
11
19
  import pkg from './package.json' with { type: 'json' }
12
20
 
13
21
  const program = new Command()
@@ -24,9 +32,12 @@ program
24
32
  .option('-x, --xai', 'Use xAI Grok')
25
33
  .option('-g, --gemini', 'Use Google Gemini')
26
34
  .option('-j, --json', 'Output as JSON instead of streaming')
35
+ .option('-n, --no-stream', 'Disable streaming (print full response at once)')
27
36
  .option('-c, --continue <n>', 'Continue conversation N (1=last, 2=second-to-last)', Number.parseInt)
28
37
  .option('--cid <id>', 'Continue conversation by ID')
29
38
  .option('-f, --file <path>', 'Read content from file')
39
+ .option('-i, --image', 'Generate an image (uses Gemini)')
40
+ .option('-d, --output-dir <path>', 'Directory to save generated images (default: current directory)')
30
41
  .action(async (question, options) => {
31
42
  try {
32
43
  const stdinContent = await readStdin()
@@ -48,8 +59,19 @@ program
48
59
  const config = await loadConfig()
49
60
  await ensureDirectories()
50
61
 
51
- const providerName = determineProvider(options, config)
52
- const { model, name, modelName } = getProvider(providerName, config)
62
+ let model
63
+ let name
64
+ let modelName
65
+
66
+ if (options.image) {
67
+ if (options.perplexity || options.openai || options.anthropic || options.xai) {
68
+ console.error('Note: --image uses Gemini regardless of other provider flags')
69
+ }
70
+ ;({ model, name, modelName } = getImageProvider(config))
71
+ } else {
72
+ const providerName = determineProvider(options, config)
73
+ ;({ model, name, modelName } = getProvider(providerName, config))
74
+ }
53
75
 
54
76
  let conversation
55
77
  let existingPath = null
@@ -86,7 +108,23 @@ program
86
108
 
87
109
  let responseText
88
110
 
89
- if (options.json) {
111
+ if (options.image) {
112
+ const { text, files, duration } = await generateImageResponse(model, conversation.messages)
113
+ const imagePaths = await saveImages(files, options.outputDir)
114
+ responseText = text || ''
115
+ conversation.messages.push({
116
+ role: 'assistant',
117
+ content: responseText,
118
+ images: imagePaths
119
+ })
120
+ await saveConversation(conversation, existingPath)
121
+
122
+ if (options.json) {
123
+ outputJson(conversation, responseText, undefined, duration, imagePaths)
124
+ } else {
125
+ printImageResponse(responseText, imagePaths, duration, modelName)
126
+ }
127
+ } else if (options.json) {
90
128
  const { text, sources, duration } = await generateResponse(model, conversation.messages)
91
129
  responseText = text
92
130
  conversation.messages.push({
@@ -95,6 +133,15 @@ program
95
133
  })
96
134
  await saveConversation(conversation, existingPath)
97
135
  outputJson(conversation, responseText, sources, duration)
136
+ } else if (!options.stream) {
137
+ const { text, sources, duration } = await generateResponse(model, conversation.messages)
138
+ responseText = text
139
+ conversation.messages.push({
140
+ role: 'assistant',
141
+ content: responseText
142
+ })
143
+ await saveConversation(conversation, existingPath)
144
+ printResponse(responseText, sources, duration, modelName)
98
145
  } else {
99
146
  responseText = await streamResponse(model, conversation.messages, modelName)
100
147
  conversation.messages.push({
package/lib/config.mjs CHANGED
@@ -51,4 +51,4 @@ async function ensureDirectories() {
51
51
  await mkdir(CONVERSATIONS_DIR, { recursive: true })
52
52
  }
53
53
 
54
- export { loadConfig, ensureDirectories, parseConfig, ASKIMO_DIR, CONVERSATIONS_DIR }
54
+ export { ASKIMO_DIR, CONVERSATIONS_DIR, ensureDirectories, loadConfig, parseConfig }
@@ -127,4 +127,4 @@ async function saveConversation(conversation, existingPath = null) {
127
127
  return filePath
128
128
  }
129
129
 
130
- export { createConversation, loadConversation, loadConversationById, getAllConversations, saveConversation }
130
+ export { createConversation, getAllConversations, loadConversation, loadConversationById, saveConversation }
@@ -0,0 +1,189 @@
1
+ let currentPage = 1
2
+ // biome-ignore lint/correctness/noUndeclaredVariables: global set by prior <script> tag in HTML
3
+ const totalPages = Math.ceil(conversations.length / ITEMS_PER_PAGE)
4
+
5
+ function escapeHtml(text) {
6
+ const div = document.createElement('div')
7
+ div.textContent = text
8
+ return div.innerHTML
9
+ }
10
+
11
+ function formatDate(isoString) {
12
+ const date = new Date(isoString)
13
+ return date.toLocaleDateString('en-US', {
14
+ year: 'numeric',
15
+ month: 'short',
16
+ day: 'numeric',
17
+ hour: '2-digit',
18
+ minute: '2-digit'
19
+ })
20
+ }
21
+
22
+ function renderMessages(messages) {
23
+ return messages
24
+ .map(
25
+ (msg) => `
26
+ <div class="message ${msg.role}">
27
+ <div class="message-role">${msg.role === 'user' ? 'You' : 'Assistant'}</div>
28
+ <div class="message-content">${escapeHtml(msg.content)}</div>
29
+ </div>
30
+ `
31
+ )
32
+ .join('')
33
+ }
34
+
35
+ function renderPage(page) {
36
+ currentPage = page
37
+ const container = document.getElementById('conversations-container')
38
+ // biome-ignore lint/correctness/noUndeclaredVariables: global set by prior <script> tag in HTML
39
+ const start = (page - 1) * ITEMS_PER_PAGE
40
+ // biome-ignore lint/correctness/noUndeclaredVariables: global set by prior <script> tag in HTML
41
+ const end = Math.min(start + ITEMS_PER_PAGE, conversations.length)
42
+ // biome-ignore lint/correctness/noUndeclaredVariables: global set by prior <script> tag in HTML
43
+ const pageConversations = conversations.slice(start, end)
44
+
45
+ // biome-ignore lint/correctness/noUndeclaredVariables: global set by prior <script> tag in HTML
46
+ if (conversations.length === 0) {
47
+ container.innerHTML = `
48
+ <div class="empty">
49
+ <p>No conversations found.</p>
50
+ <p>Start a conversation with: <code>askimo "your question"</code></p>
51
+ </div>
52
+ `
53
+ return
54
+ }
55
+
56
+ const rows = pageConversations
57
+ .map((conv, idx) => {
58
+ const globalIndex = start + idx
59
+ return `
60
+ <tr class="conversation-row" data-index="${globalIndex}">
61
+ <td class="id">
62
+ <span class="id-text">${escapeHtml(conv.id)}</span>
63
+ <button class="copy-btn" data-id="${escapeHtml(conv.id)}" title="Copy ID">
64
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
65
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
66
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
67
+ </svg>
68
+ </button>
69
+ </td>
70
+ <td class="provider">${escapeHtml(conv.provider)}</td>
71
+ <td class="model">${escapeHtml(conv.model)}</td>
72
+ <td class="date">${formatDate(conv.createdAt)}</td>
73
+ <td class="messages">${conv.messageCount}</td>
74
+ <td class="preview">${escapeHtml(conv.preview)}</td>
75
+ </tr>
76
+ <tr class="conversation-detail" data-index="${globalIndex}">
77
+ <td colspan="6">
78
+ <div class="detail-content">
79
+ ${renderMessages(conv.messages)}
80
+ </div>
81
+ </td>
82
+ </tr>
83
+ `
84
+ })
85
+ .join('')
86
+
87
+ const pagination =
88
+ totalPages > 1
89
+ ? `
90
+ <div class="pagination">
91
+ <button class="page-btn" onclick="renderPage(1)" ${currentPage === 1 ? 'disabled' : ''}>
92
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
93
+ <polyline points="11 17 6 12 11 7"></polyline>
94
+ <polyline points="18 17 13 12 18 7"></polyline>
95
+ </svg>
96
+ </button>
97
+ <button class="page-btn" onclick="renderPage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
98
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
99
+ <polyline points="15 18 9 12 15 6"></polyline>
100
+ </svg>
101
+ </button>
102
+ <span class="page-info">Page ${currentPage} of ${totalPages}</span>
103
+ <button class="page-btn" onclick="renderPage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''}>
104
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
105
+ <polyline points="9 18 15 12 9 6"></polyline>
106
+ </svg>
107
+ </button>
108
+ <button class="page-btn" onclick="renderPage(${totalPages})" ${currentPage === totalPages ? 'disabled' : ''}>
109
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
110
+ <polyline points="13 17 18 12 13 7"></polyline>
111
+ <polyline points="6 17 11 12 6 7"></polyline>
112
+ </svg>
113
+ </button>
114
+ </div>
115
+ `
116
+ : ''
117
+
118
+ container.innerHTML = `
119
+ <table>
120
+ <thead>
121
+ <tr>
122
+ <th>ID</th>
123
+ <th>Provider</th>
124
+ <th>Model</th>
125
+ <th>Date</th>
126
+ <th>Messages</th>
127
+ <th>Preview</th>
128
+ </tr>
129
+ </thead>
130
+ <tbody>
131
+ ${rows}
132
+ </tbody>
133
+ </table>
134
+ ${pagination}
135
+ `
136
+
137
+ attachEventListeners()
138
+ }
139
+
140
+ function attachEventListeners() {
141
+ // Accordion behavior
142
+ document.querySelectorAll('.conversation-row').forEach((row) => {
143
+ row.addEventListener('click', (e) => {
144
+ if (e.target.closest('.copy-btn')) return
145
+
146
+ const index = row.dataset.index
147
+ const detailRow = document.querySelector(`.conversation-detail[data-index="${index}"]`)
148
+ const isExpanded = row.classList.contains('expanded')
149
+
150
+ document.querySelectorAll('.conversation-row.expanded').forEach((r) => {
151
+ r.classList.remove('expanded')
152
+ })
153
+ document.querySelectorAll('.conversation-detail.expanded').forEach((d) => {
154
+ d.classList.remove('expanded')
155
+ })
156
+
157
+ if (!isExpanded) {
158
+ row.classList.add('expanded')
159
+ detailRow.classList.add('expanded')
160
+ }
161
+ })
162
+ })
163
+
164
+ // Copy button functionality
165
+ document.querySelectorAll('.copy-btn').forEach((btn) => {
166
+ btn.addEventListener('click', async (e) => {
167
+ e.stopPropagation()
168
+ const id = btn.dataset.id
169
+
170
+ try {
171
+ await navigator.clipboard.writeText(id)
172
+ btn.classList.add('copied')
173
+ btn.innerHTML =
174
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>'
175
+
176
+ setTimeout(() => {
177
+ btn.classList.remove('copied')
178
+ btn.innerHTML =
179
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'
180
+ }, 2000)
181
+ } catch (err) {
182
+ console.error('Failed to copy:', err)
183
+ }
184
+ })
185
+ })
186
+ }
187
+
188
+ // Initial render
189
+ renderPage(1)
@@ -1,38 +1,21 @@
1
+ import fs from 'node:fs/promises'
1
2
  import hcat from 'hcat'
2
3
  import { getAllConversations } from './conversation.mjs'
3
4
 
4
- function formatDate(isoString) {
5
- const date = new Date(isoString)
6
- return date.toLocaleDateString('en-US', {
7
- year: 'numeric',
8
- month: 'short',
9
- day: 'numeric',
10
- hour: '2-digit',
11
- minute: '2-digit'
12
- })
13
- }
14
-
15
5
  function getPreview(messages, maxLength = 100) {
16
6
  const firstUserMessage = messages.find((m) => m.role === 'user')
17
7
  if (!firstUserMessage) return 'No messages'
18
8
 
19
9
  const content = firstUserMessage.content
20
10
  if (content.length <= maxLength) return content
21
- return content.slice(0, maxLength) + '...'
22
- }
23
-
24
- function escapeHtml(text) {
25
- return text
26
- .replace(/&/g, '&amp;')
27
- .replace(/</g, '&lt;')
28
- .replace(/>/g, '&gt;')
29
- .replace(/"/g, '&quot;')
30
- .replace(/'/g, '&#039;')
11
+ return `${content.slice(0, maxLength)}...`
31
12
  }
32
13
 
33
14
  const ITEMS_PER_PAGE = 20
34
15
 
35
- function generateHtml(conversations) {
16
+ async function generateHtml(conversations) {
17
+ const clientJs = await fs.readFile(new URL('conversationsClient.js', import.meta.url), 'utf8')
18
+
36
19
  // Prepare conversation data for client-side rendering
37
20
  const conversationsJson = JSON.stringify(
38
21
  conversations.map((conv) => ({
@@ -385,177 +368,11 @@ function generateHtml(conversations) {
385
368
  </div>
386
369
 
387
370
  <script>
388
- const conversations = ${conversationsJson};
389
- const ITEMS_PER_PAGE = ${ITEMS_PER_PAGE};
390
- let currentPage = 1;
391
- const totalPages = Math.ceil(conversations.length / ITEMS_PER_PAGE);
392
-
393
- function escapeHtml(text) {
394
- const div = document.createElement('div');
395
- div.textContent = text;
396
- return div.innerHTML;
397
- }
398
-
399
- function formatDate(isoString) {
400
- const date = new Date(isoString);
401
- return date.toLocaleDateString('en-US', {
402
- year: 'numeric',
403
- month: 'short',
404
- day: 'numeric',
405
- hour: '2-digit',
406
- minute: '2-digit'
407
- });
408
- }
409
-
410
- function renderMessages(messages) {
411
- return messages.map(msg => \`
412
- <div class="message \${msg.role}">
413
- <div class="message-role">\${msg.role === 'user' ? 'You' : 'Assistant'}</div>
414
- <div class="message-content">\${escapeHtml(msg.content)}</div>
415
- </div>
416
- \`).join('');
417
- }
418
-
419
- function renderPage(page) {
420
- currentPage = page;
421
- const container = document.getElementById('conversations-container');
422
- const start = (page - 1) * ITEMS_PER_PAGE;
423
- const end = Math.min(start + ITEMS_PER_PAGE, conversations.length);
424
- const pageConversations = conversations.slice(start, end);
425
-
426
- if (conversations.length === 0) {
427
- container.innerHTML = \`
428
- <div class="empty">
429
- <p>No conversations found.</p>
430
- <p>Start a conversation with: <code>askimo "your question"</code></p>
431
- </div>
432
- \`;
433
- return;
434
- }
435
-
436
- const rows = pageConversations.map((conv, idx) => {
437
- const globalIndex = start + idx;
438
- return \`
439
- <tr class="conversation-row" data-index="\${globalIndex}">
440
- <td class="id">
441
- <span class="id-text">\${escapeHtml(conv.id)}</span>
442
- <button class="copy-btn" data-id="\${escapeHtml(conv.id)}" title="Copy ID">
443
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
444
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
445
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
446
- </svg>
447
- </button>
448
- </td>
449
- <td class="provider">\${escapeHtml(conv.provider)}</td>
450
- <td class="model">\${escapeHtml(conv.model)}</td>
451
- <td class="date">\${formatDate(conv.createdAt)}</td>
452
- <td class="messages">\${conv.messageCount}</td>
453
- <td class="preview">\${escapeHtml(conv.preview)}</td>
454
- </tr>
455
- <tr class="conversation-detail" data-index="\${globalIndex}">
456
- <td colspan="6">
457
- <div class="detail-content">
458
- \${renderMessages(conv.messages)}
459
- </div>
460
- </td>
461
- </tr>
462
- \`;
463
- }).join('');
464
-
465
- const pagination = totalPages > 1 ? \`
466
- <div class="pagination">
467
- <button class="page-btn" onclick="renderPage(1)" \${currentPage === 1 ? 'disabled' : ''}>
468
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
469
- <polyline points="11 17 6 12 11 7"></polyline>
470
- <polyline points="18 17 13 12 18 7"></polyline>
471
- </svg>
472
- </button>
473
- <button class="page-btn" onclick="renderPage(\${currentPage - 1})" \${currentPage === 1 ? 'disabled' : ''}>
474
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
475
- <polyline points="15 18 9 12 15 6"></polyline>
476
- </svg>
477
- </button>
478
- <span class="page-info">Page \${currentPage} of \${totalPages}</span>
479
- <button class="page-btn" onclick="renderPage(\${currentPage + 1})" \${currentPage === totalPages ? 'disabled' : ''}>
480
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
481
- <polyline points="9 18 15 12 9 6"></polyline>
482
- </svg>
483
- </button>
484
- <button class="page-btn" onclick="renderPage(\${totalPages})" \${currentPage === totalPages ? 'disabled' : ''}>
485
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
486
- <polyline points="13 17 18 12 13 7"></polyline>
487
- <polyline points="6 17 11 12 6 7"></polyline>
488
- </svg>
489
- </button>
490
- </div>
491
- \` : '';
492
-
493
- container.innerHTML = \`
494
- <table>
495
- <thead>
496
- <tr>
497
- <th>ID</th>
498
- <th>Provider</th>
499
- <th>Model</th>
500
- <th>Date</th>
501
- <th>Messages</th>
502
- <th>Preview</th>
503
- </tr>
504
- </thead>
505
- <tbody>
506
- \${rows}
507
- </tbody>
508
- </table>
509
- \${pagination}
510
- \`;
511
-
512
- attachEventListeners();
513
- }
514
-
515
- function attachEventListeners() {
516
- // Accordion behavior
517
- document.querySelectorAll('.conversation-row').forEach(row => {
518
- row.addEventListener('click', (e) => {
519
- if (e.target.closest('.copy-btn')) return;
520
-
521
- const index = row.dataset.index;
522
- const detailRow = document.querySelector('.conversation-detail[data-index="' + index + '"]');
523
- const isExpanded = row.classList.contains('expanded');
524
-
525
- document.querySelectorAll('.conversation-row.expanded').forEach(r => r.classList.remove('expanded'));
526
- document.querySelectorAll('.conversation-detail.expanded').forEach(d => d.classList.remove('expanded'));
527
-
528
- if (!isExpanded) {
529
- row.classList.add('expanded');
530
- detailRow.classList.add('expanded');
531
- }
532
- });
533
- });
534
-
535
- // Copy button functionality
536
- document.querySelectorAll('.copy-btn').forEach(btn => {
537
- btn.addEventListener('click', async (e) => {
538
- e.stopPropagation();
539
- const id = btn.dataset.id;
540
-
541
- try {
542
- await navigator.clipboard.writeText(id);
543
- btn.classList.add('copied');
544
- btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>';
545
-
546
- setTimeout(() => {
547
- btn.classList.remove('copied');
548
- btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
549
- }, 2000);
550
- } catch (err) {
551
- console.error('Failed to copy:', err);
552
- }
553
- });
554
- });
555
- }
556
-
557
- // Initial render
558
- renderPage(1);
371
+ const conversations = ${conversationsJson}
372
+ const ITEMS_PER_PAGE = ${ITEMS_PER_PAGE}
373
+ </script>
374
+ <script>
375
+ ${clientJs}
559
376
  </script>
560
377
  </body>
561
378
  </html>`
@@ -563,10 +380,10 @@ function generateHtml(conversations) {
563
380
 
564
381
  async function showConversationsInBrowser() {
565
382
  const conversations = await getAllConversations()
566
- const html = generateHtml(conversations)
383
+ const html = await generateHtml(conversations)
567
384
 
568
385
  console.log('Opening conversations in browser...')
569
386
  hcat(html, { port: 0 })
570
387
  }
571
388
 
572
- export { showConversationsInBrowser, generateHtml }
389
+ export { generateHtml, showConversationsInBrowser }
package/lib/image.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ const MEDIA_TYPE_EXTENSIONS = {
5
+ 'image/png': '.png',
6
+ 'image/jpeg': '.jpg',
7
+ 'image/webp': '.webp'
8
+ }
9
+
10
+ function buildImagePath(outputDir, timestamp, index, mediaType) {
11
+ const ext = MEDIA_TYPE_EXTENSIONS[mediaType] || '.png'
12
+ const safestamp = timestamp.replace(/:/g, '-')
13
+ const filename = `askimo-${safestamp}-${index}${ext}`
14
+ return path.join(outputDir, filename)
15
+ }
16
+
17
+ async function saveImages(files, outputDir) {
18
+ if (!files?.length) return []
19
+
20
+ const dir = outputDir || process.cwd()
21
+ await fs.mkdir(dir, { recursive: true })
22
+
23
+ const timestamp = new Date().toISOString()
24
+ const savedPaths = []
25
+
26
+ for (let i = 0; i < files.length; i++) {
27
+ const file = files[i]
28
+ const filePath = buildImagePath(dir, timestamp, i + 1, file.mediaType)
29
+ await fs.writeFile(filePath, file.data)
30
+ savedPaths.push(filePath)
31
+ }
32
+
33
+ return savedPaths
34
+ }
35
+
36
+ export { buildImagePath, saveImages }
package/lib/input.mjs CHANGED
@@ -52,4 +52,4 @@ function buildMessage(prompt, content) {
52
52
  return content || prompt || null
53
53
  }
54
54
 
55
- export { readStdin, readFile, buildMessage }
55
+ export { buildMessage, readFile, readStdin }
package/lib/providers.mjs CHANGED
@@ -12,6 +12,8 @@ const DEFAULT_MODELS = {
12
12
  gemini: 'gemini-3-pro-preview'
13
13
  }
14
14
 
15
+ const DEFAULT_IMAGE_MODEL = 'gemini-3-pro-image-preview'
16
+
15
17
  // Provider name mapping (askimo name → models.dev name)
16
18
  const PROVIDER_MAP = {
17
19
  gemini: 'google'
@@ -112,6 +114,20 @@ function getProvider(providerName, config) {
112
114
  }
113
115
  }
114
116
 
117
+ function getImageProvider(config) {
118
+ const apiKey = config.GOOGLE_GENERATIVE_AI_API_KEY
119
+ if (!apiKey) {
120
+ throw new Error('GOOGLE_GENERATIVE_AI_API_KEY not found in config')
121
+ }
122
+ const modelName = config.IMAGE_MODEL || DEFAULT_IMAGE_MODEL
123
+ const google = createGoogleGenerativeAI({ apiKey })
124
+ return {
125
+ model: google(modelName),
126
+ name: 'gemini',
127
+ modelName
128
+ }
129
+ }
130
+
115
131
  function determineProvider(options, config = {}) {
116
132
  if (options.openai) return 'openai'
117
133
  if (options.anthropic) return 'anthropic'
@@ -127,4 +143,4 @@ function determineProvider(options, config = {}) {
127
143
  return 'perplexity'
128
144
  }
129
145
 
130
- export { getProvider, determineProvider, DEFAULT_MODELS, listModels }
146
+ export { DEFAULT_MODELS, determineProvider, getImageProvider, getProvider, listModels }
package/lib/stream.mjs CHANGED
@@ -55,7 +55,39 @@ async function generateResponse(model, messages) {
55
55
  return { text, sources, duration }
56
56
  }
57
57
 
58
- function buildJsonOutput(conversation, response, sources, duration) {
58
+ async function generateImageResponse(model, messages) {
59
+ const startTime = Date.now()
60
+
61
+ const { text, files } = await generateText({
62
+ model,
63
+ messages
64
+ })
65
+
66
+ const duration = Date.now() - startTime
67
+ const imageFiles = files?.filter((f) => f.mediaType?.startsWith('image/'))
68
+ return { text, files: imageFiles, duration }
69
+ }
70
+
71
+ function printResponse(text, sources, duration, modelName) {
72
+ process.stdout.write(text)
73
+ process.stdout.write('\n')
74
+
75
+ if (sources?.length > 0) {
76
+ process.stdout.write('\n\x1b[2m─── Sources ───\x1b[0m\n')
77
+ sources.forEach((source, index) => {
78
+ const num = index + 1
79
+ const title = source.title || source.url
80
+ process.stdout.write(`\x1b[2m[${num}] ${title}\x1b[0m\n`)
81
+ if (source.url && source.title) {
82
+ process.stdout.write(`\x1b[2m ${source.url}\x1b[0m\n`)
83
+ }
84
+ })
85
+ }
86
+
87
+ process.stdout.write(`\n\x1b[2m${modelName} · ${formatDuration(duration)}\x1b[0m\n`)
88
+ }
89
+
90
+ function buildJsonOutput(conversation, response, sources, duration, images) {
59
91
  const lastUserMessage = conversation.messages.findLast((m) => m.role === 'user')
60
92
  const output = {
61
93
  provider: conversation.provider,
@@ -71,12 +103,43 @@ function buildJsonOutput(conversation, response, sources, duration) {
71
103
  output.sources = sources
72
104
  }
73
105
 
106
+ if (images?.length > 0) {
107
+ output.images = images
108
+ }
109
+
74
110
  return output
75
111
  }
76
112
 
77
- function outputJson(conversation, response, sources, duration) {
78
- const output = buildJsonOutput(conversation, response, sources, duration)
113
+ function printImageResponse(text, imagePaths, duration, modelName) {
114
+ if (text) {
115
+ process.stdout.write(text)
116
+ process.stdout.write('\n')
117
+ }
118
+
119
+ if (imagePaths?.length > 0) {
120
+ for (const imgPath of imagePaths) {
121
+ process.stdout.write(`\x1b[2m[image]\x1b[0m ${imgPath}\n`)
122
+ }
123
+ }
124
+
125
+ if (!text && !imagePaths?.length) {
126
+ process.stdout.write('No image or text was generated.\n')
127
+ }
128
+
129
+ process.stdout.write(`\n\x1b[2m${modelName} · ${formatDuration(duration)}\x1b[0m\n`)
130
+ }
131
+
132
+ function outputJson(conversation, response, sources, duration, images) {
133
+ const output = buildJsonOutput(conversation, response, sources, duration, images)
79
134
  console.log(JSON.stringify(output, null, 2))
80
135
  }
81
136
 
82
- export { streamResponse, generateResponse, outputJson, buildJsonOutput }
137
+ export {
138
+ buildJsonOutput,
139
+ generateImageResponse,
140
+ generateResponse,
141
+ outputJson,
142
+ printImageResponse,
143
+ printResponse,
144
+ streamResponse
145
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "askimo",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "A CLI tool for communicating with AI providers (Perplexity, OpenAI, Anthropic)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Amit Tal",
@@ -28,17 +28,17 @@
28
28
  "repository": "https://github.com/amitosdev/askimo.git",
29
29
  "homepage": "https://github.com/amitosdev/askimo#readme",
30
30
  "devDependencies": {
31
- "@biomejs/biome": "^2.3.14",
31
+ "@biomejs/biome": "^2.4.8",
32
32
  "ava": "^6.4.1"
33
33
  },
34
34
  "dependencies": {
35
- "@ai-sdk/anthropic": "^3.0.35",
36
- "@ai-sdk/google": "^3.0.20",
37
- "@ai-sdk/openai": "^3.0.25",
38
- "@ai-sdk/perplexity": "^3.0.17",
39
- "@ai-sdk/xai": "^3.0.46",
40
- "@inquirer/input": "^5.0.4",
41
- "ai": "^6.0.69",
35
+ "@ai-sdk/anthropic": "^3.0.64",
36
+ "@ai-sdk/google": "^3.0.53",
37
+ "@ai-sdk/openai": "^3.0.48",
38
+ "@ai-sdk/perplexity": "^3.0.26",
39
+ "@ai-sdk/xai": "^3.0.74",
40
+ "@inquirer/input": "^5.0.10",
41
+ "ai": "^6.0.138",
42
42
  "commander": "^14.0.3",
43
43
  "hcat": "^2.2.1"
44
44
  },
package/test/image.mjs ADDED
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import test from 'ava'
4
+ import { buildImagePath, saveImages } from '../lib/image.mjs'
5
+
6
+ test('saveImages returns empty array when files is undefined', async (t) => {
7
+ const result = await saveImages(undefined)
8
+ t.deepEqual(result, [])
9
+ })
10
+
11
+ test('saveImages returns empty array when files is empty', async (t) => {
12
+ const result = await saveImages([])
13
+ t.deepEqual(result, [])
14
+ })
15
+
16
+ test('saveImages writes files and returns paths', async (t) => {
17
+ const absDir = path.join(process.cwd(), `test-saveImages-${Date.now()}`)
18
+
19
+ const files = [
20
+ { mediaType: 'image/png', data: Buffer.from('fake-png') },
21
+ { mediaType: 'image/jpeg', data: Buffer.from('fake-jpg') }
22
+ ]
23
+
24
+ const paths = await saveImages(files, absDir)
25
+
26
+ t.is(paths.length, 2)
27
+ t.true(paths[0].endsWith('.png'))
28
+ t.true(paths[1].endsWith('.jpg'))
29
+
30
+ const content0 = await fs.readFile(paths[0])
31
+ t.deepEqual(content0, Buffer.from('fake-png'))
32
+
33
+ const content1 = await fs.readFile(paths[1])
34
+ t.deepEqual(content1, Buffer.from('fake-jpg'))
35
+
36
+ // cleanup
37
+ await fs.rm(absDir, { recursive: true })
38
+ })
39
+
40
+ test('buildImagePath uses correct extension for png', (t) => {
41
+ const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/png')
42
+ t.true(result.endsWith('-1.png'))
43
+ })
44
+
45
+ test('buildImagePath uses correct extension for jpeg', (t) => {
46
+ const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/jpeg')
47
+ t.true(result.endsWith('-1.jpg'))
48
+ })
49
+
50
+ test('buildImagePath uses correct extension for webp', (t) => {
51
+ const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/webp')
52
+ t.true(result.endsWith('-1.webp'))
53
+ })
54
+
55
+ test('buildImagePath defaults to .png for unknown media type', (t) => {
56
+ const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/bmp')
57
+ t.true(result.endsWith('-1.png'))
58
+ })
59
+
60
+ test('buildImagePath replaces colons in timestamp', (t) => {
61
+ const result = buildImagePath('/tmp', '2026-01-01T12:30:45.000Z', 1, 'image/png')
62
+ t.false(result.includes(':'))
63
+ })
package/test/stream.mjs CHANGED
@@ -101,3 +101,22 @@ test('buildJsonOutput excludes sources when null', (t) => {
101
101
  const output = buildJsonOutput(conversation, 'response', null)
102
102
  t.false('sources' in output)
103
103
  })
104
+
105
+ test('buildJsonOutput includes images when provided', (t) => {
106
+ const conversation = createMockConversation()
107
+ const images = ['/tmp/askimo-1.png', '/tmp/askimo-2.png']
108
+ const output = buildJsonOutput(conversation, 'response', undefined, undefined, images)
109
+ t.deepEqual(output.images, images)
110
+ })
111
+
112
+ test('buildJsonOutput excludes images when empty array', (t) => {
113
+ const conversation = createMockConversation()
114
+ const output = buildJsonOutput(conversation, 'response', undefined, undefined, [])
115
+ t.false('images' in output)
116
+ })
117
+
118
+ test('buildJsonOutput excludes images when undefined', (t) => {
119
+ const conversation = createMockConversation()
120
+ const output = buildJsonOutput(conversation, 'response', undefined, undefined, undefined)
121
+ t.false('images' in output)
122
+ })