create-tether-app 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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +729 -0
- package/package.json +59 -0
- package/template/.env.example +18 -0
- package/template/README.md.template +123 -0
- package/template/backend/app/__init__.py.template +5 -0
- package/template/backend/app/main.py +66 -0
- package/template/backend/app/routes/__init__.py +3 -0
- package/template/backend/app/routes/chat.py +151 -0
- package/template/backend/app/routes/health.py +28 -0
- package/template/backend/app/routes/models.py +126 -0
- package/template/backend/app/services/__init__.py +3 -0
- package/template/backend/app/services/llm.py +526 -0
- package/template/backend/pyproject.toml.template +34 -0
- package/template/backend/scripts/build.py +112 -0
- package/template/frontend/App.css +58 -0
- package/template/frontend/App.tsx +62 -0
- package/template/frontend/components/Chat.css +220 -0
- package/template/frontend/components/Chat.tsx +284 -0
- package/template/frontend/components/ChatMessage.css +206 -0
- package/template/frontend/components/ChatMessage.tsx +62 -0
- package/template/frontend/components/ModelStatus.css +62 -0
- package/template/frontend/components/ModelStatus.tsx +103 -0
- package/template/frontend/hooks/useApi.ts +334 -0
- package/template/frontend/index.css +92 -0
- package/template/frontend/main.tsx +10 -0
- package/template/frontend/vite-env.d.ts +1 -0
- package/template/index.html.template +13 -0
- package/template/package.json.template +33 -0
- package/template/postcss.config.js.template +6 -0
- package/template/public/tether.svg +15 -0
- package/template/src-tauri/.cargo/config.toml +66 -0
- package/template/src-tauri/Cargo.lock +4764 -0
- package/template/src-tauri/Cargo.toml +24 -0
- package/template/src-tauri/build.rs +3 -0
- package/template/src-tauri/capabilities/default.json +40 -0
- package/template/src-tauri/icons/128x128.png +0 -0
- package/template/src-tauri/icons/128x128@2x.png +0 -0
- package/template/src-tauri/icons/32x32.png +0 -0
- package/template/src-tauri/icons/icon.icns +0 -0
- package/template/src-tauri/icons/icon.ico +0 -0
- package/template/src-tauri/src/main.rs +65 -0
- package/template/src-tauri/src/sidecar.rs +110 -0
- package/template/src-tauri/tauri.conf.json.template +44 -0
- package/template/tailwind.config.js.template +19 -0
- package/template/tsconfig.json +21 -0
- package/template/tsconfig.node.json +11 -0
- package/template/vite.config.ts +27 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
.app {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
height: 100%;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.app-header {
|
|
8
|
+
display: flex;
|
|
9
|
+
justify-content: space-between;
|
|
10
|
+
align-items: center;
|
|
11
|
+
padding: 1rem 1.5rem;
|
|
12
|
+
border-bottom: 1px solid var(--color-border);
|
|
13
|
+
background-color: var(--color-surface);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.app-header h1 {
|
|
17
|
+
font-size: 1.25rem;
|
|
18
|
+
font-weight: 600;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.app-main {
|
|
22
|
+
flex: 1;
|
|
23
|
+
overflow: hidden;
|
|
24
|
+
display: flex;
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.loading,
|
|
29
|
+
.error {
|
|
30
|
+
flex: 1;
|
|
31
|
+
display: flex;
|
|
32
|
+
flex-direction: column;
|
|
33
|
+
align-items: center;
|
|
34
|
+
justify-content: center;
|
|
35
|
+
gap: 1rem;
|
|
36
|
+
color: var(--color-text-muted);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.error {
|
|
40
|
+
color: var(--color-error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.error.disconnected {
|
|
44
|
+
color: var(--color-warning, #d97706);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.error-detail {
|
|
48
|
+
font-size: 0.875rem;
|
|
49
|
+
color: var(--color-text-muted);
|
|
50
|
+
max-width: 400px;
|
|
51
|
+
text-align: center;
|
|
52
|
+
white-space: pre-wrap;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.loading-hint {
|
|
56
|
+
font-size: 0.75rem;
|
|
57
|
+
opacity: 0.7;
|
|
58
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useBackendStatus } from "./hooks/useApi";
|
|
2
|
+
import { Chat } from "./components/Chat";
|
|
3
|
+
import { ModelStatus } from "./components/ModelStatus";
|
|
4
|
+
import "./App.css";
|
|
5
|
+
|
|
6
|
+
function App() {
|
|
7
|
+
const { status, health, modelInfo, error, retry, changeModel } =
|
|
8
|
+
useBackendStatus();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="app">
|
|
12
|
+
<header className="app-header">
|
|
13
|
+
<h1>Tether App</h1>
|
|
14
|
+
<ModelStatus
|
|
15
|
+
status={status}
|
|
16
|
+
health={health}
|
|
17
|
+
modelInfo={modelInfo}
|
|
18
|
+
onModelChange={changeModel}
|
|
19
|
+
/>
|
|
20
|
+
</header>
|
|
21
|
+
|
|
22
|
+
<main className="app-main">
|
|
23
|
+
{status === "connecting" && (
|
|
24
|
+
<div className="loading">
|
|
25
|
+
<div className="spinner" />
|
|
26
|
+
<p>Connecting to backend...</p>
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
|
|
30
|
+
{status === "loading-model" && (
|
|
31
|
+
<div className="loading">
|
|
32
|
+
<div className="spinner" />
|
|
33
|
+
<p>Loading model...</p>
|
|
34
|
+
<p className="loading-hint">
|
|
35
|
+
This may take a moment for large models
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
|
|
40
|
+
{status === "disconnected" && (
|
|
41
|
+
<div className="error disconnected">
|
|
42
|
+
<p>Connection lost</p>
|
|
43
|
+
<p className="error-detail">The backend is no longer responding</p>
|
|
44
|
+
<button onClick={retry}>Reconnect</button>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
{status === "error" && (
|
|
49
|
+
<div className="error">
|
|
50
|
+
<p>Failed to connect</p>
|
|
51
|
+
{error && <p className="error-detail">{error.message}</p>}
|
|
52
|
+
<button onClick={retry}>Retry</button>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
{status === "connected" && <Chat />}
|
|
57
|
+
</main>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default App;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
.chat {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
height: 100%;
|
|
5
|
+
position: relative;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* Drag and drop overlay */
|
|
9
|
+
.chat-dragging {
|
|
10
|
+
outline: 2px dashed var(--color-primary);
|
|
11
|
+
outline-offset: -4px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.drop-overlay {
|
|
15
|
+
position: absolute;
|
|
16
|
+
inset: 0;
|
|
17
|
+
background: rgba(var(--color-primary-rgb, 147, 51, 234), 0.1);
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
justify-content: center;
|
|
21
|
+
z-index: 10;
|
|
22
|
+
pointer-events: none;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.drop-message {
|
|
26
|
+
background: var(--color-primary);
|
|
27
|
+
color: white;
|
|
28
|
+
padding: 1rem 2rem;
|
|
29
|
+
border-radius: var(--radius);
|
|
30
|
+
font-weight: 500;
|
|
31
|
+
font-size: 1rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.chat-messages {
|
|
35
|
+
flex: 1;
|
|
36
|
+
overflow-y: auto;
|
|
37
|
+
padding: 1rem;
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
gap: 1rem;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.chat-empty {
|
|
44
|
+
flex: 1;
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
align-items: center;
|
|
48
|
+
justify-content: center;
|
|
49
|
+
color: var(--color-text-muted);
|
|
50
|
+
text-align: center;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.chat-empty p:first-child {
|
|
54
|
+
font-size: 1.125rem;
|
|
55
|
+
font-weight: 500;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.chat-hint {
|
|
59
|
+
font-size: 0.875rem;
|
|
60
|
+
margin-top: 0.5rem;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.chat-loading {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 0.5rem;
|
|
67
|
+
color: var(--color-text-muted);
|
|
68
|
+
font-size: 0.875rem;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.chat-error {
|
|
72
|
+
padding: 0.75rem;
|
|
73
|
+
background-color: rgba(248, 113, 113, 0.1);
|
|
74
|
+
border: 1px solid var(--color-error);
|
|
75
|
+
border-radius: var(--radius);
|
|
76
|
+
color: var(--color-error);
|
|
77
|
+
font-size: 0.875rem;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.chat-form {
|
|
81
|
+
padding: 1rem;
|
|
82
|
+
border-top: 1px solid var(--color-border);
|
|
83
|
+
background-color: var(--color-surface);
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
gap: 0.5rem;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.chat-input-wrapper {
|
|
90
|
+
display: flex;
|
|
91
|
+
gap: 0.5rem;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.chat-input {
|
|
95
|
+
flex: 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.chat-options {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
justify-content: space-between;
|
|
102
|
+
gap: 1rem;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.thinking-toggle {
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 0.5rem;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
font-size: 0.75rem;
|
|
111
|
+
color: var(--color-text-muted);
|
|
112
|
+
user-select: none;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.thinking-toggle input[type="checkbox"] {
|
|
116
|
+
width: 1rem;
|
|
117
|
+
height: 1rem;
|
|
118
|
+
accent-color: rgb(147, 51, 234);
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.thinking-toggle:hover {
|
|
123
|
+
color: var(--color-text);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.toggle-label {
|
|
127
|
+
font-weight: 500;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.clear-button {
|
|
131
|
+
background-color: transparent;
|
|
132
|
+
color: var(--color-text-muted);
|
|
133
|
+
font-size: 0.75rem;
|
|
134
|
+
padding: 0.25rem 0.5rem;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.clear-button:hover {
|
|
138
|
+
background-color: var(--color-surface-hover);
|
|
139
|
+
color: var(--color-text);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Image upload button */
|
|
143
|
+
.image-button {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
padding: 0.5rem;
|
|
148
|
+
background: transparent;
|
|
149
|
+
border: 1px solid var(--color-border);
|
|
150
|
+
border-radius: var(--radius);
|
|
151
|
+
color: var(--color-text-muted);
|
|
152
|
+
cursor: pointer;
|
|
153
|
+
transition: all 0.15s;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.image-button:hover:not(:disabled) {
|
|
157
|
+
border-color: var(--color-primary);
|
|
158
|
+
color: var(--color-primary);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.image-button:disabled {
|
|
162
|
+
opacity: 0.5;
|
|
163
|
+
cursor: not-allowed;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* Image warning */
|
|
167
|
+
.image-warning {
|
|
168
|
+
font-size: 0.75rem;
|
|
169
|
+
color: var(--color-warning, #d97706);
|
|
170
|
+
background: rgba(217, 119, 6, 0.1);
|
|
171
|
+
padding: 0.5rem 0.75rem;
|
|
172
|
+
border-radius: 6px;
|
|
173
|
+
margin-bottom: 0.5rem;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* Pending images preview */
|
|
177
|
+
.pending-images {
|
|
178
|
+
display: flex;
|
|
179
|
+
gap: 0.5rem;
|
|
180
|
+
flex-wrap: wrap;
|
|
181
|
+
padding-bottom: 0.5rem;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.pending-image {
|
|
185
|
+
position: relative;
|
|
186
|
+
width: 60px;
|
|
187
|
+
height: 60px;
|
|
188
|
+
border-radius: 6px;
|
|
189
|
+
overflow: hidden;
|
|
190
|
+
border: 1px solid var(--color-border);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.pending-image img {
|
|
194
|
+
width: 100%;
|
|
195
|
+
height: 100%;
|
|
196
|
+
object-fit: cover;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.remove-image {
|
|
200
|
+
position: absolute;
|
|
201
|
+
top: 2px;
|
|
202
|
+
right: 2px;
|
|
203
|
+
width: 18px;
|
|
204
|
+
height: 18px;
|
|
205
|
+
padding: 0;
|
|
206
|
+
display: flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
justify-content: center;
|
|
209
|
+
background: rgba(0, 0, 0, 0.6);
|
|
210
|
+
color: white;
|
|
211
|
+
border: none;
|
|
212
|
+
border-radius: 50%;
|
|
213
|
+
font-size: 14px;
|
|
214
|
+
line-height: 1;
|
|
215
|
+
cursor: pointer;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.remove-image:hover {
|
|
219
|
+
background: rgba(0, 0, 0, 0.8);
|
|
220
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { useChat } from "../hooks/useApi";
|
|
3
|
+
import { ChatMessage } from "./ChatMessage";
|
|
4
|
+
import "./Chat.css";
|
|
5
|
+
|
|
6
|
+
export function Chat() {
|
|
7
|
+
const [input, setInput] = useState("");
|
|
8
|
+
const [thinkingEnabled, setThinkingEnabled] = useState(true);
|
|
9
|
+
const [pendingImages, setPendingImages] = useState<string[]>([]);
|
|
10
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
11
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
12
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
13
|
+
const { messages, isLoading, error, sendMessage, clearMessages } = useChat();
|
|
14
|
+
|
|
15
|
+
const scrollToBottom = () => {
|
|
16
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
scrollToBottom();
|
|
21
|
+
}, [messages]);
|
|
22
|
+
|
|
23
|
+
const fileToBase64 = (file: File): Promise<string> => {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const reader = new FileReader();
|
|
26
|
+
reader.onload = () => {
|
|
27
|
+
// Remove data URL prefix (e.g., "data:image/png;base64,")
|
|
28
|
+
const result = reader.result as string;
|
|
29
|
+
const base64 = result.split(",")[1];
|
|
30
|
+
resolve(base64);
|
|
31
|
+
};
|
|
32
|
+
reader.onerror = reject;
|
|
33
|
+
reader.readAsDataURL(file);
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const processFiles = async (files: FileList | File[]) => {
|
|
38
|
+
const newImages: string[] = [];
|
|
39
|
+
for (const file of Array.from(files)) {
|
|
40
|
+
if (file.type.startsWith("image/")) {
|
|
41
|
+
const base64 = await fileToBase64(file);
|
|
42
|
+
newImages.push(base64);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (newImages.length > 0) {
|
|
46
|
+
setPendingImages((prev) => [...prev, ...newImages]);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
51
|
+
const files = e.target.files;
|
|
52
|
+
if (!files) return;
|
|
53
|
+
|
|
54
|
+
await processFiles(files);
|
|
55
|
+
|
|
56
|
+
// Reset input so same file can be selected again
|
|
57
|
+
if (fileInputRef.current) {
|
|
58
|
+
fileInputRef.current.value = "";
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Drag and drop handlers
|
|
63
|
+
const dragCounterRef = useRef(0);
|
|
64
|
+
|
|
65
|
+
const handleDragEnter = (e: React.DragEvent) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
e.stopPropagation();
|
|
68
|
+
dragCounterRef.current++;
|
|
69
|
+
if (e.dataTransfer.types.includes("Files")) {
|
|
70
|
+
setIsDragging(true);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const handleDragLeave = (e: React.DragEvent) => {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
e.stopPropagation();
|
|
82
|
+
dragCounterRef.current--;
|
|
83
|
+
if (dragCounterRef.current === 0) {
|
|
84
|
+
setIsDragging(false);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleDrop = async (e: React.DragEvent) => {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
dragCounterRef.current = 0;
|
|
92
|
+
setIsDragging(false);
|
|
93
|
+
|
|
94
|
+
const files = e.dataTransfer.files;
|
|
95
|
+
if (files.length > 0) {
|
|
96
|
+
await processFiles(files);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Paste handler
|
|
101
|
+
const handlePaste = async (e: React.ClipboardEvent) => {
|
|
102
|
+
const items = e.clipboardData.items;
|
|
103
|
+
const imageFiles: File[] = [];
|
|
104
|
+
|
|
105
|
+
for (const item of Array.from(items)) {
|
|
106
|
+
if (item.type.startsWith("image/")) {
|
|
107
|
+
const file = item.getAsFile();
|
|
108
|
+
if (file) {
|
|
109
|
+
imageFiles.push(file);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (imageFiles.length > 0) {
|
|
115
|
+
e.preventDefault(); // Prevent pasting image as text
|
|
116
|
+
await processFiles(imageFiles);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const removeImage = (index: number) => {
|
|
121
|
+
setPendingImages((prev) => prev.filter((_, i) => i !== index));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
if ((!input.trim() && pendingImages.length === 0) || isLoading) return;
|
|
127
|
+
|
|
128
|
+
const message = input;
|
|
129
|
+
const images = pendingImages.length > 0 ? pendingImages : undefined;
|
|
130
|
+
setInput("");
|
|
131
|
+
setPendingImages([]);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await sendMessage(message, { think: thinkingEnabled, images });
|
|
135
|
+
} catch {
|
|
136
|
+
// Error is already handled in the hook
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div
|
|
142
|
+
className={`chat ${isDragging ? "chat-dragging" : ""}`}
|
|
143
|
+
onDragEnter={handleDragEnter}
|
|
144
|
+
onDragOver={handleDragOver}
|
|
145
|
+
onDragLeave={handleDragLeave}
|
|
146
|
+
onDrop={handleDrop}
|
|
147
|
+
>
|
|
148
|
+
{isDragging && (
|
|
149
|
+
<div className="drop-overlay">
|
|
150
|
+
<div className="drop-message">Drop images here</div>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
<div className="chat-messages">
|
|
154
|
+
{messages.length === 0 && (
|
|
155
|
+
<div className="chat-empty">
|
|
156
|
+
<p>Start a conversation</p>
|
|
157
|
+
<p className="chat-hint">
|
|
158
|
+
Type a message below to begin chatting with the AI
|
|
159
|
+
</p>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{messages.map((message, index) => (
|
|
164
|
+
<ChatMessage key={index} message={message} />
|
|
165
|
+
))}
|
|
166
|
+
|
|
167
|
+
{isLoading && (
|
|
168
|
+
<div className="chat-loading">
|
|
169
|
+
<div className="spinner" />
|
|
170
|
+
<span>Thinking...</span>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{error && (
|
|
175
|
+
<div className="chat-error">
|
|
176
|
+
<p>Error: {error.message}</p>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
<div ref={messagesEndRef} />
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<form className="chat-form" onSubmit={handleSubmit}>
|
|
184
|
+
{pendingImages.length > 0 && (
|
|
185
|
+
<>
|
|
186
|
+
{thinkingEnabled && (
|
|
187
|
+
<div className="image-warning">
|
|
188
|
+
Thinking mode is not supported with images and will be disabled
|
|
189
|
+
for this message.
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
<div className="pending-images">
|
|
193
|
+
{pendingImages.map((img, index) => (
|
|
194
|
+
<div key={index} className="pending-image">
|
|
195
|
+
<img
|
|
196
|
+
src={`data:image/jpeg;base64,${img}`}
|
|
197
|
+
alt={`Pending ${index + 1}`}
|
|
198
|
+
/>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
className="remove-image"
|
|
202
|
+
onClick={() => removeImage(index)}
|
|
203
|
+
aria-label="Remove image"
|
|
204
|
+
>
|
|
205
|
+
×
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
<div className="chat-input-wrapper">
|
|
213
|
+
<input
|
|
214
|
+
type="file"
|
|
215
|
+
ref={fileInputRef}
|
|
216
|
+
onChange={handleImageSelect}
|
|
217
|
+
accept="image/*"
|
|
218
|
+
multiple
|
|
219
|
+
hidden
|
|
220
|
+
/>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
className="image-button"
|
|
224
|
+
onClick={() => fileInputRef.current?.click()}
|
|
225
|
+
disabled={isLoading}
|
|
226
|
+
title="Add image (vision models)"
|
|
227
|
+
>
|
|
228
|
+
<svg
|
|
229
|
+
width="20"
|
|
230
|
+
height="20"
|
|
231
|
+
viewBox="0 0 24 24"
|
|
232
|
+
fill="none"
|
|
233
|
+
stroke="currentColor"
|
|
234
|
+
strokeWidth="2"
|
|
235
|
+
strokeLinecap="round"
|
|
236
|
+
strokeLinejoin="round"
|
|
237
|
+
>
|
|
238
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
239
|
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
240
|
+
<polyline points="21 15 16 10 5 21" />
|
|
241
|
+
</svg>
|
|
242
|
+
</button>
|
|
243
|
+
<input
|
|
244
|
+
type="text"
|
|
245
|
+
value={input}
|
|
246
|
+
onChange={(e) => setInput(e.target.value)}
|
|
247
|
+
onPaste={handlePaste}
|
|
248
|
+
placeholder="Type a message..."
|
|
249
|
+
disabled={isLoading}
|
|
250
|
+
className="chat-input"
|
|
251
|
+
/>
|
|
252
|
+
<button
|
|
253
|
+
type="submit"
|
|
254
|
+
disabled={
|
|
255
|
+
isLoading || (!input.trim() && pendingImages.length === 0)
|
|
256
|
+
}
|
|
257
|
+
>
|
|
258
|
+
Send
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
<div className="chat-options">
|
|
262
|
+
<label className="thinking-toggle">
|
|
263
|
+
<input
|
|
264
|
+
type="checkbox"
|
|
265
|
+
checked={thinkingEnabled}
|
|
266
|
+
onChange={(e) => setThinkingEnabled(e.target.checked)}
|
|
267
|
+
disabled={isLoading}
|
|
268
|
+
/>
|
|
269
|
+
<span className="toggle-label">Thinking (supported models)</span>
|
|
270
|
+
</label>
|
|
271
|
+
{messages.length > 0 && (
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
onClick={clearMessages}
|
|
275
|
+
className="clear-button"
|
|
276
|
+
>
|
|
277
|
+
Clear Chat
|
|
278
|
+
</button>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
</form>
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|