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.
Files changed (48) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +729 -0
  3. package/package.json +59 -0
  4. package/template/.env.example +18 -0
  5. package/template/README.md.template +123 -0
  6. package/template/backend/app/__init__.py.template +5 -0
  7. package/template/backend/app/main.py +66 -0
  8. package/template/backend/app/routes/__init__.py +3 -0
  9. package/template/backend/app/routes/chat.py +151 -0
  10. package/template/backend/app/routes/health.py +28 -0
  11. package/template/backend/app/routes/models.py +126 -0
  12. package/template/backend/app/services/__init__.py +3 -0
  13. package/template/backend/app/services/llm.py +526 -0
  14. package/template/backend/pyproject.toml.template +34 -0
  15. package/template/backend/scripts/build.py +112 -0
  16. package/template/frontend/App.css +58 -0
  17. package/template/frontend/App.tsx +62 -0
  18. package/template/frontend/components/Chat.css +220 -0
  19. package/template/frontend/components/Chat.tsx +284 -0
  20. package/template/frontend/components/ChatMessage.css +206 -0
  21. package/template/frontend/components/ChatMessage.tsx +62 -0
  22. package/template/frontend/components/ModelStatus.css +62 -0
  23. package/template/frontend/components/ModelStatus.tsx +103 -0
  24. package/template/frontend/hooks/useApi.ts +334 -0
  25. package/template/frontend/index.css +92 -0
  26. package/template/frontend/main.tsx +10 -0
  27. package/template/frontend/vite-env.d.ts +1 -0
  28. package/template/index.html.template +13 -0
  29. package/template/package.json.template +33 -0
  30. package/template/postcss.config.js.template +6 -0
  31. package/template/public/tether.svg +15 -0
  32. package/template/src-tauri/.cargo/config.toml +66 -0
  33. package/template/src-tauri/Cargo.lock +4764 -0
  34. package/template/src-tauri/Cargo.toml +24 -0
  35. package/template/src-tauri/build.rs +3 -0
  36. package/template/src-tauri/capabilities/default.json +40 -0
  37. package/template/src-tauri/icons/128x128.png +0 -0
  38. package/template/src-tauri/icons/128x128@2x.png +0 -0
  39. package/template/src-tauri/icons/32x32.png +0 -0
  40. package/template/src-tauri/icons/icon.icns +0 -0
  41. package/template/src-tauri/icons/icon.ico +0 -0
  42. package/template/src-tauri/src/main.rs +65 -0
  43. package/template/src-tauri/src/sidecar.rs +110 -0
  44. package/template/src-tauri/tauri.conf.json.template +44 -0
  45. package/template/tailwind.config.js.template +19 -0
  46. package/template/tsconfig.json +21 -0
  47. package/template/tsconfig.node.json +11 -0
  48. 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
+ }