@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.
- package/LICENSE +1 -1
- package/README.md +83 -19
- package/package.json +52 -47
- package/src/agent/prompts/systemPrompt.ts +198 -68
- package/src/agent/prompts/toolsPrompt.ts +217 -135
- package/src/agent/provider/anthropic.ts +19 -15
- package/src/agent/provider/google.ts +21 -17
- package/src/agent/provider/ollama.ts +80 -41
- package/src/agent/provider/openai.ts +107 -67
- package/src/agent/provider/reasoning.ts +29 -0
- package/src/agent/provider/xai.ts +19 -15
- package/src/agent/tools/definitions.ts +9 -5
- package/src/agent/tools/executor.ts +655 -46
- package/src/agent/tools/exploreExecutor.ts +12 -12
- package/src/agent/tools/fetch.ts +58 -0
- package/src/agent/tools/glob.ts +20 -4
- package/src/agent/tools/grep.ts +62 -8
- package/src/agent/tools/plan.ts +27 -0
- package/src/agent/tools/read.ts +2 -0
- package/src/agent/types.ts +6 -6
- package/src/components/App.tsx +67 -25
- package/src/components/CustomInput.tsx +274 -68
- package/src/components/Main.tsx +323 -168
- package/src/components/ShortcutsModal.tsx +11 -8
- package/src/components/main/ChatPage.tsx +217 -58
- package/src/components/main/HomePage.tsx +5 -1
- package/src/components/main/ThinkingIndicator.tsx +11 -1
- package/src/components/main/types.ts +11 -10
- package/src/index.tsx +3 -5
- package/src/utils/approvalBridge.ts +29 -8
- package/src/utils/approvalModeBridge.ts +17 -0
- package/src/utils/commands/approvals.ts +48 -0
- package/src/utils/commands/image.ts +109 -0
- package/src/utils/commands/index.ts +5 -1
- package/src/utils/diffRendering.tsx +13 -14
- package/src/utils/history.ts +82 -40
- package/src/utils/imageBridge.ts +28 -0
- package/src/utils/images.ts +31 -0
- package/src/utils/models.ts +0 -7
- package/src/utils/notificationBridge.ts +23 -0
- package/src/utils/toolFormatting.ts +162 -43
- package/src/web/app.tsx +94 -34
- package/src/web/assets/css/ChatPage.css +102 -30
- package/src/web/assets/css/MessageItem.css +26 -29
- package/src/web/assets/css/ThinkingIndicator.css +44 -6
- package/src/web/assets/css/ToolMessage.css +36 -14
- package/src/web/components/ChatPage.tsx +228 -105
- package/src/web/components/HomePage.tsx +6 -6
- package/src/web/components/MessageItem.tsx +88 -89
- package/src/web/components/Setup.tsx +1 -1
- package/src/web/components/Sidebar.tsx +1 -1
- package/src/web/components/ThinkingIndicator.tsx +40 -21
- package/src/web/router.ts +1 -1
- package/src/web/server.tsx +187 -39
- package/src/web/storage.ts +23 -1
- 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) =>
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|
|
@@ -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-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
64
|
+
<div className="thinking-block">
|
|
65
|
+
<div className="thinking-indicator">
|
|
66
|
+
<span className="thinking-icon">⁘</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
package/src/web/server.tsx
CHANGED
|
@@ -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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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 />);
|
package/src/web/storage.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|