refactor: async-stream and improved reference handling

This commit is contained in:
Per Stark
2025-02-27 13:49:45 +01:00
parent 4ce272d5be
commit 21f0ebef33
7 changed files with 254 additions and 194 deletions

1
Cargo.lock generated
View File

@@ -6041,6 +6041,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-openai",
"async-stream",
"axum",
"axum-htmx",
"axum_session",

View File

@@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
anyhow = "1.0.94"
async-openai = "0.24.1"
async-stream = "0.3.6"
axum = { version = "0.7.5", features = ["multipart", "macros"] }
axum-htmx = "0.6.0"
axum_session = "0.14.4"

View File

@@ -2057,6 +2057,21 @@
}
}
}
.indicator {
position: relative;
display: inline-flex;
width: max-content;
:where(.indicator-item) {
z-index: 1;
position: absolute;
white-space: nowrap;
top: var(--inidicator-t, 0);
bottom: var(--inidicator-b, auto);
left: var(--inidicator-s, auto);
right: var(--inidicator-e, 0);
translate: var(--inidicator-x, 50%) var(--indicator-y, -50%);
}
}
.collapse-title {
grid-column-start: 1;
grid-row-start: 1;
@@ -2158,21 +2173,9 @@
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-2 {
top: calc(var(--spacing) * 2);
}
.top-5 {
top: calc(var(--spacing) * 5);
}
.top-8 {
top: calc(var(--spacing) * 8);
}
.top-9 {
top: calc(var(--spacing) * 9);
}
.top-10 {
top: calc(var(--spacing) * 10);
}
.right-0 {
right: calc(var(--spacing) * 0);
}
@@ -2541,33 +2544,12 @@
}
}
}
.z-0 {
z-index: 0;
}
.z-1 {
z-index: 1;
}
.z-2 {
z-index: 2;
}
.z-3 {
z-index: 3;
}
.z-5 {
z-index: 5;
}
.z-20 {
z-index: 20;
}
.z-50 {
z-index: 50;
}
.z-\[\12 ަ<EFBFBD><EFBFBD>\$<EFBFBD><EFBFBD><EFBFBD>w<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>T<EFBFBD>\:<EFBFBD><EFBFBD>G<EFBFBD><EFBFBD>\1e \] {
z-index: ަ<EFBFBD><EFBFBD>$<EFBFBD><EFBFBD><EFBFBD>w<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>T<EFBFBD>:<EFBFBD><EFBFBD>G<EFBFBD><EFBFBD>;
}
.z-\[\12 ަ<EFBFBD><EFBFBD>\$<EFBFBD><EFBFBD><EFBFBD>ӕ\/\12 h<EFBFBD>Jc<EFBFBD>\1a \\<EFBFBD><EFBFBD><EFBFBD>\=<EFBFBD>i\] {
z-index: ަ<EFBFBD><EFBFBD>$<EFBFBD><EFBFBD><EFBFBD>ӕ/h<EFBFBD>Jc<EFBFBD>\<EFBFBD><EFBFBD><EFBFBD>=<EFBFBD>i;
}
.modal-box {
grid-column-start: 1;
grid-row-start: 1;
@@ -2639,9 +2621,6 @@
.list-col-wrap {
grid-row-start: 2;
}
.float-left {
float: left;
}
.container {
width: 100%;
@media (width >= 40rem) {
@@ -2660,12 +2639,6 @@
max-width: 96rem;
}
}
.m-2 {
margin: calc(var(--spacing) * 2);
}
.m-9 {
margin: calc(var(--spacing) * 9);
}
.filter {
display: flex;
flex-wrap: wrap;
@@ -2783,6 +2756,12 @@
}
}
}
.my-0 {
margin-block: calc(var(--spacing) * 0);
}
.my-2 {
margin-block: calc(var(--spacing) * 2);
}
.my-4 {
margin-block: calc(var(--spacing) * 4);
}
@@ -3679,6 +3658,9 @@
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
}
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
@@ -3719,6 +3701,9 @@
.mb-10 {
margin-bottom: calc(var(--spacing) * 10);
}
.ml-1 {
margin-left: calc(var(--spacing) * 1);
}
.alert {
display: grid;
width: 100%;
@@ -3814,20 +3799,6 @@
background-image: radial-gradient( circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * 0.5)), transparent );
box-shadow: 0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), transparent);
}
.status\! {
display: inline-block !important;
aspect-ratio: 1 / 1 !important;
width: calc(0.25rem * 2) !important;
height: calc(0.25rem * 2) !important;
border-radius: var(--radius-selector) !important;
background-color: color-mix(in oklab, var(--color-base-content) 20%, transparent) !important;
background-position: center !important;
background-repeat: no-repeat !important;
vertical-align: middle !important;
color: color-mix(in oklab, var(--color-black) 30%, transparent) !important;
background-image: radial-gradient( circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * 0.5)), transparent ) !important;
box-shadow: 0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), transparent) !important;
}
.kbd {
display: inline-flex;
align-items: center;
@@ -4089,27 +4060,18 @@
width: calc(var(--spacing) * 10);
height: calc(var(--spacing) * 10);
}
.h-4 {
height: calc(var(--spacing) * 4);
.h-3 {
height: calc(var(--spacing) * 3);
}
.h-5 {
height: calc(var(--spacing) * 5);
}
.h-6 {
height: calc(var(--spacing) * 6);
}
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-24 {
height: calc(var(--spacing) * 24);
}
.h-32 {
height: calc(var(--spacing) * 32);
}
.h-36 {
height: calc(var(--spacing) * 36);
}
.min-h-\[100dvh\] {
min-height: 100dvh;
}
@@ -4122,11 +4084,8 @@
.loading-sm {
width: calc(var(--size-selector, 0.25rem) * 5);
}
.w-1 {
width: calc(var(--spacing) * 1);
}
.w-2 {
width: calc(var(--spacing) * 2);
.w-3 {
width: calc(var(--spacing) * 3);
}
.w-5 {
width: calc(var(--spacing) * 5);
@@ -4137,9 +4096,6 @@
.w-72 {
width: calc(var(--spacing) * 72);
}
.w-\[<EFBFBD><EFBFBD><EFBFBD>\}<EFBFBD>C<EFBFBD>9JO\1 <EFBFBD>j\] {
width: <EFBFBD><EFBFBD><EFBFBD>}<EFBFBD>C<EFBFBD>9JO<EFBFBD>j;
}
.w-full {
width: 100%;
}
@@ -4152,6 +4108,18 @@
.max-w-3xl {
max-width: var(--container-3xl);
}
.max-w-\[10ch\] {
max-width: 10ch;
}
.max-w-\[20ch\] {
max-width: 20ch;
}
.max-w-\[50px\] {
max-width: 50px;
}
.max-w-\[90\%\] {
max-width: 90%;
}
.max-w-\[160px\] {
max-width: 160px;
}
@@ -4188,6 +4156,9 @@
--tw-scale-z: 75%;
scale: var(--tw-scale-x) var(--tw-scale-y);
}
.rotate-180 {
rotate: 180deg;
}
.swap-rotate {
.swap-on, .swap-indeterminate, input:indeterminate ~ .swap-on {
rotate: 45deg;
@@ -4225,6 +4196,9 @@
background-repeat: no-repeat;
background-position-x: -50%;
}
.animate-pulse {
animation: var(--animate-pulse);
}
.link {
cursor: pointer;
text-decoration-line: underline;
@@ -4278,6 +4252,9 @@
.justify-start {
justify-content: flex-start;
}
.gap-1 {
gap: calc(var(--spacing) * 1);
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
@@ -4337,6 +4314,9 @@
.overflow-clip {
overflow: clip;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-x-auto {
overflow-x: auto;
}
@@ -4355,9 +4335,8 @@
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-t-2xl {
border-top-left-radius: var(--radius-2xl);
border-top-right-radius: var(--radius-2xl);
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-t-none {
border-top-left-radius: 0;
@@ -4371,10 +4350,6 @@
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
}
.textarea-ghost {
background-color: transparent;
box-shadow: none;
@@ -4391,14 +4366,17 @@
color: var(--color-success-content);
--alert-color: var(--color-success);
}
.border-base-300 {
border-color: var(--color-base-300);
}
.border-base-content {
border-color: var(--color-base-content);
}
.border-base-content\/5 {
border-color: color-mix(in oklab, var(--color-base-content) 5%, transparent);
}
.border-primary {
border-color: var(--color-primary);
.border-gray-200 {
border-color: var(--color-gray-200);
}
.bg-accent {
background-color: var(--color-accent);
@@ -4409,6 +4387,12 @@
.bg-base-200 {
background-color: var(--color-base-200);
}
.bg-gray-50 {
background-color: var(--color-gray-50);
}
.bg-gray-200 {
background-color: var(--color-gray-200);
}
.bg-secondary {
background-color: var(--color-secondary);
}
@@ -4419,12 +4403,6 @@
--tw-gradient-position: to right in oklab,;
background-image: linear-gradient(var(--tw-gradient-stops));
}
.\!bg-none {
background-image: none !important;
}
.bg-none {
background-image: none;
}
.from-primary {
--tw-gradient-from: var(--color-primary);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position,) var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
@@ -4451,18 +4429,12 @@
.p-2 {
padding: calc(var(--spacing) * 2);
}
.p-3 {
padding: calc(var(--spacing) * 3);
}
.p-4 {
padding: calc(var(--spacing) * 4);
}
.p-5 {
padding: calc(var(--spacing) * 5);
}
.p-9 {
padding: calc(var(--spacing) * 9);
}
.menu-title {
padding-inline: calc(0.25rem * 3);
padding-block: calc(0.25rem * 2);
@@ -4470,6 +4442,11 @@
font-size: 0.875rem;
font-weight: 600;
}
.badge-sm {
--size: calc(var(--size-selector, 0.25rem) * 5);
font-size: 0.75rem;
padding-inline: calc(0.25rem * 2.5 - var(--border));
}
.badge-xs {
--size: calc(var(--size-selector, 0.25rem) * 4);
font-size: 0.625rem;
@@ -4493,14 +4470,23 @@
.px-1 {
padding-inline: calc(var(--spacing) * 1);
}
.px-2 {
padding-inline: calc(var(--spacing) * 2);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-1 {
padding-block: calc(var(--spacing) * 1);
}
.py-4 {
padding-block: calc(var(--spacing) * 4);
}
.pt-5 {
padding-top: calc(var(--spacing) * 5);
.pt-1 {
padding-top: calc(var(--spacing) * 1);
}
.pt-10 {
padding-top: calc(var(--spacing) * 10);
@@ -4517,18 +4503,15 @@
.pb-0 {
padding-bottom: calc(var(--spacing) * 0);
}
.pb-4 {
padding-bottom: calc(var(--spacing) * 4);
}
.pb-8 {
padding-bottom: calc(var(--spacing) * 8);
}
.pb-10 {
padding-bottom: calc(var(--spacing) * 10);
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.font-satoshi {
font-family: Satoshi, sans-serif;
}
@@ -4586,6 +4569,10 @@
--tw-font-weight: var(--font-weight-light);
font-weight: var(--font-weight-light);
}
.font-medium {
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.font-semibold {
--tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold);
@@ -4603,6 +4590,10 @@
.whitespace-pre-wrap {
white-space: pre-wrap;
}
.badge-neutral {
--badge-color: var(--color-neutral);
color: var(--color-neutral-content);
}
.badge-primary {
--badge-color: var(--color-primary);
color: var(--color-primary-content);
@@ -4625,12 +4616,21 @@
.text-base-content\/60 {
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
}
.text-blue-500 {
color: var(--color-blue-500);
}
.text-error {
color: var(--color-error);
}
.text-gray-400 {
color: var(--color-gray-400);
}
.text-gray-500 {
color: var(--color-gray-500);
}
.text-gray-700 {
color: var(--color-gray-700);
}
.text-primary {
color: var(--color-primary);
}
@@ -4672,6 +4672,9 @@
.underline {
text-decoration-line: underline;
}
.opacity-50 {
opacity: 50%;
}
.opacity-60 {
opacity: 60%;
}
@@ -4722,6 +4725,11 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-transform {
transition-property: transform, translate, scale, rotate;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.ease-in {
--tw-ease: var(--ease-in);
transition-timing-function: var(--ease-in);
@@ -4734,9 +4742,6 @@
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.\[a-zA-Z\:\\-\] {
a-zA-Z: \-;
}
.btn-accent {
--btn-color: var(--color-accent);
--btn-fg: var(--color-accent-content);
@@ -4785,6 +4790,13 @@
--tw-prose-td-borders: color-mix(in oklab, var(--color-base-content) 20%, transparent);
}
}
.hover\:text-blue-700 {
&:hover {
@media (hover: hover) {
color: var(--color-blue-700);
}
}
}
.hover\:text-gray-700 {
&:hover {
@media (hover: hover) {
@@ -4792,21 +4804,24 @@
}
}
}
.hover\:underline {
&:hover {
@media (hover: hover) {
text-decoration-line: underline;
}
}
}
.focus\:outline-none {
&:focus {
--tw-outline-style: none;
outline-style: none;
}
}
.sm\:mt-4 {
@media (width >= 40rem) {
margin-top: calc(var(--spacing) * 4);
}
}
.sm\:mb-2 {
@media (width >= 40rem) {
margin-bottom: calc(var(--spacing) * 2);
}
}
.sm\:mb-5 {
@media (width >= 40rem) {
margin-bottom: calc(var(--spacing) * 5);
}
}
.sm\:max-w-md {
@media (width >= 40rem) {
max-width: var(--container-md);
@@ -4828,12 +4843,6 @@
border-bottom-left-radius: var(--radius-2xl);
}
}
.sm\:rounded-b-none {
@media (width >= 40rem) {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}
.sm\:px-0 {
@media (width >= 40rem) {
padding-inline: calc(var(--spacing) * 0);

View File

@@ -1,15 +1,24 @@
use std::{pin::Pin, time::Duration};
use std::{pin::Pin, sync::Arc, time::Duration};
use async_stream::stream;
use axum::{
extract::{Query, State},
response::{sse::Event, Sse},
response::{
sse::{Event, KeepAlive},
Sse,
},
};
use axum_session_auth::AuthSession;
use axum_session_surreal::SessionSurrealPool;
use futures::{stream, Stream, StreamExt, TryStreamExt};
use futures::{
stream::{self, once},
Stream, StreamExt, TryStreamExt,
};
use json_stream_parser::JsonStreamParser;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::from_str;
use surrealdb::{engine::any::Any, Surreal};
use tokio::sync::{mpsc::channel, Mutex};
use tracing::{error, info};
use crate::{
@@ -19,7 +28,7 @@ use crate::{
create_chat_request, create_user_message, format_entities_json, LLMResponseFormat,
},
},
server::AppState,
server::{routes::html::render_template, AppState},
storage::{
db::{get_item, store_item, SurrealDbClient},
types::{
@@ -29,6 +38,7 @@ use crate::{
},
};
// Error handling function
fn create_error_stream(
message: impl Into<String>,
) -> Pin<Box<dyn Stream<Item = Result<Event, axum::Error>> + Send>> {
@@ -127,8 +137,9 @@ pub async fn get_response_stream(
};
// 5. Create channel for collecting complete response
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(1000);
let (tx, mut rx) = channel::<String>(1000);
let tx_clone = tx.clone();
let (tx_final, mut rx_final) = channel::<Vec<String>>(1);
// 6. Set up the collection task for DB storage
let db_client = state.surreal_db_client.clone();
@@ -142,13 +153,15 @@ pub async fn get_response_stream(
}
// Try to extract structured data
if let Ok(response) = serde_json::from_str::<LLMResponseFormat>(&full_json) {
if let Ok(response) = from_str::<LLMResponseFormat>(&full_json) {
let references: Vec<String> = response
.references
.into_iter()
.map(|r| r.reference)
.collect();
let _ = tx_final.send(references.clone()).await;
let ai_message = Message::new(
user_message.conversation_id,
MessageRole::AI,
@@ -176,7 +189,7 @@ pub async fn get_response_stream(
});
// Create a shared state for tracking the JSON parsing
let json_state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamParserState::new()));
let json_state = Arc::new(Mutex::new(StreamParserState::new()));
// 7. Create the response event stream
let event_stream = openai_stream
@@ -185,7 +198,7 @@ pub async fn get_response_stream(
let tx_storage = tx_clone.clone();
let json_state = json_state.clone();
async move {
stream! {
match result {
Ok(response) => {
let content = response
@@ -200,29 +213,71 @@ pub async fn get_response_stream(
// Process through JSON parser
let mut state = json_state.lock().await;
let display_content = state.process_chunk(&content);
drop(state);
if !display_content.is_empty() {
return Ok(Event::default()
yield Ok(Event::default()
.event("chat_message")
.data(display_content));
}
// Empty or filtered content
Ok(Event::default().event("chat_message").data(""))
} else {
Ok(Event::default().event("chat_message").data(""))
// If display_content is empty, don't yield anything
}
// If content is empty, don't yield anything
}
Err(e) => {
yield Ok(Event::default()
.event("error")
.data(format!("Stream error: {}", e)));
}
Err(e) => Ok(Event::default()
.event("error")
.data(format!("Stream error: {}", e))),
}
}
})
.buffered(10)
.chain(stream::once(async {
.flatten()
.chain(stream::once(async move {
if let Some(references) = rx_final.recv().await {
// Don't send any event if references is empty
if references.is_empty() {
return Ok(Event::default().event("empty")); // This event won't be sent
}
// Prepare data for template
#[derive(Serialize)]
struct ReferenceData {
references: Vec<String>,
user_message_id: String,
}
// Render template with references
match render_template(
"chat/reference_list.html",
ReferenceData {
references,
user_message_id: user_message.id,
},
state.templates.clone(),
) {
Ok(html) => {
// Extract the String from Html<String>
let html_string = html.0; // Convert Html<String> to String
// Return the rendered HTML
Ok(Event::default().event("references").data(html_string))
}
Err(_) => {
// Handle template rendering error
Ok(Event::default()
.event("error")
.data("Failed to render references"))
}
}
} else {
// Handle case where no references were received
Ok(Event::default()
.event("error")
.data("Failed to retrieve references"))
}
}))
.chain(once(async {
Ok(Event::default()
.event("close_stream")
.data("Stream complete"))
@@ -230,7 +285,7 @@ pub async fn get_response_stream(
info!("OpenAI streaming started");
Sse::new(event_stream.boxed()).keep_alive(
axum::response::sse::KeepAlive::new()
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keep-alive"),
)
@@ -259,7 +314,6 @@ impl StreamParserState {
}
// Get the current state of the JSON
// The get_result() method returns a &Value, not a Result
let json = self.parser.get_result();
// Check if we're in the answer field
@@ -283,46 +337,3 @@ impl StreamParserState {
String::new()
}
}
// 7. Create the response event stream
// let event_stream = openai_stream
// .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
// .map(move |result| {
// let tx = tx_clone.clone();
// async move {
// match result {
// Ok(response) => {
// let content = response
// .choices
// .first()
// .and_then(|choice| choice.delta.content.clone())
// .unwrap_or_default();
// if !content.is_empty() {
// let _ = tx.send(content.clone()).await;
// Ok(Event::default().event("chat_message").data(content))
// } else {
// Ok(Event::default().event("chat_message").data(""))
// }
// }
// Err(e) => Ok(Event::default()
// .event("error")
// .data(format!("Stream error: {}", e))),
// }
// }
// })
// .buffered(10)
// .chain(stream::once(async {
// Ok(Event::default()
// .event("close_stream")
// .data("Stream complete"))
// }));
// info!("OpenAI streaming started");
// Sse::new(event_stream.boxed()).keep_alive(
// axum::response::sse::KeepAlive::new()
// .interval(Duration::from_secs(15))
// .text("keep-alive"),
// )
// }

View File

@@ -0,0 +1,30 @@
<div class="relative my-2">
<button id="references-toggle-{{user_message_id}}"
class="text-xs text-blue-500 hover:text-blue-700 hover:underline focus:outline-none flex items-center">
References
{% include "icons/chevron_icon.html" %}
</button>
<div id="references-content-{{user_message_id}}" class="hidden max-w-full mt-1">
<div class="flex flex-wrap">
{% for reference in references %}
<span class="badge badge-sm text-xs truncate max-w-[20ch] overflow-hidden text-left block tooltip"
hx-get="/knowledge/{{reference}}" hx-trigger="mouseenter once delay:500ms"
hx-target="#tooltip-content-{{loop.index}}-{{user_message_id}}" hx-swap="innerHTML">
{{reference}}
<div id="tooltip-content-{{loop.index}}-{{user_message_id}}" class="tooltip-content">
<!-- Loading indicator while content is fetched -->
<div class="animate-pulse text-gray-400 text-xs">Loading...</div>
</div>
</span>
{% endfor %}
</div>
</div>
</div>
<script>
document.getElementById('references-toggle-{{user_message_id}}').addEventListener('click', function () {
const content = document.getElementById('references-content-{{user_message_id}}');
const icon = document.getElementById('toggle-icon');
content.classList.toggle('hidden');
icon.classList.toggle('rotate-180');
});
</script>

View File

@@ -1,14 +1,16 @@
<div class="chat chat-end">
<div class="chat-header">User</div>
<div class="chat-bubble">
{{user_message.content}}
</div>
</div>
<div class="chat chat-start">
<div class="chat-header">AI</div>
<div class="chat-bubble" hx-ext="sse" sse-connect="/chat/response-stream?message_id={{user_message.id}}"
sse-swap="chat_message" sse-close="close_stream" hx-swap="beforeend">
<span class="loading loading-dots loading-sm loading-id-{{user_message.id}}"></span>
<div hx-ext="sse" sse-connect="/chat/response-stream?message_id={{user_message.id}}" sse-close="close_stream"
hx-swap="beforeend">
<div class="chat-bubble" sse-swap="chat_message">
<span class="loading loading-dots loading-sm loading-id-{{user_message.id}}"></span>
</div>
<div class="chat-footer opacity-50 max-w-[90%] flex-wrap" sse-swap="references">
</div>
</div>
</div>
<script>

View File

@@ -0,0 +1,6 @@
<svg class="w-3 h-3 ml-1 transition-transform" id="toggle-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 326 B