iris-chatbot 5.3.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris-chatbot",
3
- "version": "5.3.1",
3
+ "version": "5.4.0",
4
4
  "private": false,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "bin": {
@@ -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.1",
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.3.1",
9
+ "version": "5.4.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.4.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
+ }
@@ -137,8 +137,7 @@ button:focus-visible {
137
137
  }
138
138
 
139
139
  .message-card.user .message-content {
140
- display: flex;
141
- align-items: center;
140
+ display: block;
142
141
  min-height: 1.4em;
143
142
  line-height: 1.45;
144
143
  }
@@ -147,6 +146,43 @@ button:focus-visible {
147
146
  margin: 0;
148
147
  }
149
148
 
149
+ .user-message-content {
150
+ display: flex;
151
+ flex-direction: column;
152
+ gap: 8px;
153
+ }
154
+
155
+ .user-attachment-row {
156
+ display: flex;
157
+ flex-wrap: wrap;
158
+ gap: 6px;
159
+ }
160
+
161
+ .user-attachment-pill {
162
+ display: inline-flex;
163
+ align-items: center;
164
+ gap: 6px;
165
+ max-width: 240px;
166
+ border-radius: 999px;
167
+ border: 1px solid rgba(255, 255, 255, 0.34);
168
+ background: rgba(255, 255, 255, 0.15);
169
+ color: #ffffff;
170
+ padding: 4px 9px;
171
+ font-size: 11px;
172
+ line-height: 1;
173
+ }
174
+
175
+ .user-attachment-kind {
176
+ font-weight: 700;
177
+ opacity: 0.9;
178
+ }
179
+
180
+ .user-attachment-title {
181
+ overflow: hidden;
182
+ text-overflow: ellipsis;
183
+ white-space: nowrap;
184
+ }
185
+
150
186
  .message-row.user .message-content> :first-child,
151
187
  .message-row.user .message-content> :last-child {
152
188
  margin-top: 0;
@@ -917,7 +953,7 @@ button:focus-visible {
917
953
  line-height: 1.4;
918
954
  padding-top: 7px;
919
955
  padding-bottom: 9px;
920
- padding-left: 6px;
956
+ padding-left: 0;
921
957
  /* One line by default; height grows with content in Composer.tsx */
922
958
  min-height: calc(1.4em + 7px + 9px);
923
959
  }
@@ -956,6 +992,23 @@ button:focus-visible {
956
992
  border-color: transparent;
957
993
  }
958
994
 
995
+ .send-button:disabled {
996
+ opacity: 0.65;
997
+ cursor: not-allowed;
998
+ }
999
+
1000
+ .composer-attach-button {
1001
+ background: transparent;
1002
+ border-color: transparent;
1003
+ color: var(--text-secondary);
1004
+ }
1005
+
1006
+ .composer-attach-button:hover {
1007
+ background: transparent;
1008
+ color: var(--text-primary);
1009
+ border-color: transparent;
1010
+ }
1011
+
959
1012
  .mic-button {
960
1013
  background: transparent;
961
1014
  color: var(--text-secondary);
@@ -1223,6 +1276,55 @@ button:focus-visible {
1223
1276
  gap: 0;
1224
1277
  }
1225
1278
 
1279
+ .composer-attachments {
1280
+ display: flex;
1281
+ flex-wrap: wrap;
1282
+ gap: 8px;
1283
+ padding: 0 2px 10px;
1284
+ }
1285
+
1286
+ .composer-attachment-chip {
1287
+ display: inline-flex;
1288
+ align-items: center;
1289
+ gap: 6px;
1290
+ max-width: 260px;
1291
+ border: 1px solid var(--border);
1292
+ background: var(--panel-2);
1293
+ border-radius: 999px;
1294
+ padding: 4px 8px;
1295
+ font-size: 12px;
1296
+ color: var(--text-secondary);
1297
+ }
1298
+
1299
+ .composer-attachment-icon {
1300
+ display: inline-flex;
1301
+ align-items: center;
1302
+ justify-content: center;
1303
+ color: var(--text-muted);
1304
+ }
1305
+
1306
+ .composer-attachment-name {
1307
+ overflow: hidden;
1308
+ text-overflow: ellipsis;
1309
+ white-space: nowrap;
1310
+ }
1311
+
1312
+ .composer-attachment-remove {
1313
+ display: inline-flex;
1314
+ align-items: center;
1315
+ justify-content: center;
1316
+ border: none;
1317
+ background: transparent;
1318
+ color: var(--text-muted);
1319
+ width: 16px;
1320
+ height: 16px;
1321
+ border-radius: 999px;
1322
+ }
1323
+
1324
+ .composer-attachment-remove:hover {
1325
+ color: var(--text-primary);
1326
+ }
1327
+
1226
1328
  .quoted-context-container {
1227
1329
  display: flex;
1228
1330
  align-items: flex-start;