@widgetic/chat 0.1.4
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/README.md +440 -0
- package/dist/adapters/index.d.ts +2 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/widgeticAdapter.d.ts +185 -0
- package/dist/adapters/widgeticAdapter.js +766 -0
- package/dist/components/ActionBar.svelte +342 -0
- package/dist/components/ActionBar.svelte.d.ts +37 -0
- package/dist/components/AttachmentDisplay.svelte +547 -0
- package/dist/components/AttachmentDisplay.svelte.d.ts +12 -0
- package/dist/components/Chat.svelte +1253 -0
- package/dist/components/Chat.svelte.d.ts +112 -0
- package/dist/components/ChatHeader.svelte +182 -0
- package/dist/components/ChatHeader.svelte.d.ts +12 -0
- package/dist/components/ChatInput.svelte +290 -0
- package/dist/components/ChatInput.svelte.d.ts +15 -0
- package/dist/components/ChatMessages.svelte +996 -0
- package/dist/components/ChatMessages.svelte.d.ts +28 -0
- package/dist/components/CodeBlock.svelte +286 -0
- package/dist/components/CodeBlock.svelte.d.ts +10 -0
- package/dist/components/ContextPreview.svelte +66 -0
- package/dist/components/ContextPreview.svelte.d.ts +8 -0
- package/dist/components/LoadingIndicator.svelte +151 -0
- package/dist/components/LoadingIndicator.svelte.d.ts +9 -0
- package/dist/components/StatusIndicator.svelte +116 -0
- package/dist/components/StatusIndicator.svelte.d.ts +9 -0
- package/dist/components/SuggestionButtons.svelte +244 -0
- package/dist/components/SuggestionButtons.svelte.d.ts +26 -0
- package/dist/components/index.d.ts +12 -0
- package/dist/components/index.js +12 -0
- package/dist/config.d.ts +43 -0
- package/dist/config.js +67 -0
- package/dist/constants/colors.d.ts +20 -0
- package/dist/constants/colors.js +16 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +20 -0
- package/dist/services/chatService.d.ts +72 -0
- package/dist/services/chatService.js +355 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/stores/chatStore.d.ts +46 -0
- package/dist/stores/chatStore.js +219 -0
- package/dist/stores/index.d.ts +2 -0
- package/dist/stores/index.js +2 -0
- package/dist/types/adapter.d.ts +86 -0
- package/dist/types/adapter.js +1 -0
- package/dist/types/api-temp.d.ts +61 -0
- package/dist/types/api-temp.js +42 -0
- package/dist/types/attachment.d.ts +84 -0
- package/dist/types/attachment.js +33 -0
- package/dist/types/chat.d.ts +114 -0
- package/dist/types/chat.js +1 -0
- package/dist/types/config.d.ts +89 -0
- package/dist/types/config.js +63 -0
- package/dist/types/context.d.ts +108 -0
- package/dist/types/context.js +11 -0
- package/dist/types/conversation.d.ts +45 -0
- package/dist/types/conversation.js +1 -0
- package/dist/types/events.d.ts +141 -0
- package/dist/types/events.js +1 -0
- package/dist/types/index.d.ts +16 -0
- package/dist/types/index.js +10 -0
- package/dist/types/message.d.ts +82 -0
- package/dist/types/message.js +1 -0
- package/dist/types/state.d.ts +117 -0
- package/dist/types/state.js +1 -0
- package/dist/utils/fileUtils.d.ts +93 -0
- package/dist/utils/fileUtils.js +299 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.js +4 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.js +28 -0
- package/package.json +104 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ChatConfig, ChatContext } from '../types/index.js';
|
|
2
|
+
interface ChatMethods {
|
|
3
|
+
sendMessage: (content: string, attachments?: File[]) => Promise<void>;
|
|
4
|
+
addAssistantMessage: (content: string, skipPersist?: boolean) => void;
|
|
5
|
+
addUserMessage: (content: string, attachments?: Array<{
|
|
6
|
+
id: string;
|
|
7
|
+
url: string;
|
|
8
|
+
file_type: string;
|
|
9
|
+
file_name: string;
|
|
10
|
+
file_size?: number;
|
|
11
|
+
}>) => void;
|
|
12
|
+
/** Returns the current conversation ID (null if no conversation is active). */
|
|
13
|
+
getConversationId: () => string | null;
|
|
14
|
+
/** Add an image attachment (data URL) to the input area for preview before sending. */
|
|
15
|
+
addImageAttachment: (dataUrl: string, fileName?: string) => void;
|
|
16
|
+
/** Get unsent draft text from the chat input. */
|
|
17
|
+
getDraftText: () => string;
|
|
18
|
+
/** Restore unsent draft text into the chat input. */
|
|
19
|
+
setDraftText: (text: string) => void;
|
|
20
|
+
/** Get attachment preview data URLs in current order. */
|
|
21
|
+
getAttachmentDataUrls: () => string[];
|
|
22
|
+
/** Get CDN upload row IDs for image attachments (codegen attachmentIds). */
|
|
23
|
+
getAttachmentUploadIds: () => string[];
|
|
24
|
+
/** Replace chat attachments from stored data URLs (restores draft attachments). */
|
|
25
|
+
restoreAttachmentsFromDataUrls: (dataUrls: string[]) => void;
|
|
26
|
+
/** Clear draft text and pending attachments. */
|
|
27
|
+
clearDraft: () => void;
|
|
28
|
+
/** Remove stale "Preview build failed" suffix from the latest assistant summary after a successful rebuild. */
|
|
29
|
+
clearStalePreviewBuildFailures: () => void;
|
|
30
|
+
/** Mark the latest assistant message that contains a commit as widget HEAD. */
|
|
31
|
+
markWidgetHeadAtLatestCommit: () => void;
|
|
32
|
+
/** Update text on the most recent codegen assistant status message. */
|
|
33
|
+
updateLatestCodegenStatusMessage: (content: string) => void;
|
|
34
|
+
/** Reload messages from backend and refresh widget HEAD marker. */
|
|
35
|
+
reloadConversationMessages: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
interface Props {
|
|
38
|
+
context: ChatContext;
|
|
39
|
+
config?: Partial<ChatConfig>;
|
|
40
|
+
class?: string;
|
|
41
|
+
/** Inline style passed to the root chat container (e.g. `height: 600px`). */
|
|
42
|
+
style?: string;
|
|
43
|
+
isCompleted?: boolean;
|
|
44
|
+
onStatsUpdate?: (messageCount: number, firstMessage: string | null) => void;
|
|
45
|
+
onChatReady?: (chatMethods: ChatMethods) => void;
|
|
46
|
+
onCompletionToggle?: () => void;
|
|
47
|
+
/** When provided, called instead of the built-in mock service for sending messages.
|
|
48
|
+
* The parent handles the actual API call and can push assistant messages via addAssistantMessage. */
|
|
49
|
+
onSendMessage?: (content: string, attachments?: Array<{
|
|
50
|
+
file?: File;
|
|
51
|
+
url?: string;
|
|
52
|
+
uploadId?: string;
|
|
53
|
+
fileType: string;
|
|
54
|
+
fileName: string;
|
|
55
|
+
fileSize?: number;
|
|
56
|
+
}>) => Promise<void>;
|
|
57
|
+
/** Backend integration callbacks — when provided, replace mock chatService methods */
|
|
58
|
+
onLoadConversations?: (contextId: string) => Promise<any[] | null>;
|
|
59
|
+
onLoadMessages?: (conversationId: string) => Promise<any[]>;
|
|
60
|
+
onCreateConversation?: (contextId: string, title: string) => Promise<any>;
|
|
61
|
+
onSaveMessage?: (conversationId: string, content: string, messageType: string, attachments?: Array<{
|
|
62
|
+
url?: string;
|
|
63
|
+
file_type?: string;
|
|
64
|
+
file_name?: string | null;
|
|
65
|
+
file_size?: number | null;
|
|
66
|
+
display_order?: number;
|
|
67
|
+
}>) => Promise<any>;
|
|
68
|
+
onRestoreCheckpointBackend?: (conversationId: string, checkpointMessageId: string) => Promise<any>;
|
|
69
|
+
onTakeScreenshot?: () => Promise<string | null>;
|
|
70
|
+
/** Fires whenever the active conversation ID changes (including initial null → uuid). */
|
|
71
|
+
onConversationChange?: (conversationId: string | null) => void;
|
|
72
|
+
/** Fires when unsent draft text or pending attachments change (for parent localStorage sync). */
|
|
73
|
+
onDraftChange?: () => void;
|
|
74
|
+
/**
|
|
75
|
+
* When provided, Retry on a failed codegen turn calls this instead of sendMessage,
|
|
76
|
+
* so the host can re-run codegen without duplicating the user bubble.
|
|
77
|
+
*/
|
|
78
|
+
onRetryMessage?: (content: string) => Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* When provided, attachments are uploaded immediately and stored as CDN URLs
|
|
81
|
+
* instead of large data URLs in chat history. Host should use TUS for files >10MB.
|
|
82
|
+
*/
|
|
83
|
+
onUploadChatFile?: (file: File, options?: {
|
|
84
|
+
onProgress?: (percentage: number) => void;
|
|
85
|
+
source?: string;
|
|
86
|
+
}) => Promise<{
|
|
87
|
+
id: string;
|
|
88
|
+
url: string;
|
|
89
|
+
fileType: string;
|
|
90
|
+
fileName: string;
|
|
91
|
+
fileSize: number;
|
|
92
|
+
}>;
|
|
93
|
+
showHeader?: boolean;
|
|
94
|
+
/** Custom title displayed in the header. If empty/undefined, defaults to context.title or context.id. */
|
|
95
|
+
headerTitle?: string;
|
|
96
|
+
/** When false, hides the icon + type description row below the title, making the header compact. */
|
|
97
|
+
showDescriptionHeader?: boolean;
|
|
98
|
+
/** When false, suppresses all console.log/warn output from @widgetic/chat. Errors still show. */
|
|
99
|
+
showLogs?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* When true, dragging the resize handle between messages and the action bar
|
|
102
|
+
* emits an `onMessagesResize` delta so the parent can grow the chat container
|
|
103
|
+
* itself (not just the internal messages area). When false (default), only the
|
|
104
|
+
* internal messages area grows/shrinks within the current chat height.
|
|
105
|
+
*/
|
|
106
|
+
resizeExpandsContainer?: boolean;
|
|
107
|
+
/** Delta in pixels for the current drag frame (positive = grew downward). */
|
|
108
|
+
onMessagesResize?: (deltaPx: number) => void;
|
|
109
|
+
}
|
|
110
|
+
declare const Chat: import("svelte").Component<Props, {}, "">;
|
|
111
|
+
type Chat = ReturnType<typeof Chat>;
|
|
112
|
+
export default Chat;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ChatContext, ChatConversation } from '../types/index.js';
|
|
3
|
+
|
|
4
|
+
// Props
|
|
5
|
+
interface Props {
|
|
6
|
+
context: ChatContext;
|
|
7
|
+
conversations: ChatConversation[];
|
|
8
|
+
currentConversationId: string | null;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
onNewConversation: () => void;
|
|
11
|
+
onSwitchConversation: (conversationId: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
context,
|
|
16
|
+
conversations,
|
|
17
|
+
currentConversationId,
|
|
18
|
+
onClose,
|
|
19
|
+
onNewConversation,
|
|
20
|
+
onSwitchConversation
|
|
21
|
+
}: Props = $props();
|
|
22
|
+
|
|
23
|
+
// Local state
|
|
24
|
+
let showConversationMenu = $state(false);
|
|
25
|
+
|
|
26
|
+
function handleConversationSelect(conversationId: string) {
|
|
27
|
+
showConversationMenu = false;
|
|
28
|
+
onSwitchConversation(conversationId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatContextType(type: string): string {
|
|
32
|
+
switch (type) {
|
|
33
|
+
case 'frame': return '🖼️ Frame';
|
|
34
|
+
case 'text': return '📄 Document';
|
|
35
|
+
case 'canvas': return '🎨 Canvas';
|
|
36
|
+
default: return '💬 Chat';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function truncateTitle(title: string, maxLength: number = 30): string {
|
|
41
|
+
return title.length > maxLength ? title.substring(0, maxLength) + '...' : title;
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<header class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-600 px-4 py-3 flex-shrink-0">
|
|
46
|
+
<div class="flex items-center justify-between">
|
|
47
|
+
<!-- Context Info -->
|
|
48
|
+
<div class="flex items-center gap-3 flex-1 min-w-0">
|
|
49
|
+
<div class="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-lg">
|
|
50
|
+
{#if context.type === 'frame'}
|
|
51
|
+
📱
|
|
52
|
+
{:else if context.type === 'text'}
|
|
53
|
+
📄
|
|
54
|
+
{:else if context.type === 'image'}
|
|
55
|
+
🖼️
|
|
56
|
+
{:else if context.type === 'document'}
|
|
57
|
+
📋
|
|
58
|
+
{:else}
|
|
59
|
+
💬
|
|
60
|
+
{/if}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="min-w-0 flex-1">
|
|
64
|
+
<h3 class="font-semibold text-gray-900 dark:text-gray-100 text-sm truncate">
|
|
65
|
+
{context.title || context.id}
|
|
66
|
+
</h3>
|
|
67
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
68
|
+
{context.type} chat
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- Header Actions -->
|
|
74
|
+
<div class="flex items-center gap-2">
|
|
75
|
+
<!-- Conversation selector/New conversation -->
|
|
76
|
+
{#if conversations && conversations.length > 1}
|
|
77
|
+
<div class="relative">
|
|
78
|
+
<button
|
|
79
|
+
class="w-8 h-8 rounded-md flex items-center justify-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
80
|
+
onclick={() => showConversationMenu = !showConversationMenu}
|
|
81
|
+
title="Switch conversation"
|
|
82
|
+
aria-label="Switch conversation"
|
|
83
|
+
>
|
|
84
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
|
85
|
+
<circle cx="12" cy="12" r="1"/>
|
|
86
|
+
<circle cx="19" cy="12" r="1"/>
|
|
87
|
+
<circle cx="5" cy="12" r="1"/>
|
|
88
|
+
</svg>
|
|
89
|
+
</button>
|
|
90
|
+
|
|
91
|
+
<!-- Conversation Menu -->
|
|
92
|
+
{#if showConversationMenu}
|
|
93
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
94
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
95
|
+
<div
|
|
96
|
+
class="fixed inset-0 z-40"
|
|
97
|
+
onclick={() => showConversationMenu = false}
|
|
98
|
+
></div>
|
|
99
|
+
|
|
100
|
+
<div class="absolute top-full right-0 mt-1 w-64 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg z-50 max-h-80 overflow-hidden">
|
|
101
|
+
<!-- Menu Header -->
|
|
102
|
+
<div class="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-600">
|
|
103
|
+
<span class="font-medium text-gray-900 dark:text-gray-100 text-sm">Conversations</span>
|
|
104
|
+
<button
|
|
105
|
+
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded transition-colors"
|
|
106
|
+
onclick={onNewConversation}
|
|
107
|
+
>
|
|
108
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
|
|
109
|
+
<path d="M12 5v14"/>
|
|
110
|
+
<path d="M5 12h14"/>
|
|
111
|
+
</svg>
|
|
112
|
+
New
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<!-- Conversation List -->
|
|
117
|
+
<div class="max-h-60 overflow-y-auto">
|
|
118
|
+
{#each conversations as conversation (conversation.id)}
|
|
119
|
+
<button
|
|
120
|
+
class="w-full flex items-center justify-between p-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-600 last:border-b-0 transition-colors"
|
|
121
|
+
class:active={conversation.id === currentConversationId}
|
|
122
|
+
onclick={() => {
|
|
123
|
+
onSwitchConversation(conversation.id);
|
|
124
|
+
showConversationMenu = false;
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<span class="font-medium text-sm truncate text-gray-900 dark:text-gray-100">{conversation.title || 'Untitled'}</span>
|
|
128
|
+
<div class="flex items-center gap-2">
|
|
129
|
+
{#if conversation._unreadCount && conversation._unreadCount > 0}
|
|
130
|
+
<span class="bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full min-w-[1.25rem] h-5 flex items-center justify-center">
|
|
131
|
+
{conversation._unreadCount}
|
|
132
|
+
</span>
|
|
133
|
+
{/if}
|
|
134
|
+
</div>
|
|
135
|
+
</button>
|
|
136
|
+
{/each}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
{/if}
|
|
140
|
+
</div>
|
|
141
|
+
{:else}
|
|
142
|
+
<!-- New conversation button when only one conversation -->
|
|
143
|
+
<button
|
|
144
|
+
class="w-8 h-8 rounded-md flex items-center justify-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
145
|
+
onclick={onNewConversation}
|
|
146
|
+
title="New conversation"
|
|
147
|
+
aria-label="Start new conversation"
|
|
148
|
+
>
|
|
149
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
|
150
|
+
<path d="M12 5v14"/>
|
|
151
|
+
<path d="M5 12h14"/>
|
|
152
|
+
</svg>
|
|
153
|
+
</button>
|
|
154
|
+
{/if}
|
|
155
|
+
|
|
156
|
+
<!-- Close button -->
|
|
157
|
+
<button
|
|
158
|
+
class="w-8 h-8 rounded-md flex items-center justify-center text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900 transition-colors"
|
|
159
|
+
onclick={onClose}
|
|
160
|
+
title="Close chat"
|
|
161
|
+
aria-label="Close chat"
|
|
162
|
+
>
|
|
163
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
|
164
|
+
<path d="M18 6L6 18"/>
|
|
165
|
+
<path d="M6 6l12 12"/>
|
|
166
|
+
</svg>
|
|
167
|
+
</button>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</header>
|
|
171
|
+
|
|
172
|
+
<style>
|
|
173
|
+
.active {
|
|
174
|
+
background-color: rgb(239 246 255);
|
|
175
|
+
color: rgb(30 58 138);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
:global(.dark) .active {
|
|
179
|
+
background-color: rgb(30 58 138);
|
|
180
|
+
color: rgb(191 219 254);
|
|
181
|
+
}
|
|
182
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ChatContext, ChatConversation } from '../types/index.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
context: ChatContext;
|
|
4
|
+
conversations: ChatConversation[];
|
|
5
|
+
currentConversationId: string | null;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
onNewConversation: () => void;
|
|
8
|
+
onSwitchConversation: (conversationId: string) => void;
|
|
9
|
+
}
|
|
10
|
+
declare const ChatHeader: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type ChatHeader = ReturnType<typeof ChatHeader>;
|
|
12
|
+
export default ChatHeader;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { chatLog } from '../utils/logger.js';
|
|
4
|
+
import type { AttachmentInput } from '../types/attachment.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
value: string;
|
|
8
|
+
attachments: AttachmentInput[];
|
|
9
|
+
isRecording: boolean;
|
|
10
|
+
isSending: boolean;
|
|
11
|
+
isUploading: boolean;
|
|
12
|
+
onSend: (content: string, attachments?: AttachmentInput[]) => void;
|
|
13
|
+
onInputChange: (text: string) => void;
|
|
14
|
+
onAttachmentsChange: (attachments: AttachmentInput[]) => void;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
value,
|
|
20
|
+
attachments,
|
|
21
|
+
isRecording,
|
|
22
|
+
isSending,
|
|
23
|
+
isUploading,
|
|
24
|
+
onSend,
|
|
25
|
+
onInputChange,
|
|
26
|
+
onAttachmentsChange,
|
|
27
|
+
placeholder = "Type your message..."
|
|
28
|
+
}: Props = $props();
|
|
29
|
+
|
|
30
|
+
let textArea: HTMLTextAreaElement;
|
|
31
|
+
|
|
32
|
+
onMount(() => {
|
|
33
|
+
chatLog('ChatInput mounted for value:', value);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
onDestroy(() => {
|
|
37
|
+
chatLog('ChatInput destroyed for value:', value);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function handleSubmit() {
|
|
41
|
+
if (!value.trim() && attachments.length === 0) return;
|
|
42
|
+
if (isSending) return;
|
|
43
|
+
|
|
44
|
+
onSend(value.trim(), attachments.length > 0 ? attachments : undefined);
|
|
45
|
+
|
|
46
|
+
// Keep focus on textarea after sending
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
if (textArea) {
|
|
49
|
+
textArea.focus();
|
|
50
|
+
}
|
|
51
|
+
}, 100);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
55
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
handleSubmit();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function removeAttachment(index: number) {
|
|
62
|
+
const newAttachments = attachments.filter((_, i) => i !== index);
|
|
63
|
+
onAttachmentsChange(newAttachments);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let draggingAttachmentIndex: number | null = $state(null);
|
|
67
|
+
|
|
68
|
+
function handleAttachmentDragStart(index: number, e: DragEvent) {
|
|
69
|
+
draggingAttachmentIndex = index;
|
|
70
|
+
if (e.dataTransfer) {
|
|
71
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
72
|
+
e.dataTransfer.setData('text/plain', String(index));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function handleAttachmentDragOver(e: DragEvent) {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function handleAttachmentDrop(targetIndex: number, e: DragEvent) {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
if (draggingAttachmentIndex === null || draggingAttachmentIndex === targetIndex) {
|
|
84
|
+
draggingAttachmentIndex = null;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const reordered = [...attachments];
|
|
88
|
+
const [moved] = reordered.splice(draggingAttachmentIndex, 1);
|
|
89
|
+
reordered.splice(targetIndex, 0, moved);
|
|
90
|
+
onAttachmentsChange(reordered);
|
|
91
|
+
draggingAttachmentIndex = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handleAttachmentDragEnd() {
|
|
95
|
+
draggingAttachmentIndex = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let lightboxSrc: string | null = $state(null);
|
|
99
|
+
|
|
100
|
+
function openLightbox(attachment: AttachmentInput) {
|
|
101
|
+
const src = attachment.preview || URL.createObjectURL(attachment.file);
|
|
102
|
+
lightboxSrc = src;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Auto-resize textarea up to max-height, then scroll
|
|
106
|
+
$effect(() => {
|
|
107
|
+
if (textArea) {
|
|
108
|
+
textArea.style.height = 'auto';
|
|
109
|
+
const maxH = 120;
|
|
110
|
+
textArea.style.height = Math.min(textArea.scrollHeight, maxH) + 'px';
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Update textarea value when prop changes (for clearing)
|
|
115
|
+
$effect(() => {
|
|
116
|
+
if (textArea && textArea.value !== value) {
|
|
117
|
+
chatLog('ChatInput: Updating textarea value from', textArea.value, 'to', value);
|
|
118
|
+
textArea.value = value;
|
|
119
|
+
// Re-trigger auto-resize after value change
|
|
120
|
+
textArea.style.height = 'auto';
|
|
121
|
+
const maxH = 120;
|
|
122
|
+
textArea.style.height = Math.min(textArea.scrollHeight, maxH) + 'px';
|
|
123
|
+
|
|
124
|
+
// If value was cleared (after sending), refocus
|
|
125
|
+
if (value === '' && document.activeElement !== textArea) {
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
if (textArea) {
|
|
128
|
+
textArea.focus();
|
|
129
|
+
}
|
|
130
|
+
}, 50);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Debug value changes
|
|
136
|
+
$effect(() => {
|
|
137
|
+
chatLog('ChatInput: Value prop changed to:', value);
|
|
138
|
+
});
|
|
139
|
+
</script>
|
|
140
|
+
|
|
141
|
+
<div class="chat-input-ct border-t border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800">
|
|
142
|
+
<!-- Attachments Preview -->
|
|
143
|
+
{#if attachments.length > 0}
|
|
144
|
+
<div class="attachments-preview-ct flex flex-wrap gap-2 p-3 border-b border-gray-100 dark:border-gray-700">
|
|
145
|
+
{#each attachments as attachment, index}
|
|
146
|
+
<div
|
|
147
|
+
class="attachment-preview-item-ct relative bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden"
|
|
148
|
+
draggable="true"
|
|
149
|
+
ondragstart={(e) => handleAttachmentDragStart(index, e)}
|
|
150
|
+
ondragover={handleAttachmentDragOver}
|
|
151
|
+
ondrop={(e) => handleAttachmentDrop(index, e)}
|
|
152
|
+
ondragend={handleAttachmentDragEnd}
|
|
153
|
+
>
|
|
154
|
+
<span class="attachment-index-badge absolute top-0.5 left-0.5 z-10 w-4 h-4 flex items-center justify-center rounded-full bg-blue-600 text-white text-[9px] font-bold shadow">{index + 1}</span>
|
|
155
|
+
{#if attachment.file.type.startsWith('image/')}
|
|
156
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
157
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
158
|
+
<img
|
|
159
|
+
src={attachment.preview || URL.createObjectURL(attachment.file)}
|
|
160
|
+
alt={attachment.file.name}
|
|
161
|
+
class="image-preview-ct w-16 h-16 object-cover cursor-pointer"
|
|
162
|
+
onclick={() => openLightbox(attachment)}
|
|
163
|
+
/>
|
|
164
|
+
{:else}
|
|
165
|
+
<!-- File Preview -->
|
|
166
|
+
<div class="file-preview-ct flex items-center gap-2 p-3 w-32">
|
|
167
|
+
<span class="text-lg">📄</span>
|
|
168
|
+
<span class="text-xs text-gray-600 dark:text-gray-300 truncate flex-1">{attachment.file.name}</span>
|
|
169
|
+
</div>
|
|
170
|
+
{/if}
|
|
171
|
+
|
|
172
|
+
<!-- Remove Button -->
|
|
173
|
+
<button
|
|
174
|
+
class="absolute top-1 right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600"
|
|
175
|
+
onclick={() => removeAttachment(index)}
|
|
176
|
+
aria-label="Remove attachment"
|
|
177
|
+
>
|
|
178
|
+
✕
|
|
179
|
+
</button>
|
|
180
|
+
|
|
181
|
+
{#if attachment.uploadProgress && attachment.uploadProgress > 0}
|
|
182
|
+
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-200">
|
|
183
|
+
<div class="h-full bg-blue-500 transition-all duration-300" style="width: {attachment.uploadProgress}%"></div>
|
|
184
|
+
</div>
|
|
185
|
+
{/if}
|
|
186
|
+
</div>
|
|
187
|
+
{/each}
|
|
188
|
+
</div>
|
|
189
|
+
{/if}
|
|
190
|
+
|
|
191
|
+
<!-- Input Area -->
|
|
192
|
+
<div class="input-area-ct p-4">
|
|
193
|
+
<div class="input-area-inner-ct flex items-end gap-3 bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
|
|
194
|
+
<textarea
|
|
195
|
+
bind:this={textArea}
|
|
196
|
+
value={value}
|
|
197
|
+
oninput={(e) => onInputChange((e.target as HTMLTextAreaElement).value)}
|
|
198
|
+
onkeydown={handleKeyDown}
|
|
199
|
+
{placeholder}
|
|
200
|
+
class="flex-1 bg-transparent border-none outline-none resize-y text-sm placeholder-gray-500 dark:text-gray-100 dark:placeholder-gray-400"
|
|
201
|
+
style="min-height: 30px; max-height: 120px; overflow-y: auto;"
|
|
202
|
+
rows="1"
|
|
203
|
+
disabled={isSending}
|
|
204
|
+
></textarea>
|
|
205
|
+
|
|
206
|
+
<div class="send-button-ct flex items-center gap-2">
|
|
207
|
+
<!-- Send Button -->
|
|
208
|
+
<button
|
|
209
|
+
class="send-button-btn w-8 h-8 rounded-lg bg-blue-600 hover:bg-blue-700 flex items-center justify-center transition-colors"
|
|
210
|
+
class:bg-gray-300={(!value.trim() && attachments.length === 0) || isSending}
|
|
211
|
+
class:cursor-not-allowed={(!value.trim() && attachments.length === 0) || isSending}
|
|
212
|
+
onclick={handleSubmit}
|
|
213
|
+
disabled={(!value.trim() && attachments.length === 0) || isSending}
|
|
214
|
+
aria-label={isSending ? 'Sending message...' : 'Send message'}
|
|
215
|
+
title={isSending ? 'Sending message...' : 'Send message'}
|
|
216
|
+
>
|
|
217
|
+
{#if isSending}
|
|
218
|
+
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24">
|
|
219
|
+
<circle cx="12" cy="12" r="10" fill="none" stroke="white" stroke-width="2" opacity="0.3"/>
|
|
220
|
+
<path d="M22 12a10 10 0 0 1-10 10" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
|
221
|
+
</svg>
|
|
222
|
+
{:else}
|
|
223
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="white" class="w-4 h-4">
|
|
224
|
+
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
225
|
+
</svg>
|
|
226
|
+
{/if}
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<!-- Lightbox overlay for input attachment images -->
|
|
234
|
+
{#if lightboxSrc}
|
|
235
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
236
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
237
|
+
<div class="input-lightbox-overlay" onclick={() => lightboxSrc = null}>
|
|
238
|
+
<button class="input-lightbox-close" onclick={() => lightboxSrc = null} title="Close">✕</button>
|
|
239
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
240
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
241
|
+
<div class="input-lightbox-content" onclick={(e) => e.stopPropagation()}>
|
|
242
|
+
<img src={lightboxSrc} alt="Attachment preview" class="input-lightbox-image" />
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
{/if}
|
|
246
|
+
|
|
247
|
+
<style>
|
|
248
|
+
.input-lightbox-overlay {
|
|
249
|
+
position: fixed;
|
|
250
|
+
inset: 0;
|
|
251
|
+
z-index: 9999;
|
|
252
|
+
display: flex;
|
|
253
|
+
align-items: center;
|
|
254
|
+
justify-content: center;
|
|
255
|
+
background: rgba(0, 0, 0, 0.85);
|
|
256
|
+
backdrop-filter: blur(4px);
|
|
257
|
+
}
|
|
258
|
+
.input-lightbox-close {
|
|
259
|
+
position: absolute;
|
|
260
|
+
top: 16px;
|
|
261
|
+
right: 16px;
|
|
262
|
+
width: 36px;
|
|
263
|
+
height: 36px;
|
|
264
|
+
border-radius: 50%;
|
|
265
|
+
border: none;
|
|
266
|
+
background: rgba(255, 255, 255, 0.15);
|
|
267
|
+
color: white;
|
|
268
|
+
font-size: 18px;
|
|
269
|
+
cursor: pointer;
|
|
270
|
+
display: flex;
|
|
271
|
+
align-items: center;
|
|
272
|
+
justify-content: center;
|
|
273
|
+
transition: background 0.2s;
|
|
274
|
+
z-index: 10;
|
|
275
|
+
}
|
|
276
|
+
.input-lightbox-close:hover {
|
|
277
|
+
background: rgba(255, 255, 255, 0.3);
|
|
278
|
+
}
|
|
279
|
+
.input-lightbox-content {
|
|
280
|
+
max-width: 90vw;
|
|
281
|
+
max-height: 90vh;
|
|
282
|
+
}
|
|
283
|
+
.input-lightbox-image {
|
|
284
|
+
max-width: 90vw;
|
|
285
|
+
max-height: 85vh;
|
|
286
|
+
object-fit: contain;
|
|
287
|
+
border-radius: 8px;
|
|
288
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
289
|
+
}
|
|
290
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AttachmentInput } from '../types/attachment.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
value: string;
|
|
4
|
+
attachments: AttachmentInput[];
|
|
5
|
+
isRecording: boolean;
|
|
6
|
+
isSending: boolean;
|
|
7
|
+
isUploading: boolean;
|
|
8
|
+
onSend: (content: string, attachments?: AttachmentInput[]) => void;
|
|
9
|
+
onInputChange: (text: string) => void;
|
|
10
|
+
onAttachmentsChange: (attachments: AttachmentInput[]) => void;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
}
|
|
13
|
+
declare const ChatInput: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type ChatInput = ReturnType<typeof ChatInput>;
|
|
15
|
+
export default ChatInput;
|