@arcote.tech/arc-chat 0.4.6
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 +25 -0
- package/src/aggregates/conversation.ts +151 -0
- package/src/aggregates/message.ts +178 -0
- package/src/arc.d.ts +6 -0
- package/src/chat-builder.ts +76 -0
- package/src/index.ts +25 -0
- package/src/react/chat-input.tsx +79 -0
- package/src/react/chat-message.tsx +100 -0
- package/src/react/chat.tsx +117 -0
- package/src/react/index.ts +16 -0
- package/src/react/question-tabs.tsx +157 -0
- package/src/react/tool-use-block.tsx +34 -0
- package/src/react/types.ts +36 -0
- package/tsconfig.json +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arcote.tech/arc-chat",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.4.6",
|
|
5
|
+
"private": false,
|
|
6
|
+
"description": "Chat module with AI integration for Arc framework",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"type-check": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@arcote.tech/arc": "^0.4.6",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.4.6",
|
|
15
|
+
"@arcote.tech/arc-auth": "^0.4.6",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.4.6",
|
|
17
|
+
"@arcote.tech/platform": "^0.4.6",
|
|
18
|
+
"lucide-react": ">=0.400.0",
|
|
19
|
+
"react": ">=18.0.0",
|
|
20
|
+
"typescript": "^5.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "latest"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
.build();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export type ConversationAggregate = ReturnType<typeof createConversationAggregate>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/// <reference path="../arc.d.ts" />
|
|
2
|
+
import {
|
|
3
|
+
aggregate,
|
|
4
|
+
date,
|
|
5
|
+
id,
|
|
6
|
+
string,
|
|
7
|
+
type ArcId,
|
|
8
|
+
} from "@arcote.tech/arc";
|
|
9
|
+
import type { Token } from "@arcote.tech/arc-auth";
|
|
10
|
+
import type { LLMProvider } from "@arcote.tech/arc-ai";
|
|
11
|
+
|
|
12
|
+
// ─── ID ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const createMessageId = <const Name extends string>(data: {
|
|
15
|
+
name: Name;
|
|
16
|
+
}) => id(`${data.name}Message`);
|
|
17
|
+
|
|
18
|
+
export type MessageId<Name extends string = string> = ReturnType<
|
|
19
|
+
typeof createMessageId<Name>
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
// ─── Aggregate ───────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export type MessageAggregateData = {
|
|
25
|
+
name: string;
|
|
26
|
+
messageId: ArcId<any>;
|
|
27
|
+
conversationId: ArcId<any>;
|
|
28
|
+
accountId: ArcId<any>;
|
|
29
|
+
userToken: Token;
|
|
30
|
+
resolveProvider: (model: string) => LLMProvider | undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const createMessageAggregate = <
|
|
34
|
+
const Data extends MessageAggregateData,
|
|
35
|
+
>(
|
|
36
|
+
data: Data,
|
|
37
|
+
) => {
|
|
38
|
+
const { messageId, conversationId, userToken, resolveProvider } = data;
|
|
39
|
+
|
|
40
|
+
return aggregate(`${data.name}Messages`, messageId, {
|
|
41
|
+
conversationId,
|
|
42
|
+
role: string(),
|
|
43
|
+
content: string(),
|
|
44
|
+
toolCalls: string().optional(),
|
|
45
|
+
toolResults: string().optional(),
|
|
46
|
+
completionId: string().optional(),
|
|
47
|
+
createdAt: date(),
|
|
48
|
+
})
|
|
49
|
+
.publicEvent(
|
|
50
|
+
"messageSent",
|
|
51
|
+
{
|
|
52
|
+
messageId,
|
|
53
|
+
conversationId,
|
|
54
|
+
role: string(),
|
|
55
|
+
content: string(),
|
|
56
|
+
},
|
|
57
|
+
async (ctx, event) => {
|
|
58
|
+
const p = event.payload;
|
|
59
|
+
await ctx.set(p.messageId, {
|
|
60
|
+
conversationId: p.conversationId,
|
|
61
|
+
role: p.role,
|
|
62
|
+
content: p.content,
|
|
63
|
+
createdAt: event.createdAt,
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
.publicEvent(
|
|
69
|
+
"assistantMessageCompleted",
|
|
70
|
+
{
|
|
71
|
+
messageId,
|
|
72
|
+
conversationId,
|
|
73
|
+
content: string(),
|
|
74
|
+
completionId: string().optional(),
|
|
75
|
+
toolCalls: string().optional(),
|
|
76
|
+
toolResults: string().optional(),
|
|
77
|
+
},
|
|
78
|
+
async (ctx, event) => {
|
|
79
|
+
const p = event.payload;
|
|
80
|
+
await ctx.set(p.messageId, {
|
|
81
|
+
conversationId: p.conversationId,
|
|
82
|
+
role: "assistant",
|
|
83
|
+
content: p.content,
|
|
84
|
+
completionId: p.completionId,
|
|
85
|
+
toolCalls: p.toolCalls,
|
|
86
|
+
toolResults: p.toolResults,
|
|
87
|
+
createdAt: event.createdAt,
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
.mutateMethod(
|
|
93
|
+
"sendMessage",
|
|
94
|
+
{
|
|
95
|
+
params: {
|
|
96
|
+
conversationId,
|
|
97
|
+
content: string().minLength(1),
|
|
98
|
+
model: string().optional(),
|
|
99
|
+
tools: string().optional(),
|
|
100
|
+
webSearch: string().optional(),
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
ONLY_SERVER &&
|
|
104
|
+
(async (ctx, params) => {
|
|
105
|
+
const userMsgId = messageId.generate();
|
|
106
|
+
await ctx.messageSent.emit({
|
|
107
|
+
messageId: userMsgId,
|
|
108
|
+
conversationId: params.conversationId,
|
|
109
|
+
role: "user",
|
|
110
|
+
content: params.content,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const history = await ctx.$query.find({
|
|
114
|
+
where: { conversationId: params.conversationId },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const messages = history.map((msg) => ({
|
|
118
|
+
role: msg.role as "user" | "assistant" | "system" | "tool",
|
|
119
|
+
content: msg.content,
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
messages.push({ role: "user", content: params.content });
|
|
123
|
+
|
|
124
|
+
const model = params.model ?? "gpt-4o";
|
|
125
|
+
|
|
126
|
+
const provider = resolveProvider(model);
|
|
127
|
+
if (!provider) {
|
|
128
|
+
return { error: "PROVIDER_NOT_FOUND" as const };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const toolDefs = params.tools
|
|
133
|
+
? JSON.parse(params.tools)
|
|
134
|
+
: undefined;
|
|
135
|
+
const webSearch = params.webSearch === "true";
|
|
136
|
+
|
|
137
|
+
const result = await provider.complete({
|
|
138
|
+
model,
|
|
139
|
+
messages,
|
|
140
|
+
tools: toolDefs,
|
|
141
|
+
webSearch,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const assistantMsgId = messageId.generate();
|
|
145
|
+
await ctx.assistantMessageCompleted.emit({
|
|
146
|
+
messageId: assistantMsgId,
|
|
147
|
+
conversationId: params.conversationId,
|
|
148
|
+
content: result.content,
|
|
149
|
+
toolCalls: result.toolCalls.length
|
|
150
|
+
? JSON.stringify(result.toolCalls)
|
|
151
|
+
: undefined,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
messageId: assistantMsgId,
|
|
156
|
+
content: result.content,
|
|
157
|
+
toolCalls: result.toolCalls,
|
|
158
|
+
finishReason: result.finishReason,
|
|
159
|
+
};
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const errorMessage =
|
|
162
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
163
|
+
return { error: errorMessage };
|
|
164
|
+
}
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
.clientQuery(
|
|
169
|
+
"getByConversation",
|
|
170
|
+
async (ctx, params: { conversationId: string }) =>
|
|
171
|
+
ctx.$query.find({ where: { conversationId: params.conversationId } }),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
.protectBy(userToken, (p) => ({ accountId: p.accountId }))
|
|
175
|
+
.build();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export type MessageAggregate = ReturnType<typeof createMessageAggregate>;
|
package/src/arc.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
aggregateContextElement,
|
|
3
|
+
context,
|
|
4
|
+
type AggregateConstructorAny,
|
|
5
|
+
type ArcContextElement,
|
|
6
|
+
} from "@arcote.tech/arc";
|
|
7
|
+
import type { AccountId, Token } from "@arcote.tech/arc-auth";
|
|
8
|
+
import type { AIBuilder } from "@arcote.tech/arc-ai";
|
|
9
|
+
import { createConversationId, createConversationAggregate } from "./aggregates/conversation";
|
|
10
|
+
import { createMessageId, createMessageAggregate } from "./aggregates/message";
|
|
11
|
+
|
|
12
|
+
export class ChatBuilder<
|
|
13
|
+
ConvId,
|
|
14
|
+
MsgId,
|
|
15
|
+
Conversation extends AggregateConstructorAny,
|
|
16
|
+
Message extends AggregateConstructorAny,
|
|
17
|
+
Elements extends ArcContextElement<any>[],
|
|
18
|
+
> {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly _name: string,
|
|
21
|
+
readonly conversationId: ConvId,
|
|
22
|
+
readonly messageId: MsgId,
|
|
23
|
+
readonly Conversation: Conversation,
|
|
24
|
+
readonly Message: Message,
|
|
25
|
+
readonly elements: Elements,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
build() {
|
|
29
|
+
return {
|
|
30
|
+
context: context(this.elements),
|
|
31
|
+
conversationId: this.conversationId,
|
|
32
|
+
messageId: this.messageId,
|
|
33
|
+
Conversation: this.Conversation,
|
|
34
|
+
Message: this.Message,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function chat<const Name extends string>(config: {
|
|
40
|
+
name: Name;
|
|
41
|
+
accountId: AccountId;
|
|
42
|
+
userToken: Token;
|
|
43
|
+
ai: ReturnType<AIBuilder<any, any, any, any, any, any>["build"]>;
|
|
44
|
+
}) {
|
|
45
|
+
const conversationId = createConversationId({ name: config.name });
|
|
46
|
+
const messageId = createMessageId({ name: config.name });
|
|
47
|
+
|
|
48
|
+
const Conversation = createConversationAggregate({
|
|
49
|
+
name: config.name,
|
|
50
|
+
conversationId,
|
|
51
|
+
accountId: config.accountId,
|
|
52
|
+
userToken: config.userToken,
|
|
53
|
+
});
|
|
54
|
+
const conversationElement = aggregateContextElement(Conversation);
|
|
55
|
+
|
|
56
|
+
const Message = createMessageAggregate({
|
|
57
|
+
name: config.name,
|
|
58
|
+
messageId,
|
|
59
|
+
conversationId,
|
|
60
|
+
accountId: config.accountId,
|
|
61
|
+
userToken: config.userToken,
|
|
62
|
+
resolveProvider: config.ai.resolveProvider,
|
|
63
|
+
});
|
|
64
|
+
const messageElement = aggregateContextElement(Message);
|
|
65
|
+
|
|
66
|
+
const elements = [conversationElement, messageElement];
|
|
67
|
+
|
|
68
|
+
return new ChatBuilder(
|
|
69
|
+
config.name,
|
|
70
|
+
conversationId,
|
|
71
|
+
messageId,
|
|
72
|
+
Conversation,
|
|
73
|
+
Message,
|
|
74
|
+
elements,
|
|
75
|
+
);
|
|
76
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// --- Builder API ---
|
|
2
|
+
export { chat, ChatBuilder } from "./chat-builder";
|
|
3
|
+
|
|
4
|
+
// --- Aggregate factories & types ---
|
|
5
|
+
export { createConversationAggregate, createConversationId } from "./aggregates/conversation";
|
|
6
|
+
export type { ConversationAggregate, ConversationId } from "./aggregates/conversation";
|
|
7
|
+
export { createMessageAggregate, createMessageId } from "./aggregates/message";
|
|
8
|
+
export type { MessageAggregate, MessageId } from "./aggregates/message";
|
|
9
|
+
|
|
10
|
+
// --- React components ---
|
|
11
|
+
export {
|
|
12
|
+
Chat,
|
|
13
|
+
ChatMessage,
|
|
14
|
+
ChatInput,
|
|
15
|
+
QuestionTabs,
|
|
16
|
+
ToolUseBlock,
|
|
17
|
+
} from "./react";
|
|
18
|
+
export type {
|
|
19
|
+
ChatMessageData,
|
|
20
|
+
ChatModel,
|
|
21
|
+
SendMessageOptions,
|
|
22
|
+
ToolUse,
|
|
23
|
+
Question,
|
|
24
|
+
QuestionAnswers,
|
|
25
|
+
} from "./react";
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// --- Components ---
|
|
2
|
+
export { Chat } from "./chat";
|
|
3
|
+
export { ChatMessage } from "./chat-message";
|
|
4
|
+
export { ChatInput } from "./chat-input";
|
|
5
|
+
export { QuestionTabs } from "./question-tabs";
|
|
6
|
+
export { ToolUseBlock } from "./tool-use-block";
|
|
7
|
+
|
|
8
|
+
// --- Types ---
|
|
9
|
+
export type {
|
|
10
|
+
ChatMessageData,
|
|
11
|
+
ChatModel,
|
|
12
|
+
SendMessageOptions,
|
|
13
|
+
ToolUse,
|
|
14
|
+
Question,
|
|
15
|
+
QuestionAnswers,
|
|
16
|
+
} from "./types";
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Trans } from "@arcote.tech/platform";
|
|
2
|
+
import { Box, Button, TextareaField } from "@arcote.tech/arc-ds";
|
|
3
|
+
import { Send, Check } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import type { Question, QuestionAnswers } from "./types";
|
|
6
|
+
|
|
7
|
+
interface QuestionTabsProps {
|
|
8
|
+
questions: Question[];
|
|
9
|
+
onSubmit: (answers: QuestionAnswers) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function QuestionTabs({ questions, onSubmit }: QuestionTabsProps) {
|
|
13
|
+
const [activeTab, setActiveTab] = useState(0);
|
|
14
|
+
const [answers, setAnswers] = useState<QuestionAnswers>(
|
|
15
|
+
() =>
|
|
16
|
+
Object.fromEntries(
|
|
17
|
+
questions.map((q) => [q.id, { selected: [], text: "" }]),
|
|
18
|
+
),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const current = questions[activeTab];
|
|
22
|
+
const currentAnswer = answers[current.id] ?? { selected: [], text: "" };
|
|
23
|
+
|
|
24
|
+
const toggleOption = (option: string) => {
|
|
25
|
+
const selected = currentAnswer.selected.includes(option)
|
|
26
|
+
? currentAnswer.selected.filter((o) => o !== option)
|
|
27
|
+
: [...currentAnswer.selected, option];
|
|
28
|
+
setAnswers((prev) => ({
|
|
29
|
+
...prev,
|
|
30
|
+
[current.id]: { ...prev[current.id], selected },
|
|
31
|
+
}));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const setText = (text: string) => {
|
|
35
|
+
setAnswers((prev) => ({
|
|
36
|
+
...prev,
|
|
37
|
+
[current.id]: { ...prev[current.id], text },
|
|
38
|
+
}));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const hasCustomText = currentAnswer.text.trim().length > 0;
|
|
42
|
+
|
|
43
|
+
const totalAnswered = questions.filter(
|
|
44
|
+
(q) =>
|
|
45
|
+
(answers[q.id]?.selected.length ?? 0) > 0 ||
|
|
46
|
+
(answers[q.id]?.text ?? "").trim().length > 0,
|
|
47
|
+
).length;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Box className="p-0 overflow-hidden">
|
|
51
|
+
{/* Tabs */}
|
|
52
|
+
<div className="flex border-b border-border overflow-x-auto">
|
|
53
|
+
{questions.map((q, i) => {
|
|
54
|
+
const hasAnswer =
|
|
55
|
+
(answers[q.id]?.selected.length ?? 0) > 0 ||
|
|
56
|
+
(answers[q.id]?.text ?? "").trim().length > 0;
|
|
57
|
+
return (
|
|
58
|
+
<button
|
|
59
|
+
key={q.id}
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={() => setActiveTab(i)}
|
|
62
|
+
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium whitespace-nowrap transition-colors border-b-2 -mb-px ${
|
|
63
|
+
i === activeTab
|
|
64
|
+
? "border-primary text-primary"
|
|
65
|
+
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
66
|
+
}`}
|
|
67
|
+
>
|
|
68
|
+
{hasAnswer && (
|
|
69
|
+
<span className="h-1.5 w-1.5 rounded-full bg-primary shrink-0" />
|
|
70
|
+
)}
|
|
71
|
+
{q.label}
|
|
72
|
+
</button>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Content */}
|
|
78
|
+
<div className="p-3 space-y-1.5">
|
|
79
|
+
<p className="text-xs text-muted-foreground mb-2">
|
|
80
|
+
{current.description}
|
|
81
|
+
</p>
|
|
82
|
+
|
|
83
|
+
{/* Options */}
|
|
84
|
+
{current.options.map((option) => {
|
|
85
|
+
const isSelected = currentAnswer.selected.includes(option);
|
|
86
|
+
return (
|
|
87
|
+
<button
|
|
88
|
+
key={option}
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={() => toggleOption(option)}
|
|
91
|
+
className={`flex items-center gap-2.5 w-full rounded-lg px-3 py-2.5 text-left transition-colors ${
|
|
92
|
+
isSelected
|
|
93
|
+
? "bg-primary/10 border border-primary/20"
|
|
94
|
+
: "bg-muted/50 border border-transparent hover:bg-muted"
|
|
95
|
+
}`}
|
|
96
|
+
>
|
|
97
|
+
<div
|
|
98
|
+
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
|
|
99
|
+
isSelected
|
|
100
|
+
? "border-primary bg-primary"
|
|
101
|
+
: "border-input bg-transparent"
|
|
102
|
+
}`}
|
|
103
|
+
>
|
|
104
|
+
{isSelected && (
|
|
105
|
+
<Check className="h-3 w-3 text-primary-foreground" />
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
<span className="text-sm">{option}</span>
|
|
109
|
+
</button>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
|
|
113
|
+
{/* Custom option */}
|
|
114
|
+
<div
|
|
115
|
+
className={`flex items-start gap-2.5 w-full rounded-lg px-3 py-2.5 transition-colors ${
|
|
116
|
+
hasCustomText
|
|
117
|
+
? "bg-primary/10 border border-primary/20"
|
|
118
|
+
: "bg-muted/50 border border-transparent"
|
|
119
|
+
}`}
|
|
120
|
+
>
|
|
121
|
+
<div
|
|
122
|
+
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors mt-0.5 ${
|
|
123
|
+
hasCustomText
|
|
124
|
+
? "border-primary bg-primary"
|
|
125
|
+
: "border-input bg-transparent"
|
|
126
|
+
}`}
|
|
127
|
+
>
|
|
128
|
+
{hasCustomText && (
|
|
129
|
+
<Check className="h-3 w-3 text-primary-foreground" />
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
<div className="flex-1">
|
|
133
|
+
<TextareaField
|
|
134
|
+
value={currentAnswer.text}
|
|
135
|
+
onChange={(val) => setText(val ?? "")}
|
|
136
|
+
placeholder="Własna odpowiedź..."
|
|
137
|
+
rows={1}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Footer */}
|
|
144
|
+
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
|
145
|
+
<span className="text-[10px] text-muted-foreground">
|
|
146
|
+
{totalAnswered}/{questions.length} <Trans>uzupełnione</Trans>
|
|
147
|
+
</span>
|
|
148
|
+
<Button
|
|
149
|
+
size="sm"
|
|
150
|
+
icon={Send}
|
|
151
|
+
label={<Trans>Wyślij</Trans>}
|
|
152
|
+
onClick={() => onSubmit(answers)}
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
</Box>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { CheckCircle2, ArrowRight } from "lucide-react";
|
|
2
|
+
import type { ToolUse } from "./types";
|
|
3
|
+
|
|
4
|
+
interface ToolUseBlockProps {
|
|
5
|
+
toolUse: ToolUse;
|
|
6
|
+
onClick?: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ToolUseBlock({ toolUse, onClick }: ToolUseBlockProps) {
|
|
10
|
+
const Component = onClick ? "button" : "div";
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<Component
|
|
14
|
+
type={onClick ? "button" : undefined}
|
|
15
|
+
onClick={onClick}
|
|
16
|
+
className={`flex items-start gap-3 w-full rounded-xl border border-green-500/20 bg-green-500/5 p-3 text-left transition-colors ${
|
|
17
|
+
onClick ? "hover:bg-green-500/10 cursor-pointer" : ""
|
|
18
|
+
}`}
|
|
19
|
+
>
|
|
20
|
+
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
|
|
21
|
+
<div className="flex-1 min-w-0">
|
|
22
|
+
<p className="text-xs font-medium text-green-700 dark:text-green-400">
|
|
23
|
+
{toolUse.action}
|
|
24
|
+
</p>
|
|
25
|
+
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
|
|
26
|
+
{toolUse.value}
|
|
27
|
+
</p>
|
|
28
|
+
</div>
|
|
29
|
+
{onClick && (
|
|
30
|
+
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
|
|
31
|
+
)}
|
|
32
|
+
</Component>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface ToolUse {
|
|
2
|
+
action: string;
|
|
3
|
+
value: string;
|
|
4
|
+
link?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Question {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
description: string;
|
|
11
|
+
options: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface QuestionAnswers {
|
|
15
|
+
[questionId: string]: { selected: string[]; text: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ChatMessageData {
|
|
19
|
+
id: string;
|
|
20
|
+
role: "user" | "assistant" | "system" | "tool";
|
|
21
|
+
content: string;
|
|
22
|
+
toolUses?: ToolUse[];
|
|
23
|
+
questions?: Question[];
|
|
24
|
+
isStreaming?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChatModel {
|
|
28
|
+
value: string;
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SendMessageOptions {
|
|
33
|
+
model: string;
|
|
34
|
+
webSearch: boolean;
|
|
35
|
+
attachments?: string[];
|
|
36
|
+
}
|
package/tsconfig.json
ADDED