@arcote.tech/arc-chat 0.4.7 → 0.5.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/package.json +6 -6
- package/src/aggregates/message.ts +68 -78
- package/src/chat-builder.ts +75 -51
- package/src/index.ts +22 -4
- package/src/listeners/ai-generation-listener.ts +293 -0
- package/src/react/index.ts +8 -9
- package/src/react/use-chat.ts +260 -0
- package/src/routes/chat-stream-route.ts +31 -0
- package/src/routes/tool-results-route.ts +49 -0
- package/src/streaming/stream-registry.ts +146 -0
- package/src/aggregates/conversation.ts +0 -151
- package/src/react/chat-input.tsx +0 -79
- package/src/react/chat-message.tsx +0 -100
- package/src/react/chat.tsx +0 -117
- package/src/react/question-tabs.tsx +0 -157
- package/src/react/tool-use-block.tsx +0 -34
- package/src/react/types.ts +0 -36
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ChatStreamEvent, ToolResult } from "@arcote.tech/arc-ai";
|
|
2
|
+
|
|
3
|
+
// ─── Stream Session ─────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface StreamSession {
|
|
6
|
+
readonly sessionId: string;
|
|
7
|
+
push(event: ChatStreamEvent): void;
|
|
8
|
+
createReadableStream(): ReadableStream<Uint8Array>;
|
|
9
|
+
waitForClientToolResults(timeoutMs?: number): Promise<ToolResult[]>;
|
|
10
|
+
resolveClientToolResults(results: ToolResult[]): void;
|
|
11
|
+
close(): void;
|
|
12
|
+
isClosed(): boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── Registry ───────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const sessions = new Map<string, StreamSession>();
|
|
18
|
+
|
|
19
|
+
export function createStreamSession(sessionId: string): StreamSession {
|
|
20
|
+
const existing = sessions.get(sessionId);
|
|
21
|
+
if (existing) return existing;
|
|
22
|
+
|
|
23
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
24
|
+
let closed = false;
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const buffer: ChatStreamEvent[] = [];
|
|
27
|
+
|
|
28
|
+
// Client tool results coordination
|
|
29
|
+
let toolResultsResolve: ((results: ToolResult[]) => void) | null = null;
|
|
30
|
+
|
|
31
|
+
// Keep-alive interval
|
|
32
|
+
let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
33
|
+
|
|
34
|
+
function encode(event: ChatStreamEvent): Uint8Array {
|
|
35
|
+
return encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function startKeepAlive() {
|
|
39
|
+
keepAliveInterval = setInterval(() => {
|
|
40
|
+
if (controller && !closed) {
|
|
41
|
+
try {
|
|
42
|
+
controller.enqueue(encoder.encode(`: ping\n\n`));
|
|
43
|
+
} catch {
|
|
44
|
+
// Controller closed, clean up
|
|
45
|
+
cleanup();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, 5000);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cleanup() {
|
|
52
|
+
if (keepAliveInterval) {
|
|
53
|
+
clearInterval(keepAliveInterval);
|
|
54
|
+
keepAliveInterval = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const session: StreamSession = {
|
|
59
|
+
sessionId,
|
|
60
|
+
|
|
61
|
+
push(event: ChatStreamEvent) {
|
|
62
|
+
if (closed) return;
|
|
63
|
+
buffer.push(event);
|
|
64
|
+
if (controller) {
|
|
65
|
+
try {
|
|
66
|
+
controller.enqueue(encode(event));
|
|
67
|
+
} catch {
|
|
68
|
+
// Client disconnected, ignore
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
createReadableStream(): ReadableStream<Uint8Array> {
|
|
74
|
+
return new ReadableStream<Uint8Array>({
|
|
75
|
+
start(ctrl) {
|
|
76
|
+
controller = ctrl;
|
|
77
|
+
|
|
78
|
+
// Replay buffered events for late-connecting clients
|
|
79
|
+
for (const event of buffer) {
|
|
80
|
+
ctrl.enqueue(encode(event));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
startKeepAlive();
|
|
84
|
+
},
|
|
85
|
+
cancel() {
|
|
86
|
+
controller = null;
|
|
87
|
+
cleanup();
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
waitForClientToolResults(timeoutMs = 60_000): Promise<ToolResult[]> {
|
|
93
|
+
return new Promise<ToolResult[]>((resolve, reject) => {
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
toolResultsResolve = null;
|
|
96
|
+
reject(new Error("Client tool results timeout"));
|
|
97
|
+
}, timeoutMs);
|
|
98
|
+
|
|
99
|
+
toolResultsResolve = (results) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
toolResultsResolve = null;
|
|
102
|
+
resolve(results);
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
resolveClientToolResults(results: ToolResult[]) {
|
|
108
|
+
if (toolResultsResolve) {
|
|
109
|
+
toolResultsResolve(results);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
close() {
|
|
114
|
+
if (closed) return;
|
|
115
|
+
closed = true;
|
|
116
|
+
cleanup();
|
|
117
|
+
if (controller) {
|
|
118
|
+
try {
|
|
119
|
+
controller.close();
|
|
120
|
+
} catch {
|
|
121
|
+
// Already closed
|
|
122
|
+
}
|
|
123
|
+
controller = null;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
isClosed() {
|
|
128
|
+
return closed;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
sessions.set(sessionId, session);
|
|
133
|
+
return session;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getStreamSession(sessionId: string): StreamSession | undefined {
|
|
137
|
+
return sessions.get(sessionId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function deleteStreamSession(sessionId: string): void {
|
|
141
|
+
const session = sessions.get(sessionId);
|
|
142
|
+
if (session) {
|
|
143
|
+
session.close();
|
|
144
|
+
sessions.delete(sessionId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
/// <reference path="../arc.d.ts" />
|
|
2
|
-
import {
|
|
3
|
-
aggregate,
|
|
4
|
-
date,
|
|
5
|
-
id,
|
|
6
|
-
number,
|
|
7
|
-
string,
|
|
8
|
-
type ArcId,
|
|
9
|
-
} from "@arcote.tech/arc";
|
|
10
|
-
import type { Token } from "@arcote.tech/arc-auth";
|
|
11
|
-
|
|
12
|
-
// ─── ID ──────────────────────────────────────────────────────────
|
|
13
|
-
|
|
14
|
-
export const createConversationId = <const Name extends string>(data: {
|
|
15
|
-
name: Name;
|
|
16
|
-
}) => id(`${data.name}Conversation`);
|
|
17
|
-
|
|
18
|
-
export type ConversationId<Name extends string = string> = ReturnType<
|
|
19
|
-
typeof createConversationId<Name>
|
|
20
|
-
>;
|
|
21
|
-
|
|
22
|
-
// ─── Aggregate ───────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
export type ConversationAggregateData = {
|
|
25
|
-
name: string;
|
|
26
|
-
conversationId: ArcId<any>;
|
|
27
|
-
accountId: ArcId<any>;
|
|
28
|
-
userToken: Token;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export const createConversationAggregate = <
|
|
32
|
-
const Data extends ConversationAggregateData,
|
|
33
|
-
>(
|
|
34
|
-
data: Data,
|
|
35
|
-
) => {
|
|
36
|
-
const { conversationId, accountId, userToken } = data;
|
|
37
|
-
|
|
38
|
-
return aggregate(
|
|
39
|
-
`${data.name}Conversations`,
|
|
40
|
-
conversationId,
|
|
41
|
-
{
|
|
42
|
-
accountId,
|
|
43
|
-
title: string(),
|
|
44
|
-
model: string(),
|
|
45
|
-
createdAt: date(),
|
|
46
|
-
lastMessageAt: date().optional(),
|
|
47
|
-
messageCount: number(),
|
|
48
|
-
},
|
|
49
|
-
)
|
|
50
|
-
.publicEvent(
|
|
51
|
-
"conversationCreated",
|
|
52
|
-
{
|
|
53
|
-
conversationId,
|
|
54
|
-
accountId,
|
|
55
|
-
title: string(),
|
|
56
|
-
model: string(),
|
|
57
|
-
},
|
|
58
|
-
async (ctx, event) => {
|
|
59
|
-
const p = event.payload;
|
|
60
|
-
await ctx.set(p.conversationId, {
|
|
61
|
-
accountId: p.accountId,
|
|
62
|
-
title: p.title,
|
|
63
|
-
model: p.model,
|
|
64
|
-
createdAt: event.createdAt,
|
|
65
|
-
messageCount: 0,
|
|
66
|
-
});
|
|
67
|
-
},
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
.publicEvent(
|
|
71
|
-
"conversationRenamed",
|
|
72
|
-
{
|
|
73
|
-
conversationId,
|
|
74
|
-
title: string(),
|
|
75
|
-
},
|
|
76
|
-
async (ctx, event) => {
|
|
77
|
-
await ctx.modify(event.payload.conversationId, {
|
|
78
|
-
title: event.payload.title,
|
|
79
|
-
});
|
|
80
|
-
},
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
.publicEvent(
|
|
84
|
-
"conversationUpdated",
|
|
85
|
-
{
|
|
86
|
-
conversationId,
|
|
87
|
-
messageCount: number(),
|
|
88
|
-
},
|
|
89
|
-
async (ctx, event) => {
|
|
90
|
-
await ctx.modify(event.payload.conversationId, {
|
|
91
|
-
lastMessageAt: event.createdAt,
|
|
92
|
-
messageCount: event.payload.messageCount,
|
|
93
|
-
});
|
|
94
|
-
},
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
.mutateMethod(
|
|
98
|
-
"create",
|
|
99
|
-
{
|
|
100
|
-
params: {
|
|
101
|
-
title: string().optional(),
|
|
102
|
-
model: string(),
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
ONLY_SERVER &&
|
|
106
|
-
(async (ctx, params) => {
|
|
107
|
-
const cId = conversationId.generate();
|
|
108
|
-
const aId = ctx.$auth.params.accountId;
|
|
109
|
-
|
|
110
|
-
await ctx.conversationCreated.emit({
|
|
111
|
-
conversationId: cId,
|
|
112
|
-
accountId: accountId.parse(aId),
|
|
113
|
-
title: params.title ?? "New conversation",
|
|
114
|
-
model: params.model,
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
return { conversationId: cId };
|
|
118
|
-
}),
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
.mutateMethod(
|
|
122
|
-
"rename",
|
|
123
|
-
{
|
|
124
|
-
params: {
|
|
125
|
-
_id: conversationId,
|
|
126
|
-
title: string().minLength(1),
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
ONLY_SERVER &&
|
|
130
|
-
(async (ctx, params) => {
|
|
131
|
-
await ctx.conversationRenamed.emit({
|
|
132
|
-
conversationId: params._id,
|
|
133
|
-
title: params.title,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
return { success: true };
|
|
137
|
-
}),
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
.clientQuery("getAll", async (ctx) => ctx.$query.find({}))
|
|
141
|
-
.clientQuery(
|
|
142
|
-
"getById",
|
|
143
|
-
async (ctx, params: { _id: string }) =>
|
|
144
|
-
ctx.$query.findOne({ _id: params._id }),
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
.protectBy(userToken, (p) => ({ accountId: p.accountId }))
|
|
148
|
-
;
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
export type ConversationAggregate = ReturnType<typeof createConversationAggregate>;
|
package/src/react/chat-input.tsx
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { Trans } from "@arcote.tech/platform";
|
|
2
|
-
import { Box, Button, TextareaField, SearchSelect } from "@arcote.tech/arc-ds";
|
|
3
|
-
import { Send, Globe } from "lucide-react";
|
|
4
|
-
import { useState, type ReactNode } from "react";
|
|
5
|
-
import type { ChatModel, SendMessageOptions } from "./types";
|
|
6
|
-
|
|
7
|
-
interface ChatInputProps {
|
|
8
|
-
onSend: (message: string, options: SendMessageOptions) => void;
|
|
9
|
-
models: ChatModel[];
|
|
10
|
-
defaultModel?: string;
|
|
11
|
-
/** Extra toolbar content (e.g., attach menu) rendered after web search toggle */
|
|
12
|
-
toolbar?: ReactNode;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function ChatInput({
|
|
16
|
-
onSend,
|
|
17
|
-
models,
|
|
18
|
-
defaultModel,
|
|
19
|
-
toolbar,
|
|
20
|
-
}: ChatInputProps) {
|
|
21
|
-
const [message, setMessage] = useState("");
|
|
22
|
-
const [model, setModel] = useState(defaultModel ?? models[0]?.value ?? "");
|
|
23
|
-
const [webSearch, setWebSearch] = useState(false);
|
|
24
|
-
|
|
25
|
-
const handleSend = () => {
|
|
26
|
-
const text = message.trim();
|
|
27
|
-
if (!text) return;
|
|
28
|
-
onSend(text, { model, webSearch });
|
|
29
|
-
setMessage("");
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<Box className="p-3 space-y-2">
|
|
34
|
-
{/* Input + send */}
|
|
35
|
-
<div className="flex items-end gap-2">
|
|
36
|
-
<div className="flex-1">
|
|
37
|
-
<TextareaField
|
|
38
|
-
value={message}
|
|
39
|
-
onChange={(val) => setMessage(val ?? "")}
|
|
40
|
-
placeholder="Napisz wiadomość..."
|
|
41
|
-
rows={1}
|
|
42
|
-
/>
|
|
43
|
-
</div>
|
|
44
|
-
<Button
|
|
45
|
-
size="sm"
|
|
46
|
-
icon={Send}
|
|
47
|
-
onClick={handleSend}
|
|
48
|
-
disabled={!message.trim()}
|
|
49
|
-
/>
|
|
50
|
-
</div>
|
|
51
|
-
|
|
52
|
-
{/* Toolbar */}
|
|
53
|
-
<div className="flex items-center gap-1.5">
|
|
54
|
-
<div className="w-[130px]">
|
|
55
|
-
<SearchSelect
|
|
56
|
-
value={model}
|
|
57
|
-
onChange={setModel}
|
|
58
|
-
options={models}
|
|
59
|
-
placeholder="Model..."
|
|
60
|
-
position="absolute"
|
|
61
|
-
direction="up"
|
|
62
|
-
size="sm"
|
|
63
|
-
allowClear={false}
|
|
64
|
-
/>
|
|
65
|
-
</div>
|
|
66
|
-
|
|
67
|
-
<Button
|
|
68
|
-
variant={webSearch ? "default" : "ghost"}
|
|
69
|
-
size="xs"
|
|
70
|
-
icon={Globe}
|
|
71
|
-
label={<Trans>Web</Trans>}
|
|
72
|
-
onClick={() => setWebSearch((v) => !v)}
|
|
73
|
-
/>
|
|
74
|
-
|
|
75
|
-
{toolbar}
|
|
76
|
-
</div>
|
|
77
|
-
</Box>
|
|
78
|
-
);
|
|
79
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { Trans } from "@arcote.tech/platform";
|
|
2
|
-
import { Button } from "@arcote.tech/arc-ds";
|
|
3
|
-
import { Bot, User, MessageSquare } from "lucide-react";
|
|
4
|
-
import type { ChatMessageData } from "./types";
|
|
5
|
-
import { ToolUseBlock } from "./tool-use-block";
|
|
6
|
-
|
|
7
|
-
interface ChatMessageProps {
|
|
8
|
-
message: ChatMessageData;
|
|
9
|
-
onAnswerQuestions?: () => void;
|
|
10
|
-
onToolUseClick?: (link: string) => void;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function ChatMessage({
|
|
14
|
-
message,
|
|
15
|
-
onAnswerQuestions,
|
|
16
|
-
onToolUseClick,
|
|
17
|
-
}: ChatMessageProps) {
|
|
18
|
-
const isUser = message.role === "user";
|
|
19
|
-
const hasUnansweredQuestions =
|
|
20
|
-
message.questions && message.questions.length > 0;
|
|
21
|
-
|
|
22
|
-
return (
|
|
23
|
-
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
|
|
24
|
-
{/* Avatar */}
|
|
25
|
-
<div
|
|
26
|
-
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full mt-0.5 ${
|
|
27
|
-
isUser ? "bg-primary/10" : "bg-muted"
|
|
28
|
-
}`}
|
|
29
|
-
>
|
|
30
|
-
{isUser ? (
|
|
31
|
-
<User className="h-3.5 w-3.5 text-primary" />
|
|
32
|
-
) : (
|
|
33
|
-
<Bot className="h-3.5 w-3.5 text-muted-foreground" />
|
|
34
|
-
)}
|
|
35
|
-
</div>
|
|
36
|
-
|
|
37
|
-
{/* Content */}
|
|
38
|
-
<div
|
|
39
|
-
className={`flex-1 min-w-0 space-y-2 ${isUser ? "text-right" : ""}`}
|
|
40
|
-
>
|
|
41
|
-
<div
|
|
42
|
-
className={`inline-block rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
|
|
43
|
-
isUser
|
|
44
|
-
? "bg-primary/10 text-foreground rounded-tr-sm"
|
|
45
|
-
: "bg-card border border-border rounded-tl-sm"
|
|
46
|
-
}`}
|
|
47
|
-
>
|
|
48
|
-
{message.content.split("\n").map((line, i) => (
|
|
49
|
-
<p key={i} className={i > 0 ? "mt-1.5" : ""}>
|
|
50
|
-
{line.startsWith("• ") ? (
|
|
51
|
-
<span className="flex items-start gap-1.5 text-left">
|
|
52
|
-
<span className="text-primary mt-px">•</span>
|
|
53
|
-
<span>{line.slice(2)}</span>
|
|
54
|
-
</span>
|
|
55
|
-
) : (
|
|
56
|
-
line
|
|
57
|
-
)}
|
|
58
|
-
</p>
|
|
59
|
-
))}
|
|
60
|
-
{message.isStreaming && (
|
|
61
|
-
<span className="inline-block w-1.5 h-4 bg-foreground/60 animate-pulse ml-0.5 -mb-0.5" />
|
|
62
|
-
)}
|
|
63
|
-
</div>
|
|
64
|
-
|
|
65
|
-
{/* Tool uses */}
|
|
66
|
-
{message.toolUses && message.toolUses.length > 0 && (
|
|
67
|
-
<div className="space-y-1.5">
|
|
68
|
-
{message.toolUses.map((tu, i) => (
|
|
69
|
-
<ToolUseBlock
|
|
70
|
-
key={i}
|
|
71
|
-
toolUse={tu}
|
|
72
|
-
onClick={
|
|
73
|
-
tu.link && onToolUseClick
|
|
74
|
-
? () => onToolUseClick(tu.link!)
|
|
75
|
-
: undefined
|
|
76
|
-
}
|
|
77
|
-
/>
|
|
78
|
-
))}
|
|
79
|
-
</div>
|
|
80
|
-
)}
|
|
81
|
-
|
|
82
|
-
{/* Questions indicator */}
|
|
83
|
-
{hasUnansweredQuestions && onAnswerQuestions && (
|
|
84
|
-
<div className="flex items-center gap-2">
|
|
85
|
-
<span className="text-xs text-muted-foreground">
|
|
86
|
-
{message.questions!.length}{" "}
|
|
87
|
-
<Trans>pytań do odpowiedzenia</Trans>
|
|
88
|
-
</span>
|
|
89
|
-
<Button
|
|
90
|
-
size="xs"
|
|
91
|
-
icon={MessageSquare}
|
|
92
|
-
label={<Trans>Odpowiedz</Trans>}
|
|
93
|
-
onClick={onAnswerQuestions}
|
|
94
|
-
/>
|
|
95
|
-
</div>
|
|
96
|
-
)}
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
);
|
|
100
|
-
}
|
package/src/react/chat.tsx
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { useState, useRef, useEffect, type ReactNode } from "react";
|
|
2
|
-
import type {
|
|
3
|
-
ChatMessageData,
|
|
4
|
-
ChatModel,
|
|
5
|
-
QuestionAnswers,
|
|
6
|
-
SendMessageOptions,
|
|
7
|
-
} from "./types";
|
|
8
|
-
import { ChatMessage } from "./chat-message";
|
|
9
|
-
import { ChatInput } from "./chat-input";
|
|
10
|
-
import { QuestionTabs } from "./question-tabs";
|
|
11
|
-
|
|
12
|
-
interface ChatProps {
|
|
13
|
-
/** Message history */
|
|
14
|
-
messages: ChatMessageData[];
|
|
15
|
-
/** Available AI models */
|
|
16
|
-
models: ChatModel[];
|
|
17
|
-
/** Default model to use */
|
|
18
|
-
defaultModel?: string;
|
|
19
|
-
/** Called when user sends a message */
|
|
20
|
-
onSend: (message: string, options: SendMessageOptions) => void;
|
|
21
|
-
/** Called when user submits answers to questions */
|
|
22
|
-
onAnswerQuestions?: (
|
|
23
|
-
messageId: string,
|
|
24
|
-
answers: QuestionAnswers,
|
|
25
|
-
) => void;
|
|
26
|
-
/** Called when user clicks a tool use link */
|
|
27
|
-
onToolUseClick?: (link: string) => void;
|
|
28
|
-
/** Header content (title, description) */
|
|
29
|
-
header?: ReactNode;
|
|
30
|
-
/** Sidebar content (consultation plan, etc.) */
|
|
31
|
-
sidebar?: ReactNode;
|
|
32
|
-
/** Extra toolbar content for ChatInput (attach menu, etc.) */
|
|
33
|
-
toolbar?: ReactNode;
|
|
34
|
-
/** Max width class for the message area */
|
|
35
|
-
maxWidth?: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function Chat({
|
|
39
|
-
messages,
|
|
40
|
-
models,
|
|
41
|
-
defaultModel,
|
|
42
|
-
onSend,
|
|
43
|
-
onAnswerQuestions,
|
|
44
|
-
onToolUseClick,
|
|
45
|
-
header,
|
|
46
|
-
sidebar,
|
|
47
|
-
toolbar,
|
|
48
|
-
maxWidth = "max-w-3xl",
|
|
49
|
-
}: ChatProps) {
|
|
50
|
-
const [answeringMessageId, setAnsweringMessageId] = useState<string | null>(
|
|
51
|
-
null,
|
|
52
|
-
);
|
|
53
|
-
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
54
|
-
|
|
55
|
-
const answeringMessage = answeringMessageId
|
|
56
|
-
? messages.find((m) => m.id === answeringMessageId)
|
|
57
|
-
: null;
|
|
58
|
-
|
|
59
|
-
// Auto-scroll on new messages
|
|
60
|
-
useEffect(() => {
|
|
61
|
-
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
62
|
-
}, [messages.length]);
|
|
63
|
-
|
|
64
|
-
return (
|
|
65
|
-
<div className="flex gap-6">
|
|
66
|
-
{/* Sidebar */}
|
|
67
|
-
{sidebar && (
|
|
68
|
-
<div className="hidden lg:block w-64 shrink-0 sticky top-24 self-start">
|
|
69
|
-
{sidebar}
|
|
70
|
-
</div>
|
|
71
|
-
)}
|
|
72
|
-
|
|
73
|
-
{/* Main chat area */}
|
|
74
|
-
<div className="flex-1 min-w-0 space-y-4">
|
|
75
|
-
{/* Header */}
|
|
76
|
-
{header}
|
|
77
|
-
|
|
78
|
-
{/* Messages */}
|
|
79
|
-
<div className={`${maxWidth} mx-auto space-y-4`}>
|
|
80
|
-
{messages.map((msg) => (
|
|
81
|
-
<ChatMessage
|
|
82
|
-
key={msg.id}
|
|
83
|
-
message={msg}
|
|
84
|
-
onAnswerQuestions={
|
|
85
|
-
msg.questions?.length
|
|
86
|
-
? () => setAnsweringMessageId(msg.id)
|
|
87
|
-
: undefined
|
|
88
|
-
}
|
|
89
|
-
onToolUseClick={onToolUseClick}
|
|
90
|
-
/>
|
|
91
|
-
))}
|
|
92
|
-
<div ref={messagesEndRef} />
|
|
93
|
-
</div>
|
|
94
|
-
|
|
95
|
-
{/* Input area — sticky at bottom */}
|
|
96
|
-
<div className={`sticky bottom-0 ${maxWidth} mx-auto w-full pb-4`}>
|
|
97
|
-
{answeringMessage?.questions ? (
|
|
98
|
-
<QuestionTabs
|
|
99
|
-
questions={answeringMessage.questions}
|
|
100
|
-
onSubmit={(answers) => {
|
|
101
|
-
onAnswerQuestions?.(answeringMessageId!, answers);
|
|
102
|
-
setAnsweringMessageId(null);
|
|
103
|
-
}}
|
|
104
|
-
/>
|
|
105
|
-
) : (
|
|
106
|
-
<ChatInput
|
|
107
|
-
onSend={onSend}
|
|
108
|
-
models={models}
|
|
109
|
-
defaultModel={defaultModel}
|
|
110
|
-
toolbar={toolbar}
|
|
111
|
-
/>
|
|
112
|
-
)}
|
|
113
|
-
</div>
|
|
114
|
-
</div>
|
|
115
|
-
</div>
|
|
116
|
-
);
|
|
117
|
-
}
|