@houston-ai/chat 0.2.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/README.md +47 -0
- package/package.json +34 -0
- package/src/ai-elements/conversation.tsx +168 -0
- package/src/ai-elements/message.tsx +378 -0
- package/src/ai-elements/prompt-input.tsx +1507 -0
- package/src/ai-elements/reasoning.tsx +226 -0
- package/src/ai-elements/shimmer.tsx +77 -0
- package/src/ai-elements/suggestion.tsx +57 -0
- package/src/channel-avatar.tsx +57 -0
- package/src/chat-helpers.tsx +239 -0
- package/src/chat-input-parts.tsx +98 -0
- package/src/chat-input.tsx +231 -0
- package/src/chat-panel.tsx +194 -0
- package/src/feed-merge.ts +43 -0
- package/src/feed-to-messages.ts +174 -0
- package/src/index.ts +180 -0
- package/src/progress-panel.tsx +82 -0
- package/src/styles.css +3 -0
- package/src/types.ts +29 -0
- package/src/typewriter.tsx +43 -0
- package/src/use-progress-steps.ts +52 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal sub-components for ChatInput.
|
|
3
|
+
* Not exported from the package index — used only by chat-input.tsx.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useRef } from "react";
|
|
7
|
+
import { createPortal } from "react-dom";
|
|
8
|
+
import { FileIcon, XIcon } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// FileChip — shows an attached file with a remove button
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface FileChipProps {
|
|
15
|
+
name: string;
|
|
16
|
+
onRemove: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function FileChip({ name, onRemove }: FileChipProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex items-center gap-1 bg-secondary border border-black/[0.08] rounded-md px-2 py-1 text-xs text-foreground">
|
|
22
|
+
<FileIcon className="size-3 shrink-0 text-muted-foreground" />
|
|
23
|
+
<span className="max-w-[140px] truncate">{name}</span>
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
onClick={onRemove}
|
|
27
|
+
className="ml-0.5 rounded-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
28
|
+
aria-label={`Remove ${name}`}
|
|
29
|
+
>
|
|
30
|
+
<XIcon className="size-3" />
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// AttachMenu — portal-rendered popup triggered by the + button.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export interface AttachMenuItem {
|
|
41
|
+
label: string;
|
|
42
|
+
icon: React.ReactNode;
|
|
43
|
+
onClick: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AttachMenuProps {
|
|
47
|
+
items: AttachMenuItem[];
|
|
48
|
+
onClose: () => void;
|
|
49
|
+
anchorRef: React.RefObject<HTMLElement | null>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function AttachMenu({ items, onClose, anchorRef }: AttachMenuProps) {
|
|
53
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
const posRef = useRef<{ left: number; bottom: number } | null>(null);
|
|
55
|
+
|
|
56
|
+
if (!posRef.current && anchorRef.current) {
|
|
57
|
+
const rect = anchorRef.current.getBoundingClientRect();
|
|
58
|
+
posRef.current = { left: rect.left, bottom: window.innerHeight - rect.top + 6 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Close on Escape
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const handler = (e: KeyboardEvent) => {
|
|
64
|
+
if (e.key === "Escape") onClose();
|
|
65
|
+
};
|
|
66
|
+
document.addEventListener("keydown", handler);
|
|
67
|
+
return () => document.removeEventListener("keydown", handler);
|
|
68
|
+
}, [onClose]);
|
|
69
|
+
|
|
70
|
+
const pos = posRef.current;
|
|
71
|
+
|
|
72
|
+
const menu = (
|
|
73
|
+
<>
|
|
74
|
+
<div className="fixed inset-0 z-40" onClick={onClose} aria-hidden="true" />
|
|
75
|
+
<div
|
|
76
|
+
ref={menuRef}
|
|
77
|
+
className="fixed z-50 min-w-[180px] rounded-xl border border-black/[0.08] bg-white shadow-lg py-1"
|
|
78
|
+
style={pos ? { left: pos.left, bottom: pos.bottom } : { left: 0, bottom: 0, visibility: "hidden" as const }}
|
|
79
|
+
role="menu"
|
|
80
|
+
>
|
|
81
|
+
{items.map((item, i) => (
|
|
82
|
+
<button
|
|
83
|
+
key={i}
|
|
84
|
+
type="button"
|
|
85
|
+
role="menuitem"
|
|
86
|
+
onClick={() => { item.onClick(); onClose(); }}
|
|
87
|
+
className="flex w-full items-center gap-2.5 px-3 py-2 text-sm text-foreground hover:bg-accent transition-colors"
|
|
88
|
+
>
|
|
89
|
+
{item.icon}
|
|
90
|
+
{item.label}
|
|
91
|
+
</button>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return createPortal(menu, document.body);
|
|
98
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatInput — prompt input with file attachments.
|
|
3
|
+
*
|
|
4
|
+
* Attachments render as cards ABOVE the composer (outside overflow-clip).
|
|
5
|
+
* The + button triggers a native file input via a <label> (most reliable).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useId, useState } from "react";
|
|
9
|
+
import type { PromptInputMessage } from "./ai-elements/prompt-input";
|
|
10
|
+
import {
|
|
11
|
+
PromptInput,
|
|
12
|
+
PromptInputBody,
|
|
13
|
+
PromptInputTextarea,
|
|
14
|
+
PromptInputSubmit,
|
|
15
|
+
} from "./ai-elements/prompt-input";
|
|
16
|
+
import {
|
|
17
|
+
AudioLinesIcon,
|
|
18
|
+
FileSpreadsheetIcon,
|
|
19
|
+
FileTextIcon,
|
|
20
|
+
ImageIcon,
|
|
21
|
+
MicIcon,
|
|
22
|
+
PlusIcon,
|
|
23
|
+
XIcon,
|
|
24
|
+
FileIcon as LucideFileIcon,
|
|
25
|
+
} from "lucide-react";
|
|
26
|
+
|
|
27
|
+
type InputStatus = "ready" | "streaming" | "submitted";
|
|
28
|
+
|
|
29
|
+
export interface ChatInputProps {
|
|
30
|
+
onSend: (text: string, files: File[]) => void;
|
|
31
|
+
onStop?: () => void;
|
|
32
|
+
status?: InputStatus;
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getExt(name: string): string {
|
|
37
|
+
const dot = name.lastIndexOf(".");
|
|
38
|
+
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getTypeLabel(ext: string): string {
|
|
42
|
+
const map: Record<string, string> = {
|
|
43
|
+
pdf: "PDF", doc: "Word", docx: "Word", txt: "Text", rtf: "Rich Text",
|
|
44
|
+
csv: "Spreadsheet", xls: "Excel", xlsx: "Excel",
|
|
45
|
+
png: "Image", jpg: "Image", jpeg: "Image", gif: "Image", svg: "Image",
|
|
46
|
+
zip: "Zip Archive", rar: "Archive", "7z": "Archive",
|
|
47
|
+
};
|
|
48
|
+
return map[ext] ?? (ext ? ext.toUpperCase() : "File");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** File type icon matching @houston-ai/workspace's FileRow icons */
|
|
52
|
+
function AttachmentIcon({ ext }: { ext: string }) {
|
|
53
|
+
if (ext === "pdf") {
|
|
54
|
+
return (
|
|
55
|
+
<div className="size-8 rounded-md bg-[#E5252A] flex items-center justify-center shrink-0">
|
|
56
|
+
<svg className="size-4" viewBox="0 0 16 16" fill="none">
|
|
57
|
+
<text
|
|
58
|
+
x="8" y="11.5" textAnchor="middle" fill="white"
|
|
59
|
+
fontSize="8" fontWeight="700" fontFamily="system-ui, sans-serif"
|
|
60
|
+
>
|
|
61
|
+
PDF
|
|
62
|
+
</text>
|
|
63
|
+
</svg>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (["xlsx", "xls", "csv"].includes(ext)) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="size-8 rounded-md bg-[#34A853] flex items-center justify-center shrink-0">
|
|
70
|
+
<FileSpreadsheetIcon className="size-4 text-white" strokeWidth={2} />
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (["doc", "docx", "txt", "rtf"].includes(ext)) {
|
|
75
|
+
return (
|
|
76
|
+
<div className="size-8 rounded-md bg-[#4285F4] flex items-center justify-center shrink-0">
|
|
77
|
+
<FileTextIcon className="size-4 text-white" strokeWidth={2} />
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (["png", "jpg", "jpeg", "gif", "svg", "webp", "tif", "tiff"].includes(ext)) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="size-8 rounded-md bg-[#9333EA] flex items-center justify-center shrink-0">
|
|
84
|
+
<ImageIcon className="size-4 text-white" strokeWidth={2} />
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return (
|
|
89
|
+
<div className="size-8 rounded-md bg-stone-400 flex items-center justify-center shrink-0">
|
|
90
|
+
<LucideFileIcon className="size-4 text-white" strokeWidth={2} />
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function ChatInput({
|
|
96
|
+
onSend,
|
|
97
|
+
onStop,
|
|
98
|
+
status = "ready",
|
|
99
|
+
placeholder = "Type a message...",
|
|
100
|
+
}: ChatInputProps) {
|
|
101
|
+
const [text, setText] = useState("");
|
|
102
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
103
|
+
const fileInputId = useId();
|
|
104
|
+
|
|
105
|
+
const handleTextChange = useCallback(
|
|
106
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value),
|
|
107
|
+
[],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const handleSubmit = useCallback(
|
|
111
|
+
(message: PromptInputMessage) => {
|
|
112
|
+
const trimmed = message.text?.trim();
|
|
113
|
+
if (!trimmed && files.length === 0) return;
|
|
114
|
+
onSend(trimmed ?? "", files);
|
|
115
|
+
setText("");
|
|
116
|
+
setFiles([]);
|
|
117
|
+
},
|
|
118
|
+
[onSend, files],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const handleFileChange = useCallback(
|
|
122
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
123
|
+
if (!e.target.files || e.target.files.length === 0) return;
|
|
124
|
+
setFiles((prev) => [...prev, ...Array.from(e.target.files!)]);
|
|
125
|
+
e.target.value = "";
|
|
126
|
+
},
|
|
127
|
+
[],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const removeFile = useCallback((index: number) => {
|
|
131
|
+
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const hasContent = text.trim().length > 0 || files.length > 0;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="shrink-0 px-4 pb-6 pt-2">
|
|
138
|
+
<div className="max-w-3xl mx-auto">
|
|
139
|
+
{/* Native file input — hidden, triggered by label */}
|
|
140
|
+
<input
|
|
141
|
+
id={fileInputId}
|
|
142
|
+
type="file"
|
|
143
|
+
multiple
|
|
144
|
+
className="sr-only"
|
|
145
|
+
onChange={handleFileChange}
|
|
146
|
+
tabIndex={-1}
|
|
147
|
+
/>
|
|
148
|
+
|
|
149
|
+
{/* Attachment cards — ABOVE the composer, always-visible scrollbar */}
|
|
150
|
+
{files.length > 0 && (
|
|
151
|
+
<div
|
|
152
|
+
className="flex gap-2 pb-1 mb-2 overflow-x-auto"
|
|
153
|
+
style={{ scrollbarWidth: "thin" }}
|
|
154
|
+
>
|
|
155
|
+
{files.map((file, idx) => {
|
|
156
|
+
const ext = getExt(file.name);
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
key={`${file.name}-${idx}`}
|
|
160
|
+
className="relative flex items-center gap-2.5 rounded-xl border border-black/[0.08] bg-white pl-2.5 pr-8 py-2 min-w-0 shrink-0 max-w-[240px] shadow-sm"
|
|
161
|
+
>
|
|
162
|
+
<AttachmentIcon ext={ext} />
|
|
163
|
+
<div className="min-w-0">
|
|
164
|
+
<p className="text-xs font-medium text-foreground truncate leading-tight">
|
|
165
|
+
{file.name}
|
|
166
|
+
</p>
|
|
167
|
+
<p className="text-[10px] text-muted-foreground leading-tight">
|
|
168
|
+
{getTypeLabel(ext)}
|
|
169
|
+
</p>
|
|
170
|
+
</div>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={() => removeFile(idx)}
|
|
174
|
+
className="absolute top-1.5 right-1.5 size-4 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70 transition-colors"
|
|
175
|
+
aria-label={`Remove ${file.name}`}
|
|
176
|
+
>
|
|
177
|
+
<XIcon className="size-2.5" strokeWidth={3} />
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
})}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
<PromptInput onSubmit={handleSubmit}>
|
|
186
|
+
{/* + button — label triggers file input natively */}
|
|
187
|
+
<div className="flex items-center [grid-area:leading]">
|
|
188
|
+
<label
|
|
189
|
+
htmlFor={fileInputId}
|
|
190
|
+
className="flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-accent transition-colors cursor-pointer"
|
|
191
|
+
aria-label="Attach files"
|
|
192
|
+
>
|
|
193
|
+
<PlusIcon className="size-5" />
|
|
194
|
+
</label>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<PromptInputBody>
|
|
198
|
+
<PromptInputTextarea
|
|
199
|
+
onChange={handleTextChange}
|
|
200
|
+
value={text}
|
|
201
|
+
placeholder={placeholder}
|
|
202
|
+
/>
|
|
203
|
+
</PromptInputBody>
|
|
204
|
+
|
|
205
|
+
<div className="flex items-center gap-1.5 [grid-area:trailing]">
|
|
206
|
+
{status === "ready" && (
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
className="flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-accent transition-colors"
|
|
210
|
+
aria-label="Dictate"
|
|
211
|
+
>
|
|
212
|
+
<MicIcon className="size-5" />
|
|
213
|
+
</button>
|
|
214
|
+
)}
|
|
215
|
+
{!hasContent && status === "ready" ? (
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-primary-foreground transition-colors"
|
|
219
|
+
aria-label="Voice mode"
|
|
220
|
+
>
|
|
221
|
+
<AudioLinesIcon className="size-5" />
|
|
222
|
+
</button>
|
|
223
|
+
) : (
|
|
224
|
+
<PromptInputSubmit status={status} onStop={onStop} />
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
</PromptInput>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatPanel -- THE single chat experience component.
|
|
3
|
+
* Follows the Vercel AI Elements chatbot example exactly.
|
|
4
|
+
* Generic version: accepts feedItems/status as props, no store dependencies.
|
|
5
|
+
*/
|
|
6
|
+
import { useMemo } from "react";
|
|
7
|
+
import type { FeedItem } from "./types";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
Conversation,
|
|
12
|
+
ConversationContent,
|
|
13
|
+
ConversationScrollButton,
|
|
14
|
+
} from "./ai-elements/conversation";
|
|
15
|
+
import {
|
|
16
|
+
Message,
|
|
17
|
+
MessageContent,
|
|
18
|
+
MessageResponse,
|
|
19
|
+
} from "./ai-elements/message";
|
|
20
|
+
import {
|
|
21
|
+
Reasoning,
|
|
22
|
+
ReasoningContent,
|
|
23
|
+
ReasoningTrigger,
|
|
24
|
+
} from "./ai-elements/reasoning";
|
|
25
|
+
|
|
26
|
+
import { feedItemsToMessages, ToolsAndCards } from "./chat-helpers";
|
|
27
|
+
import type { ToolsAndCardsProps } from "./chat-helpers";
|
|
28
|
+
import { ChatInput } from "./chat-input";
|
|
29
|
+
import { Shimmer } from "./ai-elements/shimmer";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// ChatPanel props
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
type ChatStatus = "ready" | "streaming" | "submitted";
|
|
36
|
+
|
|
37
|
+
export interface ChatPanelProps {
|
|
38
|
+
sessionKey: string;
|
|
39
|
+
feedItems: FeedItem[];
|
|
40
|
+
onSend: (text: string, files: File[]) => void;
|
|
41
|
+
onStop?: () => void;
|
|
42
|
+
onBack?: () => void;
|
|
43
|
+
isLoading: boolean;
|
|
44
|
+
placeholder?: string;
|
|
45
|
+
emptyState?: ReactNode;
|
|
46
|
+
/** Override status derivation. If not provided, status is derived from feedItems. */
|
|
47
|
+
status?: ChatStatus;
|
|
48
|
+
/**
|
|
49
|
+
* Custom loading indicator shown when status is "submitted" and no messages yet.
|
|
50
|
+
* Defaults to a shimmering "Thinking..." text.
|
|
51
|
+
*/
|
|
52
|
+
thinkingIndicator?: ReactNode;
|
|
53
|
+
/**
|
|
54
|
+
* Optional transform applied to each assistant message's content before rendering.
|
|
55
|
+
* Return { content, extra } where content is the cleaned text and extra is
|
|
56
|
+
* an optional ReactNode rendered below the message response.
|
|
57
|
+
*/
|
|
58
|
+
transformContent?: (content: string) => {
|
|
59
|
+
content: string;
|
|
60
|
+
extra?: ReactNode;
|
|
61
|
+
};
|
|
62
|
+
/** Props forwarded to ToolsAndCards for custom tool rendering */
|
|
63
|
+
toolLabels?: ToolsAndCardsProps["toolLabels"];
|
|
64
|
+
isSpecialTool?: ToolsAndCardsProps["isSpecialTool"];
|
|
65
|
+
renderToolResult?: ToolsAndCardsProps["renderToolResult"];
|
|
66
|
+
/** Optional callback to render an avatar for a message (e.g., channel logo). */
|
|
67
|
+
renderMessageAvatar?: (msg: import("./feed-to-messages").ChatMessage) => ReactNode | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function deriveStatus(items: FeedItem[], isLoading: boolean): ChatStatus {
|
|
71
|
+
const last = items[items.length - 1];
|
|
72
|
+
if (
|
|
73
|
+
last?.feed_type === "assistant_text_streaming" ||
|
|
74
|
+
last?.feed_type === "thinking_streaming" ||
|
|
75
|
+
last?.feed_type === "thinking" ||
|
|
76
|
+
last?.feed_type === "tool_call" ||
|
|
77
|
+
last?.feed_type === "tool_result"
|
|
78
|
+
)
|
|
79
|
+
return "streaming";
|
|
80
|
+
if (last?.feed_type === "user_message") return "submitted";
|
|
81
|
+
if (isLoading && items.length === 0) return "submitted";
|
|
82
|
+
return "ready";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const DefaultThinkingIndicator = () => (
|
|
86
|
+
<div className="py-1">
|
|
87
|
+
<Shimmer duration={2}>Thinking...</Shimmer>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
export function ChatPanel({
|
|
92
|
+
feedItems,
|
|
93
|
+
onSend,
|
|
94
|
+
onStop,
|
|
95
|
+
onBack,
|
|
96
|
+
isLoading,
|
|
97
|
+
placeholder = "Type a message...",
|
|
98
|
+
emptyState,
|
|
99
|
+
status: statusProp,
|
|
100
|
+
thinkingIndicator,
|
|
101
|
+
transformContent,
|
|
102
|
+
toolLabels,
|
|
103
|
+
isSpecialTool,
|
|
104
|
+
renderToolResult,
|
|
105
|
+
renderMessageAvatar,
|
|
106
|
+
}: ChatPanelProps) {
|
|
107
|
+
const status = statusProp ?? deriveStatus(feedItems, isLoading);
|
|
108
|
+
const messages = useMemo(() => feedItemsToMessages(feedItems), [feedItems]);
|
|
109
|
+
const hasMessages = messages.length > 0;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
|
113
|
+
{onBack && (
|
|
114
|
+
<div className="max-w-3xl mx-auto w-full px-4 pt-3">
|
|
115
|
+
<button
|
|
116
|
+
onClick={onBack}
|
|
117
|
+
className="text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
|
118
|
+
>
|
|
119
|
+
<span>←</span> Back to chats
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
{hasMessages || status !== "ready" ? (
|
|
124
|
+
<Conversation className="flex-1 min-h-0">
|
|
125
|
+
<ConversationContent className="max-w-3xl mx-auto">
|
|
126
|
+
{messages.map((msg, idx) => {
|
|
127
|
+
const isLastMsg = idx === messages.length - 1;
|
|
128
|
+
const streaming = msg.isStreaming && isLastMsg;
|
|
129
|
+
return (
|
|
130
|
+
<Message from={msg.from} key={msg.key} avatar={renderMessageAvatar?.(msg)}>
|
|
131
|
+
<div>
|
|
132
|
+
{msg.reasoning && (
|
|
133
|
+
<Reasoning
|
|
134
|
+
isStreaming={msg.reasoning.isStreaming && isLastMsg}
|
|
135
|
+
defaultOpen={msg.reasoning.isStreaming && isLastMsg}
|
|
136
|
+
>
|
|
137
|
+
<ReasoningTrigger />
|
|
138
|
+
<ReasoningContent>
|
|
139
|
+
{msg.reasoning.content}
|
|
140
|
+
</ReasoningContent>
|
|
141
|
+
</Reasoning>
|
|
142
|
+
)}
|
|
143
|
+
{msg.tools.length > 0 && (
|
|
144
|
+
<ToolsAndCards
|
|
145
|
+
tools={msg.tools}
|
|
146
|
+
isStreaming={streaming}
|
|
147
|
+
toolLabels={toolLabels}
|
|
148
|
+
isSpecialTool={isSpecialTool}
|
|
149
|
+
renderToolResult={renderToolResult}
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
{msg.content && (() => {
|
|
153
|
+
const transformed = msg.from === "assistant" && transformContent
|
|
154
|
+
? transformContent(msg.content)
|
|
155
|
+
: null;
|
|
156
|
+
const displayContent = transformed?.content ?? msg.content;
|
|
157
|
+
return (
|
|
158
|
+
<MessageContent>
|
|
159
|
+
<MessageResponse isAnimating={streaming}>
|
|
160
|
+
{displayContent}
|
|
161
|
+
</MessageResponse>
|
|
162
|
+
{transformed?.extra}
|
|
163
|
+
</MessageContent>
|
|
164
|
+
);
|
|
165
|
+
})()}
|
|
166
|
+
</div>
|
|
167
|
+
</Message>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
{status === "submitted" && (
|
|
171
|
+
<Message from="assistant">
|
|
172
|
+
<MessageContent>
|
|
173
|
+
{thinkingIndicator ?? <DefaultThinkingIndicator />}
|
|
174
|
+
</MessageContent>
|
|
175
|
+
</Message>
|
|
176
|
+
)}
|
|
177
|
+
</ConversationContent>
|
|
178
|
+
<ConversationScrollButton />
|
|
179
|
+
</Conversation>
|
|
180
|
+
) : (
|
|
181
|
+
<div className="flex-1 min-h-0 flex items-center justify-center">
|
|
182
|
+
{emptyState}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<ChatInput
|
|
187
|
+
onSend={onSend}
|
|
188
|
+
onStop={onStop}
|
|
189
|
+
status={status}
|
|
190
|
+
placeholder={placeholder}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { FeedItem } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Smart-merge a new FeedItem into an existing feed array.
|
|
5
|
+
*
|
|
6
|
+
* Handles streaming replacement logic:
|
|
7
|
+
* - `thinking_streaming` replaces previous `thinking_streaming`
|
|
8
|
+
* - `thinking` (final) replaces last `thinking_streaming`
|
|
9
|
+
* - `assistant_text_streaming` replaces previous `assistant_text_streaming`
|
|
10
|
+
* - `assistant_text` (final) replaces last `assistant_text_streaming`
|
|
11
|
+
* - Everything else is appended.
|
|
12
|
+
*
|
|
13
|
+
* Use this in your Zustand/Redux store to avoid duplicating merge logic.
|
|
14
|
+
*/
|
|
15
|
+
export function mergeFeedItem(items: FeedItem[], item: FeedItem): FeedItem[] {
|
|
16
|
+
const last = items[items.length - 1];
|
|
17
|
+
|
|
18
|
+
if (item.feed_type === "thinking_streaming") {
|
|
19
|
+
if (last?.feed_type === "thinking_streaming") {
|
|
20
|
+
return [...items.slice(0, -1), item];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (item.feed_type === "thinking") {
|
|
25
|
+
if (last?.feed_type === "thinking_streaming") {
|
|
26
|
+
return [...items.slice(0, -1), item];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (item.feed_type === "assistant_text_streaming") {
|
|
31
|
+
if (last?.feed_type === "assistant_text_streaming") {
|
|
32
|
+
return [...items.slice(0, -1), item];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (item.feed_type === "assistant_text") {
|
|
37
|
+
if (last?.feed_type === "assistant_text_streaming") {
|
|
38
|
+
return [...items.slice(0, -1), item];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return [...items, item];
|
|
43
|
+
}
|