@raingor/pi-web-switch 0.4.1 → 0.4.2

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.
@@ -1,863 +0,0 @@
1
- // ChatInput — message input bar with model selector, tool preset, and send controls.
2
- // Ported and simplified from pi-web's components/ChatInput.tsx.
3
-
4
- import { useState, useRef, useCallback, useEffect, type KeyboardEvent, forwardRef, useImperativeHandle } from "react";
5
- import type { AttachedImage, ChatInputHandle, ModelEntry, SlashCommandInfo, QueuedMessages } from "@/types/chat";
6
- import { useTranslation } from "@/lib/i18n";
7
-
8
- interface Props {
9
- onSend: (message: string, images?: AttachedImage[]) => void;
10
- onAbort: () => void;
11
- onSteer?: (message: string, images?: AttachedImage[]) => void;
12
- onFollowUp?: (message: string, images?: AttachedImage[]) => void;
13
- isStreaming: boolean;
14
- model?: { provider: string; modelId: string } | null;
15
- isAutoModelSelection?: boolean;
16
- modelNames?: Record<string, string>;
17
- modelList?: ModelEntry[];
18
- modelError?: string | null;
19
- onModelChange: (provider: string, modelId: string) => void;
20
- onCompact?: () => void;
21
- onAbortCompaction?: () => void;
22
- isCompacting?: boolean;
23
- compactError?: string | null;
24
- toolPreset: "none" | "default" | "full";
25
- onToolPresetChange: (preset: "none" | "default" | "full") => void;
26
- thinkingLevel: string;
27
- onThinkingLevelChange: (level: any) => void;
28
- retryInfo?: { attempt: number; maxAttempts: number; errorMessage?: string } | null;
29
- queuedMessages?: QueuedMessages;
30
- onRecallQueue?: () => void;
31
- slashCommands?: SlashCommandInfo[];
32
- slashCommandsLoading?: boolean;
33
- onLoadSlashCommands?: () => void;
34
- onBuiltinCommand?: (text: string) => Promise<{ handled: boolean; message?: string; error?: string }>;
35
- inputHistory?: string[];
36
- draftKey?: string;
37
- cwd?: string;
38
- }
39
-
40
- const TEXTAREA_MIN_HEIGHT = 44;
41
- const TEXTAREA_MAX_HEIGHT = 200;
42
- const DRAFT_STORAGE_PREFIX = "pi-chat-draft:";
43
-
44
- export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(props, ref) {
45
- const {
46
- onSend, onAbort, onSteer, onFollowUp, isStreaming,
47
- model, isAutoModelSelection, modelNames, modelList, modelError, onModelChange,
48
- onCompact, isCompacting, compactError,
49
- toolPreset, onToolPresetChange,
50
- thinkingLevel, onThinkingLevelChange,
51
- retryInfo, queuedMessages, onRecallQueue,
52
- slashCommands, slashCommandsLoading, onLoadSlashCommands,
53
- onBuiltinCommand, inputHistory, draftKey, cwd,
54
- } = props;
55
-
56
- const { t } = useTranslation();
57
- const [text, setText] = useState("");
58
- const [showModelMenu, setShowModelMenu] = useState(false);
59
- const [modelSearchQuery, setModelSearchQuery] = useState("");
60
- const [showToolMenu, setShowToolMenu] = useState(false);
61
- const [showSlashMenu, setShowSlashMenu] = useState(false);
62
- const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
63
- const [historyIndex, setHistoryIndex] = useState(-1);
64
- const textareaRef = useRef<HTMLTextAreaElement>(null);
65
- const fileInputRef = useRef<HTMLInputElement>(null);
66
- const modelSearchInputRef = useRef<HTMLInputElement>(null);
67
- const draftKeyRef = useRef(draftKey);
68
- draftKeyRef.current = draftKey;
69
-
70
- // Focus search input when model menu opens
71
- useEffect(() => {
72
- if (showModelMenu) {
73
- setModelSearchQuery("");
74
- requestAnimationFrame(() => {
75
- modelSearchInputRef.current?.focus();
76
- });
77
- }
78
- }, [showModelMenu]);
79
-
80
- // ─── Imperative Handle ─────────────────────────────────
81
-
82
- useImperativeHandle(ref, () => ({
83
- insertText: (t: string) => {
84
- setText((prev) => prev + t);
85
- requestAnimationFrame(() => {
86
- textareaRef.current?.focus();
87
- textareaRef.current?.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length);
88
- });
89
- },
90
- insertIfEmpty: (content: string) => {
91
- setText((prev) => (prev.trim() === "" ? content : prev));
92
- textareaRef.current?.focus();
93
- },
94
- prependText: (t: string) => {
95
- setText((prev) => t + (prev ? "\n\n" + prev : ""));
96
- textareaRef.current?.focus();
97
- },
98
- addImages: (files: File[]) => {
99
- for (const file of files) {
100
- if (!file.type.startsWith("image/")) continue;
101
- const reader = new FileReader();
102
- reader.onload = () => {
103
- const dataUrl = reader.result as string;
104
- const base64 = dataUrl.split(",")[1];
105
- if (base64) {
106
- setAttachedImages((prev) => [...prev, {
107
- data: base64,
108
- mimeType: file.type,
109
- previewUrl: dataUrl,
110
- }]);
111
- }
112
- };
113
- reader.readAsDataURL(file);
114
- }
115
- },
116
- }), []);
117
-
118
- // ─── Draft Persistence ─────────────────────────────────
119
-
120
- useEffect(() => {
121
- if (draftKey) {
122
- const saved = localStorage.getItem(DRAFT_STORAGE_PREFIX + draftKey);
123
- if (saved !== null) setText(saved);
124
- }
125
- }, [draftKey]);
126
-
127
- useEffect(() => {
128
- if (draftKey) {
129
- localStorage.setItem(DRAFT_STORAGE_PREFIX + draftKey, text);
130
- }
131
- }, [text, draftKey]);
132
-
133
- // ─── Auto-resize Textarea ──────────────────────────────
134
-
135
- useEffect(() => {
136
- const ta = textareaRef.current;
137
- if (!ta) return;
138
- ta.style.height = "auto";
139
- ta.style.height = `${Math.min(ta.scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
140
- }, [text]);
141
-
142
- // ─── Send ──────────────────────────────────────────────
143
-
144
- const handleSend = useCallback(async () => {
145
- const trimmed = text.trim();
146
- if (!trimmed && !attachedImages.length) return;
147
- if (isStreaming) return;
148
-
149
- // Check for built-in slash commands
150
- if (onBuiltinCommand && trimmed.startsWith("/")) {
151
- const result = await onBuiltinCommand(trimmed);
152
- if (result.handled) {
153
- setText("");
154
- setAttachedImages([]);
155
- setHistoryIndex(-1);
156
- return;
157
- }
158
- }
159
-
160
- onSend(trimmed, attachedImages.length ? attachedImages : undefined);
161
- setText("");
162
- setAttachedImages([]);
163
- setHistoryIndex(-1);
164
- }, [text, attachedImages, isStreaming, onSend, onBuiltinCommand]);
165
-
166
- // ─── Keyboard Shortcuts ────────────────────────────────
167
-
168
- const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
169
- // Enter to send (without shift)
170
- if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
171
- e.preventDefault();
172
- if (isStreaming) {
173
- onSteer?.(text);
174
- } else {
175
- void handleSend();
176
- }
177
- return;
178
- }
179
-
180
- // Shift+Enter for new line (default behavior)
181
-
182
- // History navigation
183
- if (e.key === "ArrowUp" && !e.shiftKey && text === "" && inputHistory && inputHistory.length > 0) {
184
- e.preventDefault();
185
- const newIdx = historyIndex === -1 ? 0 : Math.min(historyIndex + 1, inputHistory.length - 1);
186
- setHistoryIndex(newIdx);
187
- setText(inputHistory[newIdx] ?? "");
188
- return;
189
- }
190
- if (e.key === "ArrowDown" && !e.shiftKey && historyIndex >= 0) {
191
- e.preventDefault();
192
- const newIdx = historyIndex - 1;
193
- setHistoryIndex(newIdx);
194
- setText(newIdx === -1 ? "" : inputHistory?.[newIdx] ?? "");
195
- return;
196
- }
197
-
198
- // Slash command menu
199
- if (e.key === "/" && text === "") {
200
- setShowSlashMenu(true);
201
- if (onLoadSlashCommands) onLoadSlashCommands();
202
- }
203
- if (e.key === "Escape") {
204
- setShowSlashMenu(false);
205
- setShowModelMenu(false);
206
- setShowToolMenu(false);
207
- }
208
- }, [text, isStreaming, historyIndex, inputHistory, onSteer, handleSend, onLoadSlashCommands]);
209
-
210
- // ─── Image Attach ──────────────────────────────────────
211
-
212
- const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
213
- const files = Array.from(e.target.files ?? []);
214
- if (ref && typeof ref === "object") {
215
- ref.current?.addImages(files);
216
- }
217
- if (fileInputRef.current) fileInputRef.current.value = "";
218
- }, [ref]);
219
-
220
- const handlePaste = useCallback((e: React.ClipboardEvent) => {
221
- const items = e.clipboardData.items;
222
- const images: File[] = [];
223
- for (const item of items) {
224
- if (item.type.startsWith("image/")) {
225
- const file = item.getAsFile();
226
- if (file) images.push(file);
227
- }
228
- }
229
- if (images.length > 0) {
230
- e.preventDefault();
231
- if (ref && typeof ref === "object") {
232
- ref.current?.addImages(images);
233
- }
234
- }
235
- }, [ref]);
236
-
237
- const removeImage = useCallback((index: number) => {
238
- setAttachedImages((prev) => {
239
- const next = [...prev];
240
- const removed = next.splice(index, 1)[0];
241
- if (removed?.previewUrl.startsWith("blob:")) URL.revokeObjectURL(removed.previewUrl);
242
- return next;
243
- });
244
- }, []);
245
-
246
- // ─── Render ────────────────────────────────────────────
247
-
248
- const modelLabel = model
249
- ? modelNames?.[`${model.provider}:${model.modelId}`] ?? model.modelId
250
- : isAutoModelSelection
251
- ? t("chat.auto")
252
- : t("chat.no_model");
253
-
254
- return (
255
- <div style={{
256
- position: "relative",
257
- padding: "0 16px 12px 16px",
258
- }}>
259
- {/* Queue banner */}
260
- {queuedMessages && (queuedMessages.steering.length > 0 || queuedMessages.followUp.length > 0) && (
261
- <div style={{
262
- marginBottom: 8,
263
- padding: "6px 10px",
264
- borderRadius: 8,
265
- background: "var(--bg-panel)",
266
- border: "1px solid var(--border)",
267
- fontSize: 12,
268
- color: "var(--text-muted)",
269
- display: "flex",
270
- alignItems: "center",
271
- gap: 8,
272
- }}>
273
- <span>
274
- {t("chat.steering_queued", String(queuedMessages.steering.length), String(queuedMessages.followUp.length))}
275
- </span>
276
- {onRecallQueue && (
277
- <button
278
- onClick={onRecallQueue}
279
- style={{
280
- marginLeft: "auto",
281
- background: "none",
282
- border: "none",
283
- color: "var(--accent)",
284
- cursor: "pointer",
285
- fontSize: 12,
286
- }}
287
- >
288
- {t("chat.recall")}
289
- </button>
290
- )}
291
- </div>
292
- )}
293
-
294
- {/* Compact banner */}
295
- {isCompacting && (
296
- <div style={{
297
- marginBottom: 8,
298
- padding: "6px 10px",
299
- borderRadius: 8,
300
- background: "rgba(37,99,235,0.06)",
301
- border: "1px solid rgba(37,99,235,0.2)",
302
- fontSize: 12,
303
- color: "var(--accent)",
304
- display: "flex",
305
- alignItems: "center",
306
- gap: 8,
307
- }}>
308
- <svg className="animate-spin" width="12" height="12" viewBox="0 0 24 24" fill="none">
309
- <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" opacity="0.25" />
310
- <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
311
- </svg>
312
- {t("chat.compacting")}
313
- </div>
314
- )}
315
- {compactError && (
316
- <div style={{
317
- marginBottom: 8,
318
- padding: "6px 10px",
319
- borderRadius: 8,
320
- background: "rgba(239,68,68,0.06)",
321
- fontSize: 12,
322
- color: "#dc2626",
323
- }}>
324
- {t("chat.compaction_failed", compactError)}
325
- </div>
326
- )}
327
- {retryInfo && (
328
- <div style={{
329
- marginBottom: 8,
330
- padding: "6px 10px",
331
- borderRadius: 8,
332
- background: "rgba(234,179,8,0.06)",
333
- fontSize: 12,
334
- color: "#d97706",
335
- }}>
336
- {t("chat.retrying", String(retryInfo.attempt), String(retryInfo.maxAttempts), retryInfo.errorMessage ? ": " + retryInfo.errorMessage : "")}
337
- </div>
338
- )}
339
-
340
- {/* Slash command dropdown */}
341
- {showSlashMenu && slashCommands && slashCommands.length > 0 && (
342
- <div style={{
343
- position: "absolute",
344
- bottom: "100%",
345
- left: 16,
346
- right: 16,
347
- maxHeight: 200,
348
- overflow: "auto",
349
- background: "var(--bg-panel)",
350
- border: "1px solid var(--border)",
351
- borderRadius: 8,
352
- marginBottom: 4,
353
- zIndex: 10,
354
- boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
355
- }}>
356
- {slashCommandsLoading && (
357
- <div style={{ padding: "8px 12px", fontSize: 12, color: "var(--text-muted)" }}>{t("chat.loading")}</div>
358
- )}
359
- {slashCommands.map((cmd) => (
360
- <button
361
- key={cmd.name}
362
- onClick={() => {
363
- setText(`/${cmd.name} `);
364
- setShowSlashMenu(false);
365
- textareaRef.current?.focus();
366
- }}
367
- style={{
368
- display: "block",
369
- width: "100%",
370
- padding: "6px 12px",
371
- background: "none",
372
- border: "none",
373
- cursor: "pointer",
374
- textAlign: "left",
375
- fontSize: 13,
376
- color: "var(--text)",
377
- }}
378
- >
379
- <span style={{ color: "var(--accent)" }}>/{cmd.name}</span>
380
- {cmd.description && (
381
- <span style={{ color: "var(--text-muted)", marginLeft: 8, fontSize: 12 }}>{cmd.description}</span>
382
- )}
383
- </button>
384
- ))}
385
- </div>
386
- )}
387
-
388
- {/* Model selector dropdown */}
389
- {showModelMenu && (
390
- <div style={{
391
- position: "absolute",
392
- bottom: "100%",
393
- left: 16,
394
- right: 16,
395
- maxHeight: 320,
396
- display: "flex",
397
- flexDirection: "column",
398
- background: "var(--bg)",
399
- border: "1px solid var(--border)",
400
- borderRadius: 10,
401
- marginBottom: 6,
402
- zIndex: 20,
403
- minWidth: 280,
404
- boxShadow: "0 8px 24px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06)",
405
- }}>
406
- {/* Search header */}
407
- <div style={{
408
- padding: "10px 12px",
409
- borderBottom: "1px solid var(--border)",
410
- display: "flex",
411
- alignItems: "center",
412
- gap: 8,
413
- background: "var(--bg-panel)",
414
- borderRadius: "10px 10px 0 0",
415
- }}>
416
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
417
- <circle cx="11" cy="11" r="8" />
418
- <path d="m21 21-4.3-4.3" />
419
- </svg>
420
- <input
421
- ref={modelSearchInputRef}
422
- type="text"
423
- value={modelSearchQuery}
424
- onChange={(e) => setModelSearchQuery(e.target.value)}
425
- placeholder={t("chat.search_models")}
426
- style={{
427
- flex: 1,
428
- border: "none",
429
- outline: "none",
430
- background: "transparent",
431
- fontSize: 13,
432
- color: "var(--text)",
433
- padding: 0,
434
- }}
435
- onKeyDown={(e) => {
436
- if (e.key === "Escape") {
437
- setShowModelMenu(false);
438
- }
439
- }}
440
- />
441
- {modelSearchQuery && (
442
- <button
443
- onClick={() => setModelSearchQuery("")}
444
- style={{
445
- background: "none",
446
- border: "none",
447
- cursor: "pointer",
448
- padding: 2,
449
- display: "flex",
450
- alignItems: "center",
451
- justifyContent: "center",
452
- color: "var(--text-muted)",
453
- }}
454
- >
455
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
456
- <path d="M18 6 6 18" />
457
- <path d="m6 6 12 12" />
458
- </svg>
459
- </button>
460
- )}
461
- </div>
462
-
463
- {/* Model list */}
464
- <div style={{
465
- overflow: "auto",
466
- maxHeight: 260,
467
- }}>
468
- {(() => {
469
- // Build display list: always include current model if set, then add other models
470
- const displayList: ModelEntry[] = [];
471
- const existingKeys = new Set<string>();
472
-
473
- // Add current model first if it exists
474
- if (model) {
475
- const currentModelEntry: ModelEntry = {
476
- id: model.modelId,
477
- name: modelNames?.[`${model.provider}:${model.modelId}`] ?? model.modelId,
478
- provider: model.provider,
479
- };
480
- displayList.push(currentModelEntry);
481
- existingKeys.add(`${model.provider}:${model.modelId}`);
482
- }
483
-
484
- // Add other models from modelList
485
- if (modelList && modelList.length > 0) {
486
- for (const m of modelList) {
487
- const key = `${m.provider}:${m.id}`;
488
- if (!existingKeys.has(key)) {
489
- displayList.push(m);
490
- existingKeys.add(key);
491
- }
492
- }
493
- }
494
-
495
- // Filter by search query
496
- const filteredList = modelSearchQuery.trim()
497
- ? displayList.filter((m) =>
498
- m.name.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
499
- m.provider.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
500
- m.id.toLowerCase().includes(modelSearchQuery.toLowerCase())
501
- )
502
- : displayList;
503
-
504
- // If nothing to show
505
- if (filteredList.length === 0) {
506
- return (
507
- <div style={{ padding: "16px", fontSize: 13, color: "var(--text-muted)", textAlign: "center" }}>
508
- {modelSearchQuery
509
- ? t("chat.no_models_found")
510
- : (modelError ?? t("chat.no_models_available"))}
511
- </div>
512
- );
513
- }
514
-
515
- return filteredList.map((m) => (
516
- <button
517
- key={`${m.provider}:${m.id}`}
518
- onClick={() => {
519
- onModelChange(m.provider, m.id);
520
- setShowModelMenu(false);
521
- setModelSearchQuery("");
522
- }}
523
- onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
524
- onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}
525
- style={{
526
- display: "flex",
527
- alignItems: "center",
528
- gap: 8,
529
- width: "100%",
530
- padding: "8px 12px",
531
- background: model?.provider === m.provider && model?.modelId === m.id ? "var(--bg-selected)" : "none",
532
- border: "none",
533
- cursor: "pointer",
534
- textAlign: "left",
535
- fontSize: 13,
536
- color: "var(--text)",
537
- transition: "background 0.1s",
538
- }}
539
- >
540
- <span style={{ fontWeight: 500 }}>{m.name}</span>
541
- <span style={{ fontSize: 11, color: "var(--text-muted)", marginLeft: "auto" }}>{m.provider}</span>
542
- {model?.provider === m.provider && model?.modelId === m.id && (
543
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
544
- <polyline points="20 6 9 17 4 12" />
545
- </svg>
546
- )}
547
- </button>
548
- ));
549
- })()}
550
- </div>
551
-
552
- {/* Footer with count */}
553
- <div style={{
554
- padding: "6px 12px",
555
- borderTop: "1px solid var(--border)",
556
- fontSize: 11,
557
- color: "var(--text-muted)",
558
- textAlign: "center",
559
- background: "var(--bg-panel)",
560
- borderRadius: "0 0 10px 10px",
561
- }}>
562
- {(() => {
563
- const totalCount = (model ? 1 : 0) + (modelList?.length ?? 0);
564
- const query = modelSearchQuery.trim();
565
- if (query) {
566
- const filteredCount = (modelList ?? []).filter((m: ModelEntry) =>
567
- m.name.toLowerCase().includes(query.toLowerCase()) ||
568
- m.provider.toLowerCase().includes(query.toLowerCase()) ||
569
- m.id.toLowerCase().includes(query.toLowerCase())
570
- ).length + (model && (
571
- (modelNames?.[`${model.provider}:${model.modelId}`] ?? model.modelId).toLowerCase().includes(query.toLowerCase()) ||
572
- model.provider.toLowerCase().includes(query.toLowerCase()) ||
573
- model.modelId.toLowerCase().includes(query.toLowerCase())
574
- ) ? 1 : 0);
575
- return t("chat.models_filtered", String(filteredCount), String(totalCount));
576
- }
577
- return t("chat.models_count", String(totalCount));
578
- })()}
579
- </div>
580
- </div>
581
- )}
582
-
583
- {/* Tool preset dropdown */}
584
- {showToolMenu && (
585
- <div style={{
586
- position: "absolute",
587
- bottom: "100%",
588
- left: 100,
589
- background: "var(--bg-panel)",
590
- border: "1px solid var(--border)",
591
- borderRadius: 8,
592
- marginBottom: 4,
593
- zIndex: 10,
594
- minWidth: 160,
595
- boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
596
- }}>
597
- {(["default", "full", "none"] as const).map((preset) => (
598
- <button
599
- key={preset}
600
- onClick={() => {
601
- onToolPresetChange(preset);
602
- setShowToolMenu(false);
603
- }}
604
- style={{
605
- display: "block",
606
- width: "100%",
607
- padding: "8px 12px",
608
- background: "none",
609
- border: "none",
610
- cursor: "pointer",
611
- textAlign: "left",
612
- fontSize: 13,
613
- color: "var(--text)",
614
- }}
615
- >
616
- {preset === "default" ? t("chat.default_tools") : preset === "full" ? t("chat.all_tools") : t("chat.no_tools")}
617
- {toolPreset === preset && <span style={{ marginLeft: 8, color: "var(--accent)" }}>✓</span>}
618
- </button>
619
- ))}
620
- </div>
621
- )}
622
-
623
- {/* Attached images preview */}
624
- {attachedImages.length > 0 && (
625
- <div style={{
626
- display: "flex",
627
- gap: 6,
628
- marginBottom: 8,
629
- flexWrap: "wrap",
630
- }}>
631
- {attachedImages.map((img, i) => (
632
- <div key={i} style={{ position: "relative" }}>
633
- <img
634
- src={img.previewUrl}
635
- alt="attachment"
636
- style={{ width: 60, height: 60, objectFit: "cover", borderRadius: 6, border: "1px solid var(--border)" }}
637
- />
638
- <button
639
- onClick={() => removeImage(i)}
640
- style={{
641
- position: "absolute",
642
- top: -4,
643
- right: -4,
644
- width: 18,
645
- height: 18,
646
- borderRadius: "50%",
647
- background: "var(--text)",
648
- color: "var(--bg)",
649
- border: "none",
650
- cursor: "pointer",
651
- display: "flex",
652
- alignItems: "center",
653
- justifyContent: "center",
654
- fontSize: 11,
655
- }}
656
- >
657
- ×
658
- </button>
659
- </div>
660
- ))}
661
- </div>
662
- )}
663
-
664
- {/* Input bar */}
665
- <div style={{
666
- display: "flex",
667
- alignItems: "flex-end",
668
- gap: 0,
669
- borderRadius: 12,
670
- border: "1px solid var(--border)",
671
- background: "var(--bg)",
672
- boxShadow: "0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)",
673
- overflow: "hidden",
674
- transition: "border-color 0.15s ease, box-shadow 0.15s ease",
675
- }}>
676
- {/* Model selector button */}
677
- <button
678
- onClick={() => { setShowModelMenu(!showModelMenu); setShowToolMenu(false); setShowSlashMenu(false); }}
679
- title={modelError ?? (modelList && modelList.length > 0 ? undefined : t("chat.no_models_available"))}
680
- style={{
681
- display: "flex",
682
- alignItems: "center",
683
- gap: 4,
684
- minHeight: TEXTAREA_MIN_HEIGHT,
685
- height: "auto",
686
- padding: "0 10px",
687
- background: "none",
688
- border: "none",
689
- borderRight: "1px solid var(--border)",
690
- color: model ? "var(--text)" : "var(--text-muted)",
691
- cursor: "pointer",
692
- fontSize: 12,
693
- whiteSpace: "nowrap",
694
- flexShrink: 0,
695
- opacity: modelList && modelList.length === 0 && !model ? 0.5 : 1,
696
- }}
697
- >
698
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
699
- <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
700
- <path d="M19 3v4" />
701
- <path d="M21 5h-4" />
702
- </svg>
703
- <span style={{ maxWidth: 140, overflow: "hidden", textOverflow: "ellipsis", fontWeight: model ? 500 : 400 }}>{modelLabel}</span>
704
- {(modelList && modelList.length > 0) && (
705
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: -2, opacity: 0.6 }}>
706
- <polyline points="6 9 12 15 18 9" />
707
- </svg>
708
- )}
709
- </button>
710
-
711
- {/* Tool preset button */}
712
- <button
713
- onClick={() => { setShowToolMenu(!showToolMenu); setShowModelMenu(false); setShowSlashMenu(false); }}
714
- title={t("chat.tool_preset")}
715
- style={{
716
- display: "flex",
717
- alignItems: "center",
718
- justifyContent: "center",
719
- minHeight: TEXTAREA_MIN_HEIGHT,
720
- height: "auto",
721
- width: 36,
722
- background: "none",
723
- border: "none",
724
- borderRight: "1px solid var(--border)",
725
- color: toolPreset === "none" ? "var(--text-dim)" : "var(--text-muted)",
726
- cursor: "pointer",
727
- flexShrink: 0,
728
- }}
729
- >
730
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
731
- <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
732
- </svg>
733
- </button>
734
-
735
- {/* Compact button */}
736
- {onCompact && (
737
- <button
738
- onClick={onCompact}
739
- disabled={isCompacting}
740
- title={t("chat.compact_context")}
741
- style={{
742
- display: "flex",
743
- alignItems: "center",
744
- justifyContent: "center",
745
- minHeight: TEXTAREA_MIN_HEIGHT,
746
- height: "auto",
747
- width: 36,
748
- background: "none",
749
- border: "none",
750
- borderRight: "1px solid var(--border)",
751
- color: isCompacting ? "var(--accent)" : "var(--text-dim)",
752
- cursor: isCompacting ? "wait" : "pointer",
753
- flexShrink: 0,
754
- }}
755
- >
756
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
757
- <path d="M21 12a9 9 0 1 1-3-6.7L21 8" />
758
- <path d="M21 3v5h-5" />
759
- </svg>
760
- </button>
761
- )}
762
-
763
- {/* Textarea */}
764
- <textarea
765
- ref={textareaRef}
766
- value={text}
767
- onChange={(e) => setText(e.target.value)}
768
- onKeyDown={handleKeyDown}
769
- onPaste={handlePaste}
770
- placeholder={isStreaming ? t("chat.type_to_steer") : t("chat.send_message")}
771
- rows={1}
772
- style={{
773
- flex: 1,
774
- minHeight: TEXTAREA_MIN_HEIGHT,
775
- maxHeight: TEXTAREA_MAX_HEIGHT,
776
- padding: "10px 12px",
777
- border: "none",
778
- outline: "none",
779
- background: "transparent",
780
- color: "var(--text)",
781
- fontSize: 14,
782
- lineHeight: 1.5,
783
- resize: "none",
784
- fontFamily: "inherit",
785
- boxSizing: "border-box",
786
- }}
787
- />
788
-
789
- {/* Image attach button */}
790
- <button
791
- onClick={() => fileInputRef.current?.click()}
792
- title={t("chat.attach_image")}
793
- style={{
794
- display: "flex",
795
- alignItems: "center",
796
- justifyContent: "center",
797
- minHeight: TEXTAREA_MIN_HEIGHT,
798
- height: "auto",
799
- width: 36,
800
- background: "none",
801
- border: "none",
802
- color: "var(--text-dim)",
803
- cursor: "pointer",
804
- flexShrink: 0,
805
- }}
806
- >
807
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
808
- <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
809
- </svg>
810
- </button>
811
- <input
812
- ref={fileInputRef}
813
- type="file"
814
- accept="image/*"
815
- multiple
816
- onChange={handleFileSelect}
817
- style={{ display: "none" }}
818
- />
819
-
820
- {/* Send/Abort button */}
821
- <button
822
- onClick={isStreaming ? onAbort : handleSend}
823
- disabled={!isStreaming && !text.trim() && !attachedImages.length}
824
- title={isStreaming ? t("chat.stop") : t("chat.send")}
825
- style={{
826
- display: "flex",
827
- alignItems: "center",
828
- justifyContent: "center",
829
- minHeight: TEXTAREA_MIN_HEIGHT,
830
- height: "auto",
831
- width: 44,
832
- background: isStreaming
833
- ? "rgba(239,68,68,0.1)"
834
- : (text.trim() || attachedImages.length) ? "var(--accent)" : "transparent",
835
- border: "none",
836
- borderLeft: "1px solid var(--border)",
837
- color: isStreaming
838
- ? "#dc2626"
839
- : (text.trim() || attachedImages.length) ? "#fff" : "var(--text-dim)",
840
- cursor: isStreaming || (text.trim() || attachedImages.length) ? "pointer" : "default",
841
- flexShrink: 0,
842
- borderRadius: "0 11px 11px 0",
843
- }}
844
- >
845
- {isStreaming ? (
846
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
847
- <rect x="6" y="6" width="12" height="12" rx="2" />
848
- </svg>
849
- ) : (
850
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
851
- <line x1="12" y1="19" x2="12" y2="5" />
852
- <polyline points="5 12 12 5 19 12" />
853
- </svg>
854
- )}
855
- </button>
856
- </div>
857
-
858
- {modelError && (
859
- <div style={{ marginTop: 4, fontSize: 11, color: "#d97706" }}>{modelError}</div>
860
- )}
861
- </div>
862
- );
863
- });