@kirosnn/mosaic 0.0.9 → 0.71.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 (56) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +83 -19
  3. package/package.json +52 -47
  4. package/src/agent/prompts/systemPrompt.ts +198 -68
  5. package/src/agent/prompts/toolsPrompt.ts +217 -135
  6. package/src/agent/provider/anthropic.ts +19 -15
  7. package/src/agent/provider/google.ts +21 -17
  8. package/src/agent/provider/ollama.ts +80 -41
  9. package/src/agent/provider/openai.ts +107 -67
  10. package/src/agent/provider/reasoning.ts +29 -0
  11. package/src/agent/provider/xai.ts +19 -15
  12. package/src/agent/tools/definitions.ts +9 -5
  13. package/src/agent/tools/executor.ts +655 -46
  14. package/src/agent/tools/exploreExecutor.ts +12 -12
  15. package/src/agent/tools/fetch.ts +58 -0
  16. package/src/agent/tools/glob.ts +20 -4
  17. package/src/agent/tools/grep.ts +62 -8
  18. package/src/agent/tools/plan.ts +27 -0
  19. package/src/agent/tools/read.ts +2 -0
  20. package/src/agent/types.ts +6 -6
  21. package/src/components/App.tsx +67 -25
  22. package/src/components/CustomInput.tsx +274 -68
  23. package/src/components/Main.tsx +323 -168
  24. package/src/components/ShortcutsModal.tsx +11 -8
  25. package/src/components/main/ChatPage.tsx +217 -58
  26. package/src/components/main/HomePage.tsx +5 -1
  27. package/src/components/main/ThinkingIndicator.tsx +11 -1
  28. package/src/components/main/types.ts +11 -10
  29. package/src/index.tsx +3 -5
  30. package/src/utils/approvalBridge.ts +29 -8
  31. package/src/utils/approvalModeBridge.ts +17 -0
  32. package/src/utils/commands/approvals.ts +48 -0
  33. package/src/utils/commands/image.ts +109 -0
  34. package/src/utils/commands/index.ts +5 -1
  35. package/src/utils/diffRendering.tsx +13 -14
  36. package/src/utils/history.ts +82 -40
  37. package/src/utils/imageBridge.ts +28 -0
  38. package/src/utils/images.ts +31 -0
  39. package/src/utils/models.ts +0 -7
  40. package/src/utils/notificationBridge.ts +23 -0
  41. package/src/utils/toolFormatting.ts +162 -43
  42. package/src/web/app.tsx +94 -34
  43. package/src/web/assets/css/ChatPage.css +102 -30
  44. package/src/web/assets/css/MessageItem.css +26 -29
  45. package/src/web/assets/css/ThinkingIndicator.css +44 -6
  46. package/src/web/assets/css/ToolMessage.css +36 -14
  47. package/src/web/components/ChatPage.tsx +228 -105
  48. package/src/web/components/HomePage.tsx +6 -6
  49. package/src/web/components/MessageItem.tsx +88 -89
  50. package/src/web/components/Setup.tsx +1 -1
  51. package/src/web/components/Sidebar.tsx +1 -1
  52. package/src/web/components/ThinkingIndicator.tsx +40 -21
  53. package/src/web/router.ts +1 -1
  54. package/src/web/server.tsx +187 -39
  55. package/src/web/storage.ts +23 -1
  56. package/src/web/types.ts +7 -6
@@ -4,39 +4,11 @@ import ReactMarkdown from 'react-markdown';
4
4
  import remarkGfm from 'remark-gfm';
5
5
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
6
6
  import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
7
- import { Message } from '../types';
7
+ import { Message } from '../types';
8
+ import { toDataUrl } from '../../utils/images';
8
9
  import { parseDiffLine, getDiffLineColors } from '../utils';
9
10
  import '../assets/css/global.css'
10
11
 
11
- function BlendIcon() {
12
- return (
13
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="blend-icon">
14
- <circle cx="12" cy="6.5" r="1.4" />
15
- <circle cx="17.5" cy="12" r="1.4" />
16
- <circle cx="12" cy="17.5" r="1.4" />
17
- <circle cx="6.5" cy="12" r="1.4" />
18
- </svg>
19
- );
20
- }
21
-
22
- function formatBlendTime(ms: number): string {
23
- const totalSeconds = Math.floor(ms / 1000);
24
- const minutes = Math.floor(totalSeconds / 60);
25
- const seconds = totalSeconds % 60;
26
-
27
- if (minutes >= 60) {
28
- const hours = Math.floor(minutes / 60);
29
- const remainingMinutes = minutes % 60;
30
- return `${hours}h ${remainingMinutes}m`;
31
- }
32
-
33
- if (minutes > 0) {
34
- return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
35
- }
36
-
37
- return `${seconds}s`;
38
- }
39
-
40
12
  interface MessageItemProps {
41
13
  message: Message;
42
14
  }
@@ -72,6 +44,35 @@ function renderDiffLine(line: string, index: number): React.ReactElement {
72
44
  );
73
45
  }
74
46
 
47
+ function renderToolLine(line: string, index: number): React.ReactElement {
48
+ const parsed = parseDiffLine(line);
49
+ if (parsed.isDiffLine) {
50
+ return renderDiffLine(line, index);
51
+ }
52
+
53
+ const planMatch = line.match(/^(\s*)>\s*(\[[~x ]\])?\s*(.*)$/);
54
+ if (planMatch) {
55
+ const [, leading, bracket, rest] = planMatch;
56
+ const isActive = bracket === '[~]';
57
+ return (
58
+ <div key={index} className="tool-line plan-line">
59
+ <span className="plan-indent">{leading || ''}</span>
60
+ <span className="plan-prefix">></span>
61
+ <span> </span>
62
+ {bracket && <span className={`plan-bracket${isActive ? ' active' : ''}`}>{bracket}</span>}
63
+ {bracket && <span> </span>}
64
+ <span className="plan-step">{rest}</span>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div key={index} className="tool-line">
71
+ {line}
72
+ </div>
73
+ );
74
+ }
75
+
75
76
  function parseToolHeader(content: string): { name: string; info: string | null; bodyLines: string[] } {
76
77
  const lines = content.split('\n');
77
78
  const firstLine = lines[0] || '';
@@ -88,11 +89,12 @@ function parseToolHeader(content: string): { name: string; info: string | null;
88
89
  export function MessageItem({ message }: MessageItemProps) {
89
90
  if (message.role === 'tool') {
90
91
  const statusClass = message.success === false ? 'error' : message.isRunning ? 'running' : 'success';
92
+ const planClass = message.toolName === 'plan' ? 'plan-tool' : '';
91
93
 
92
94
  const { name, info, bodyLines } = parseToolHeader(message.content);
93
95
 
94
96
  return (
95
- <div className={`message tool ${statusClass}`}>
97
+ <div className={`message tool ${statusClass} ${planClass}`}>
96
98
  <div className="message-content">
97
99
  <div className="tool-header">
98
100
  <span className={`tool-name ${message.toolName === 'stop' ? 'no-bold' : ''}`}>{name}</span>
@@ -105,7 +107,7 @@ export function MessageItem({ message }: MessageItemProps) {
105
107
  </div>
106
108
  {bodyLines.length > 0 && (
107
109
  <div className="tool-output">
108
- {bodyLines.map((line, index) => renderDiffLine(line, index))}
110
+ {bodyLines.map((line, index) => renderToolLine(line, index))}
109
111
  </div>
110
112
  )}
111
113
  </div>
@@ -114,66 +116,63 @@ export function MessageItem({ message }: MessageItemProps) {
114
116
  }
115
117
 
116
118
  if (message.role === 'assistant') {
117
- const showBlend = message.responseDuration && message.responseDuration > 60000;
118
-
119
119
  return (
120
- <>
121
- <div className="message assistant">
122
- <div className="message-content">
123
- {message.thinkingContent && (
124
- <details className="thinking-section">
125
- <summary>Thinking...</summary>
126
- <pre className="thinking-content">{message.thinkingContent}</pre>
127
- </details>
128
- )}
129
- <div className="markdown-content">
130
- <ReactMarkdown
131
- remarkPlugins={[remarkGfm]}
132
- components={{
133
- code({ node, className, children, ...props }) {
134
- const match = /language-(\w+)/.exec(className || '');
135
- const { ref, ...rest } = props as any;
136
- return match ? (
137
- <SyntaxHighlighter
138
- style={vscDarkPlus as any}
139
- language={match[1]}
140
- PreTag="div"
141
- {...rest}
142
- >
143
- {String(children).replace(/\n$/, '')}
144
- </SyntaxHighlighter>
145
- ) : (
146
- <code className={className} {...props}>
147
- {children}
148
- </code>
149
- );
150
- }
151
- }}
152
- >
153
- {message.content}
154
- </ReactMarkdown>
155
- </div>
120
+ <div className="message assistant">
121
+ <div className="message-content">
122
+ {message.thinkingContent && (
123
+ <details className="thinking-section">
124
+ <summary>Thinking...</summary>
125
+ <pre className="thinking-content">{message.thinkingContent}</pre>
126
+ </details>
127
+ )}
128
+ <div className="markdown-content">
129
+ <ReactMarkdown
130
+ remarkPlugins={[remarkGfm]}
131
+ components={{
132
+ code({ node, className, children, ...props }) {
133
+ const match = /language-(\w+)/.exec(className || '');
134
+ const { ref, ...rest } = props as any;
135
+ return match ? (
136
+ <SyntaxHighlighter
137
+ style={vscDarkPlus as any}
138
+ language={match[1]}
139
+ PreTag="div"
140
+ {...rest}
141
+ >
142
+ {String(children).replace(/\n$/, '')}
143
+ </SyntaxHighlighter>
144
+ ) : (
145
+ <code className={className} {...props}>
146
+ {children}
147
+ </code>
148
+ );
149
+ }
150
+ }}
151
+ >
152
+ {message.content}
153
+ </ReactMarkdown>
156
154
  </div>
157
155
  </div>
158
- {showBlend && (
159
- <div className="message assistant blend-message">
160
- <div className="message-content blend-indicator">
161
- <BlendIcon />
162
- <span className="blend-text">
163
- {message.blendWord || 'Blended'} for {formatBlendTime(message.responseDuration!)}
164
- </span>
165
- </div>
166
- </div>
167
- )}
168
- </>
156
+ </div>
169
157
  );
170
158
  }
171
159
 
172
- return (
173
- <div className={`message ${message.role} ${message.isError ? 'error' : ''}`}>
174
- <div className="message-content">
175
- {message.displayContent || message.content}
176
- </div>
177
- </div>
178
- );
179
- }
160
+ const hasImages = Array.isArray(message.images) && message.images.length > 0;
161
+
162
+ return (
163
+ <div className={`message ${message.role} ${message.isError ? 'error' : ''}`}>
164
+ <div className="message-content">
165
+ {hasImages && (
166
+ <div className="message-images">
167
+ {message.images!.map((img) => (
168
+ <img key={img.id} src={toDataUrl(img)} alt={img.name} />
169
+ ))}
170
+ </div>
171
+ )}
172
+ <div className="message-text">
173
+ {message.displayContent || message.content}
174
+ </div>
175
+ </div>
176
+ </div>
177
+ );
178
+ }
@@ -208,4 +208,4 @@ export function Setup({ onComplete }: SetupProps) {
208
208
  </div>
209
209
  </div>
210
210
  );
211
- }
211
+ }
@@ -289,4 +289,4 @@ export function Sidebar({
289
289
  )}
290
290
  </>
291
291
  );
292
- }
292
+ }
@@ -19,6 +19,7 @@ const THINKING_WORDS = [
19
19
  interface ThinkingIndicatorProps {
20
20
  startTime?: number;
21
21
  tokens?: number;
22
+ nextStep?: string;
22
23
  }
23
24
 
24
25
  function formatElapsedTime(startTime: number | undefined): string {
@@ -36,7 +37,7 @@ function formatElapsedTime(startTime: number | undefined): string {
36
37
  return `${seconds}s`;
37
38
  }
38
39
 
39
- export function ThinkingIndicator({ startTime, tokens }: ThinkingIndicatorProps) {
40
+ export function ThinkingIndicator({ startTime, tokens, nextStep }: ThinkingIndicatorProps) {
40
41
  const [shimmerPos, setShimmerPos] = useState(-2);
41
42
  const [, setTick] = useState(0);
42
43
  const thinkingWord = useMemo(
@@ -60,26 +61,44 @@ export function ThinkingIndicator({ startTime, tokens }: ThinkingIndicatorProps)
60
61
  const elapsedStr = formatElapsedTime(startTime);
61
62
 
62
63
  return (
63
- <div className="thinking-indicator">
64
- <span className="thinking-icon">&#x2058;</span>
65
- <span className="thinking-text">
66
- {text.split("").map((char, index) => {
67
- const inShimmer = index === shimmerPos || index === shimmerPos - 1;
68
- return (
69
- <span
70
- key={index}
71
- className={inShimmer ? "shimmer-active" : "shimmer-dim"}
72
- >
73
- {char}
74
- </span>
75
- );
76
- })}
77
- </span>
78
- {elapsedStr && <span className="thinking-elapsed"> - {elapsedStr}</span>}
79
- <span className="thinking-hint"> - esc to cancel</span>
80
- {tokens !== undefined && tokens > 0 && (
81
- <span className="thinking-tokens"> - {tokens.toLocaleString()} tokens</span>
64
+ <div className="thinking-block">
65
+ <div className="thinking-indicator">
66
+ <span className="thinking-icon">&#x2058;</span>
67
+ <span className="thinking-text">
68
+ {text.split("").map((char, index) => {
69
+ const inShimmer = index === shimmerPos || index === shimmerPos - 1;
70
+ return (
71
+ <span
72
+ key={index}
73
+ className={inShimmer ? "shimmer-active" : "shimmer-dim"}
74
+ >
75
+ {char}
76
+ </span>
77
+ );
78
+ })}
79
+ </span>
80
+ {elapsedStr && (
81
+ <>
82
+ <span className="thinking-sep"> </span>
83
+ <span className="thinking-elapsed">{elapsedStr}</span>
84
+ </>
85
+ )}
86
+ <span className="thinking-sep"> — </span>
87
+ <span className="thinking-hint">esc to cancel</span>
88
+ {tokens !== undefined && tokens > 0 && (
89
+ <>
90
+ <span className="thinking-sep"> — </span>
91
+ <span className="thinking-tokens">{tokens.toLocaleString()} tokens</span>
92
+ </>
93
+ )}
94
+ </div>
95
+ {nextStep && (
96
+ <div className="thinking-next-line">
97
+ <span className="thinking-next-icon">⎿ </span>
98
+ <span className="thinking-next-label">Next:</span>
99
+ <span className="thinking-next-step">{nextStep}</span>
100
+ </div>
82
101
  )}
83
102
  </div>
84
103
  );
85
- }
104
+ }
package/src/web/router.ts CHANGED
@@ -43,4 +43,4 @@ export function navigateTo(route: Route): void {
43
43
  export function replaceTo(route: Route): void {
44
44
  const path = buildPath(route);
45
45
  window.history.replaceState(null, '', path);
46
- }
46
+ }
@@ -2,16 +2,18 @@ import { serve } from "bun";
2
2
  import { join } from "path";
3
3
  import { existsSync, readdirSync, statSync } from "fs";
4
4
  import { build } from "bun";
5
- import { createCliRenderer, TextAttributes } from "@opentui/core";
6
- import { createRoot } from "@opentui/react";
7
- import React from "react";
8
- import { exec } from "child_process";
5
+ import { createCliRenderer, TextAttributes } from "@opentui/core";
6
+ import { createRoot } from "@opentui/react";
7
+ import React from "react";
8
+ import { exec } from "child_process";
9
+ import type { ImagePart, TextPart, UserContent } from "ai";
10
+ import type { ImageAttachment } from "../utils/images";
9
11
 
10
12
  const PORT = 8192;
11
13
  const HOST = "127.0.0.1";
12
14
 
13
15
  import { subscribeQuestion, answerQuestion } from "../utils/questionBridge";
14
- import { subscribeApproval, respondApproval } from "../utils/approvalBridge";
16
+ import { subscribeApproval, respondApproval, getCurrentApproval } from "../utils/approvalBridge";
15
17
 
16
18
  let currentAbortController: AbortController | null = null;
17
19
 
@@ -107,10 +109,35 @@ function installExternalLogCapture() {
107
109
  }
108
110
  }
109
111
 
110
- installExternalLogCapture();
111
-
112
- let appJsContent: string | null = null;
113
- let appCssContent: string | null = null;
112
+ installExternalLogCapture();
113
+
114
+ let appJsContent: string | null = null;
115
+ let appCssContent: string | null = null;
116
+
117
+ function buildUserContent(text: string, images?: ImageAttachment[]): UserContent {
118
+ if (!images || images.length === 0) return text;
119
+ const parts: Array<TextPart | ImagePart> = [];
120
+ parts.push({ type: "text", text });
121
+ for (const img of images) {
122
+ parts.push({ type: "image", image: img.data, mimeType: img.mimeType });
123
+ }
124
+ return parts;
125
+ }
126
+
127
+ function buildConversationHistory(
128
+ history: Array<{ role: string; content: string; images?: ImageAttachment[] }>,
129
+ allowImages: boolean
130
+ ) {
131
+ return history
132
+ .filter((m) => m.role === "user" || m.role === "assistant")
133
+ .map((m) => {
134
+ if (m.role === "user") {
135
+ const content = allowImages ? buildUserContent(m.content, m.images) : m.content;
136
+ return { role: "user" as const, content };
137
+ }
138
+ return { role: "assistant" as const, content: m.content };
139
+ });
140
+ }
114
141
 
115
142
  async function buildApp() {
116
143
  const appPath = join(__dirname, "app.tsx");
@@ -335,13 +362,108 @@ async function startServer(port: number, maxRetries = 10) {
335
362
  });
336
363
  }
337
364
 
338
- if (url.pathname === "/api/config" && request.method === "GET") {
339
- const { readConfig } = await import("../utils/config");
340
- const config = readConfig();
341
- return new Response(JSON.stringify({
342
- provider: config.provider,
343
- model: config.model
344
- }), {
365
+ if (url.pathname === "/api/config" && request.method === "GET") {
366
+ const { readConfig } = await import("../utils/config");
367
+ const config = readConfig();
368
+ return new Response(JSON.stringify({
369
+ provider: config.provider,
370
+ model: config.model,
371
+ requireApprovals: config.requireApprovals !== false
372
+ }), {
373
+ headers: { "Content-Type": "application/json" },
374
+ });
375
+ }
376
+
377
+ if (url.pathname === "/api/approvals" && request.method === "GET") {
378
+ const { readConfig } = await import("../utils/config");
379
+ const config = readConfig();
380
+ return new Response(JSON.stringify({
381
+ requireApprovals: config.requireApprovals !== false
382
+ }), {
383
+ headers: { "Content-Type": "application/json" },
384
+ });
385
+ }
386
+
387
+ if (url.pathname === "/api/approvals" && request.method === "POST") {
388
+ const body = (await request.json()) as { requireApprovals?: boolean };
389
+ if (typeof body.requireApprovals !== "boolean") {
390
+ return new Response(JSON.stringify({ error: "Invalid requireApprovals value" }), {
391
+ status: 400,
392
+ headers: { "Content-Type": "application/json" },
393
+ });
394
+ }
395
+ const { setRequireApprovals } = await import("../utils/config");
396
+ setRequireApprovals(body.requireApprovals);
397
+ if (!body.requireApprovals && getCurrentApproval()) {
398
+ respondApproval(true);
399
+ }
400
+ return new Response(JSON.stringify({ success: true, requireApprovals: body.requireApprovals }), {
401
+ headers: { "Content-Type": "application/json" },
402
+ });
403
+ }
404
+
405
+ if (url.pathname === "/api/tui-conversations" && request.method === "GET") {
406
+ const { loadConversations } = await import("../utils/history");
407
+ const historyConversations = loadConversations();
408
+ const mapped = historyConversations.map((conv) => {
409
+ const steps = Array.isArray(conv.steps) ? conv.steps : [];
410
+ const baseTimestamp = typeof conv.timestamp === "number" ? conv.timestamp : Date.now();
411
+ const messages = steps.map((step, index) => ({
412
+ id: `${conv.id}_${index}`,
413
+ role: step.type === "tool" ? "tool" : step.type,
414
+ content: step.content,
415
+ images: step.images,
416
+ toolName: step.toolName,
417
+ toolArgs: step.toolArgs,
418
+ toolResult: step.toolResult,
419
+ timestamp: step.timestamp,
420
+ responseDuration: step.responseDuration,
421
+ blendWord: step.blendWord
422
+ }));
423
+
424
+ return {
425
+ id: `tui_${conv.id}`,
426
+ title: conv.title ?? null,
427
+ messages,
428
+ workspace: conv.workspace ?? null,
429
+ createdAt: baseTimestamp,
430
+ updatedAt: baseTimestamp
431
+ };
432
+ });
433
+
434
+ return new Response(JSON.stringify(mapped), {
435
+ headers: { "Content-Type": "application/json" },
436
+ });
437
+ }
438
+
439
+ if (url.pathname === "/api/tui-conversation/rename" && request.method === "POST") {
440
+ const body = (await request.json()) as { id: string; title: string | null };
441
+ if (!body?.id || typeof body.id !== "string") {
442
+ return new Response(JSON.stringify({ error: "Invalid id" }), {
443
+ status: 400,
444
+ headers: { "Content-Type": "application/json" },
445
+ });
446
+ }
447
+ const historyId = body.id.startsWith("tui_") ? body.id.slice(4) : body.id;
448
+ const { updateConversationTitle } = await import("../utils/history");
449
+ const success = updateConversationTitle(historyId, body.title ?? null);
450
+ return new Response(JSON.stringify({ success }), {
451
+ headers: { "Content-Type": "application/json" },
452
+ });
453
+ }
454
+
455
+ if (url.pathname === "/api/tui-conversation/delete" && request.method === "POST") {
456
+ const body = (await request.json()) as { id: string };
457
+ if (!body?.id || typeof body.id !== "string") {
458
+ return new Response(JSON.stringify({ error: "Invalid id" }), {
459
+ status: 400,
460
+ headers: { "Content-Type": "application/json" },
461
+ });
462
+ }
463
+ const historyId = body.id.startsWith("tui_") ? body.id.slice(4) : body.id;
464
+ const { deleteConversation } = await import("../utils/history");
465
+ const success = deleteConversation(historyId);
466
+ return new Response(JSON.stringify({ success }), {
345
467
  headers: { "Content-Type": "application/json" },
346
468
  });
347
469
  }
@@ -392,22 +514,33 @@ async function startServer(port: number, maxRetries = 10) {
392
514
  });
393
515
  }
394
516
 
395
- if (url.pathname === "/api/message" && request.method === "POST") {
396
- const body = (await request.json()) as {
397
- message: string;
398
- history: Array<{ role: string; content: string }>;
399
- };
400
-
401
- if (!body.message || typeof body.message !== "string") {
402
- addLog("Invalid message format");
403
- return new Response(JSON.stringify({ error: "Invalid message format" }), {
404
- status: 400,
405
- headers: { "Content-Type": "application/json" },
406
- });
407
-
408
- }
409
-
410
- addLog("Message received");
517
+ if (url.pathname === "/api/message" && request.method === "POST") {
518
+ const body = (await request.json()) as {
519
+ message?: string;
520
+ images?: ImageAttachment[];
521
+ history?: Array<{ role: string; content: string; images?: ImageAttachment[] }>;
522
+ };
523
+
524
+ if (typeof body.message !== "string") {
525
+ addLog("Invalid message format");
526
+ return new Response(JSON.stringify({ error: "Invalid message format" }), {
527
+ status: 400,
528
+ headers: { "Content-Type": "application/json" },
529
+ });
530
+ }
531
+
532
+ const messageText = body.message ?? "";
533
+ const images = Array.isArray(body.images) ? body.images : [];
534
+
535
+ if (!messageText.trim() && images.length === 0) {
536
+ addLog("Empty message");
537
+ return new Response(JSON.stringify({ error: "Empty message" }), {
538
+ status: 400,
539
+ headers: { "Content-Type": "application/json" },
540
+ });
541
+ }
542
+
543
+ addLog("Message received");
411
544
 
412
545
  currentAbortController = new AbortController();
413
546
  const abortSignal = currentAbortController.signal;
@@ -485,12 +618,27 @@ async function startServer(port: number, maxRetries = 10) {
485
618
  return;
486
619
  }
487
620
 
488
- const agent = new Agent();
489
- const conversationHistory = body.history || [];
490
- conversationHistory.push({ role: "user", content: body.message });
491
-
492
-
493
- for await (const event of agent.streamMessages(conversationHistory as any, {})) {
621
+ const agent = new Agent();
622
+ let allowImages = false;
623
+ try {
624
+ const { readConfig } = await import("../utils/config");
625
+ const config = readConfig();
626
+ if (config.model) {
627
+ const { findModelsDevModelById, modelAcceptsImages } = await import("../utils/models");
628
+ const result = await findModelsDevModelById(config.model);
629
+ allowImages = Boolean(result && result.model && modelAcceptsImages(result.model));
630
+ }
631
+ } catch { }
632
+
633
+ const conversationHistory = buildConversationHistory(body.history || [], allowImages);
634
+ const userImages = allowImages ? images : [];
635
+ conversationHistory.push({
636
+ role: "user",
637
+ content: allowImages ? buildUserContent(messageText, userImages) : messageText
638
+ });
639
+
640
+
641
+ for await (const event of agent.streamMessages(conversationHistory as any, {})) {
494
642
  if (aborted) break;
495
643
  if (!safeEnqueue(JSON.stringify(event) + "\n")) break;
496
644
  }
@@ -659,4 +807,4 @@ function ServerStatus() {
659
807
  }
660
808
 
661
809
  const renderer = await createCliRenderer();
662
- createRoot(renderer).render(<ServerStatus />);
810
+ createRoot(renderer).render(<ServerStatus />);
@@ -26,6 +26,28 @@ export function getAllConversations(): Conversation[] {
26
26
  }
27
27
  }
28
28
 
29
+ export function mergeConversations(incoming: Conversation[]): boolean {
30
+ if (!incoming.length) return false;
31
+
32
+ const existing = getAllConversations();
33
+ const byId = new Map(existing.map((conv) => [conv.id, conv]));
34
+ let changed = false;
35
+
36
+ for (const conv of incoming) {
37
+ const current = byId.get(conv.id);
38
+ if (!current || conv.updatedAt > current.updatedAt) {
39
+ byId.set(conv.id, conv);
40
+ changed = true;
41
+ }
42
+ }
43
+
44
+ if (changed) {
45
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(byId.values())));
46
+ }
47
+
48
+ return changed;
49
+ }
50
+
29
51
  export function getConversation(id: string): Conversation | null {
30
52
  const conversations = getAllConversations();
31
53
  return conversations.find(c => c.id === id) || null;
@@ -89,4 +111,4 @@ export function formatWorkspace(path: string | null | undefined): string {
89
111
  }
90
112
 
91
113
  return normalized;
92
- }
114
+ }
package/src/web/types.ts CHANGED
@@ -1,8 +1,9 @@
1
- export interface Message {
2
- id: string;
3
- role: 'user' | 'assistant' | 'tool';
4
- content: string;
5
- displayContent?: string;
1
+ export interface Message {
2
+ id: string;
3
+ role: 'user' | 'assistant' | 'tool';
4
+ content: string;
5
+ images?: import("../utils/images").ImageAttachment[];
6
+ displayContent?: string;
6
7
  isError?: boolean;
7
8
  toolName?: string;
8
9
  toolArgs?: Record<string, unknown>;
@@ -14,4 +15,4 @@ export interface Message {
14
15
  runningStartTime?: number;
15
16
  responseDuration?: number;
16
17
  blendWord?: string;
17
- }
18
+ }