@terreno/ui 0.1.0 → 0.3.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/AIRequestExplorer.d.ts +31 -0
- package/dist/AIRequestExplorer.js +44 -0
- package/dist/AIRequestExplorer.js.map +1 -0
- package/dist/AttachmentPreview.d.ts +8 -0
- package/dist/AttachmentPreview.js +16 -0
- package/dist/AttachmentPreview.js.map +1 -0
- package/dist/Common.d.ts +44 -0
- package/dist/FilePickerButton.d.ts +13 -0
- package/dist/FilePickerButton.js +50 -0
- package/dist/FilePickerButton.js.map +1 -0
- package/dist/GPTChat.d.ts +66 -0
- package/dist/GPTChat.js +112 -0
- package/dist/GPTChat.js.map +1 -0
- package/dist/GPTMemoryModal.d.ts +8 -0
- package/dist/GPTMemoryModal.js +14 -0
- package/dist/GPTMemoryModal.js.map +1 -0
- package/dist/SocialLoginButton.d.ts +19 -0
- package/dist/SocialLoginButton.js +119 -0
- package/dist/SocialLoginButton.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/login/LoginScreen.d.ts +25 -0
- package/dist/login/LoginScreen.js +55 -0
- package/dist/login/LoginScreen.js.map +1 -0
- package/dist/login/index.d.ts +2 -0
- package/dist/login/index.js +2 -0
- package/dist/login/index.js.map +1 -0
- package/dist/login/loginTypes.d.ts +48 -0
- package/dist/login/loginTypes.js +2 -0
- package/dist/login/loginTypes.js.map +1 -0
- package/dist/signUp/OAuthButtons.d.ts +18 -0
- package/dist/signUp/OAuthButtons.js +15 -0
- package/dist/signUp/OAuthButtons.js.map +1 -0
- package/dist/signUp/PasswordRequirements.d.ts +15 -0
- package/dist/signUp/PasswordRequirements.js +14 -0
- package/dist/signUp/PasswordRequirements.js.map +1 -0
- package/dist/signUp/SignUpScreen.d.ts +26 -0
- package/dist/signUp/SignUpScreen.js +64 -0
- package/dist/signUp/SignUpScreen.js.map +1 -0
- package/dist/signUp/Swiper.d.ts +13 -0
- package/dist/signUp/Swiper.js +16 -0
- package/dist/signUp/Swiper.js.map +1 -0
- package/dist/signUp/index.d.ts +6 -0
- package/dist/signUp/index.js +6 -0
- package/dist/signUp/index.js.map +1 -0
- package/dist/signUp/passwordPresets.d.ts +9 -0
- package/dist/signUp/passwordPresets.js +41 -0
- package/dist/signUp/passwordPresets.js.map +1 -0
- package/dist/signUp/signUpTypes.d.ts +90 -0
- package/dist/signUp/signUpTypes.js +2 -0
- package/dist/signUp/signUpTypes.js.map +1 -0
- package/package.json +4 -2
- package/src/AIRequestExplorer.tsx +147 -0
- package/src/AttachmentPreview.tsx +63 -0
- package/src/Common.ts +52 -0
- package/src/FilePickerButton.tsx +88 -0
- package/src/GPTChat.tsx +551 -0
- package/src/GPTMemoryModal.tsx +50 -0
- package/src/SocialLoginButton.test.tsx +158 -0
- package/src/SocialLoginButton.tsx +182 -0
- package/src/__snapshots__/SocialLoginButton.test.tsx.snap +277 -0
- package/src/index.tsx +9 -0
- package/src/login/LoginScreen.test.tsx +148 -0
- package/src/login/LoginScreen.tsx +159 -0
- package/src/login/__snapshots__/LoginScreen.test.tsx.snap +630 -0
- package/src/login/index.ts +2 -0
- package/src/login/loginTypes.ts +51 -0
- package/src/signUp/OAuthButtons.test.tsx +45 -0
- package/src/signUp/OAuthButtons.tsx +52 -0
- package/src/signUp/PasswordRequirements.test.tsx +41 -0
- package/src/signUp/PasswordRequirements.tsx +49 -0
- package/src/signUp/SignUpScreen.test.tsx +134 -0
- package/src/signUp/SignUpScreen.tsx +172 -0
- package/src/signUp/Swiper.test.tsx +46 -0
- package/src/signUp/Swiper.tsx +59 -0
- package/src/signUp/__snapshots__/OAuthButtons.test.tsx.snap +272 -0
- package/src/signUp/__snapshots__/PasswordRequirements.test.tsx.snap +427 -0
- package/src/signUp/__snapshots__/SignUpScreen.test.tsx.snap +851 -0
- package/src/signUp/__snapshots__/Swiper.test.tsx.snap +249 -0
- package/src/signUp/index.ts +13 -0
- package/src/signUp/passwordPresets.test.ts +57 -0
- package/src/signUp/passwordPresets.ts +43 -0
- package/src/signUp/signUpTypes.ts +94 -0
package/src/GPTChat.tsx
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import React, {useCallback, useRef, useState} from "react";
|
|
2
|
+
import {Image as RNImage, type ScrollView as RNScrollView} from "react-native";
|
|
3
|
+
|
|
4
|
+
import {AttachmentPreview} from "./AttachmentPreview";
|
|
5
|
+
import {Box} from "./Box";
|
|
6
|
+
import {Button} from "./Button";
|
|
7
|
+
import type {SelectedFile} from "./FilePickerButton";
|
|
8
|
+
import {FilePickerButton} from "./FilePickerButton";
|
|
9
|
+
import {Heading} from "./Heading";
|
|
10
|
+
import {Icon} from "./Icon";
|
|
11
|
+
import {IconButton} from "./IconButton";
|
|
12
|
+
import {MarkdownView} from "./MarkdownView";
|
|
13
|
+
import {Modal} from "./Modal";
|
|
14
|
+
import {Spinner} from "./Spinner";
|
|
15
|
+
import {Text} from "./Text";
|
|
16
|
+
import {TextArea} from "./TextArea";
|
|
17
|
+
import {TextField} from "./TextField";
|
|
18
|
+
|
|
19
|
+
// ============================================================
|
|
20
|
+
// Content Part Types (mirroring backend types for rendering)
|
|
21
|
+
// ============================================================
|
|
22
|
+
|
|
23
|
+
export interface TextContentPart {
|
|
24
|
+
type: "text";
|
|
25
|
+
text: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ImageContentPart {
|
|
29
|
+
type: "image";
|
|
30
|
+
url: string;
|
|
31
|
+
mimeType?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FileContentPart {
|
|
35
|
+
type: "file";
|
|
36
|
+
url: string;
|
|
37
|
+
filename?: string;
|
|
38
|
+
mimeType: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type MessageContentPart = TextContentPart | ImageContentPart | FileContentPart;
|
|
42
|
+
|
|
43
|
+
// ============================================================
|
|
44
|
+
// Tool Call Types
|
|
45
|
+
// ============================================================
|
|
46
|
+
|
|
47
|
+
export interface ToolCallInfo {
|
|
48
|
+
args: Record<string, unknown>;
|
|
49
|
+
toolCallId: string;
|
|
50
|
+
toolName: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ToolResultInfo {
|
|
54
|
+
result: unknown;
|
|
55
|
+
toolCallId: string;
|
|
56
|
+
toolName: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================================
|
|
60
|
+
// Message Types
|
|
61
|
+
// ============================================================
|
|
62
|
+
|
|
63
|
+
export interface GPTChatMessage {
|
|
64
|
+
content: string;
|
|
65
|
+
contentParts?: MessageContentPart[];
|
|
66
|
+
role: "user" | "assistant" | "system" | "tool-call" | "tool-result";
|
|
67
|
+
toolCall?: ToolCallInfo;
|
|
68
|
+
toolResult?: ToolResultInfo;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface GPTChatHistory {
|
|
72
|
+
id: string;
|
|
73
|
+
prompts: GPTChatMessage[];
|
|
74
|
+
title?: string;
|
|
75
|
+
updated?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface MCPServerStatus {
|
|
79
|
+
connected: boolean;
|
|
80
|
+
name: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface GPTChatProps {
|
|
84
|
+
attachments?: SelectedFile[];
|
|
85
|
+
currentHistoryId?: string;
|
|
86
|
+
currentMessages: GPTChatMessage[];
|
|
87
|
+
geminiApiKey?: string;
|
|
88
|
+
histories: GPTChatHistory[];
|
|
89
|
+
isStreaming?: boolean;
|
|
90
|
+
mcpServers?: MCPServerStatus[];
|
|
91
|
+
onAttachFiles?: (files: SelectedFile[]) => void;
|
|
92
|
+
onCreateHistory: () => void;
|
|
93
|
+
onDeleteHistory: (id: string) => void;
|
|
94
|
+
onGeminiApiKeyChange?: (key: string) => void;
|
|
95
|
+
onMemoryEdit?: (memory: string) => void;
|
|
96
|
+
onRemoveAttachment?: (index: number) => void;
|
|
97
|
+
onSelectHistory: (id: string) => void;
|
|
98
|
+
onSubmit: (prompt: string) => void;
|
|
99
|
+
onUpdateTitle?: (id: string, title: string) => void;
|
|
100
|
+
systemMemory?: string;
|
|
101
|
+
testID?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ToolCallCard = ({toolCall}: {toolCall: ToolCallInfo}): React.ReactElement => {
|
|
105
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<Box border="default" padding={2} rounding="md">
|
|
109
|
+
<Box
|
|
110
|
+
accessibilityHint="Toggle tool call details"
|
|
111
|
+
accessibilityLabel={`Tool: ${toolCall.toolName}`}
|
|
112
|
+
alignItems="center"
|
|
113
|
+
direction="row"
|
|
114
|
+
gap={1}
|
|
115
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
116
|
+
>
|
|
117
|
+
<Icon iconName="wrench" size="xs" />
|
|
118
|
+
<Text bold size="sm">
|
|
119
|
+
Tool: {toolCall.toolName}
|
|
120
|
+
</Text>
|
|
121
|
+
<Icon iconName={isExpanded ? "chevron-up" : "chevron-down"} size="xs" />
|
|
122
|
+
</Box>
|
|
123
|
+
{isExpanded ? (
|
|
124
|
+
<Box marginTop={1} padding={1}>
|
|
125
|
+
<Text color="secondaryDark" size="sm">
|
|
126
|
+
{JSON.stringify(toolCall.args, null, 2)}
|
|
127
|
+
</Text>
|
|
128
|
+
</Box>
|
|
129
|
+
) : null}
|
|
130
|
+
</Box>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const ToolResultCard = ({toolResult}: {toolResult: ToolResultInfo}): React.ReactElement => {
|
|
135
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Box border="default" padding={2} rounding="md">
|
|
139
|
+
<Box
|
|
140
|
+
accessibilityHint="Toggle tool result details"
|
|
141
|
+
accessibilityLabel={`Result: ${toolResult.toolName}`}
|
|
142
|
+
alignItems="center"
|
|
143
|
+
direction="row"
|
|
144
|
+
gap={1}
|
|
145
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
146
|
+
>
|
|
147
|
+
<Icon iconName="check" size="xs" />
|
|
148
|
+
<Text bold size="sm">
|
|
149
|
+
Result: {toolResult.toolName}
|
|
150
|
+
</Text>
|
|
151
|
+
<Icon iconName={isExpanded ? "chevron-up" : "chevron-down"} size="xs" />
|
|
152
|
+
</Box>
|
|
153
|
+
{isExpanded ? (
|
|
154
|
+
<Box marginTop={1} padding={1}>
|
|
155
|
+
<Text color="secondaryDark" size="sm">
|
|
156
|
+
{typeof toolResult.result === "string"
|
|
157
|
+
? toolResult.result
|
|
158
|
+
: JSON.stringify(toolResult.result, null, 2)}
|
|
159
|
+
</Text>
|
|
160
|
+
</Box>
|
|
161
|
+
) : null}
|
|
162
|
+
</Box>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const handleDownloadFile = (url: string, filename: string): void => {
|
|
167
|
+
if (typeof window === "undefined") {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const link = document.createElement("a");
|
|
171
|
+
link.href = url;
|
|
172
|
+
link.download = filename;
|
|
173
|
+
document.body.appendChild(link);
|
|
174
|
+
link.click();
|
|
175
|
+
document.body.removeChild(link);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const MessageContentParts = ({parts}: {parts: MessageContentPart[]}): React.ReactElement => {
|
|
179
|
+
return (
|
|
180
|
+
<Box gap={2}>
|
|
181
|
+
{parts.map((part, index) => {
|
|
182
|
+
if (part.type === "image") {
|
|
183
|
+
return (
|
|
184
|
+
<RNImage
|
|
185
|
+
key={`content-${index}`}
|
|
186
|
+
resizeMode="contain"
|
|
187
|
+
source={{uri: part.url}}
|
|
188
|
+
style={{borderRadius: 8, height: 400, maxWidth: 800, minWidth: 400, width: "100%"}}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (part.type === "file") {
|
|
193
|
+
const hasDownloadableUrl = part.url?.startsWith("data:") || part.url?.startsWith("http");
|
|
194
|
+
const filename = part.filename ?? "File";
|
|
195
|
+
const isPdf = part.mimeType === "application/pdf";
|
|
196
|
+
const iconName = isPdf ? "file-pdf" : "file";
|
|
197
|
+
|
|
198
|
+
if (hasDownloadableUrl) {
|
|
199
|
+
return (
|
|
200
|
+
<Box
|
|
201
|
+
accessibilityHint="Download this file"
|
|
202
|
+
accessibilityLabel={`File: ${filename}`}
|
|
203
|
+
alignItems="center"
|
|
204
|
+
border="default"
|
|
205
|
+
direction="row"
|
|
206
|
+
gap={1}
|
|
207
|
+
key={`content-${index}`}
|
|
208
|
+
onClick={() => handleDownloadFile(part.url, filename)}
|
|
209
|
+
padding={2}
|
|
210
|
+
rounding="md"
|
|
211
|
+
>
|
|
212
|
+
<Icon iconName={iconName} size="sm" />
|
|
213
|
+
<Text size="sm">{filename}</Text>
|
|
214
|
+
<Icon iconName="download" size="xs" />
|
|
215
|
+
</Box>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<Box
|
|
221
|
+
alignItems="center"
|
|
222
|
+
border="default"
|
|
223
|
+
direction="row"
|
|
224
|
+
gap={1}
|
|
225
|
+
key={`content-${index}`}
|
|
226
|
+
padding={2}
|
|
227
|
+
rounding="md"
|
|
228
|
+
>
|
|
229
|
+
<Icon iconName={iconName} size="sm" />
|
|
230
|
+
<Text size="sm">{filename}</Text>
|
|
231
|
+
</Box>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
})}
|
|
236
|
+
</Box>
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const MCPStatusIndicator = ({servers}: {servers: MCPServerStatus[]}): React.ReactElement => {
|
|
241
|
+
const [showList, setShowList] = useState(false);
|
|
242
|
+
const connectedCount = servers.filter((s) => s.connected).length;
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<Box>
|
|
246
|
+
<Box
|
|
247
|
+
accessibilityHint="Show MCP server list"
|
|
248
|
+
accessibilityLabel="MCP server status"
|
|
249
|
+
alignItems="center"
|
|
250
|
+
direction="row"
|
|
251
|
+
gap={1}
|
|
252
|
+
onClick={() => setShowList(!showList)}
|
|
253
|
+
>
|
|
254
|
+
<Box
|
|
255
|
+
color={connectedCount > 0 ? "success" : "error"}
|
|
256
|
+
height={8}
|
|
257
|
+
rounding="circle"
|
|
258
|
+
width={8}
|
|
259
|
+
/>
|
|
260
|
+
<Text color="secondaryDark" size="sm">
|
|
261
|
+
{connectedCount}/{servers.length} MCP
|
|
262
|
+
</Text>
|
|
263
|
+
</Box>
|
|
264
|
+
{showList ? (
|
|
265
|
+
<Box border="default" marginTop={1} padding={2} position="absolute" rounding="md">
|
|
266
|
+
{servers.map((server) => (
|
|
267
|
+
<Box alignItems="center" direction="row" gap={1} key={server.name} padding={1}>
|
|
268
|
+
<Box
|
|
269
|
+
color={server.connected ? "success" : "error"}
|
|
270
|
+
height={6}
|
|
271
|
+
rounding="circle"
|
|
272
|
+
width={6}
|
|
273
|
+
/>
|
|
274
|
+
<Text size="sm">{server.name}</Text>
|
|
275
|
+
</Box>
|
|
276
|
+
))}
|
|
277
|
+
</Box>
|
|
278
|
+
) : null}
|
|
279
|
+
</Box>
|
|
280
|
+
);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export const GPTChat = ({
|
|
284
|
+
attachments = [],
|
|
285
|
+
currentHistoryId,
|
|
286
|
+
currentMessages,
|
|
287
|
+
geminiApiKey,
|
|
288
|
+
histories,
|
|
289
|
+
isStreaming = false,
|
|
290
|
+
mcpServers,
|
|
291
|
+
onAttachFiles,
|
|
292
|
+
onCreateHistory,
|
|
293
|
+
onDeleteHistory,
|
|
294
|
+
onGeminiApiKeyChange,
|
|
295
|
+
onMemoryEdit,
|
|
296
|
+
onRemoveAttachment,
|
|
297
|
+
onSelectHistory,
|
|
298
|
+
onSubmit,
|
|
299
|
+
systemMemory,
|
|
300
|
+
testID,
|
|
301
|
+
}: GPTChatProps): React.ReactElement => {
|
|
302
|
+
const [inputValue, setInputValue] = useState("");
|
|
303
|
+
const scrollViewRef = useRef<RNScrollView>(null);
|
|
304
|
+
const [isScrolledUp, setIsScrolledUp] = useState(false);
|
|
305
|
+
const [isApiKeyModalVisible, setIsApiKeyModalVisible] = useState(false);
|
|
306
|
+
const [apiKeyDraft, setApiKeyDraft] = useState(geminiApiKey ?? "");
|
|
307
|
+
|
|
308
|
+
const handleSubmit = useCallback(() => {
|
|
309
|
+
const trimmed = inputValue.trim();
|
|
310
|
+
if (!trimmed || isStreaming) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
onSubmit(trimmed);
|
|
314
|
+
setInputValue("");
|
|
315
|
+
}, [inputValue, isStreaming, onSubmit]);
|
|
316
|
+
|
|
317
|
+
const handleCopyMessage = useCallback(async (text: string) => {
|
|
318
|
+
const Clipboard = await import("expo-clipboard");
|
|
319
|
+
await Clipboard.setStringAsync(text);
|
|
320
|
+
}, []);
|
|
321
|
+
|
|
322
|
+
const scrollToBottom = useCallback(() => {
|
|
323
|
+
scrollViewRef.current?.scrollToEnd({animated: true});
|
|
324
|
+
setIsScrolledUp(false);
|
|
325
|
+
}, []);
|
|
326
|
+
|
|
327
|
+
const handleFilesSelected = useCallback(
|
|
328
|
+
(files: SelectedFile[]) => {
|
|
329
|
+
onAttachFiles?.(files);
|
|
330
|
+
},
|
|
331
|
+
[onAttachFiles]
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const handleOpenApiKeyModal = useCallback(() => {
|
|
335
|
+
setApiKeyDraft(geminiApiKey ?? "");
|
|
336
|
+
setIsApiKeyModalVisible(true);
|
|
337
|
+
}, [geminiApiKey]);
|
|
338
|
+
|
|
339
|
+
const handleSaveApiKey = useCallback(() => {
|
|
340
|
+
onGeminiApiKeyChange?.(apiKeyDraft);
|
|
341
|
+
setIsApiKeyModalVisible(false);
|
|
342
|
+
}, [apiKeyDraft, onGeminiApiKeyChange]);
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<Box direction="row" flex="grow" testID={testID}>
|
|
346
|
+
{/* Sidebar */}
|
|
347
|
+
<Box border="default" color="base" minWidth={250} overflow="scrollY" padding={3} width="30%">
|
|
348
|
+
<Box alignItems="center" direction="row" justifyContent="between" marginBottom={3}>
|
|
349
|
+
<Heading size="sm">Chats</Heading>
|
|
350
|
+
<Box direction="row" gap={1}>
|
|
351
|
+
{mcpServers && mcpServers.length > 0 ? (
|
|
352
|
+
<MCPStatusIndicator servers={mcpServers} />
|
|
353
|
+
) : null}
|
|
354
|
+
{onGeminiApiKeyChange ? (
|
|
355
|
+
<IconButton
|
|
356
|
+
accessibilityLabel="Set Gemini API key"
|
|
357
|
+
iconName="key"
|
|
358
|
+
onClick={handleOpenApiKeyModal}
|
|
359
|
+
testID="gpt-api-key-button"
|
|
360
|
+
/>
|
|
361
|
+
) : null}
|
|
362
|
+
{onMemoryEdit ? (
|
|
363
|
+
<IconButton
|
|
364
|
+
accessibilityLabel="Edit system memory"
|
|
365
|
+
iconName="gear"
|
|
366
|
+
onClick={() => onMemoryEdit(systemMemory ?? "")}
|
|
367
|
+
testID="gpt-memory-button"
|
|
368
|
+
/>
|
|
369
|
+
) : null}
|
|
370
|
+
<IconButton
|
|
371
|
+
accessibilityLabel="New chat"
|
|
372
|
+
iconName="plus"
|
|
373
|
+
onClick={onCreateHistory}
|
|
374
|
+
testID="gpt-new-chat-button"
|
|
375
|
+
/>
|
|
376
|
+
</Box>
|
|
377
|
+
</Box>
|
|
378
|
+
|
|
379
|
+
{histories.map((history) => (
|
|
380
|
+
<Box
|
|
381
|
+
accessibilityHint="Opens this chat history"
|
|
382
|
+
accessibilityLabel={`Select chat: ${history.title ?? "New Chat"}`}
|
|
383
|
+
alignItems="center"
|
|
384
|
+
color={history.id === currentHistoryId ? "primary" : undefined}
|
|
385
|
+
direction="row"
|
|
386
|
+
justifyContent="between"
|
|
387
|
+
key={history.id}
|
|
388
|
+
marginBottom={1}
|
|
389
|
+
onClick={() => onSelectHistory(history.id)}
|
|
390
|
+
padding={2}
|
|
391
|
+
rounding="md"
|
|
392
|
+
>
|
|
393
|
+
<Text
|
|
394
|
+
color={history.id === currentHistoryId ? "inverted" : "primary"}
|
|
395
|
+
size="sm"
|
|
396
|
+
truncate
|
|
397
|
+
>
|
|
398
|
+
{history.title ?? "New Chat"}
|
|
399
|
+
</Text>
|
|
400
|
+
<IconButton
|
|
401
|
+
accessibilityLabel={`Delete chat: ${history.title ?? "New Chat"}`}
|
|
402
|
+
iconName="trash"
|
|
403
|
+
onClick={() => onDeleteHistory(history.id)}
|
|
404
|
+
testID={`gpt-delete-history-${history.id}`}
|
|
405
|
+
variant="destructive"
|
|
406
|
+
/>
|
|
407
|
+
</Box>
|
|
408
|
+
))}
|
|
409
|
+
</Box>
|
|
410
|
+
|
|
411
|
+
{/* Chat Panel */}
|
|
412
|
+
<Box direction="column" flex="grow" padding={4}>
|
|
413
|
+
{/* Messages */}
|
|
414
|
+
<Box flex="grow" gap={3} marginBottom={3} overflow="scrollY">
|
|
415
|
+
{currentMessages.map((message, index) => {
|
|
416
|
+
// Tool call/result messages
|
|
417
|
+
if (message.role === "tool-call" && message.toolCall) {
|
|
418
|
+
return (
|
|
419
|
+
<Box alignItems="start" key={`msg-${index}`} maxWidth="80%">
|
|
420
|
+
<ToolCallCard toolCall={message.toolCall} />
|
|
421
|
+
</Box>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (message.role === "tool-result" && message.toolResult) {
|
|
425
|
+
return (
|
|
426
|
+
<Box alignItems="start" key={`msg-${index}`} maxWidth="80%">
|
|
427
|
+
<ToolResultCard toolResult={message.toolResult} />
|
|
428
|
+
</Box>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const hasImages = message.contentParts?.some((p) => p.type === "image");
|
|
433
|
+
return (
|
|
434
|
+
<Box alignItems={message.role === "user" ? "end" : "start"} key={`msg-${index}`}>
|
|
435
|
+
<Box
|
|
436
|
+
color={message.role === "user" ? "primary" : "neutralLight"}
|
|
437
|
+
maxWidth={hasImages ? "90%" : "80%"}
|
|
438
|
+
padding={3}
|
|
439
|
+
rounding="lg"
|
|
440
|
+
>
|
|
441
|
+
{/* Render content parts (images, files) */}
|
|
442
|
+
{message.contentParts && message.contentParts.length > 0 ? (
|
|
443
|
+
<Box marginBottom={message.content ? 2 : 0}>
|
|
444
|
+
<MessageContentParts
|
|
445
|
+
parts={message.contentParts.filter((p) => p.type !== "text")}
|
|
446
|
+
/>
|
|
447
|
+
</Box>
|
|
448
|
+
) : null}
|
|
449
|
+
|
|
450
|
+
{/* Render text content */}
|
|
451
|
+
{message.role === "assistant" ? (
|
|
452
|
+
<MarkdownView>{message.content}</MarkdownView>
|
|
453
|
+
) : (
|
|
454
|
+
<Text color={message.role === "user" ? "inverted" : "primary"}>
|
|
455
|
+
{message.content}
|
|
456
|
+
</Text>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
{/* Copy button */}
|
|
460
|
+
{message.role === "assistant" ? (
|
|
461
|
+
<Box alignItems="end" marginTop={1}>
|
|
462
|
+
<IconButton
|
|
463
|
+
accessibilityLabel="Copy message"
|
|
464
|
+
iconName="copy"
|
|
465
|
+
onClick={() => handleCopyMessage(message.content)}
|
|
466
|
+
testID={`gpt-copy-msg-${index}`}
|
|
467
|
+
/>
|
|
468
|
+
</Box>
|
|
469
|
+
) : null}
|
|
470
|
+
</Box>
|
|
471
|
+
</Box>
|
|
472
|
+
);
|
|
473
|
+
})}
|
|
474
|
+
{isStreaming ? (
|
|
475
|
+
<Box alignItems="start" padding={2}>
|
|
476
|
+
<Spinner size="sm" />
|
|
477
|
+
</Box>
|
|
478
|
+
) : null}
|
|
479
|
+
</Box>
|
|
480
|
+
|
|
481
|
+
{/* Scroll to bottom button */}
|
|
482
|
+
{isScrolledUp && isStreaming ? (
|
|
483
|
+
<Box alignItems="center" marginBottom={2}>
|
|
484
|
+
<Button
|
|
485
|
+
iconName="arrow-down"
|
|
486
|
+
onClick={scrollToBottom}
|
|
487
|
+
text="Scroll to bottom"
|
|
488
|
+
variant="outline"
|
|
489
|
+
/>
|
|
490
|
+
</Box>
|
|
491
|
+
) : null}
|
|
492
|
+
|
|
493
|
+
{/* Attachment preview */}
|
|
494
|
+
{attachments.length > 0 && onRemoveAttachment ? (
|
|
495
|
+
<AttachmentPreview attachments={attachments} onRemove={onRemoveAttachment} />
|
|
496
|
+
) : null}
|
|
497
|
+
|
|
498
|
+
{/* Input */}
|
|
499
|
+
<Box alignItems="end" direction="row" gap={2}>
|
|
500
|
+
{onAttachFiles ? (
|
|
501
|
+
<FilePickerButton
|
|
502
|
+
disabled={isStreaming}
|
|
503
|
+
onFilesSelected={handleFilesSelected}
|
|
504
|
+
testID="gpt-attach-button"
|
|
505
|
+
/>
|
|
506
|
+
) : null}
|
|
507
|
+
<Box flex="grow">
|
|
508
|
+
<TextArea
|
|
509
|
+
disabled={isStreaming}
|
|
510
|
+
onChange={setInputValue}
|
|
511
|
+
placeholder="Type a message..."
|
|
512
|
+
testID="gpt-input"
|
|
513
|
+
value={inputValue}
|
|
514
|
+
/>
|
|
515
|
+
</Box>
|
|
516
|
+
<Button
|
|
517
|
+
disabled={!inputValue.trim() || isStreaming}
|
|
518
|
+
iconName="paper-plane"
|
|
519
|
+
onClick={handleSubmit}
|
|
520
|
+
testID="gpt-submit"
|
|
521
|
+
text="Send"
|
|
522
|
+
/>
|
|
523
|
+
</Box>
|
|
524
|
+
</Box>
|
|
525
|
+
|
|
526
|
+
{onGeminiApiKeyChange ? (
|
|
527
|
+
<Modal
|
|
528
|
+
onDismiss={() => setIsApiKeyModalVisible(false)}
|
|
529
|
+
primaryButtonOnClick={handleSaveApiKey}
|
|
530
|
+
primaryButtonText="Save"
|
|
531
|
+
secondaryButtonOnClick={() => setIsApiKeyModalVisible(false)}
|
|
532
|
+
secondaryButtonText="Cancel"
|
|
533
|
+
size="sm"
|
|
534
|
+
subtitle="Provide your own Gemini API key for AI requests."
|
|
535
|
+
title="Gemini API Key"
|
|
536
|
+
visible={isApiKeyModalVisible}
|
|
537
|
+
>
|
|
538
|
+
<Box padding={2}>
|
|
539
|
+
<TextField
|
|
540
|
+
onChange={setApiKeyDraft}
|
|
541
|
+
placeholder="Enter Gemini API key..."
|
|
542
|
+
testID="gpt-api-key-input"
|
|
543
|
+
type="password"
|
|
544
|
+
value={apiKeyDraft}
|
|
545
|
+
/>
|
|
546
|
+
</Box>
|
|
547
|
+
</Modal>
|
|
548
|
+
) : null}
|
|
549
|
+
</Box>
|
|
550
|
+
);
|
|
551
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React, {useCallback, useState} from "react";
|
|
2
|
+
|
|
3
|
+
import {Box} from "./Box";
|
|
4
|
+
import {Modal} from "./Modal";
|
|
5
|
+
import {TextArea} from "./TextArea";
|
|
6
|
+
|
|
7
|
+
export interface GPTMemoryModalProps {
|
|
8
|
+
memory: string;
|
|
9
|
+
onDismiss: () => void;
|
|
10
|
+
onSave: (memory: string) => void;
|
|
11
|
+
visible: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const GPTMemoryModal = ({
|
|
15
|
+
memory,
|
|
16
|
+
onDismiss,
|
|
17
|
+
onSave,
|
|
18
|
+
visible,
|
|
19
|
+
}: GPTMemoryModalProps): React.ReactElement => {
|
|
20
|
+
const [value, setValue] = useState(memory);
|
|
21
|
+
|
|
22
|
+
const handleSave = useCallback(() => {
|
|
23
|
+
onSave(value);
|
|
24
|
+
onDismiss();
|
|
25
|
+
}, [onDismiss, onSave, value]);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Modal
|
|
29
|
+
onDismiss={onDismiss}
|
|
30
|
+
primaryButtonOnClick={handleSave}
|
|
31
|
+
primaryButtonText="Save"
|
|
32
|
+
secondaryButtonOnClick={onDismiss}
|
|
33
|
+
secondaryButtonText="Cancel"
|
|
34
|
+
size="md"
|
|
35
|
+
subtitle="Customize the system prompt for your AI assistant."
|
|
36
|
+
title="System Memory"
|
|
37
|
+
visible={visible}
|
|
38
|
+
>
|
|
39
|
+
<Box padding={2}>
|
|
40
|
+
<TextArea
|
|
41
|
+
onChange={setValue}
|
|
42
|
+
placeholder="Enter system instructions..."
|
|
43
|
+
rows={10}
|
|
44
|
+
testID="gpt-memory-textarea"
|
|
45
|
+
value={value}
|
|
46
|
+
/>
|
|
47
|
+
</Box>
|
|
48
|
+
</Modal>
|
|
49
|
+
);
|
|
50
|
+
};
|