iris-chatbot 5.3.0 → 5.4.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 +1 -1
- package/template/next-env.d.ts +1 -1
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/src/app/api/chat/route.ts +321 -23
- package/template/src/app/api/chat/uploads/route.ts +220 -0
- package/template/src/app/globals.css +105 -3
- package/template/src/components/ChatView.tsx +210 -5
- package/template/src/components/Composer.tsx +93 -3
- package/template/src/components/MessageCard.tsx +28 -1
- package/template/src/lib/attachments.ts +222 -0
- package/template/src/lib/data.ts +4 -1
- package/template/src/lib/types.ts +11 -0
package/package.json
CHANGED
package/template/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/types/routes.d.ts";
|
|
3
|
+
import "./.next/dev/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iris",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "iris",
|
|
9
|
-
"version": "5.
|
|
9
|
+
"version": "5.4.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.72.1",
|
|
12
12
|
"clsx": "^2.1.1",
|
package/template/package.json
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
@@ -638,10 +686,103 @@ function normalizeWebSourcesEnabled(value: boolean | undefined): boolean {
|
|
|
638
686
|
return value !== false;
|
|
639
687
|
}
|
|
640
688
|
|
|
689
|
+
type WebSearchMode = "off" | "auto" | "required";
|
|
690
|
+
|
|
641
691
|
function supportsWebSourcesViaOpenAI(connection: RuntimeConnection): boolean {
|
|
642
692
|
return connection.kind === "builtin" && connection.provider === "openai";
|
|
643
693
|
}
|
|
644
694
|
|
|
695
|
+
function getLastUserMessageText(messages: ChatMessageInput[]): string {
|
|
696
|
+
return [...messages].reverse().find((message) => message.role === "user")?.content?.trim() ?? "";
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function decideWebSearchMode(params: {
|
|
700
|
+
enabled: boolean;
|
|
701
|
+
connection: RuntimeConnection;
|
|
702
|
+
messages: ChatMessageInput[];
|
|
703
|
+
}): WebSearchMode {
|
|
704
|
+
if (!params.enabled || !supportsWebSourcesViaOpenAI(params.connection)) {
|
|
705
|
+
return "off";
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const text = getLastUserMessageText(params.messages).replace(/\s+/g, " ").trim().toLowerCase();
|
|
709
|
+
if (!text) {
|
|
710
|
+
return "auto";
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const disableSearchRequested =
|
|
714
|
+
/\b(?:no|without|skip)\s+(?:web\s+)?(?:search|browsing|browse|lookup|look up)\b/.test(text) ||
|
|
715
|
+
/\b(?:don't|do not)\s+(?:web\s+)?(?:search|browse|look up|lookup)\b/.test(text) ||
|
|
716
|
+
/\boffline only\b/.test(text);
|
|
717
|
+
if (disableSearchRequested) {
|
|
718
|
+
return "off";
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const explicitSearchRequested =
|
|
722
|
+
/\bsearch(?:\s+the)?\s+web\b/.test(text) ||
|
|
723
|
+
/\blook(?:\s+it)?\s+up\b/.test(text) ||
|
|
724
|
+
/\bcheck(?:\s+it)?\s+online\b/.test(text) ||
|
|
725
|
+
/\bverify(?:\s+it)?\s+online\b/.test(text) ||
|
|
726
|
+
/\bbrowse(?:\s+the)?\s+web\b/.test(text) ||
|
|
727
|
+
/\bgoogle\s+(?:it|this)\b/.test(text);
|
|
728
|
+
if (explicitSearchRequested) {
|
|
729
|
+
return "required";
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const asksForSources =
|
|
733
|
+
/\b(?:source|sources|cite|citation|citations|reference|references|link|links)\b/.test(text);
|
|
734
|
+
const freshnessSignals =
|
|
735
|
+
/\b(?:latest|current|currently|today|right now|as of|recent|recently|up[- ]to[- ]date|breaking)\b/.test(
|
|
736
|
+
text,
|
|
737
|
+
);
|
|
738
|
+
const volatileDomainSignals =
|
|
739
|
+
/\b(?:price|pricing|cost|fee|subscription|per token|rate limit|stock|share price|market cap|forecast|weather|score|standings|odds|election|poll)\b/.test(
|
|
740
|
+
text,
|
|
741
|
+
);
|
|
742
|
+
const leadershipSignals =
|
|
743
|
+
/\b(?:who is|current)\s+(?:the\s+)?(?:ceo|president|prime minister|governor|mayor)\b/.test(text);
|
|
744
|
+
const metricSignals =
|
|
745
|
+
/\b(?:average|median|how many|number of|count|rate|percentage|percent|statistics?|stats?)\b/.test(
|
|
746
|
+
text,
|
|
747
|
+
);
|
|
748
|
+
const metricEntitySignals =
|
|
749
|
+
/\b(?:users?|people|prompts?|requests?|queries?|api|model|gpt|traffic|revenue|downloads?|adoption|daily|weekly|monthly|yearly|per day|per week|per month)\b/.test(
|
|
750
|
+
text,
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
if (
|
|
754
|
+
asksForSources ||
|
|
755
|
+
freshnessSignals ||
|
|
756
|
+
volatileDomainSignals ||
|
|
757
|
+
leadershipSignals ||
|
|
758
|
+
(metricSignals && metricEntitySignals)
|
|
759
|
+
) {
|
|
760
|
+
return "required";
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const likelyFactualQuestion =
|
|
764
|
+
text.includes("?") || /^(?:what|who|when|where|which|how)\b/.test(text);
|
|
765
|
+
const uncertaintySignals =
|
|
766
|
+
/\b(?:not sure|unsure|uncertain|verify|double-check|confirm|accurate|accuracy)\b/.test(text);
|
|
767
|
+
if (likelyFactualQuestion && uncertaintySignals) {
|
|
768
|
+
return "auto";
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return "off";
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function withWebSearchGuidance(system: string | undefined, mode: WebSearchMode): string | undefined {
|
|
775
|
+
const trimmed = system?.trim() || "";
|
|
776
|
+
if (mode === "off") {
|
|
777
|
+
return trimmed || undefined;
|
|
778
|
+
}
|
|
779
|
+
const guidance =
|
|
780
|
+
mode === "required"
|
|
781
|
+
? "Web search is required for this turn. Use web_search before answering and cite the sources you relied on."
|
|
782
|
+
: "Use web_search when the request depends on current or uncertain facts (pricing, statistics, recency). For stable/general knowledge, answer directly without web_search.";
|
|
783
|
+
return trimmed ? `${trimmed}\n\n${guidance}` : guidance;
|
|
784
|
+
}
|
|
785
|
+
|
|
645
786
|
function mergeCitationSources(
|
|
646
787
|
target: Map<string, ProviderCitationSource>,
|
|
647
788
|
sources: ProviderCitationSource[] | undefined,
|
|
@@ -2481,12 +2622,97 @@ async function tryDirectAutomationFastPath(params: {
|
|
|
2481
2622
|
return true;
|
|
2482
2623
|
}
|
|
2483
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
|
+
|
|
2484
2709
|
async function streamPlainChat(params: {
|
|
2485
2710
|
connection: RuntimeConnection;
|
|
2486
2711
|
model: string;
|
|
2487
2712
|
system?: string;
|
|
2488
2713
|
messages: ChatMessageInput[];
|
|
2489
2714
|
enableWebSources: boolean;
|
|
2715
|
+
webSearchMode?: WebSearchMode;
|
|
2490
2716
|
send: (chunk: ChatStreamChunk) => void;
|
|
2491
2717
|
signal?: AbortSignal;
|
|
2492
2718
|
}) {
|
|
@@ -2506,19 +2732,65 @@ async function streamPlainChat(params: {
|
|
|
2506
2732
|
throw new Error("Missing Anthropic API key for this connection.");
|
|
2507
2733
|
}
|
|
2508
2734
|
const client = new Anthropic({ apiKey: params.connection.apiKey });
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
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
|
+
});
|
|
2761
|
+
|
|
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
|
+
});
|
|
2518
2789
|
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
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
|
+
}
|
|
2522
2794
|
}
|
|
2523
2795
|
}
|
|
2524
2796
|
return;
|
|
@@ -2536,20 +2808,30 @@ async function streamPlainChat(params: {
|
|
|
2536
2808
|
baseURL: openAIBaseURL,
|
|
2537
2809
|
defaultHeaders: openAIHeaders,
|
|
2538
2810
|
});
|
|
2539
|
-
const useWebSources =
|
|
2540
|
-
|
|
2811
|
+
const useWebSources = params.enableWebSources && supportsWebSourcesViaOpenAI(params.connection);
|
|
2812
|
+
const webSearchMode: WebSearchMode = useWebSources
|
|
2813
|
+
? params.webSearchMode === "required"
|
|
2814
|
+
? "required"
|
|
2815
|
+
: "auto"
|
|
2816
|
+
: "off";
|
|
2817
|
+
const instructions = withWebSearchGuidance(params.system, webSearchMode);
|
|
2541
2818
|
const response = await client.responses.create(
|
|
2542
2819
|
{
|
|
2543
2820
|
model: params.model,
|
|
2544
|
-
instructions
|
|
2821
|
+
instructions,
|
|
2545
2822
|
input: params.messages.map((message) => ({
|
|
2546
2823
|
type: "message",
|
|
2547
2824
|
role: message.role,
|
|
2548
|
-
content: message
|
|
2825
|
+
content: toOpenAIMessageContent(message),
|
|
2549
2826
|
})),
|
|
2550
|
-
tools:
|
|
2551
|
-
tool_choice:
|
|
2552
|
-
|
|
2827
|
+
tools: webSearchMode !== "off" ? [{ type: "web_search" }] : undefined,
|
|
2828
|
+
tool_choice:
|
|
2829
|
+
webSearchMode === "required"
|
|
2830
|
+
? "required"
|
|
2831
|
+
: webSearchMode === "auto"
|
|
2832
|
+
? "auto"
|
|
2833
|
+
: undefined,
|
|
2834
|
+
include: webSearchMode !== "off" ? ["web_search_call.action.sources"] : undefined,
|
|
2553
2835
|
stream: true,
|
|
2554
2836
|
},
|
|
2555
2837
|
{ signal: params.signal },
|
|
@@ -3001,10 +3283,25 @@ export async function POST(request: Request) {
|
|
|
3001
3283
|
headers: { "Content-Type": "application/json" },
|
|
3002
3284
|
});
|
|
3003
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
|
+
}
|
|
3004
3297
|
|
|
3005
3298
|
const webSourcesEnabled = normalizeWebSourcesEnabled(body.enableWebSources);
|
|
3006
|
-
const
|
|
3007
|
-
webSourcesEnabled
|
|
3299
|
+
const webSearchMode = decideWebSearchMode({
|
|
3300
|
+
enabled: webSourcesEnabled,
|
|
3301
|
+
connection,
|
|
3302
|
+
messages: sanitizedMessages,
|
|
3303
|
+
});
|
|
3304
|
+
const enableWebSourcesForConnection = webSearchMode !== "off";
|
|
3008
3305
|
const localToolsRequested = normalizeLocalTools(body.localTools);
|
|
3009
3306
|
const memoryContext = sanitizeMemoryContext(body.memory);
|
|
3010
3307
|
const requestMeta = sanitizeChatRequestMeta(body.meta);
|
|
@@ -3046,7 +3343,7 @@ export async function POST(request: Request) {
|
|
|
3046
3343
|
});
|
|
3047
3344
|
}
|
|
3048
3345
|
|
|
3049
|
-
if (localTools.enabled) {
|
|
3346
|
+
if (localTools.enabled && !hasAttachmentsInMessages) {
|
|
3050
3347
|
const lastUserText =
|
|
3051
3348
|
[...sanitizedMessages].reverse().find((message) => message.role === "user")?.content ?? "";
|
|
3052
3349
|
const communicationIntentType = classifyDraftCommunicationIntent(lastUserText);
|
|
@@ -3111,6 +3408,7 @@ export async function POST(request: Request) {
|
|
|
3111
3408
|
system,
|
|
3112
3409
|
messages: sanitizedMessages,
|
|
3113
3410
|
enableWebSources: enableWebSourcesForConnection,
|
|
3411
|
+
webSearchMode,
|
|
3114
3412
|
send,
|
|
3115
3413
|
signal: request.signal,
|
|
3116
3414
|
});
|
|
@@ -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
|
+
}
|