iris-chatbot 5.3.1 → 5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris-chatbot",
3
- "version": "5.3.1",
3
+ "version": "5.5.0",
4
4
  "private": false,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "bin": {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "5.3.1",
3
+ "version": "5.5.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "iris",
9
- "version": "5.3.1",
9
+ "version": "5.5.0",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.72.1",
12
12
  "clsx": "^2.1.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "5.3.1",
3
+ "version": "5.5.0",
4
4
  "private": true,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "engines": {
@@ -1,6 +1,8 @@
1
1
  import OpenAI from "openai";
2
2
  import Anthropic from "@anthropic-ai/sdk";
3
+ import type { BetaContentBlockParam, BetaMessageParam } from "@anthropic-ai/sdk/resources/beta";
3
4
  import type {
5
+ ChatAttachment,
4
6
  ChatConnectionPayload,
5
7
  ChatMessageInput,
6
8
  MemoryContextPayload,
@@ -11,6 +13,10 @@ import type {
11
13
  } from "../../../lib/types";
12
14
  import { DEFAULT_LOCAL_TOOLS_SETTINGS } from "../../../lib/types";
13
15
  import { GEMINI_OPENAI_BASE_URL, supportsToolsByDefault } from "../../../lib/connections";
16
+ import {
17
+ getAttachmentProviderForConnection,
18
+ normalizeChatAttachments,
19
+ } from "../../../lib/attachments";
14
20
  import { createApprovalRequest } from "../../../lib/tooling/approvals";
15
21
  import {
16
22
  createToolRegistry,
@@ -514,17 +520,59 @@ function sanitizeChatMessages(messages: ChatMessageInput[]): ChatMessageInput[]
514
520
  continue;
515
521
  }
516
522
  const normalizedContent = message.content.replace(/\r\n/g, "\n");
517
- if (!normalizedContent.trim()) {
523
+ const normalizedAttachments =
524
+ role === "user" ? normalizeChatAttachments(message.attachments) : [];
525
+ if (!normalizedContent.trim() && normalizedAttachments.length === 0) {
518
526
  continue;
519
527
  }
520
528
  sanitized.push({
521
529
  role,
522
530
  content: normalizedContent,
531
+ ...(normalizedAttachments.length > 0
532
+ ? { attachments: normalizedAttachments }
533
+ : {}),
523
534
  });
524
535
  }
525
536
  return sanitized;
526
537
  }
527
538
 
539
+ function messageHasAttachments(message: ChatMessageInput): boolean {
540
+ return Array.isArray(message.attachments) && message.attachments.length > 0;
541
+ }
542
+
543
+ function messagesContainAttachments(messages: ChatMessageInput[]): boolean {
544
+ return messages.some((message) => messageHasAttachments(message));
545
+ }
546
+
547
+ function validateAttachmentProviderCompatibility(params: {
548
+ connection: RuntimeConnection;
549
+ messages: ChatMessageInput[];
550
+ }): string | null {
551
+ const providerFromConnection = getAttachmentProviderForConnection(params.connection);
552
+ const providers = new Set<ChatAttachment["provider"]>();
553
+ for (const message of params.messages) {
554
+ for (const attachment of message.attachments ?? []) {
555
+ providers.add(attachment.provider);
556
+ }
557
+ }
558
+
559
+ if (providers.size === 0) {
560
+ return null;
561
+ }
562
+ if (providers.size > 1) {
563
+ return "Attachments from multiple providers cannot be mixed in one request.";
564
+ }
565
+ if (!providerFromConnection) {
566
+ return "This connection does not support attachments.";
567
+ }
568
+
569
+ const providerFromAttachment = [...providers][0];
570
+ if (providerFromAttachment !== providerFromConnection) {
571
+ return `Attachments were uploaded for ${providerFromAttachment}, but the active connection is ${providerFromConnection}.`;
572
+ }
573
+ return null;
574
+ }
575
+
528
576
  function sanitizeMemoryContext(
529
577
  memory: ChatRequest["memory"],
530
578
  ): MemoryContextPayload | null {
@@ -2574,6 +2622,90 @@ async function tryDirectAutomationFastPath(params: {
2574
2622
  return true;
2575
2623
  }
2576
2624
 
2625
+ function toOpenAIMessageContent(
2626
+ message: ChatMessageInput,
2627
+ ): string | OpenAI.Responses.ResponseInputMessageContentList {
2628
+ if (message.role !== "user" || !messageHasAttachments(message)) {
2629
+ return message.content;
2630
+ }
2631
+
2632
+ const content: OpenAI.Responses.ResponseInputMessageContentList = [];
2633
+ if (message.content.trim()) {
2634
+ content.push({
2635
+ type: "input_text",
2636
+ text: message.content,
2637
+ });
2638
+ }
2639
+
2640
+ for (const attachment of message.attachments ?? []) {
2641
+ if (attachment.kind === "image") {
2642
+ content.push({
2643
+ type: "input_image",
2644
+ detail: "auto",
2645
+ file_id: attachment.providerFileId,
2646
+ });
2647
+ continue;
2648
+ }
2649
+ content.push({
2650
+ type: "input_file",
2651
+ file_id: attachment.providerFileId,
2652
+ filename: attachment.name,
2653
+ });
2654
+ }
2655
+
2656
+ if (content.length === 0) {
2657
+ content.push({
2658
+ type: "input_text",
2659
+ text: "",
2660
+ });
2661
+ }
2662
+ return content;
2663
+ }
2664
+
2665
+ function toAnthropicUserContent(message: ChatMessageInput): string | BetaContentBlockParam[] {
2666
+ if (!messageHasAttachments(message)) {
2667
+ return message.content;
2668
+ }
2669
+
2670
+ const content: BetaContentBlockParam[] = [];
2671
+ if (message.content.trim()) {
2672
+ content.push({
2673
+ type: "text",
2674
+ text: message.content,
2675
+ });
2676
+ }
2677
+
2678
+ for (const attachment of message.attachments ?? []) {
2679
+ if (attachment.kind === "image") {
2680
+ content.push({
2681
+ type: "image",
2682
+ source: {
2683
+ type: "file",
2684
+ file_id: attachment.providerFileId,
2685
+ },
2686
+ });
2687
+ continue;
2688
+ }
2689
+ content.push({
2690
+ type: "document",
2691
+ title: attachment.name,
2692
+ citations: { enabled: true },
2693
+ source: {
2694
+ type: "file",
2695
+ file_id: attachment.providerFileId,
2696
+ },
2697
+ });
2698
+ }
2699
+
2700
+ if (content.length === 0) {
2701
+ content.push({
2702
+ type: "text",
2703
+ text: "",
2704
+ });
2705
+ }
2706
+ return content;
2707
+ }
2708
+
2577
2709
  async function streamPlainChat(params: {
2578
2710
  connection: RuntimeConnection;
2579
2711
  model: string;
@@ -2600,19 +2732,65 @@ async function streamPlainChat(params: {
2600
2732
  throw new Error("Missing Anthropic API key for this connection.");
2601
2733
  }
2602
2734
  const client = new Anthropic({ apiKey: params.connection.apiKey });
2603
- const response = await client.messages.stream({
2604
- model: params.model,
2605
- max_tokens: 1024,
2606
- system: params.system,
2607
- messages: params.messages.map((message) => ({
2608
- role: message.role === "assistant" ? "assistant" : "user",
2609
- content: message.content,
2610
- })),
2611
- });
2735
+ if (messagesContainAttachments(params.messages)) {
2736
+ const systemBlocks = [
2737
+ params.system?.trim() || "",
2738
+ ...params.messages
2739
+ .filter((message) => message.role === "system")
2740
+ .map((message) => message.content.trim())
2741
+ .filter(Boolean),
2742
+ ].filter(Boolean);
2743
+ const anthropicMessages: BetaMessageParam[] = params.messages
2744
+ .filter((message) => message.role !== "system")
2745
+ .map((message) => ({
2746
+ role: message.role === "assistant" ? "assistant" : "user",
2747
+ content:
2748
+ message.role === "assistant"
2749
+ ? message.content
2750
+ : toAnthropicUserContent(message),
2751
+ }));
2752
+
2753
+ const response = await client.beta.messages.create({
2754
+ model: params.model,
2755
+ max_tokens: 1024,
2756
+ system: systemBlocks.length > 0 ? systemBlocks.join("\n\n") : undefined,
2757
+ messages: anthropicMessages,
2758
+ stream: true,
2759
+ betas: ["files-api-2025-04-14", "pdfs-2024-09-25"],
2760
+ });
2612
2761
 
2613
- for await (const event of response) {
2614
- if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
2615
- emitTokenText(params.send, event.delta.text);
2762
+ for await (const event of response) {
2763
+ const eventAny = event as {
2764
+ type?: string;
2765
+ delta?: { type?: string; text?: string };
2766
+ error?: { message?: string };
2767
+ };
2768
+ if (eventAny.type === "content_block_delta" && eventAny.delta?.type === "text_delta") {
2769
+ emitTokenText(params.send, eventAny.delta.text ?? "");
2770
+ continue;
2771
+ }
2772
+ if (eventAny.type === "error") {
2773
+ params.send({
2774
+ type: "error",
2775
+ error: eventAny.error?.message || "Anthropic response error",
2776
+ });
2777
+ }
2778
+ }
2779
+ } else {
2780
+ const response = await client.messages.stream({
2781
+ model: params.model,
2782
+ max_tokens: 1024,
2783
+ system: params.system,
2784
+ messages: params.messages.map((message) => ({
2785
+ role: message.role === "assistant" ? "assistant" : "user",
2786
+ content: message.content,
2787
+ })),
2788
+ });
2789
+
2790
+ for await (const event of response) {
2791
+ if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
2792
+ emitTokenText(params.send, event.delta.text);
2793
+ }
2616
2794
  }
2617
2795
  }
2618
2796
  return;
@@ -2644,7 +2822,7 @@ async function streamPlainChat(params: {
2644
2822
  input: params.messages.map((message) => ({
2645
2823
  type: "message",
2646
2824
  role: message.role,
2647
- content: message.content,
2825
+ content: toOpenAIMessageContent(message),
2648
2826
  })),
2649
2827
  tools: webSearchMode !== "off" ? [{ type: "web_search" }] : undefined,
2650
2828
  tool_choice:
@@ -3105,6 +3283,17 @@ export async function POST(request: Request) {
3105
3283
  headers: { "Content-Type": "application/json" },
3106
3284
  });
3107
3285
  }
3286
+ const hasAttachmentsInMessages = messagesContainAttachments(sanitizedMessages);
3287
+ const attachmentCompatibilityError = validateAttachmentProviderCompatibility({
3288
+ connection,
3289
+ messages: sanitizedMessages,
3290
+ });
3291
+ if (attachmentCompatibilityError) {
3292
+ return new Response(JSON.stringify({ error: attachmentCompatibilityError }), {
3293
+ status: 400,
3294
+ headers: { "Content-Type": "application/json" },
3295
+ });
3296
+ }
3108
3297
 
3109
3298
  const webSourcesEnabled = normalizeWebSourcesEnabled(body.enableWebSources);
3110
3299
  const webSearchMode = decideWebSearchMode({
@@ -3154,7 +3343,7 @@ export async function POST(request: Request) {
3154
3343
  });
3155
3344
  }
3156
3345
 
3157
- if (localTools.enabled) {
3346
+ if (localTools.enabled && !hasAttachmentsInMessages) {
3158
3347
  const lastUserText =
3159
3348
  [...sanitizedMessages].reverse().find((message) => message.role === "user")?.content ?? "";
3160
3349
  const communicationIntentType = classifyDraftCommunicationIntent(lastUserText);
@@ -0,0 +1,220 @@
1
+ import OpenAI from "openai";
2
+ import Anthropic from "@anthropic-ai/sdk";
3
+ import type { ChatAttachment, ChatConnectionPayload } from "../../../../lib/types";
4
+ import {
5
+ getAttachmentProviderForConnection,
6
+ MAX_ATTACHMENTS_PER_MESSAGE,
7
+ validateAttachmentCandidate,
8
+ } from "../../../../lib/attachments";
9
+
10
+ export const runtime = "nodejs";
11
+ export const dynamic = "force-dynamic";
12
+
13
+ type RuntimeConnection = {
14
+ kind: "builtin" | "openai_compatible" | "ollama";
15
+ provider?: "openai" | "anthropic" | "google";
16
+ baseUrl?: string;
17
+ apiKey?: string;
18
+ headers: Array<{ key: string; value: string }>;
19
+ };
20
+
21
+ function headersArrayToRecord(
22
+ headers: RuntimeConnection["headers"],
23
+ ): Record<string, string> | undefined {
24
+ if (!headers.length) {
25
+ return undefined;
26
+ }
27
+ const out: Record<string, string> = {};
28
+ for (const header of headers) {
29
+ const key = header.key.trim();
30
+ if (!key) {
31
+ continue;
32
+ }
33
+ out[key] = header.value;
34
+ }
35
+ return Object.keys(out).length > 0 ? out : undefined;
36
+ }
37
+
38
+ function normalizeConnection(rawConnection: unknown): RuntimeConnection | null {
39
+ if (!rawConnection || typeof rawConnection !== "object") {
40
+ return null;
41
+ }
42
+ const connection = rawConnection as Partial<ChatConnectionPayload>;
43
+ const kind =
44
+ connection.kind === "builtin" ||
45
+ connection.kind === "openai_compatible" ||
46
+ connection.kind === "ollama"
47
+ ? connection.kind
48
+ : null;
49
+ if (!kind) {
50
+ return null;
51
+ }
52
+ const provider =
53
+ kind === "builtin" &&
54
+ (connection.provider === "openai" ||
55
+ connection.provider === "anthropic" ||
56
+ connection.provider === "google")
57
+ ? connection.provider
58
+ : undefined;
59
+ const baseUrl =
60
+ typeof connection.baseUrl === "string" && connection.baseUrl.trim().length > 0
61
+ ? connection.baseUrl.trim()
62
+ : undefined;
63
+ const apiKey =
64
+ typeof connection.apiKey === "string" && connection.apiKey.trim().length > 0
65
+ ? connection.apiKey.trim()
66
+ : undefined;
67
+ const headers = Array.isArray(connection.headers)
68
+ ? connection.headers
69
+ .filter(
70
+ (header): header is { key: string; value: string } =>
71
+ Boolean(header) &&
72
+ typeof header.key === "string" &&
73
+ typeof header.value === "string",
74
+ )
75
+ .map((header) => ({
76
+ key: header.key,
77
+ value: header.value,
78
+ }))
79
+ : [];
80
+
81
+ return {
82
+ kind,
83
+ provider,
84
+ baseUrl,
85
+ apiKey,
86
+ headers,
87
+ };
88
+ }
89
+
90
+ function badRequest(message: string) {
91
+ return new Response(JSON.stringify({ error: message }), {
92
+ status: 400,
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ },
96
+ });
97
+ }
98
+
99
+ export async function POST(request: Request) {
100
+ let formData: FormData;
101
+ try {
102
+ formData = await request.formData();
103
+ } catch {
104
+ return badRequest("Invalid multipart form payload.");
105
+ }
106
+
107
+ const rawConnection = formData.get("connection");
108
+ if (typeof rawConnection !== "string" || !rawConnection.trim()) {
109
+ return badRequest("Missing connection payload.");
110
+ }
111
+
112
+ let parsedConnectionValue: unknown;
113
+ try {
114
+ parsedConnectionValue = JSON.parse(rawConnection);
115
+ } catch {
116
+ return badRequest("Invalid connection payload.");
117
+ }
118
+
119
+ const connection = normalizeConnection(parsedConnectionValue);
120
+ if (!connection) {
121
+ return badRequest("Invalid connection payload.");
122
+ }
123
+
124
+ const providerFamily = getAttachmentProviderForConnection(connection);
125
+ if (!providerFamily) {
126
+ return badRequest("This connection does not support chat attachments.");
127
+ }
128
+
129
+ const files = formData.getAll("files").filter((entry): entry is File => entry instanceof File);
130
+ if (files.length === 0) {
131
+ return badRequest("No files were uploaded.");
132
+ }
133
+ if (files.length > MAX_ATTACHMENTS_PER_MESSAGE) {
134
+ return badRequest(`You can upload up to ${MAX_ATTACHMENTS_PER_MESSAGE} files per message.`);
135
+ }
136
+
137
+ const attachments: ChatAttachment[] = [];
138
+
139
+ if (providerFamily === "anthropic") {
140
+ if (!connection.apiKey) {
141
+ return badRequest("Missing Anthropic API key.");
142
+ }
143
+ const client = new Anthropic({
144
+ apiKey: connection.apiKey,
145
+ });
146
+
147
+ for (const file of files) {
148
+ const validation = validateAttachmentCandidate({
149
+ name: file.name,
150
+ mimeType: file.type,
151
+ sizeBytes: file.size,
152
+ });
153
+ if (!validation.ok) {
154
+ return badRequest(validation.error);
155
+ }
156
+
157
+ const uploadable = new File([await file.arrayBuffer()], validation.name, {
158
+ type: validation.mimeType,
159
+ });
160
+ const uploaded = await client.beta.files.upload({
161
+ file: uploadable,
162
+ betas: ["files-api-2025-04-14"],
163
+ });
164
+
165
+ attachments.push({
166
+ provider: "anthropic",
167
+ providerFileId: uploaded.id,
168
+ kind: validation.kind,
169
+ name: validation.name,
170
+ mimeType: validation.mimeType,
171
+ sizeBytes: validation.sizeBytes,
172
+ });
173
+ }
174
+ } else {
175
+ if (connection.kind === "openai_compatible" && !connection.baseUrl) {
176
+ return badRequest("OpenAI-compatible connection is missing base URL.");
177
+ }
178
+ if (connection.kind === "builtin" && connection.provider === "openai" && !connection.apiKey) {
179
+ return badRequest("Missing OpenAI API key.");
180
+ }
181
+
182
+ const client = new OpenAI({
183
+ apiKey: connection.apiKey ?? "no-key-required",
184
+ baseURL: connection.kind === "openai_compatible" ? connection.baseUrl : undefined,
185
+ defaultHeaders: headersArrayToRecord(connection.headers),
186
+ });
187
+
188
+ for (const file of files) {
189
+ const validation = validateAttachmentCandidate({
190
+ name: file.name,
191
+ mimeType: file.type,
192
+ sizeBytes: file.size,
193
+ });
194
+ if (!validation.ok) {
195
+ return badRequest(validation.error);
196
+ }
197
+
198
+ const uploadable = await OpenAI.toFile(
199
+ await file.arrayBuffer(),
200
+ validation.name,
201
+ { type: validation.mimeType },
202
+ );
203
+ const uploaded = await client.files.create({
204
+ file: uploadable,
205
+ purpose: "user_data",
206
+ });
207
+
208
+ attachments.push({
209
+ provider: "openai",
210
+ providerFileId: uploaded.id,
211
+ kind: validation.kind,
212
+ name: validation.name,
213
+ mimeType: validation.mimeType,
214
+ sizeBytes: validation.sizeBytes,
215
+ });
216
+ }
217
+ }
218
+
219
+ return Response.json({ attachments });
220
+ }