From 4c7a401bbd2daaca94c7feaa221ac9a4c23dbe76 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 02:20:15 +0800 Subject: [PATCH 01/18] feat: add reasoning chunk in proto --- pkg/gen/api/chat/v2/chat.pb.go | 195 +++++++++++++----- proto/chat/v2/chat.proto | 7 + .../src/pkg/gen/apiclient/auth/v1/auth_pb.ts | 2 +- .../src/pkg/gen/apiclient/chat/v1/chat_pb.ts | 2 +- .../src/pkg/gen/apiclient/chat/v2/chat_pb.ts | 53 ++++- .../gen/apiclient/comment/v1/comment_pb.ts | 2 +- .../gen/apiclient/project/v1/project_pb.ts | 2 +- .../pkg/gen/apiclient/shared/v1/shared_pb.ts | 2 +- .../src/pkg/gen/apiclient/user/v1/user_pb.ts | 2 +- 9 files changed, 200 insertions(+), 67 deletions(-) diff --git a/pkg/gen/api/chat/v2/chat.pb.go b/pkg/gen/api/chat/v2/chat.pb.go index 0db41ebd..27f318fa 100644 --- a/pkg/gen/api/chat/v2/chat.pb.go +++ b/pkg/gen/api/chat/v2/chat.pb.go @@ -236,6 +236,7 @@ type MessageTypeAssistant struct { state protoimpl.MessageState `protogen:"open.v1"` Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` ModelSlug string `protobuf:"bytes,2,opt,name=model_slug,json=modelSlug,proto3" json:"model_slug,omitempty"` + Reasoning *string `protobuf:"bytes,3,opt,name=reasoning,proto3,oneof" json:"reasoning,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -284,6 +285,13 @@ func (x *MessageTypeAssistant) GetModelSlug() string { return "" } +func (x *MessageTypeAssistant) GetReasoning() string { + if x != nil && x.Reasoning != nil { + return *x.Reasoning + } + return "" +} + type MessageTypeUser struct { state protoimpl.MessageState `protogen:"open.v1"` Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` @@ -1345,6 +1353,58 @@ func (x *MessageChunk) GetDelta() string { return "" } +type ReasoningChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + MessageId string `protobuf:"bytes,1,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` // The id of the message that this chunk belongs to + Delta string `protobuf:"bytes,2,opt,name=delta,proto3" json:"delta,omitempty"` // The small piece of reasoning text + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReasoningChunk) Reset() { + *x = ReasoningChunk{} + mi := &file_chat_v2_chat_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReasoningChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReasoningChunk) ProtoMessage() {} + +func (x *ReasoningChunk) ProtoReflect() protoreflect.Message { + mi := &file_chat_v2_chat_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReasoningChunk.ProtoReflect.Descriptor instead. +func (*ReasoningChunk) Descriptor() ([]byte, []int) { + return file_chat_v2_chat_proto_rawDescGZIP(), []int{23} +} + +func (x *ReasoningChunk) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + +func (x *ReasoningChunk) GetDelta() string { + if x != nil { + return x.Delta + } + return "" +} + type IncompleteIndicator struct { state protoimpl.MessageState `protogen:"open.v1"` Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"` @@ -1355,7 +1415,7 @@ type IncompleteIndicator struct { func (x *IncompleteIndicator) Reset() { *x = IncompleteIndicator{} - mi := &file_chat_v2_chat_proto_msgTypes[23] + mi := &file_chat_v2_chat_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1367,7 +1427,7 @@ func (x *IncompleteIndicator) String() string { func (*IncompleteIndicator) ProtoMessage() {} func (x *IncompleteIndicator) ProtoReflect() protoreflect.Message { - mi := &file_chat_v2_chat_proto_msgTypes[23] + mi := &file_chat_v2_chat_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1380,7 +1440,7 @@ func (x *IncompleteIndicator) ProtoReflect() protoreflect.Message { // Deprecated: Use IncompleteIndicator.ProtoReflect.Descriptor instead. func (*IncompleteIndicator) Descriptor() ([]byte, []int) { - return file_chat_v2_chat_proto_rawDescGZIP(), []int{23} + return file_chat_v2_chat_proto_rawDescGZIP(), []int{24} } func (x *IncompleteIndicator) GetReason() string { @@ -1407,7 +1467,7 @@ type StreamPartEnd struct { func (x *StreamPartEnd) Reset() { *x = StreamPartEnd{} - mi := &file_chat_v2_chat_proto_msgTypes[24] + mi := &file_chat_v2_chat_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1419,7 +1479,7 @@ func (x *StreamPartEnd) String() string { func (*StreamPartEnd) ProtoMessage() {} func (x *StreamPartEnd) ProtoReflect() protoreflect.Message { - mi := &file_chat_v2_chat_proto_msgTypes[24] + mi := &file_chat_v2_chat_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1432,7 +1492,7 @@ func (x *StreamPartEnd) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamPartEnd.ProtoReflect.Descriptor instead. func (*StreamPartEnd) Descriptor() ([]byte, []int) { - return file_chat_v2_chat_proto_rawDescGZIP(), []int{24} + return file_chat_v2_chat_proto_rawDescGZIP(), []int{25} } func (x *StreamPartEnd) GetMessageId() string { @@ -1459,7 +1519,7 @@ type StreamFinalization struct { func (x *StreamFinalization) Reset() { *x = StreamFinalization{} - mi := &file_chat_v2_chat_proto_msgTypes[25] + mi := &file_chat_v2_chat_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1471,7 +1531,7 @@ func (x *StreamFinalization) String() string { func (*StreamFinalization) ProtoMessage() {} func (x *StreamFinalization) ProtoReflect() protoreflect.Message { - mi := &file_chat_v2_chat_proto_msgTypes[25] + mi := &file_chat_v2_chat_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1484,7 +1544,7 @@ func (x *StreamFinalization) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamFinalization.ProtoReflect.Descriptor instead. func (*StreamFinalization) Descriptor() ([]byte, []int) { - return file_chat_v2_chat_proto_rawDescGZIP(), []int{25} + return file_chat_v2_chat_proto_rawDescGZIP(), []int{26} } func (x *StreamFinalization) GetConversationId() string { @@ -1503,7 +1563,7 @@ type StreamError struct { func (x *StreamError) Reset() { *x = StreamError{} - mi := &file_chat_v2_chat_proto_msgTypes[26] + mi := &file_chat_v2_chat_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1515,7 +1575,7 @@ func (x *StreamError) String() string { func (*StreamError) ProtoMessage() {} func (x *StreamError) ProtoReflect() protoreflect.Message { - mi := &file_chat_v2_chat_proto_msgTypes[26] + mi := &file_chat_v2_chat_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1528,7 +1588,7 @@ func (x *StreamError) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamError.ProtoReflect.Descriptor instead. func (*StreamError) Descriptor() ([]byte, []int) { - return file_chat_v2_chat_proto_rawDescGZIP(), []int{26} + return file_chat_v2_chat_proto_rawDescGZIP(), []int{27} } func (x *StreamError) GetErrorMessage() string { @@ -1557,7 +1617,7 @@ type CreateConversationMessageStreamRequest struct { func (x *CreateConversationMessageStreamRequest) Reset() { *x = CreateConversationMessageStreamRequest{} - mi := &file_chat_v2_chat_proto_msgTypes[27] + mi := &file_chat_v2_chat_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1569,7 +1629,7 @@ func (x *CreateConversationMessageStreamRequest) String() string { func (*CreateConversationMessageStreamRequest) ProtoMessage() {} func (x *CreateConversationMessageStreamRequest) ProtoReflect() protoreflect.Message { - mi := &file_chat_v2_chat_proto_msgTypes[27] + mi := &file_chat_v2_chat_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1582,7 +1642,7 @@ func (x *CreateConversationMessageStreamRequest) ProtoReflect() protoreflect.Mes // Deprecated: Use CreateConversationMessageStreamRequest.ProtoReflect.Descriptor instead. func (*CreateConversationMessageStreamRequest) Descriptor() ([]byte, []int) { - return file_chat_v2_chat_proto_rawDescGZIP(), []int{27} + return file_chat_v2_chat_proto_rawDescGZIP(), []int{28} } func (x *CreateConversationMessageStreamRequest) GetProjectId() string { @@ -1646,6 +1706,7 @@ type CreateConversationMessageStreamResponse struct { // *CreateConversationMessageStreamResponse_StreamPartEnd // *CreateConversationMessageStreamResponse_StreamFinalization // *CreateConversationMessageStreamResponse_StreamError + // *CreateConversationMessageStreamResponse_ReasoningChunk ResponsePayload isCreateConversationMessageStreamResponse_ResponsePayload `protobuf_oneof:"response_payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1653,7 +1714,7 @@ type CreateConversationMessageStreamResponse struct { func (x *CreateConversationMessageStreamResponse) Reset() { *x = CreateConversationMessageStreamResponse{} - mi := &file_chat_v2_chat_proto_msgTypes[28] + mi := &file_chat_v2_chat_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1665,7 +1726,7 @@ func (x *CreateConversationMessageStreamResponse) String() string { func (*CreateConversationMessageStreamResponse) ProtoMessage() {} func (x *CreateConversationMessageStreamResponse) ProtoReflect() protoreflect.Message { - mi := &file_chat_v2_chat_proto_msgTypes[28] + mi := &file_chat_v2_chat_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1678,7 +1739,7 @@ func (x *CreateConversationMessageStreamResponse) ProtoReflect() protoreflect.Me // Deprecated: Use CreateConversationMessageStreamResponse.ProtoReflect.Descriptor instead. func (*CreateConversationMessageStreamResponse) Descriptor() ([]byte, []int) { - return file_chat_v2_chat_proto_rawDescGZIP(), []int{28} + return file_chat_v2_chat_proto_rawDescGZIP(), []int{29} } func (x *CreateConversationMessageStreamResponse) GetResponsePayload() isCreateConversationMessageStreamResponse_ResponsePayload { @@ -1751,6 +1812,15 @@ func (x *CreateConversationMessageStreamResponse) GetStreamError() *StreamError return nil } +func (x *CreateConversationMessageStreamResponse) GetReasoningChunk() *ReasoningChunk { + if x != nil { + if x, ok := x.ResponsePayload.(*CreateConversationMessageStreamResponse_ReasoningChunk); ok { + return x.ReasoningChunk + } + } + return nil +} + type isCreateConversationMessageStreamResponse_ResponsePayload interface { isCreateConversationMessageStreamResponse_ResponsePayload() } @@ -1783,6 +1853,10 @@ type CreateConversationMessageStreamResponse_StreamError struct { StreamError *StreamError `protobuf:"bytes,7,opt,name=stream_error,json=streamError,proto3,oneof"` } +type CreateConversationMessageStreamResponse_ReasoningChunk struct { + ReasoningChunk *ReasoningChunk `protobuf:"bytes,8,opt,name=reasoning_chunk,json=reasoningChunk,proto3,oneof"` +} + func (*CreateConversationMessageStreamResponse_StreamInitialization) isCreateConversationMessageStreamResponse_ResponsePayload() { } @@ -1804,6 +1878,9 @@ func (*CreateConversationMessageStreamResponse_StreamFinalization) isCreateConve func (*CreateConversationMessageStreamResponse_StreamError) isCreateConversationMessageStreamResponse_ResponsePayload() { } +func (*CreateConversationMessageStreamResponse_ReasoningChunk) isCreateConversationMessageStreamResponse_ResponsePayload() { +} + var File_chat_v2_chat_proto protoreflect.FileDescriptor const file_chat_v2_chat_proto_rawDesc = "" + @@ -1818,11 +1895,14 @@ const file_chat_v2_chat_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + "\x04args\x18\x02 \x01(\tR\x04args\"-\n" + "\x11MessageTypeSystem\x12\x18\n" + - "\acontent\x18\x01 \x01(\tR\acontent\"O\n" + + "\acontent\x18\x01 \x01(\tR\acontent\"\x80\x01\n" + "\x14MessageTypeAssistant\x12\x18\n" + "\acontent\x18\x01 \x01(\tR\acontent\x12\x1d\n" + "\n" + - "model_slug\x18\x02 \x01(\tR\tmodelSlug\"\x9e\x01\n" + + "model_slug\x18\x02 \x01(\tR\tmodelSlug\x12!\n" + + "\treasoning\x18\x03 \x01(\tH\x00R\treasoning\x88\x01\x01B\f\n" + + "\n" + + "_reasoning\"\x9e\x01\n" + "\x0fMessageTypeUser\x12\x18\n" + "\acontent\x18\x01 \x01(\tR\acontent\x12(\n" + "\rselected_text\x18\x02 \x01(\tH\x00R\fselectedText\x88\x01\x01\x12%\n" + @@ -1891,6 +1971,10 @@ const file_chat_v2_chat_proto_rawDesc = "" + "\fMessageChunk\x12\x1d\n" + "\n" + "message_id\x18\x01 \x01(\tR\tmessageId\x12\x14\n" + + "\x05delta\x18\x02 \x01(\tR\x05delta\"E\n" + + "\x0eReasoningChunk\x12\x1d\n" + + "\n" + + "message_id\x18\x01 \x01(\tR\tmessageId\x12\x14\n" + "\x05delta\x18\x02 \x01(\tR\x05delta\"N\n" + "\x13IncompleteIndicator\x12\x16\n" + "\x06reason\x18\x01 \x01(\tR\x06reason\x12\x1f\n" + @@ -1917,7 +2001,7 @@ const file_chat_v2_chat_proto_rawDesc = "" + "\x10_conversation_idB\x15\n" + "\x13_user_selected_textB\x14\n" + "\x12_conversation_typeB\x0e\n" + - "\f_surrounding\"\xb9\x04\n" + + "\f_surrounding\"\xfd\x04\n" + "'CreateConversationMessageStreamResponse\x12T\n" + "\x15stream_initialization\x18\x01 \x01(\v2\x1d.chat.v2.StreamInitializationH\x00R\x14streamInitialization\x12F\n" + "\x11stream_part_begin\x18\x02 \x01(\v2\x18.chat.v2.StreamPartBeginH\x00R\x0fstreamPartBegin\x12<\n" + @@ -1925,7 +2009,8 @@ const file_chat_v2_chat_proto_rawDesc = "" + "\x14incomplete_indicator\x18\x04 \x01(\v2\x1c.chat.v2.IncompleteIndicatorH\x00R\x13incompleteIndicator\x12@\n" + "\x0fstream_part_end\x18\x05 \x01(\v2\x16.chat.v2.StreamPartEndH\x00R\rstreamPartEnd\x12N\n" + "\x13stream_finalization\x18\x06 \x01(\v2\x1b.chat.v2.StreamFinalizationH\x00R\x12streamFinalization\x129\n" + - "\fstream_error\x18\a \x01(\v2\x14.chat.v2.StreamErrorH\x00R\vstreamErrorB\x12\n" + + "\fstream_error\x18\a \x01(\v2\x14.chat.v2.StreamErrorH\x00R\vstreamError\x12B\n" + + "\x0freasoning_chunk\x18\b \x01(\v2\x17.chat.v2.ReasoningChunkH\x00R\x0ereasoningChunkB\x12\n" + "\x10response_payload*R\n" + "\x10ConversationType\x12!\n" + "\x1dCONVERSATION_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n" + @@ -1952,7 +2037,7 @@ func file_chat_v2_chat_proto_rawDescGZIP() []byte { } var file_chat_v2_chat_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_chat_v2_chat_proto_msgTypes = make([]protoimpl.MessageInfo, 29) +var file_chat_v2_chat_proto_msgTypes = make([]protoimpl.MessageInfo, 30) var file_chat_v2_chat_proto_goTypes = []any{ (ConversationType)(0), // 0: chat.v2.ConversationType (*MessageTypeToolCall)(nil), // 1: chat.v2.MessageTypeToolCall @@ -1978,12 +2063,13 @@ var file_chat_v2_chat_proto_goTypes = []any{ (*StreamInitialization)(nil), // 21: chat.v2.StreamInitialization (*StreamPartBegin)(nil), // 22: chat.v2.StreamPartBegin (*MessageChunk)(nil), // 23: chat.v2.MessageChunk - (*IncompleteIndicator)(nil), // 24: chat.v2.IncompleteIndicator - (*StreamPartEnd)(nil), // 25: chat.v2.StreamPartEnd - (*StreamFinalization)(nil), // 26: chat.v2.StreamFinalization - (*StreamError)(nil), // 27: chat.v2.StreamError - (*CreateConversationMessageStreamRequest)(nil), // 28: chat.v2.CreateConversationMessageStreamRequest - (*CreateConversationMessageStreamResponse)(nil), // 29: chat.v2.CreateConversationMessageStreamResponse + (*ReasoningChunk)(nil), // 24: chat.v2.ReasoningChunk + (*IncompleteIndicator)(nil), // 25: chat.v2.IncompleteIndicator + (*StreamPartEnd)(nil), // 26: chat.v2.StreamPartEnd + (*StreamFinalization)(nil), // 27: chat.v2.StreamFinalization + (*StreamError)(nil), // 28: chat.v2.StreamError + (*CreateConversationMessageStreamRequest)(nil), // 29: chat.v2.CreateConversationMessageStreamRequest + (*CreateConversationMessageStreamResponse)(nil), // 30: chat.v2.CreateConversationMessageStreamResponse } var file_chat_v2_chat_proto_depIdxs = []int32{ 3, // 0: chat.v2.MessagePayload.system:type_name -> chat.v2.MessageTypeSystem @@ -2004,27 +2090,28 @@ var file_chat_v2_chat_proto_depIdxs = []int32{ 21, // 15: chat.v2.CreateConversationMessageStreamResponse.stream_initialization:type_name -> chat.v2.StreamInitialization 22, // 16: chat.v2.CreateConversationMessageStreamResponse.stream_part_begin:type_name -> chat.v2.StreamPartBegin 23, // 17: chat.v2.CreateConversationMessageStreamResponse.message_chunk:type_name -> chat.v2.MessageChunk - 24, // 18: chat.v2.CreateConversationMessageStreamResponse.incomplete_indicator:type_name -> chat.v2.IncompleteIndicator - 25, // 19: chat.v2.CreateConversationMessageStreamResponse.stream_part_end:type_name -> chat.v2.StreamPartEnd - 26, // 20: chat.v2.CreateConversationMessageStreamResponse.stream_finalization:type_name -> chat.v2.StreamFinalization - 27, // 21: chat.v2.CreateConversationMessageStreamResponse.stream_error:type_name -> chat.v2.StreamError - 10, // 22: chat.v2.ChatService.ListConversations:input_type -> chat.v2.ListConversationsRequest - 12, // 23: chat.v2.ChatService.GetConversation:input_type -> chat.v2.GetConversationRequest - 28, // 24: chat.v2.ChatService.CreateConversationMessageStream:input_type -> chat.v2.CreateConversationMessageStreamRequest - 14, // 25: chat.v2.ChatService.UpdateConversation:input_type -> chat.v2.UpdateConversationRequest - 16, // 26: chat.v2.ChatService.DeleteConversation:input_type -> chat.v2.DeleteConversationRequest - 19, // 27: chat.v2.ChatService.ListSupportedModels:input_type -> chat.v2.ListSupportedModelsRequest - 11, // 28: chat.v2.ChatService.ListConversations:output_type -> chat.v2.ListConversationsResponse - 13, // 29: chat.v2.ChatService.GetConversation:output_type -> chat.v2.GetConversationResponse - 29, // 30: chat.v2.ChatService.CreateConversationMessageStream:output_type -> chat.v2.CreateConversationMessageStreamResponse - 15, // 31: chat.v2.ChatService.UpdateConversation:output_type -> chat.v2.UpdateConversationResponse - 17, // 32: chat.v2.ChatService.DeleteConversation:output_type -> chat.v2.DeleteConversationResponse - 20, // 33: chat.v2.ChatService.ListSupportedModels:output_type -> chat.v2.ListSupportedModelsResponse - 28, // [28:34] is the sub-list for method output_type - 22, // [22:28] is the sub-list for method input_type - 22, // [22:22] is the sub-list for extension type_name - 22, // [22:22] is the sub-list for extension extendee - 0, // [0:22] is the sub-list for field type_name + 25, // 18: chat.v2.CreateConversationMessageStreamResponse.incomplete_indicator:type_name -> chat.v2.IncompleteIndicator + 26, // 19: chat.v2.CreateConversationMessageStreamResponse.stream_part_end:type_name -> chat.v2.StreamPartEnd + 27, // 20: chat.v2.CreateConversationMessageStreamResponse.stream_finalization:type_name -> chat.v2.StreamFinalization + 28, // 21: chat.v2.CreateConversationMessageStreamResponse.stream_error:type_name -> chat.v2.StreamError + 24, // 22: chat.v2.CreateConversationMessageStreamResponse.reasoning_chunk:type_name -> chat.v2.ReasoningChunk + 10, // 23: chat.v2.ChatService.ListConversations:input_type -> chat.v2.ListConversationsRequest + 12, // 24: chat.v2.ChatService.GetConversation:input_type -> chat.v2.GetConversationRequest + 29, // 25: chat.v2.ChatService.CreateConversationMessageStream:input_type -> chat.v2.CreateConversationMessageStreamRequest + 14, // 26: chat.v2.ChatService.UpdateConversation:input_type -> chat.v2.UpdateConversationRequest + 16, // 27: chat.v2.ChatService.DeleteConversation:input_type -> chat.v2.DeleteConversationRequest + 19, // 28: chat.v2.ChatService.ListSupportedModels:input_type -> chat.v2.ListSupportedModelsRequest + 11, // 29: chat.v2.ChatService.ListConversations:output_type -> chat.v2.ListConversationsResponse + 13, // 30: chat.v2.ChatService.GetConversation:output_type -> chat.v2.GetConversationResponse + 30, // 31: chat.v2.ChatService.CreateConversationMessageStream:output_type -> chat.v2.CreateConversationMessageStreamResponse + 15, // 32: chat.v2.ChatService.UpdateConversation:output_type -> chat.v2.UpdateConversationResponse + 17, // 33: chat.v2.ChatService.DeleteConversation:output_type -> chat.v2.DeleteConversationResponse + 20, // 34: chat.v2.ChatService.ListSupportedModels:output_type -> chat.v2.ListSupportedModelsResponse + 29, // [29:35] is the sub-list for method output_type + 23, // [23:29] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name } func init() { file_chat_v2_chat_proto_init() } @@ -2032,6 +2119,7 @@ func file_chat_v2_chat_proto_init() { if File_chat_v2_chat_proto != nil { return } + file_chat_v2_chat_proto_msgTypes[3].OneofWrappers = []any{} file_chat_v2_chat_proto_msgTypes[4].OneofWrappers = []any{} file_chat_v2_chat_proto_msgTypes[6].OneofWrappers = []any{ (*MessagePayload_System)(nil), @@ -2042,8 +2130,8 @@ func file_chat_v2_chat_proto_init() { (*MessagePayload_Unknown)(nil), } file_chat_v2_chat_proto_msgTypes[9].OneofWrappers = []any{} - file_chat_v2_chat_proto_msgTypes[27].OneofWrappers = []any{} - file_chat_v2_chat_proto_msgTypes[28].OneofWrappers = []any{ + file_chat_v2_chat_proto_msgTypes[28].OneofWrappers = []any{} + file_chat_v2_chat_proto_msgTypes[29].OneofWrappers = []any{ (*CreateConversationMessageStreamResponse_StreamInitialization)(nil), (*CreateConversationMessageStreamResponse_StreamPartBegin)(nil), (*CreateConversationMessageStreamResponse_MessageChunk)(nil), @@ -2051,6 +2139,7 @@ func file_chat_v2_chat_proto_init() { (*CreateConversationMessageStreamResponse_StreamPartEnd)(nil), (*CreateConversationMessageStreamResponse_StreamFinalization)(nil), (*CreateConversationMessageStreamResponse_StreamError)(nil), + (*CreateConversationMessageStreamResponse_ReasoningChunk)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -2058,7 +2147,7 @@ func file_chat_v2_chat_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chat_v2_chat_proto_rawDesc), len(file_chat_v2_chat_proto_rawDesc)), NumEnums: 1, - NumMessages: 29, + NumMessages: 30, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/chat/v2/chat.proto b/proto/chat/v2/chat.proto index 68130275..b4136c4c 100644 --- a/proto/chat/v2/chat.proto +++ b/proto/chat/v2/chat.proto @@ -52,6 +52,7 @@ message MessageTypeSystem { message MessageTypeAssistant { string content = 1; string model_slug = 2; + optional string reasoning = 3; } message MessageTypeUser { @@ -165,6 +166,11 @@ message MessageChunk { string delta = 2; // The small piece of text } +message ReasoningChunk { + string message_id = 1; // The id of the message that this chunk belongs to + string delta = 2; // The small piece of reasoning text +} + message IncompleteIndicator { string reason = 1; string response_id = 2; @@ -223,5 +229,6 @@ message CreateConversationMessageStreamResponse { StreamPartEnd stream_part_end = 5; StreamFinalization stream_finalization = 6; StreamError stream_error = 7; + ReasoningChunk reasoning_chunk = 8; } } diff --git a/webapp/_webapp/src/pkg/gen/apiclient/auth/v1/auth_pb.ts b/webapp/_webapp/src/pkg/gen/apiclient/auth/v1/auth_pb.ts index c8e55304..e4f74d48 100644 --- a/webapp/_webapp/src/pkg/gen/apiclient/auth/v1/auth_pb.ts +++ b/webapp/_webapp/src/pkg/gen/apiclient/auth/v1/auth_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.10.2 with parameter "target=ts" +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" // @generated from file auth/v1/auth.proto (package auth.v1, syntax proto3) /* eslint-disable */ diff --git a/webapp/_webapp/src/pkg/gen/apiclient/chat/v1/chat_pb.ts b/webapp/_webapp/src/pkg/gen/apiclient/chat/v1/chat_pb.ts index 279b164f..86d11a48 100644 --- a/webapp/_webapp/src/pkg/gen/apiclient/chat/v1/chat_pb.ts +++ b/webapp/_webapp/src/pkg/gen/apiclient/chat/v1/chat_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.10.2 with parameter "target=ts" +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" // @generated from file chat/v1/chat.proto (package chat.v1, syntax proto3) /* eslint-disable */ diff --git a/webapp/_webapp/src/pkg/gen/apiclient/chat/v2/chat_pb.ts b/webapp/_webapp/src/pkg/gen/apiclient/chat/v2/chat_pb.ts index c41584a5..ce0217da 100644 --- a/webapp/_webapp/src/pkg/gen/apiclient/chat/v2/chat_pb.ts +++ b/webapp/_webapp/src/pkg/gen/apiclient/chat/v2/chat_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.10.2 with parameter "target=ts" +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" // @generated from file chat/v2/chat.proto (package chat.v2, syntax proto3) /* eslint-disable */ @@ -11,7 +11,7 @@ import type { Message as Message$1 } from "@bufbuild/protobuf"; * Describes the file chat/v2/chat.proto. */ export const file_chat_v2_chat: GenFile = /*@__PURE__*/ - fileDesc("ChJjaGF0L3YyL2NoYXQucHJvdG8SB2NoYXQudjIiUAoTTWVzc2FnZVR5cGVUb29sQ2FsbBIMCgRuYW1lGAEgASgJEgwKBGFyZ3MYAiABKAkSDgoGcmVzdWx0GAMgASgJEg0KBWVycm9yGAQgASgJIkEKI01lc3NhZ2VUeXBlVG9vbENhbGxQcmVwYXJlQXJndW1lbnRzEgwKBG5hbWUYASABKAkSDAoEYXJncxgCIAEoCSIkChFNZXNzYWdlVHlwZVN5c3RlbRIPCgdjb250ZW50GAEgASgJIjsKFE1lc3NhZ2VUeXBlQXNzaXN0YW50Eg8KB2NvbnRlbnQYASABKAkSEgoKbW9kZWxfc2x1ZxgCIAEoCSJ6Cg9NZXNzYWdlVHlwZVVzZXISDwoHY29udGVudBgBIAEoCRIaCg1zZWxlY3RlZF90ZXh0GAIgASgJSACIAQESGAoLc3Vycm91bmRpbmcYByABKAlIAYgBAUIQCg5fc2VsZWN0ZWRfdGV4dEIOCgxfc3Vycm91bmRpbmciKQoSTWVzc2FnZVR5cGVVbmtub3duEhMKC2Rlc2NyaXB0aW9uGAEgASgJIuQCCg5NZXNzYWdlUGF5bG9hZBIsCgZzeXN0ZW0YASABKAsyGi5jaGF0LnYyLk1lc3NhZ2VUeXBlU3lzdGVtSAASKAoEdXNlchgCIAEoCzIYLmNoYXQudjIuTWVzc2FnZVR5cGVVc2VySAASMgoJYXNzaXN0YW50GAMgASgLMh0uY2hhdC52Mi5NZXNzYWdlVHlwZUFzc2lzdGFudEgAElMKG3Rvb2xfY2FsbF9wcmVwYXJlX2FyZ3VtZW50cxgEIAEoCzIsLmNoYXQudjIuTWVzc2FnZVR5cGVUb29sQ2FsbFByZXBhcmVBcmd1bWVudHNIABIxCgl0b29sX2NhbGwYBSABKAsyHC5jaGF0LnYyLk1lc3NhZ2VUeXBlVG9vbENhbGxIABIuCgd1bmtub3duGAYgASgLMhsuY2hhdC52Mi5NZXNzYWdlVHlwZVVua25vd25IAEIOCgxtZXNzYWdlX3R5cGUiWgoHTWVzc2FnZRISCgptZXNzYWdlX2lkGAEgASgJEigKB3BheWxvYWQYAiABKAsyFy5jaGF0LnYyLk1lc3NhZ2VQYXlsb2FkEhEKCXRpbWVzdGFtcBgDIAEoAyJhCgxDb252ZXJzYXRpb24SCgoCaWQYASABKAkSDQoFdGl0bGUYAiABKAkSEgoKbW9kZWxfc2x1ZxgDIAEoCRIiCghtZXNzYWdlcxgEIAMoCzIQLmNoYXQudjIuTWVzc2FnZSJCChhMaXN0Q29udmVyc2F0aW9uc1JlcXVlc3QSFwoKcHJvamVjdF9pZBgBIAEoCUgAiAEBQg0KC19wcm9qZWN0X2lkIkkKGUxpc3RDb252ZXJzYXRpb25zUmVzcG9uc2USLAoNY29udmVyc2F0aW9ucxgBIAMoCzIVLmNoYXQudjIuQ29udmVyc2F0aW9uIjEKFkdldENvbnZlcnNhdGlvblJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJIkYKF0dldENvbnZlcnNhdGlvblJlc3BvbnNlEisKDGNvbnZlcnNhdGlvbhgBIAEoCzIVLmNoYXQudjIuQ29udmVyc2F0aW9uIkMKGVVwZGF0ZUNvbnZlcnNhdGlvblJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg0KBXRpdGxlGAIgASgJIkkKGlVwZGF0ZUNvbnZlcnNhdGlvblJlc3BvbnNlEisKDGNvbnZlcnNhdGlvbhgBIAEoCzIVLmNoYXQudjIuQ29udmVyc2F0aW9uIjQKGURlbGV0ZUNvbnZlcnNhdGlvblJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJIhwKGkRlbGV0ZUNvbnZlcnNhdGlvblJlc3BvbnNlIoIBCg5TdXBwb3J0ZWRNb2RlbBIMCgRuYW1lGAEgASgJEgwKBHNsdWcYAiABKAkSFQoNdG90YWxfY29udGV4dBgDIAEoAxISCgptYXhfb3V0cHV0GAQgASgDEhMKC2lucHV0X3ByaWNlGAUgASgDEhQKDG91dHB1dF9wcmljZRgGIAEoAyIcChpMaXN0U3VwcG9ydGVkTW9kZWxzUmVxdWVzdCJGChtMaXN0U3VwcG9ydGVkTW9kZWxzUmVzcG9uc2USJwoGbW9kZWxzGAEgAygLMhcuY2hhdC52Mi5TdXBwb3J0ZWRNb2RlbCJDChRTdHJlYW1Jbml0aWFsaXphdGlvbhIXCg9jb252ZXJzYXRpb25faWQYASABKAkSEgoKbW9kZWxfc2x1ZxgCIAEoCSJPCg9TdHJlYW1QYXJ0QmVnaW4SEgoKbWVzc2FnZV9pZBgBIAEoCRIoCgdwYXlsb2FkGAMgASgLMhcuY2hhdC52Mi5NZXNzYWdlUGF5bG9hZCIxCgxNZXNzYWdlQ2h1bmsSEgoKbWVzc2FnZV9pZBgBIAEoCRINCgVkZWx0YRgCIAEoCSI6ChNJbmNvbXBsZXRlSW5kaWNhdG9yEg4KBnJlYXNvbhgBIAEoCRITCgtyZXNwb25zZV9pZBgCIAEoCSJNCg1TdHJlYW1QYXJ0RW5kEhIKCm1lc3NhZ2VfaWQYASABKAkSKAoHcGF5bG9hZBgDIAEoCzIXLmNoYXQudjIuTWVzc2FnZVBheWxvYWQiLQoSU3RyZWFtRmluYWxpemF0aW9uEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSIkCgtTdHJlYW1FcnJvchIVCg1lcnJvcl9tZXNzYWdlGAEgASgJIssCCiZDcmVhdGVDb252ZXJzYXRpb25NZXNzYWdlU3RyZWFtUmVxdWVzdBISCgpwcm9qZWN0X2lkGAEgASgJEhwKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCUgAiAEBEhIKCm1vZGVsX3NsdWcYAyABKAkSFAoMdXNlcl9tZXNzYWdlGAQgASgJEh8KEnVzZXJfc2VsZWN0ZWRfdGV4dBgFIAEoCUgBiAEBEjkKEWNvbnZlcnNhdGlvbl90eXBlGAYgASgOMhkuY2hhdC52Mi5Db252ZXJzYXRpb25UeXBlSAKIAQESGAoLc3Vycm91bmRpbmcYCCABKAlIA4gBAUISChBfY29udmVyc2F0aW9uX2lkQhUKE191c2VyX3NlbGVjdGVkX3RleHRCFAoSX2NvbnZlcnNhdGlvbl90eXBlQg4KDF9zdXJyb3VuZGluZyK/AwonQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbVJlc3BvbnNlEj4KFXN0cmVhbV9pbml0aWFsaXphdGlvbhgBIAEoCzIdLmNoYXQudjIuU3RyZWFtSW5pdGlhbGl6YXRpb25IABI1ChFzdHJlYW1fcGFydF9iZWdpbhgCIAEoCzIYLmNoYXQudjIuU3RyZWFtUGFydEJlZ2luSAASLgoNbWVzc2FnZV9jaHVuaxgDIAEoCzIVLmNoYXQudjIuTWVzc2FnZUNodW5rSAASPAoUaW5jb21wbGV0ZV9pbmRpY2F0b3IYBCABKAsyHC5jaGF0LnYyLkluY29tcGxldGVJbmRpY2F0b3JIABIxCg9zdHJlYW1fcGFydF9lbmQYBSABKAsyFi5jaGF0LnYyLlN0cmVhbVBhcnRFbmRIABI6ChNzdHJlYW1fZmluYWxpemF0aW9uGAYgASgLMhsuY2hhdC52Mi5TdHJlYW1GaW5hbGl6YXRpb25IABIsCgxzdHJlYW1fZXJyb3IYByABKAsyFC5jaGF0LnYyLlN0cmVhbUVycm9ySABCEgoQcmVzcG9uc2VfcGF5bG9hZCpSChBDb252ZXJzYXRpb25UeXBlEiEKHUNPTlZFUlNBVElPTl9UWVBFX1VOU1BFQ0lGSUVEEAASGwoXQ09OVkVSU0FUSU9OX1RZUEVfREVCVUcQATKoBwoLQ2hhdFNlcnZpY2USgwEKEUxpc3RDb252ZXJzYXRpb25zEiEuY2hhdC52Mi5MaXN0Q29udmVyc2F0aW9uc1JlcXVlc3QaIi5jaGF0LnYyLkxpc3RDb252ZXJzYXRpb25zUmVzcG9uc2UiJ4LT5JMCIRIfL19wZC9hcGkvdjIvY2hhdHMvY29udmVyc2F0aW9ucxKPAQoPR2V0Q29udmVyc2F0aW9uEh8uY2hhdC52Mi5HZXRDb252ZXJzYXRpb25SZXF1ZXN0GiAuY2hhdC52Mi5HZXRDb252ZXJzYXRpb25SZXNwb25zZSI5gtPkkwIzEjEvX3BkL2FwaS92Mi9jaGF0cy9jb252ZXJzYXRpb25zL3tjb252ZXJzYXRpb25faWR9EsIBCh9DcmVhdGVDb252ZXJzYXRpb25NZXNzYWdlU3RyZWFtEi8uY2hhdC52Mi5DcmVhdGVDb252ZXJzYXRpb25NZXNzYWdlU3RyZWFtUmVxdWVzdBowLmNoYXQudjIuQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbVJlc3BvbnNlIjqC0+STAjQ6ASoiLy9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMvbWVzc2FnZXMvc3RyZWFtMAESmwEKElVwZGF0ZUNvbnZlcnNhdGlvbhIiLmNoYXQudjIuVXBkYXRlQ29udmVyc2F0aW9uUmVxdWVzdBojLmNoYXQudjIuVXBkYXRlQ29udmVyc2F0aW9uUmVzcG9uc2UiPILT5JMCNjoBKjIxL19wZC9hcGkvdjIvY2hhdHMvY29udmVyc2F0aW9ucy97Y29udmVyc2F0aW9uX2lkfRKYAQoSRGVsZXRlQ29udmVyc2F0aW9uEiIuY2hhdC52Mi5EZWxldGVDb252ZXJzYXRpb25SZXF1ZXN0GiMuY2hhdC52Mi5EZWxldGVDb252ZXJzYXRpb25SZXNwb25zZSI5gtPkkwIzKjEvX3BkL2FwaS92Mi9jaGF0cy9jb252ZXJzYXRpb25zL3tjb252ZXJzYXRpb25faWR9EoIBChNMaXN0U3VwcG9ydGVkTW9kZWxzEiMuY2hhdC52Mi5MaXN0U3VwcG9ydGVkTW9kZWxzUmVxdWVzdBokLmNoYXQudjIuTGlzdFN1cHBvcnRlZE1vZGVsc1Jlc3BvbnNlIiCC0+STAhoSGC9fcGQvYXBpL3YyL2NoYXRzL21vZGVsc0J/Cgtjb20uY2hhdC52MkIJQ2hhdFByb3RvUAFaKHBhcGVyZGVidWdnZXIvcGtnL2dlbi9hcGkvY2hhdC92MjtjaGF0djKiAgNDWFiqAgdDaGF0LlYyygIHQ2hhdFxWMuICE0NoYXRcVjJcR1BCTWV0YWRhdGHqAghDaGF0OjpWMmIGcHJvdG8z", [file_google_api_annotations]); + fileDesc("ChJjaGF0L3YyL2NoYXQucHJvdG8SB2NoYXQudjIiUAoTTWVzc2FnZVR5cGVUb29sQ2FsbBIMCgRuYW1lGAEgASgJEgwKBGFyZ3MYAiABKAkSDgoGcmVzdWx0GAMgASgJEg0KBWVycm9yGAQgASgJIkEKI01lc3NhZ2VUeXBlVG9vbENhbGxQcmVwYXJlQXJndW1lbnRzEgwKBG5hbWUYASABKAkSDAoEYXJncxgCIAEoCSIkChFNZXNzYWdlVHlwZVN5c3RlbRIPCgdjb250ZW50GAEgASgJImEKFE1lc3NhZ2VUeXBlQXNzaXN0YW50Eg8KB2NvbnRlbnQYASABKAkSEgoKbW9kZWxfc2x1ZxgCIAEoCRIWCglyZWFzb25pbmcYAyABKAlIAIgBAUIMCgpfcmVhc29uaW5nInoKD01lc3NhZ2VUeXBlVXNlchIPCgdjb250ZW50GAEgASgJEhoKDXNlbGVjdGVkX3RleHQYAiABKAlIAIgBARIYCgtzdXJyb3VuZGluZxgHIAEoCUgBiAEBQhAKDl9zZWxlY3RlZF90ZXh0Qg4KDF9zdXJyb3VuZGluZyIpChJNZXNzYWdlVHlwZVVua25vd24SEwoLZGVzY3JpcHRpb24YASABKAki5AIKDk1lc3NhZ2VQYXlsb2FkEiwKBnN5c3RlbRgBIAEoCzIaLmNoYXQudjIuTWVzc2FnZVR5cGVTeXN0ZW1IABIoCgR1c2VyGAIgASgLMhguY2hhdC52Mi5NZXNzYWdlVHlwZVVzZXJIABIyCglhc3Npc3RhbnQYAyABKAsyHS5jaGF0LnYyLk1lc3NhZ2VUeXBlQXNzaXN0YW50SAASUwobdG9vbF9jYWxsX3ByZXBhcmVfYXJndW1lbnRzGAQgASgLMiwuY2hhdC52Mi5NZXNzYWdlVHlwZVRvb2xDYWxsUHJlcGFyZUFyZ3VtZW50c0gAEjEKCXRvb2xfY2FsbBgFIAEoCzIcLmNoYXQudjIuTWVzc2FnZVR5cGVUb29sQ2FsbEgAEi4KB3Vua25vd24YBiABKAsyGy5jaGF0LnYyLk1lc3NhZ2VUeXBlVW5rbm93bkgAQg4KDG1lc3NhZ2VfdHlwZSJaCgdNZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSKAoHcGF5bG9hZBgCIAEoCzIXLmNoYXQudjIuTWVzc2FnZVBheWxvYWQSEQoJdGltZXN0YW1wGAMgASgDImEKDENvbnZlcnNhdGlvbhIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgptb2RlbF9zbHVnGAMgASgJEiIKCG1lc3NhZ2VzGAQgAygLMhAuY2hhdC52Mi5NZXNzYWdlIkIKGExpc3RDb252ZXJzYXRpb25zUmVxdWVzdBIXCgpwcm9qZWN0X2lkGAEgASgJSACIAQFCDQoLX3Byb2plY3RfaWQiSQoZTGlzdENvbnZlcnNhdGlvbnNSZXNwb25zZRIsCg1jb252ZXJzYXRpb25zGAEgAygLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iMQoWR2V0Q29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiRgoXR2V0Q29udmVyc2F0aW9uUmVzcG9uc2USKwoMY29udmVyc2F0aW9uGAEgASgLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iQwoZVXBkYXRlQ29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDQoFdGl0bGUYAiABKAkiSQoaVXBkYXRlQ29udmVyc2F0aW9uUmVzcG9uc2USKwoMY29udmVyc2F0aW9uGAEgASgLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iNAoZRGVsZXRlQ29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiHAoaRGVsZXRlQ29udmVyc2F0aW9uUmVzcG9uc2UiggEKDlN1cHBvcnRlZE1vZGVsEgwKBG5hbWUYASABKAkSDAoEc2x1ZxgCIAEoCRIVCg10b3RhbF9jb250ZXh0GAMgASgDEhIKCm1heF9vdXRwdXQYBCABKAMSEwoLaW5wdXRfcHJpY2UYBSABKAMSFAoMb3V0cHV0X3ByaWNlGAYgASgDIhwKGkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXF1ZXN0IkYKG0xpc3RTdXBwb3J0ZWRNb2RlbHNSZXNwb25zZRInCgZtb2RlbHMYASADKAsyFy5jaGF0LnYyLlN1cHBvcnRlZE1vZGVsIkMKFFN0cmVhbUluaXRpYWxpemF0aW9uEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRISCgptb2RlbF9zbHVnGAIgASgJIk8KD1N0cmVhbVBhcnRCZWdpbhISCgptZXNzYWdlX2lkGAEgASgJEigKB3BheWxvYWQYAyABKAsyFy5jaGF0LnYyLk1lc3NhZ2VQYXlsb2FkIjEKDE1lc3NhZ2VDaHVuaxISCgptZXNzYWdlX2lkGAEgASgJEg0KBWRlbHRhGAIgASgJIjMKDlJlYXNvbmluZ0NodW5rEhIKCm1lc3NhZ2VfaWQYASABKAkSDQoFZGVsdGEYAiABKAkiOgoTSW5jb21wbGV0ZUluZGljYXRvchIOCgZyZWFzb24YASABKAkSEwoLcmVzcG9uc2VfaWQYAiABKAkiTQoNU3RyZWFtUGFydEVuZBISCgptZXNzYWdlX2lkGAEgASgJEigKB3BheWxvYWQYAyABKAsyFy5jaGF0LnYyLk1lc3NhZ2VQYXlsb2FkIi0KElN0cmVhbUZpbmFsaXphdGlvbhIXCg9jb252ZXJzYXRpb25faWQYASABKAkiJAoLU3RyZWFtRXJyb3ISFQoNZXJyb3JfbWVzc2FnZRgBIAEoCSLLAgomQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbVJlcXVlc3QSEgoKcHJvamVjdF9pZBgBIAEoCRIcCg9jb252ZXJzYXRpb25faWQYAiABKAlIAIgBARISCgptb2RlbF9zbHVnGAMgASgJEhQKDHVzZXJfbWVzc2FnZRgEIAEoCRIfChJ1c2VyX3NlbGVjdGVkX3RleHQYBSABKAlIAYgBARI5ChFjb252ZXJzYXRpb25fdHlwZRgGIAEoDjIZLmNoYXQudjIuQ29udmVyc2F0aW9uVHlwZUgCiAEBEhgKC3N1cnJvdW5kaW5nGAggASgJSAOIAQFCEgoQX2NvbnZlcnNhdGlvbl9pZEIVChNfdXNlcl9zZWxlY3RlZF90ZXh0QhQKEl9jb252ZXJzYXRpb25fdHlwZUIOCgxfc3Vycm91bmRpbmci8wMKJ0NyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW1SZXNwb25zZRI+ChVzdHJlYW1faW5pdGlhbGl6YXRpb24YASABKAsyHS5jaGF0LnYyLlN0cmVhbUluaXRpYWxpemF0aW9uSAASNQoRc3RyZWFtX3BhcnRfYmVnaW4YAiABKAsyGC5jaGF0LnYyLlN0cmVhbVBhcnRCZWdpbkgAEi4KDW1lc3NhZ2VfY2h1bmsYAyABKAsyFS5jaGF0LnYyLk1lc3NhZ2VDaHVua0gAEjwKFGluY29tcGxldGVfaW5kaWNhdG9yGAQgASgLMhwuY2hhdC52Mi5JbmNvbXBsZXRlSW5kaWNhdG9ySAASMQoPc3RyZWFtX3BhcnRfZW5kGAUgASgLMhYuY2hhdC52Mi5TdHJlYW1QYXJ0RW5kSAASOgoTc3RyZWFtX2ZpbmFsaXphdGlvbhgGIAEoCzIbLmNoYXQudjIuU3RyZWFtRmluYWxpemF0aW9uSAASLAoMc3RyZWFtX2Vycm9yGAcgASgLMhQuY2hhdC52Mi5TdHJlYW1FcnJvckgAEjIKD3JlYXNvbmluZ19jaHVuaxgIIAEoCzIXLmNoYXQudjIuUmVhc29uaW5nQ2h1bmtIAEISChByZXNwb25zZV9wYXlsb2FkKlIKEENvbnZlcnNhdGlvblR5cGUSIQodQ09OVkVSU0FUSU9OX1RZUEVfVU5TUEVDSUZJRUQQABIbChdDT05WRVJTQVRJT05fVFlQRV9ERUJVRxABMqgHCgtDaGF0U2VydmljZRKDAQoRTGlzdENvbnZlcnNhdGlvbnMSIS5jaGF0LnYyLkxpc3RDb252ZXJzYXRpb25zUmVxdWVzdBoiLmNoYXQudjIuTGlzdENvbnZlcnNhdGlvbnNSZXNwb25zZSIngtPkkwIhEh8vX3BkL2FwaS92Mi9jaGF0cy9jb252ZXJzYXRpb25zEo8BCg9HZXRDb252ZXJzYXRpb24SHy5jaGF0LnYyLkdldENvbnZlcnNhdGlvblJlcXVlc3QaIC5jaGF0LnYyLkdldENvbnZlcnNhdGlvblJlc3BvbnNlIjmC0+STAjMSMS9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMve2NvbnZlcnNhdGlvbl9pZH0SwgEKH0NyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW0SLy5jaGF0LnYyLkNyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW1SZXF1ZXN0GjAuY2hhdC52Mi5DcmVhdGVDb252ZXJzYXRpb25NZXNzYWdlU3RyZWFtUmVzcG9uc2UiOoLT5JMCNDoBKiIvL19wZC9hcGkvdjIvY2hhdHMvY29udmVyc2F0aW9ucy9tZXNzYWdlcy9zdHJlYW0wARKbAQoSVXBkYXRlQ29udmVyc2F0aW9uEiIuY2hhdC52Mi5VcGRhdGVDb252ZXJzYXRpb25SZXF1ZXN0GiMuY2hhdC52Mi5VcGRhdGVDb252ZXJzYXRpb25SZXNwb25zZSI8gtPkkwI2OgEqMjEvX3BkL2FwaS92Mi9jaGF0cy9jb252ZXJzYXRpb25zL3tjb252ZXJzYXRpb25faWR9EpgBChJEZWxldGVDb252ZXJzYXRpb24SIi5jaGF0LnYyLkRlbGV0ZUNvbnZlcnNhdGlvblJlcXVlc3QaIy5jaGF0LnYyLkRlbGV0ZUNvbnZlcnNhdGlvblJlc3BvbnNlIjmC0+STAjMqMS9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMve2NvbnZlcnNhdGlvbl9pZH0SggEKE0xpc3RTdXBwb3J0ZWRNb2RlbHMSIy5jaGF0LnYyLkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXF1ZXN0GiQuY2hhdC52Mi5MaXN0U3VwcG9ydGVkTW9kZWxzUmVzcG9uc2UiIILT5JMCGhIYL19wZC9hcGkvdjIvY2hhdHMvbW9kZWxzQn8KC2NvbS5jaGF0LnYyQglDaGF0UHJvdG9QAVoocGFwZXJkZWJ1Z2dlci9wa2cvZ2VuL2FwaS9jaGF0L3YyO2NoYXR2MqICA0NYWKoCB0NoYXQuVjLKAgdDaGF0XFYy4gITQ2hhdFxWMlxHUEJNZXRhZGF0YeoCCENoYXQ6OlYyYgZwcm90bzM", [file_google_api_annotations]); /** * @generated from message chat.v2.MessageTypeToolCall @@ -105,6 +105,11 @@ export type MessageTypeAssistant = Message$1<"chat.v2.MessageTypeAssistant"> & { * @generated from field: string model_slug = 2; */ modelSlug: string; + + /** + * @generated from field: optional string reasoning = 3; + */ + reasoning?: string; }; /** @@ -572,6 +577,32 @@ export type MessageChunk = Message$1<"chat.v2.MessageChunk"> & { export const MessageChunkSchema: GenMessage = /*@__PURE__*/ messageDesc(file_chat_v2_chat, 22); +/** + * @generated from message chat.v2.ReasoningChunk + */ +export type ReasoningChunk = Message$1<"chat.v2.ReasoningChunk"> & { + /** + * The id of the message that this chunk belongs to + * + * @generated from field: string message_id = 1; + */ + messageId: string; + + /** + * The small piece of reasoning text + * + * @generated from field: string delta = 2; + */ + delta: string; +}; + +/** + * Describes the message chat.v2.ReasoningChunk. + * Use `create(ReasoningChunkSchema)` to create a new message. + */ +export const ReasoningChunkSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_chat_v2_chat, 23); + /** * @generated from message chat.v2.IncompleteIndicator */ @@ -592,7 +623,7 @@ export type IncompleteIndicator = Message$1<"chat.v2.IncompleteIndicator"> & { * Use `create(IncompleteIndicatorSchema)` to create a new message. */ export const IncompleteIndicatorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_chat_v2_chat, 23); + messageDesc(file_chat_v2_chat, 24); /** * @generated from message chat.v2.StreamPartEnd @@ -614,7 +645,7 @@ export type StreamPartEnd = Message$1<"chat.v2.StreamPartEnd"> & { * Use `create(StreamPartEndSchema)` to create a new message. */ export const StreamPartEndSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_chat_v2_chat, 24); + messageDesc(file_chat_v2_chat, 25); /** * Sent when the current AI response is fully streamed @@ -638,7 +669,7 @@ export type StreamFinalization = Message$1<"chat.v2.StreamFinalization"> & { * Use `create(StreamFinalizationSchema)` to create a new message. */ export const StreamFinalizationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_chat_v2_chat, 25); + messageDesc(file_chat_v2_chat, 26); /** * @generated from message chat.v2.StreamError @@ -655,7 +686,7 @@ export type StreamError = Message$1<"chat.v2.StreamError"> & { * Use `create(StreamErrorSchema)` to create a new message. */ export const StreamErrorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_chat_v2_chat, 26); + messageDesc(file_chat_v2_chat, 27); /** * This message should be the same as CreateConversationMessageRequest @@ -706,7 +737,7 @@ export type CreateConversationMessageStreamRequest = Message$1<"chat.v2.CreateCo * Use `create(CreateConversationMessageStreamRequestSchema)` to create a new message. */ export const CreateConversationMessageStreamRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_chat_v2_chat, 27); + messageDesc(file_chat_v2_chat, 28); /** * Response for streaming a message within an existing conversation @@ -759,6 +790,12 @@ export type CreateConversationMessageStreamResponse = Message$1<"chat.v2.CreateC */ value: StreamError; case: "streamError"; + } | { + /** + * @generated from field: chat.v2.ReasoningChunk reasoning_chunk = 8; + */ + value: ReasoningChunk; + case: "reasoningChunk"; } | { case: undefined; value?: undefined }; }; @@ -767,7 +804,7 @@ export type CreateConversationMessageStreamResponse = Message$1<"chat.v2.CreateC * Use `create(CreateConversationMessageStreamResponseSchema)` to create a new message. */ export const CreateConversationMessageStreamResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_chat_v2_chat, 28); + messageDesc(file_chat_v2_chat, 29); /** * @generated from enum chat.v2.ConversationType diff --git a/webapp/_webapp/src/pkg/gen/apiclient/comment/v1/comment_pb.ts b/webapp/_webapp/src/pkg/gen/apiclient/comment/v1/comment_pb.ts index 199da2da..af73ec17 100644 --- a/webapp/_webapp/src/pkg/gen/apiclient/comment/v1/comment_pb.ts +++ b/webapp/_webapp/src/pkg/gen/apiclient/comment/v1/comment_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.10.2 with parameter "target=ts" +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" // @generated from file comment/v1/comment.proto (package comment.v1, syntax proto3) /* eslint-disable */ diff --git a/webapp/_webapp/src/pkg/gen/apiclient/project/v1/project_pb.ts b/webapp/_webapp/src/pkg/gen/apiclient/project/v1/project_pb.ts index 0fb41e97..c6d56a7a 100644 --- a/webapp/_webapp/src/pkg/gen/apiclient/project/v1/project_pb.ts +++ b/webapp/_webapp/src/pkg/gen/apiclient/project/v1/project_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.10.2 with parameter "target=ts" +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" // @generated from file project/v1/project.proto (package project.v1, syntax proto3) /* eslint-disable */ diff --git a/webapp/_webapp/src/pkg/gen/apiclient/shared/v1/shared_pb.ts b/webapp/_webapp/src/pkg/gen/apiclient/shared/v1/shared_pb.ts index 7d17d73d..d6366c7e 100644 --- a/webapp/_webapp/src/pkg/gen/apiclient/shared/v1/shared_pb.ts +++ b/webapp/_webapp/src/pkg/gen/apiclient/shared/v1/shared_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.10.2 with parameter "target=ts" +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" // @generated from file shared/v1/shared.proto (package shared.v1, syntax proto3) /* eslint-disable */ diff --git a/webapp/_webapp/src/pkg/gen/apiclient/user/v1/user_pb.ts b/webapp/_webapp/src/pkg/gen/apiclient/user/v1/user_pb.ts index ced72da5..f9b85370 100644 --- a/webapp/_webapp/src/pkg/gen/apiclient/user/v1/user_pb.ts +++ b/webapp/_webapp/src/pkg/gen/apiclient/user/v1/user_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.10.2 with parameter "target=ts" +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" // @generated from file user/v1/user.proto (package user.v1, syntax proto3) /* eslint-disable */ From b8a5b375ae3e1912c7b5d834feca09494b781de7 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 02:26:13 +0800 Subject: [PATCH 02/18] feat: reasoning: implement frontend --- .../_webapp/src/components/message-card.tsx | 1 + .../message-entry-container/assistant.tsx | 53 ++++++++++++------- .../_webapp/src/hooks/useSendMessageStream.ts | 5 ++ .../stores/conversation/handlers/converter.ts | 10 ++-- .../handlers/handleReasoningChunk.ts | 38 +++++++++++++ 5 files changed, 86 insertions(+), 21 deletions(-) create mode 100644 webapp/_webapp/src/stores/conversation/handlers/handleReasoningChunk.ts diff --git a/webapp/_webapp/src/components/message-card.tsx b/webapp/_webapp/src/components/message-card.tsx index 2a5f406c..0f6eff7f 100644 --- a/webapp/_webapp/src/components/message-card.tsx +++ b/webapp/_webapp/src/components/message-card.tsx @@ -60,6 +60,7 @@ export const MessageCard = memo(({ messageEntry, prevAttachment, animated }: Mes return ( { @@ -17,6 +18,7 @@ const preprocessMessage = (message: string): string | undefined => { export const AssistantMessageContainer = ({ message, + reasoning, messageId, animated, prevAttachment, @@ -24,6 +26,7 @@ export const AssistantMessageContainer = ({ preparing, }: { message: string; + reasoning?: string; messageId: string; animated: boolean; prevAttachment: string; @@ -49,7 +52,7 @@ export const AssistantMessageContainer = ({ } }, [user?.id, projectId, processedMessage, messageId]); - const showMessage = processedMessage?.length || 0 > 0; + const showMessage = processedMessage?.length || 0 > 0 || reasoning?.length || 0 > 0; const staleComponent = stale &&
This message is stale.
; const writingIndicator = stale || !showMessage ? null : ( @@ -64,29 +67,43 @@ export const AssistantMessageContainer = ({ )} /> ); + + const reasoningComponent = reasoning && ( +
+ +
+ ); + + const messageComponent = processedMessage && ( +
+ + {processedMessage || ""} + +
+ ); + + const actionComponent = ( +
+ + + + + +
+ ); + return ( showMessage && (
- {/* Message content */} -
- - {processedMessage || ""} - -
- + {reasoningComponent} + {messageComponent} {writingIndicator} - - {/* Stale message */} {staleComponent} - -
- - - - - -
+ {actionComponent}
) diff --git a/webapp/_webapp/src/hooks/useSendMessageStream.ts b/webapp/_webapp/src/hooks/useSendMessageStream.ts index c6b44027..3801787b 100644 --- a/webapp/_webapp/src/hooks/useSendMessageStream.ts +++ b/webapp/_webapp/src/hooks/useSendMessageStream.ts @@ -3,6 +3,7 @@ import { ConversationType, CreateConversationMessageStreamRequest, IncompleteIndicator, + ReasoningChunk, StreamFinalization, } from "../pkg/gen/apiclient/chat/v2/chat_pb"; import { PlainMessage } from "../query/types"; @@ -37,6 +38,7 @@ import { useSelectionStore } from "../stores/selection-store"; import { useSettingStore } from "../stores/setting-store"; import { useSync } from "./useSync"; import { useAdapter } from "../adapters"; +import { handleReasoningChunk } from "../stores/conversation/handlers/handleReasoningChunk"; /** * Custom React hook to handle sending a message as a stream in a conversation. @@ -144,6 +146,9 @@ export function useSendMessageStream() { case "incompleteIndicator": handleIncompleteIndicator(response.responsePayload.value as IncompleteIndicator); break; + case "reasoningChunk": + handleReasoningChunk(response.responsePayload.value as ReasoningChunk, updateStreamingMessage); + break; default: { if (response.responsePayload.value !== undefined) { const _typeCheck: never = response.responsePayload; diff --git a/webapp/_webapp/src/stores/conversation/handlers/converter.ts b/webapp/_webapp/src/stores/conversation/handlers/converter.ts index 2c6bf2b8..eddb3fa8 100644 --- a/webapp/_webapp/src/stores/conversation/handlers/converter.ts +++ b/webapp/_webapp/src/stores/conversation/handlers/converter.ts @@ -7,12 +7,16 @@ import { useConversationStore } from "../conversation-store"; export const convertMessageEntryToMessage = (messageEntry: MessageEntry): Message | undefined => { if (messageEntry.assistant) { + const assistantPayload: { content: string; reasoning?: string } = { + content: messageEntry.assistant.content, + }; + if (messageEntry.assistant.reasoning) { + assistantPayload.reasoning = messageEntry.assistant.reasoning; + } return fromJson(MessageSchema, { messageId: messageEntry.messageId, payload: { - assistant: { - content: messageEntry.assistant.content, - }, + assistant: assistantPayload, }, }); } else if (messageEntry.toolCall) { diff --git a/webapp/_webapp/src/stores/conversation/handlers/handleReasoningChunk.ts b/webapp/_webapp/src/stores/conversation/handlers/handleReasoningChunk.ts new file mode 100644 index 00000000..62e08a9f --- /dev/null +++ b/webapp/_webapp/src/stores/conversation/handlers/handleReasoningChunk.ts @@ -0,0 +1,38 @@ +import { logError } from "../../../libs/logger"; +import { ReasoningChunk, MessageTypeAssistant } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; +import { StreamingMessage } from "../../streaming-message-store"; +import { MessageEntry, MessageEntryStatus } from "../types"; + +export function handleReasoningChunk( + chunk: ReasoningChunk, + updateStreamingMessage: (updater: (prev: StreamingMessage) => StreamingMessage) => void, +) { + updateStreamingMessage((prevMessage) => { + const updatedParts = prevMessage.parts.map((part: MessageEntry) => { + const isTargetPart = part.messageId === chunk.messageId && part.assistant; + + if (!isTargetPart) return part; + + const currentReasoning = part.assistant!.reasoning ?? ""; + const updatedAssistant: MessageTypeAssistant = { + ...part.assistant!, + reasoning: currentReasoning + chunk.delta, + }; + + if (part.status !== MessageEntryStatus.PREPARING) { + logError("Reasoning chunk received for non-preparing part, this is a critical error"); + } + + return { + ...part, + assistant: updatedAssistant, + }; + }); + + return { + ...prevMessage, + parts: updatedParts, + sequence: prevMessage.sequence + 1, + }; + }); +} From 562b2ab6d6bfaa26892dd995a1d3ba1336fc7aa1 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 02:32:08 +0800 Subject: [PATCH 03/18] feat: enhance model support with detailed configuration - Introduced a new modelConfig struct to encapsulate model details including API key requirements. - Expanded the list of supported models with various configurations, including pricing and context limits. - Updated ListSupportedModels method to dynamically generate model responses based on user API key availability, marking models as disabled when necessary. - Added disabled and disabled_reason fields to the SupportedModel message in proto definitions for better client handling. --- internal/api/chat/list_supported_models_v2.go | 272 +++++++++++++----- pkg/gen/api/chat/v2/chat.pb.go | 42 ++- proto/chat/v2/chat.proto | 2 + .../src/pkg/gen/apiclient/chat/v2/chat_pb.ts | 16 +- 4 files changed, 243 insertions(+), 89 deletions(-) diff --git a/internal/api/chat/list_supported_models_v2.go b/internal/api/chat/list_supported_models_v2.go index 7c37a7bd..dfd9cb05 100644 --- a/internal/api/chat/list_supported_models_v2.go +++ b/internal/api/chat/list_supported_models_v2.go @@ -10,6 +10,173 @@ import ( "github.com/openai/openai-go/v3" ) +// modelConfig holds model configuration including whether it requires user's own API key +type modelConfig struct { + name string + slugOpenRouter string // Slug for OpenRouter API (used when user doesn't provide own key) + slugOpenAI string // Slug for OpenAI API (used when user provides own key) + totalContext int64 + maxOutput int64 + inputPrice int64 + outputPrice int64 + requireOwnKey bool // If true, this model requires user to provide their own API key +} + +// allModels defines all available models in the system +var allModels = []modelConfig{ + // Free/default models (requireOwnKey = false) + { + name: "GPT-5.1", + slugOpenRouter: "openai/gpt-5.1", + slugOpenAI: openai.ChatModelGPT5_1, + totalContext: 400000, + maxOutput: 128000, + inputPrice: 125, // $1.25 + outputPrice: 1000, // $10.00 + requireOwnKey: false, + }, + { + name: "GPT-5.2", + slugOpenRouter: "openai/gpt-5.2", + slugOpenAI: openai.ChatModelGPT5_2, + totalContext: 400000, + maxOutput: 128000, + inputPrice: 175, // $1.75 + outputPrice: 1400, // $14.00 + requireOwnKey: true, + }, + { + name: "GPT-5 Mini", + slugOpenRouter: "openai/gpt-5-mini", + slugOpenAI: openai.ChatModelGPT5Mini, + totalContext: 400000, + maxOutput: 128000, + inputPrice: 25, + outputPrice: 200, + requireOwnKey: false, + }, + { + name: "GPT-5 Nano", + slugOpenRouter: "openai/gpt-5-nano", + slugOpenAI: openai.ChatModelGPT5Nano, + totalContext: 400000, + maxOutput: 128000, + inputPrice: 5, // $0.20 + outputPrice: 40, // $0.80 + requireOwnKey: false, + }, + { + name: "GPT-4.1", + slugOpenRouter: "openai/gpt-4.1", + slugOpenAI: openai.ChatModelGPT4_1, + totalContext: 1050000, + maxOutput: 32800, + inputPrice: 200, // $2.00 + outputPrice: 800, + requireOwnKey: false, + }, + { + name: "GPT-4.1-mini", + slugOpenRouter: "openai/gpt-4.1-mini", + slugOpenAI: openai.ChatModelGPT4_1Mini, + totalContext: 128000, + maxOutput: 16400, + inputPrice: 15, + outputPrice: 60, + requireOwnKey: false, + }, + { + name: "GPT-4o", + slugOpenRouter: "openai/gpt-4o", + slugOpenAI: openai.ChatModelGPT4o, + totalContext: 128000, + maxOutput: 16400, + inputPrice: 250, + outputPrice: 1000, + requireOwnKey: true, + }, + { + name: "Qwen Plus (balanced)", + slugOpenRouter: "qwen/qwen-plus", + slugOpenAI: "qwen-plus", // OpenAI doesn't support Qwen, use OpenRouter slug + totalContext: 131100, + maxOutput: 8200, + inputPrice: 40, + outputPrice: 120, + requireOwnKey: false, + }, + { + name: "Qwen Turbo (fast)", + slugOpenRouter: "qwen/qwen-turbo", + slugOpenAI: "", // OpenAI doesn't support Qwen, use OpenRouter slug + totalContext: 1000000, + maxOutput: 8200, + inputPrice: 5, + outputPrice: 20, + requireOwnKey: false, + }, + { + name: "Gemini 2.5 Flash (fast)", + slugOpenRouter: "google/gemini-2.5-flash", + slugOpenAI: "", // OpenAI doesn't support Gemini, use OpenRouter slug + totalContext: 1050000, + maxOutput: 65500, + inputPrice: 30, + outputPrice: 250, + requireOwnKey: false, + }, + { + name: "Gemini 3 Flash Preview", + slugOpenRouter: "google/gemini-3-flash-preview", + slugOpenAI: "", // OpenAI doesn't support Gemini, use OpenRouter slug + totalContext: 1050000, + maxOutput: 65500, + inputPrice: 50, + outputPrice: 300, + requireOwnKey: false, + }, + { + name: "o1 Mini", + slugOpenRouter: "openai/o1-mini", + slugOpenAI: openai.ChatModelO1Mini, + totalContext: 128000, + maxOutput: 65536, + inputPrice: 300, // $3.00 + outputPrice: 1200, // $12.00 + requireOwnKey: true, + }, + { + name: "o3", + slugOpenRouter: "openai/o3", + slugOpenAI: openai.ChatModelO3, + totalContext: 200000, + maxOutput: 100000, + inputPrice: 200, + outputPrice: 800, + requireOwnKey: true, + }, + { + name: "o3 Mini", + slugOpenRouter: "openai/o3-mini", + slugOpenAI: openai.ChatModelO3Mini, + totalContext: 200000, + maxOutput: 100000, + inputPrice: 110, + outputPrice: 440, + requireOwnKey: true, + }, + { + name: "o4 Mini", + slugOpenRouter: "openai/o4-mini", + slugOpenAI: openai.ChatModelO4Mini, + totalContext: 128000, + maxOutput: 65536, + inputPrice: 110, + outputPrice: 440, + requireOwnKey: true, + }, +} + func (s *ChatServerV2) ListSupportedModels( ctx context.Context, req *chatv2.ListSupportedModelsRequest, @@ -24,89 +191,40 @@ func (s *ChatServerV2) ListSupportedModels( return nil, err } + hasOwnAPIKey := strings.TrimSpace(settings.OpenAIAPIKey) != "" + var models []*chatv2.SupportedModel - if strings.TrimSpace(settings.OpenAIAPIKey) == "" { - models = []*chatv2.SupportedModel{ - { - Name: "GPT-4.1", - Slug: "openai/gpt-4.1", - TotalContext: 1050000, - MaxOutput: 32800, - InputPrice: 200, - OutputPrice: 800, - }, - { - Name: "GPT-4.1-mini", - Slug: "openai/gpt-4.1-mini", - TotalContext: 128000, - MaxOutput: 16400, - InputPrice: 15, - OutputPrice: 60, - }, - { - Name: "Qwen Plus (balanced)", - Slug: "qwen/qwen-plus", - TotalContext: 131100, - MaxOutput: 8200, - InputPrice: 40, - OutputPrice: 120, - }, - { - Name: "Qwen Turbo (fast)", - Slug: "qwen/qwen-turbo", - TotalContext: 1000000, - MaxOutput: 8200, - InputPrice: 5, - OutputPrice: 20, - }, - { - Name: "Gemini 2.5 Flash (fast)", - Slug: "google/gemini-2.5-flash", - TotalContext: 1050000, - MaxOutput: 65500, - InputPrice: 30, - OutputPrice: 250, - }, - { - Name: "Gemini 3 Flash Preview", - Slug: "google/gemini-3-flash-preview", - TotalContext: 1050000, - MaxOutput: 65500, - InputPrice: 50, - OutputPrice: 300, - }, + for _, config := range allModels { + // Choose the appropriate slug based on whether user has their own API key + slug := config.slugOpenRouter + if hasOwnAPIKey { + slug = config.slugOpenAI } - } else { - models = []*chatv2.SupportedModel{ - { - Name: "GPT-4.1", - Slug: openai.ChatModelGPT4_1, - TotalContext: 1050000, - MaxOutput: 32800, - InputPrice: 200, - OutputPrice: 800, - }, - { - Name: "GPT-4o", - Slug: openai.ChatModelGPT4o, - TotalContext: 128000, - MaxOutput: 16400, - InputPrice: 250, - OutputPrice: 1000, - }, - { - Name: "GPT-4.1-mini", - Slug: openai.ChatModelGPT4_1Mini, - TotalContext: 128000, - MaxOutput: 16400, - InputPrice: 15, - OutputPrice: 60, - }, - // TODO: add user custom models + + model := &chatv2.SupportedModel{ + Name: config.name, + Slug: slug, + TotalContext: config.totalContext, + MaxOutput: config.maxOutput, + InputPrice: config.inputPrice, + OutputPrice: config.outputPrice, } + + // If model requires own key but user hasn't provided one, mark as disabled + if config.requireOwnKey && !hasOwnAPIKey { + model.Disabled = true + model.DisabledReason = stringPtr("Requires your own OpenAI API key. Configure it in Settings.") + } + + models = append(models, model) } return &chatv2.ListSupportedModelsResponse{ Models: models, }, nil } + +// stringPtr returns a pointer to the given string +func stringPtr(s string) *string { + return &s +} diff --git a/pkg/gen/api/chat/v2/chat.pb.go b/pkg/gen/api/chat/v2/chat.pb.go index 27f318fa..3ba45df6 100644 --- a/pkg/gen/api/chat/v2/chat.pb.go +++ b/pkg/gen/api/chat/v2/chat.pb.go @@ -1025,15 +1025,17 @@ func (*DeleteConversationResponse) Descriptor() ([]byte, []int) { } type SupportedModel struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"` - TotalContext int64 `protobuf:"varint,3,opt,name=total_context,json=totalContext,proto3" json:"total_context,omitempty"` - MaxOutput int64 `protobuf:"varint,4,opt,name=max_output,json=maxOutput,proto3" json:"max_output,omitempty"` - InputPrice int64 `protobuf:"varint,5,opt,name=input_price,json=inputPrice,proto3" json:"input_price,omitempty"` // in cents per 1M tokens - OutputPrice int64 `protobuf:"varint,6,opt,name=output_price,json=outputPrice,proto3" json:"output_price,omitempty"` // in cents per 1M tokens - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"` + TotalContext int64 `protobuf:"varint,3,opt,name=total_context,json=totalContext,proto3" json:"total_context,omitempty"` + MaxOutput int64 `protobuf:"varint,4,opt,name=max_output,json=maxOutput,proto3" json:"max_output,omitempty"` + InputPrice int64 `protobuf:"varint,5,opt,name=input_price,json=inputPrice,proto3" json:"input_price,omitempty"` // in cents per 1M tokens + OutputPrice int64 `protobuf:"varint,6,opt,name=output_price,json=outputPrice,proto3" json:"output_price,omitempty"` // in cents per 1M tokens + Disabled bool `protobuf:"varint,7,opt,name=disabled,proto3" json:"disabled,omitempty"` // If true, the model is disabled and cannot be used + DisabledReason *string `protobuf:"bytes,8,opt,name=disabled_reason,json=disabledReason,proto3,oneof" json:"disabled_reason,omitempty"` // The reason why the model is disabled + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SupportedModel) Reset() { @@ -1108,6 +1110,20 @@ func (x *SupportedModel) GetOutputPrice() int64 { return 0 } +func (x *SupportedModel) GetDisabled() bool { + if x != nil { + return x.Disabled + } + return false +} + +func (x *SupportedModel) GetDisabledReason() string { + if x != nil && x.DisabledReason != nil { + return *x.DisabledReason + } + return "" +} + type ListSupportedModelsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1947,7 +1963,7 @@ const file_chat_v2_chat_proto_rawDesc = "" + "\fconversation\x18\x01 \x01(\v2\x15.chat.v2.ConversationR\fconversation\"D\n" + "\x19DeleteConversationRequest\x12'\n" + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\"\x1c\n" + - "\x1aDeleteConversationResponse\"\xc0\x01\n" + + "\x1aDeleteConversationResponse\"\x9e\x02\n" + "\x0eSupportedModel\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + "\x04slug\x18\x02 \x01(\tR\x04slug\x12#\n" + @@ -1956,7 +1972,10 @@ const file_chat_v2_chat_proto_rawDesc = "" + "max_output\x18\x04 \x01(\x03R\tmaxOutput\x12\x1f\n" + "\vinput_price\x18\x05 \x01(\x03R\n" + "inputPrice\x12!\n" + - "\foutput_price\x18\x06 \x01(\x03R\voutputPrice\"\x1c\n" + + "\foutput_price\x18\x06 \x01(\x03R\voutputPrice\x12\x1a\n" + + "\bdisabled\x18\a \x01(\bR\bdisabled\x12,\n" + + "\x0fdisabled_reason\x18\b \x01(\tH\x00R\x0edisabledReason\x88\x01\x01B\x12\n" + + "\x10_disabled_reason\"\x1c\n" + "\x1aListSupportedModelsRequest\"N\n" + "\x1bListSupportedModelsResponse\x12/\n" + "\x06models\x18\x01 \x03(\v2\x17.chat.v2.SupportedModelR\x06models\"^\n" + @@ -2130,6 +2149,7 @@ func file_chat_v2_chat_proto_init() { (*MessagePayload_Unknown)(nil), } file_chat_v2_chat_proto_msgTypes[9].OneofWrappers = []any{} + file_chat_v2_chat_proto_msgTypes[17].OneofWrappers = []any{} file_chat_v2_chat_proto_msgTypes[28].OneofWrappers = []any{} file_chat_v2_chat_proto_msgTypes[29].OneofWrappers = []any{ (*CreateConversationMessageStreamResponse_StreamInitialization)(nil), diff --git a/proto/chat/v2/chat.proto b/proto/chat/v2/chat.proto index b4136c4c..8dd650a3 100644 --- a/proto/chat/v2/chat.proto +++ b/proto/chat/v2/chat.proto @@ -131,6 +131,8 @@ message SupportedModel { int64 max_output = 4; int64 input_price = 5; // in cents per 1M tokens int64 output_price = 6; // in cents per 1M tokens + bool disabled = 7; // If true, the model is disabled and cannot be used + optional string disabled_reason = 8; // The reason why the model is disabled } message ListSupportedModelsRequest { diff --git a/webapp/_webapp/src/pkg/gen/apiclient/chat/v2/chat_pb.ts b/webapp/_webapp/src/pkg/gen/apiclient/chat/v2/chat_pb.ts index ce0217da..cbc72227 100644 --- a/webapp/_webapp/src/pkg/gen/apiclient/chat/v2/chat_pb.ts +++ b/webapp/_webapp/src/pkg/gen/apiclient/chat/v2/chat_pb.ts @@ -11,7 +11,7 @@ import type { Message as Message$1 } from "@bufbuild/protobuf"; * Describes the file chat/v2/chat.proto. */ export const file_chat_v2_chat: GenFile = /*@__PURE__*/ - fileDesc("ChJjaGF0L3YyL2NoYXQucHJvdG8SB2NoYXQudjIiUAoTTWVzc2FnZVR5cGVUb29sQ2FsbBIMCgRuYW1lGAEgASgJEgwKBGFyZ3MYAiABKAkSDgoGcmVzdWx0GAMgASgJEg0KBWVycm9yGAQgASgJIkEKI01lc3NhZ2VUeXBlVG9vbENhbGxQcmVwYXJlQXJndW1lbnRzEgwKBG5hbWUYASABKAkSDAoEYXJncxgCIAEoCSIkChFNZXNzYWdlVHlwZVN5c3RlbRIPCgdjb250ZW50GAEgASgJImEKFE1lc3NhZ2VUeXBlQXNzaXN0YW50Eg8KB2NvbnRlbnQYASABKAkSEgoKbW9kZWxfc2x1ZxgCIAEoCRIWCglyZWFzb25pbmcYAyABKAlIAIgBAUIMCgpfcmVhc29uaW5nInoKD01lc3NhZ2VUeXBlVXNlchIPCgdjb250ZW50GAEgASgJEhoKDXNlbGVjdGVkX3RleHQYAiABKAlIAIgBARIYCgtzdXJyb3VuZGluZxgHIAEoCUgBiAEBQhAKDl9zZWxlY3RlZF90ZXh0Qg4KDF9zdXJyb3VuZGluZyIpChJNZXNzYWdlVHlwZVVua25vd24SEwoLZGVzY3JpcHRpb24YASABKAki5AIKDk1lc3NhZ2VQYXlsb2FkEiwKBnN5c3RlbRgBIAEoCzIaLmNoYXQudjIuTWVzc2FnZVR5cGVTeXN0ZW1IABIoCgR1c2VyGAIgASgLMhguY2hhdC52Mi5NZXNzYWdlVHlwZVVzZXJIABIyCglhc3Npc3RhbnQYAyABKAsyHS5jaGF0LnYyLk1lc3NhZ2VUeXBlQXNzaXN0YW50SAASUwobdG9vbF9jYWxsX3ByZXBhcmVfYXJndW1lbnRzGAQgASgLMiwuY2hhdC52Mi5NZXNzYWdlVHlwZVRvb2xDYWxsUHJlcGFyZUFyZ3VtZW50c0gAEjEKCXRvb2xfY2FsbBgFIAEoCzIcLmNoYXQudjIuTWVzc2FnZVR5cGVUb29sQ2FsbEgAEi4KB3Vua25vd24YBiABKAsyGy5jaGF0LnYyLk1lc3NhZ2VUeXBlVW5rbm93bkgAQg4KDG1lc3NhZ2VfdHlwZSJaCgdNZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSKAoHcGF5bG9hZBgCIAEoCzIXLmNoYXQudjIuTWVzc2FnZVBheWxvYWQSEQoJdGltZXN0YW1wGAMgASgDImEKDENvbnZlcnNhdGlvbhIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgptb2RlbF9zbHVnGAMgASgJEiIKCG1lc3NhZ2VzGAQgAygLMhAuY2hhdC52Mi5NZXNzYWdlIkIKGExpc3RDb252ZXJzYXRpb25zUmVxdWVzdBIXCgpwcm9qZWN0X2lkGAEgASgJSACIAQFCDQoLX3Byb2plY3RfaWQiSQoZTGlzdENvbnZlcnNhdGlvbnNSZXNwb25zZRIsCg1jb252ZXJzYXRpb25zGAEgAygLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iMQoWR2V0Q29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiRgoXR2V0Q29udmVyc2F0aW9uUmVzcG9uc2USKwoMY29udmVyc2F0aW9uGAEgASgLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iQwoZVXBkYXRlQ29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDQoFdGl0bGUYAiABKAkiSQoaVXBkYXRlQ29udmVyc2F0aW9uUmVzcG9uc2USKwoMY29udmVyc2F0aW9uGAEgASgLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iNAoZRGVsZXRlQ29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiHAoaRGVsZXRlQ29udmVyc2F0aW9uUmVzcG9uc2UiggEKDlN1cHBvcnRlZE1vZGVsEgwKBG5hbWUYASABKAkSDAoEc2x1ZxgCIAEoCRIVCg10b3RhbF9jb250ZXh0GAMgASgDEhIKCm1heF9vdXRwdXQYBCABKAMSEwoLaW5wdXRfcHJpY2UYBSABKAMSFAoMb3V0cHV0X3ByaWNlGAYgASgDIhwKGkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXF1ZXN0IkYKG0xpc3RTdXBwb3J0ZWRNb2RlbHNSZXNwb25zZRInCgZtb2RlbHMYASADKAsyFy5jaGF0LnYyLlN1cHBvcnRlZE1vZGVsIkMKFFN0cmVhbUluaXRpYWxpemF0aW9uEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRISCgptb2RlbF9zbHVnGAIgASgJIk8KD1N0cmVhbVBhcnRCZWdpbhISCgptZXNzYWdlX2lkGAEgASgJEigKB3BheWxvYWQYAyABKAsyFy5jaGF0LnYyLk1lc3NhZ2VQYXlsb2FkIjEKDE1lc3NhZ2VDaHVuaxISCgptZXNzYWdlX2lkGAEgASgJEg0KBWRlbHRhGAIgASgJIjMKDlJlYXNvbmluZ0NodW5rEhIKCm1lc3NhZ2VfaWQYASABKAkSDQoFZGVsdGEYAiABKAkiOgoTSW5jb21wbGV0ZUluZGljYXRvchIOCgZyZWFzb24YASABKAkSEwoLcmVzcG9uc2VfaWQYAiABKAkiTQoNU3RyZWFtUGFydEVuZBISCgptZXNzYWdlX2lkGAEgASgJEigKB3BheWxvYWQYAyABKAsyFy5jaGF0LnYyLk1lc3NhZ2VQYXlsb2FkIi0KElN0cmVhbUZpbmFsaXphdGlvbhIXCg9jb252ZXJzYXRpb25faWQYASABKAkiJAoLU3RyZWFtRXJyb3ISFQoNZXJyb3JfbWVzc2FnZRgBIAEoCSLLAgomQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbVJlcXVlc3QSEgoKcHJvamVjdF9pZBgBIAEoCRIcCg9jb252ZXJzYXRpb25faWQYAiABKAlIAIgBARISCgptb2RlbF9zbHVnGAMgASgJEhQKDHVzZXJfbWVzc2FnZRgEIAEoCRIfChJ1c2VyX3NlbGVjdGVkX3RleHQYBSABKAlIAYgBARI5ChFjb252ZXJzYXRpb25fdHlwZRgGIAEoDjIZLmNoYXQudjIuQ29udmVyc2F0aW9uVHlwZUgCiAEBEhgKC3N1cnJvdW5kaW5nGAggASgJSAOIAQFCEgoQX2NvbnZlcnNhdGlvbl9pZEIVChNfdXNlcl9zZWxlY3RlZF90ZXh0QhQKEl9jb252ZXJzYXRpb25fdHlwZUIOCgxfc3Vycm91bmRpbmci8wMKJ0NyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW1SZXNwb25zZRI+ChVzdHJlYW1faW5pdGlhbGl6YXRpb24YASABKAsyHS5jaGF0LnYyLlN0cmVhbUluaXRpYWxpemF0aW9uSAASNQoRc3RyZWFtX3BhcnRfYmVnaW4YAiABKAsyGC5jaGF0LnYyLlN0cmVhbVBhcnRCZWdpbkgAEi4KDW1lc3NhZ2VfY2h1bmsYAyABKAsyFS5jaGF0LnYyLk1lc3NhZ2VDaHVua0gAEjwKFGluY29tcGxldGVfaW5kaWNhdG9yGAQgASgLMhwuY2hhdC52Mi5JbmNvbXBsZXRlSW5kaWNhdG9ySAASMQoPc3RyZWFtX3BhcnRfZW5kGAUgASgLMhYuY2hhdC52Mi5TdHJlYW1QYXJ0RW5kSAASOgoTc3RyZWFtX2ZpbmFsaXphdGlvbhgGIAEoCzIbLmNoYXQudjIuU3RyZWFtRmluYWxpemF0aW9uSAASLAoMc3RyZWFtX2Vycm9yGAcgASgLMhQuY2hhdC52Mi5TdHJlYW1FcnJvckgAEjIKD3JlYXNvbmluZ19jaHVuaxgIIAEoCzIXLmNoYXQudjIuUmVhc29uaW5nQ2h1bmtIAEISChByZXNwb25zZV9wYXlsb2FkKlIKEENvbnZlcnNhdGlvblR5cGUSIQodQ09OVkVSU0FUSU9OX1RZUEVfVU5TUEVDSUZJRUQQABIbChdDT05WRVJTQVRJT05fVFlQRV9ERUJVRxABMqgHCgtDaGF0U2VydmljZRKDAQoRTGlzdENvbnZlcnNhdGlvbnMSIS5jaGF0LnYyLkxpc3RDb252ZXJzYXRpb25zUmVxdWVzdBoiLmNoYXQudjIuTGlzdENvbnZlcnNhdGlvbnNSZXNwb25zZSIngtPkkwIhEh8vX3BkL2FwaS92Mi9jaGF0cy9jb252ZXJzYXRpb25zEo8BCg9HZXRDb252ZXJzYXRpb24SHy5jaGF0LnYyLkdldENvbnZlcnNhdGlvblJlcXVlc3QaIC5jaGF0LnYyLkdldENvbnZlcnNhdGlvblJlc3BvbnNlIjmC0+STAjMSMS9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMve2NvbnZlcnNhdGlvbl9pZH0SwgEKH0NyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW0SLy5jaGF0LnYyLkNyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW1SZXF1ZXN0GjAuY2hhdC52Mi5DcmVhdGVDb252ZXJzYXRpb25NZXNzYWdlU3RyZWFtUmVzcG9uc2UiOoLT5JMCNDoBKiIvL19wZC9hcGkvdjIvY2hhdHMvY29udmVyc2F0aW9ucy9tZXNzYWdlcy9zdHJlYW0wARKbAQoSVXBkYXRlQ29udmVyc2F0aW9uEiIuY2hhdC52Mi5VcGRhdGVDb252ZXJzYXRpb25SZXF1ZXN0GiMuY2hhdC52Mi5VcGRhdGVDb252ZXJzYXRpb25SZXNwb25zZSI8gtPkkwI2OgEqMjEvX3BkL2FwaS92Mi9jaGF0cy9jb252ZXJzYXRpb25zL3tjb252ZXJzYXRpb25faWR9EpgBChJEZWxldGVDb252ZXJzYXRpb24SIi5jaGF0LnYyLkRlbGV0ZUNvbnZlcnNhdGlvblJlcXVlc3QaIy5jaGF0LnYyLkRlbGV0ZUNvbnZlcnNhdGlvblJlc3BvbnNlIjmC0+STAjMqMS9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMve2NvbnZlcnNhdGlvbl9pZH0SggEKE0xpc3RTdXBwb3J0ZWRNb2RlbHMSIy5jaGF0LnYyLkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXF1ZXN0GiQuY2hhdC52Mi5MaXN0U3VwcG9ydGVkTW9kZWxzUmVzcG9uc2UiIILT5JMCGhIYL19wZC9hcGkvdjIvY2hhdHMvbW9kZWxzQn8KC2NvbS5jaGF0LnYyQglDaGF0UHJvdG9QAVoocGFwZXJkZWJ1Z2dlci9wa2cvZ2VuL2FwaS9jaGF0L3YyO2NoYXR2MqICA0NYWKoCB0NoYXQuVjLKAgdDaGF0XFYy4gITQ2hhdFxWMlxHUEJNZXRhZGF0YeoCCENoYXQ6OlYyYgZwcm90bzM", [file_google_api_annotations]); + fileDesc("ChJjaGF0L3YyL2NoYXQucHJvdG8SB2NoYXQudjIiUAoTTWVzc2FnZVR5cGVUb29sQ2FsbBIMCgRuYW1lGAEgASgJEgwKBGFyZ3MYAiABKAkSDgoGcmVzdWx0GAMgASgJEg0KBWVycm9yGAQgASgJIkEKI01lc3NhZ2VUeXBlVG9vbENhbGxQcmVwYXJlQXJndW1lbnRzEgwKBG5hbWUYASABKAkSDAoEYXJncxgCIAEoCSIkChFNZXNzYWdlVHlwZVN5c3RlbRIPCgdjb250ZW50GAEgASgJImEKFE1lc3NhZ2VUeXBlQXNzaXN0YW50Eg8KB2NvbnRlbnQYASABKAkSEgoKbW9kZWxfc2x1ZxgCIAEoCRIWCglyZWFzb25pbmcYAyABKAlIAIgBAUIMCgpfcmVhc29uaW5nInoKD01lc3NhZ2VUeXBlVXNlchIPCgdjb250ZW50GAEgASgJEhoKDXNlbGVjdGVkX3RleHQYAiABKAlIAIgBARIYCgtzdXJyb3VuZGluZxgHIAEoCUgBiAEBQhAKDl9zZWxlY3RlZF90ZXh0Qg4KDF9zdXJyb3VuZGluZyIpChJNZXNzYWdlVHlwZVVua25vd24SEwoLZGVzY3JpcHRpb24YASABKAki5AIKDk1lc3NhZ2VQYXlsb2FkEiwKBnN5c3RlbRgBIAEoCzIaLmNoYXQudjIuTWVzc2FnZVR5cGVTeXN0ZW1IABIoCgR1c2VyGAIgASgLMhguY2hhdC52Mi5NZXNzYWdlVHlwZVVzZXJIABIyCglhc3Npc3RhbnQYAyABKAsyHS5jaGF0LnYyLk1lc3NhZ2VUeXBlQXNzaXN0YW50SAASUwobdG9vbF9jYWxsX3ByZXBhcmVfYXJndW1lbnRzGAQgASgLMiwuY2hhdC52Mi5NZXNzYWdlVHlwZVRvb2xDYWxsUHJlcGFyZUFyZ3VtZW50c0gAEjEKCXRvb2xfY2FsbBgFIAEoCzIcLmNoYXQudjIuTWVzc2FnZVR5cGVUb29sQ2FsbEgAEi4KB3Vua25vd24YBiABKAsyGy5jaGF0LnYyLk1lc3NhZ2VUeXBlVW5rbm93bkgAQg4KDG1lc3NhZ2VfdHlwZSJaCgdNZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSKAoHcGF5bG9hZBgCIAEoCzIXLmNoYXQudjIuTWVzc2FnZVBheWxvYWQSEQoJdGltZXN0YW1wGAMgASgDImEKDENvbnZlcnNhdGlvbhIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgptb2RlbF9zbHVnGAMgASgJEiIKCG1lc3NhZ2VzGAQgAygLMhAuY2hhdC52Mi5NZXNzYWdlIkIKGExpc3RDb252ZXJzYXRpb25zUmVxdWVzdBIXCgpwcm9qZWN0X2lkGAEgASgJSACIAQFCDQoLX3Byb2plY3RfaWQiSQoZTGlzdENvbnZlcnNhdGlvbnNSZXNwb25zZRIsCg1jb252ZXJzYXRpb25zGAEgAygLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iMQoWR2V0Q29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiRgoXR2V0Q29udmVyc2F0aW9uUmVzcG9uc2USKwoMY29udmVyc2F0aW9uGAEgASgLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iQwoZVXBkYXRlQ29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDQoFdGl0bGUYAiABKAkiSQoaVXBkYXRlQ29udmVyc2F0aW9uUmVzcG9uc2USKwoMY29udmVyc2F0aW9uGAEgASgLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iNAoZRGVsZXRlQ29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiHAoaRGVsZXRlQ29udmVyc2F0aW9uUmVzcG9uc2UixgEKDlN1cHBvcnRlZE1vZGVsEgwKBG5hbWUYASABKAkSDAoEc2x1ZxgCIAEoCRIVCg10b3RhbF9jb250ZXh0GAMgASgDEhIKCm1heF9vdXRwdXQYBCABKAMSEwoLaW5wdXRfcHJpY2UYBSABKAMSFAoMb3V0cHV0X3ByaWNlGAYgASgDEhAKCGRpc2FibGVkGAcgASgIEhwKD2Rpc2FibGVkX3JlYXNvbhgIIAEoCUgAiAEBQhIKEF9kaXNhYmxlZF9yZWFzb24iHAoaTGlzdFN1cHBvcnRlZE1vZGVsc1JlcXVlc3QiRgobTGlzdFN1cHBvcnRlZE1vZGVsc1Jlc3BvbnNlEicKBm1vZGVscxgBIAMoCzIXLmNoYXQudjIuU3VwcG9ydGVkTW9kZWwiQwoUU3RyZWFtSW5pdGlhbGl6YXRpb24SFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhIKCm1vZGVsX3NsdWcYAiABKAkiTwoPU3RyZWFtUGFydEJlZ2luEhIKCm1lc3NhZ2VfaWQYASABKAkSKAoHcGF5bG9hZBgDIAEoCzIXLmNoYXQudjIuTWVzc2FnZVBheWxvYWQiMQoMTWVzc2FnZUNodW5rEhIKCm1lc3NhZ2VfaWQYASABKAkSDQoFZGVsdGEYAiABKAkiMwoOUmVhc29uaW5nQ2h1bmsSEgoKbWVzc2FnZV9pZBgBIAEoCRINCgVkZWx0YRgCIAEoCSI6ChNJbmNvbXBsZXRlSW5kaWNhdG9yEg4KBnJlYXNvbhgBIAEoCRITCgtyZXNwb25zZV9pZBgCIAEoCSJNCg1TdHJlYW1QYXJ0RW5kEhIKCm1lc3NhZ2VfaWQYASABKAkSKAoHcGF5bG9hZBgDIAEoCzIXLmNoYXQudjIuTWVzc2FnZVBheWxvYWQiLQoSU3RyZWFtRmluYWxpemF0aW9uEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSIkCgtTdHJlYW1FcnJvchIVCg1lcnJvcl9tZXNzYWdlGAEgASgJIssCCiZDcmVhdGVDb252ZXJzYXRpb25NZXNzYWdlU3RyZWFtUmVxdWVzdBISCgpwcm9qZWN0X2lkGAEgASgJEhwKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCUgAiAEBEhIKCm1vZGVsX3NsdWcYAyABKAkSFAoMdXNlcl9tZXNzYWdlGAQgASgJEh8KEnVzZXJfc2VsZWN0ZWRfdGV4dBgFIAEoCUgBiAEBEjkKEWNvbnZlcnNhdGlvbl90eXBlGAYgASgOMhkuY2hhdC52Mi5Db252ZXJzYXRpb25UeXBlSAKIAQESGAoLc3Vycm91bmRpbmcYCCABKAlIA4gBAUISChBfY29udmVyc2F0aW9uX2lkQhUKE191c2VyX3NlbGVjdGVkX3RleHRCFAoSX2NvbnZlcnNhdGlvbl90eXBlQg4KDF9zdXJyb3VuZGluZyLzAwonQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbVJlc3BvbnNlEj4KFXN0cmVhbV9pbml0aWFsaXphdGlvbhgBIAEoCzIdLmNoYXQudjIuU3RyZWFtSW5pdGlhbGl6YXRpb25IABI1ChFzdHJlYW1fcGFydF9iZWdpbhgCIAEoCzIYLmNoYXQudjIuU3RyZWFtUGFydEJlZ2luSAASLgoNbWVzc2FnZV9jaHVuaxgDIAEoCzIVLmNoYXQudjIuTWVzc2FnZUNodW5rSAASPAoUaW5jb21wbGV0ZV9pbmRpY2F0b3IYBCABKAsyHC5jaGF0LnYyLkluY29tcGxldGVJbmRpY2F0b3JIABIxCg9zdHJlYW1fcGFydF9lbmQYBSABKAsyFi5jaGF0LnYyLlN0cmVhbVBhcnRFbmRIABI6ChNzdHJlYW1fZmluYWxpemF0aW9uGAYgASgLMhsuY2hhdC52Mi5TdHJlYW1GaW5hbGl6YXRpb25IABIsCgxzdHJlYW1fZXJyb3IYByABKAsyFC5jaGF0LnYyLlN0cmVhbUVycm9ySAASMgoPcmVhc29uaW5nX2NodW5rGAggASgLMhcuY2hhdC52Mi5SZWFzb25pbmdDaHVua0gAQhIKEHJlc3BvbnNlX3BheWxvYWQqUgoQQ29udmVyc2F0aW9uVHlwZRIhCh1DT05WRVJTQVRJT05fVFlQRV9VTlNQRUNJRklFRBAAEhsKF0NPTlZFUlNBVElPTl9UWVBFX0RFQlVHEAEyqAcKC0NoYXRTZXJ2aWNlEoMBChFMaXN0Q29udmVyc2F0aW9ucxIhLmNoYXQudjIuTGlzdENvbnZlcnNhdGlvbnNSZXF1ZXN0GiIuY2hhdC52Mi5MaXN0Q29udmVyc2F0aW9uc1Jlc3BvbnNlIieC0+STAiESHy9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMSjwEKD0dldENvbnZlcnNhdGlvbhIfLmNoYXQudjIuR2V0Q29udmVyc2F0aW9uUmVxdWVzdBogLmNoYXQudjIuR2V0Q29udmVyc2F0aW9uUmVzcG9uc2UiOYLT5JMCMxIxL19wZC9hcGkvdjIvY2hhdHMvY29udmVyc2F0aW9ucy97Y29udmVyc2F0aW9uX2lkfRLCAQofQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbRIvLmNoYXQudjIuQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbVJlcXVlc3QaMC5jaGF0LnYyLkNyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW1SZXNwb25zZSI6gtPkkwI0OgEqIi8vX3BkL2FwaS92Mi9jaGF0cy9jb252ZXJzYXRpb25zL21lc3NhZ2VzL3N0cmVhbTABEpsBChJVcGRhdGVDb252ZXJzYXRpb24SIi5jaGF0LnYyLlVwZGF0ZUNvbnZlcnNhdGlvblJlcXVlc3QaIy5jaGF0LnYyLlVwZGF0ZUNvbnZlcnNhdGlvblJlc3BvbnNlIjyC0+STAjY6ASoyMS9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMve2NvbnZlcnNhdGlvbl9pZH0SmAEKEkRlbGV0ZUNvbnZlcnNhdGlvbhIiLmNoYXQudjIuRGVsZXRlQ29udmVyc2F0aW9uUmVxdWVzdBojLmNoYXQudjIuRGVsZXRlQ29udmVyc2F0aW9uUmVzcG9uc2UiOYLT5JMCMyoxL19wZC9hcGkvdjIvY2hhdHMvY29udmVyc2F0aW9ucy97Y29udmVyc2F0aW9uX2lkfRKCAQoTTGlzdFN1cHBvcnRlZE1vZGVscxIjLmNoYXQudjIuTGlzdFN1cHBvcnRlZE1vZGVsc1JlcXVlc3QaJC5jaGF0LnYyLkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXNwb25zZSIggtPkkwIaEhgvX3BkL2FwaS92Mi9jaGF0cy9tb2RlbHNCfwoLY29tLmNoYXQudjJCCUNoYXRQcm90b1ABWihwYXBlcmRlYnVnZ2VyL3BrZy9nZW4vYXBpL2NoYXQvdjI7Y2hhdHYyogIDQ1hYqgIHQ2hhdC5WMsoCB0NoYXRcVjLiAhNDaGF0XFYyXEdQQk1ldGFkYXRh6gIIQ2hhdDo6VjJiBnByb3RvMw", [file_google_api_annotations]); /** * @generated from message chat.v2.MessageTypeToolCall @@ -455,6 +455,20 @@ export type SupportedModel = Message$1<"chat.v2.SupportedModel"> & { * @generated from field: int64 output_price = 6; */ outputPrice: bigint; + + /** + * If true, the model is disabled and cannot be used + * + * @generated from field: bool disabled = 7; + */ + disabled: boolean; + + /** + * The reason why the model is disabled + * + * @generated from field: optional string disabled_reason = 8; + */ + disabledReason?: string; }; /** From 50a6245fe7c967ee0cbf5791e4924b68da534257 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 02:38:25 +0800 Subject: [PATCH 04/18] feat: improve assistant message handling in streaming - Added HandleAssistantPartBegin to ensure the frontend prepares for assistant messages before receiving content, addressing models that send reasoning before content. - Updated HandleTextDoneItem to include reasoning content when sending the final message. - Introduced HandleReasoningDelta to manage reasoning chunks separately during streaming. --- .../services/toolkit/client/completion_v2.go | 36 ++++++++----- .../services/toolkit/handler/stream_v2.go | 52 +++++++++++++++++-- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/internal/services/toolkit/client/completion_v2.go b/internal/services/toolkit/client/completion_v2.go index e7e5b7b2..92e1af3a 100644 --- a/internal/services/toolkit/client/completion_v2.go +++ b/internal/services/toolkit/client/completion_v2.go @@ -75,7 +75,7 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream reasoning_content := "" answer_content := "" answer_content_id := "" - is_answering := false + has_sent_part_begin := false tool_info := map[int]map[string]string{} toolCalls := []openai.FinishedChatCompletionToolCall{} for stream.Next() { @@ -90,21 +90,33 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream delta := chunk.Choices[0].Delta + // Send StreamPartBegin before any content (reasoning or answer) to ensure + // the frontend has created the assistant message part before receiving chunks. + // This is critical for models that send reasoning_content before regular content. + // We use HandleAssistantPartBegin instead of HandleAddedItem because the first + // chunk with reasoning content may not have delta.Role set to "assistant". + _, hasReasoningContent := delta.JSON.ExtraFields["reasoning_content"] + _, hasReasoning := delta.JSON.ExtraFields["reasoning"] + if !has_sent_part_begin && (delta.Role == "assistant" || delta.Content != "" || hasReasoningContent || hasReasoning) { + has_sent_part_begin = true + streamHandler.HandleAssistantPartBegin(chunk.ID) + } + if field, ok := delta.JSON.ExtraFields["reasoning_content"]; ok && field.Raw() != "null" { var s string err := json.Unmarshal([]byte(field.Raw()), &s) - if err != nil { - // fmt.Println(err) + if err == nil { + reasoning_content += s + streamHandler.HandleReasoningDelta(chunk.ID, s) } - reasoning_content += s - // fmt.Print(s) - } else { - if !is_answering { - is_answering = true - // fmt.Println("\n\n========== Response ==========") - streamHandler.HandleAddedItem(chunk) + } else if field, ok := delta.JSON.ExtraFields["reasoning"]; ok && field.Raw() != "null" { + var s string + err := json.Unmarshal([]byte(field.Raw()), &s) + if err == nil { + reasoning_content += s + streamHandler.HandleReasoningDelta(chunk.ID, s) } - + } else { if delta.Content != "" { answer_content += delta.Content answer_content_id = chunk.ID @@ -155,7 +167,7 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream // fmt.Printf("FinishReason: %s\n", chunk.Choices[0].FinishReason) // answer_content += chunk.Choices[0].Delta.Content // fmt.Printf("answer_content: %s\n", answer_content) - streamHandler.HandleTextDoneItem(chunk, answer_content) + streamHandler.HandleTextDoneItem(chunk, answer_content, reasoning_content) break } } diff --git a/internal/services/toolkit/handler/stream_v2.go b/internal/services/toolkit/handler/stream_v2.go index f95ca82d..862a0a42 100644 --- a/internal/services/toolkit/handler/stream_v2.go +++ b/internal/services/toolkit/handler/stream_v2.go @@ -41,6 +41,27 @@ func (h *StreamHandlerV2) SendInitialization() { }) } +// HandleAssistantPartBegin sends a StreamPartBegin message for an assistant message. +// Unlike HandleAddedItem, this doesn't check the delta.Role field, which is important +// because reasoning models may send reasoning_content before the role field is set. +func (h *StreamHandlerV2) HandleAssistantPartBegin(messageId string) { + if h.callbackStream == nil { + return + } + h.callbackStream.Send(&chatv2.CreateConversationMessageStreamResponse{ + ResponsePayload: &chatv2.CreateConversationMessageStreamResponse_StreamPartBegin{ + StreamPartBegin: &chatv2.StreamPartBegin{ + MessageId: messageId, + Payload: &chatv2.MessagePayload{ + MessageType: &chatv2.MessagePayload_Assistant{ + Assistant: &chatv2.MessageTypeAssistant{}, + }, + }, + }, + }, + }) +} + func (h *StreamHandlerV2) HandleAddedItem(chunk openai.ChatCompletionChunk) { if h.callbackStream == nil { return @@ -98,21 +119,28 @@ func (h *StreamHandlerV2) HandleAddedItem(chunk openai.ChatCompletionChunk) { } } -func (h *StreamHandlerV2) HandleTextDoneItem(chunk openai.ChatCompletionChunk, content string) { +func (h *StreamHandlerV2) HandleTextDoneItem(chunk openai.ChatCompletionChunk, content string, reasoning string) { if h.callbackStream == nil { return } + assistant := &chatv2.MessageTypeAssistant{ + Content: content, + ModelSlug: h.modelSlug, + } + + // Only send Reasoning if it's not empty + if reasoning != "" { + assistant.Reasoning = &reasoning + } + h.callbackStream.Send(&chatv2.CreateConversationMessageStreamResponse{ ResponsePayload: &chatv2.CreateConversationMessageStreamResponse_StreamPartEnd{ StreamPartEnd: &chatv2.StreamPartEnd{ MessageId: chunk.ID, Payload: &chatv2.MessagePayload{ MessageType: &chatv2.MessagePayload_Assistant{ - Assistant: &chatv2.MessageTypeAssistant{ - Content: content, - ModelSlug: h.modelSlug, - }, + Assistant: assistant, }, }, }, @@ -155,6 +183,20 @@ func (h *StreamHandlerV2) HandleTextDelta(chunk openai.ChatCompletionChunk) { }) } +func (h *StreamHandlerV2) HandleReasoningDelta(messageId string, delta string) { + if h.callbackStream == nil { + return + } + h.callbackStream.Send(&chatv2.CreateConversationMessageStreamResponse{ + ResponsePayload: &chatv2.CreateConversationMessageStreamResponse_ReasoningChunk{ + ReasoningChunk: &chatv2.ReasoningChunk{ + MessageId: messageId, + Delta: delta, + }, + }, + }) +} + func (h *StreamHandlerV2) SendIncompleteIndicator(reason string, responseId string) { if h.callbackStream == nil { return From 7deae81750f073a01a54623a93624b679d00daa0 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 02:48:29 +0800 Subject: [PATCH 05/18] feat: add unused variable rule to ESLint configuration - Introduced a new rule for @typescript-eslint/no-unused-vars to enforce error reporting on unused variables, allowing exceptions for variables and arguments that start with an underscore. --- webapp/_webapp/eslint.config.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/webapp/_webapp/eslint.config.js b/webapp/_webapp/eslint.config.js index bdeade8b..094be7fd 100644 --- a/webapp/_webapp/eslint.config.js +++ b/webapp/_webapp/eslint.config.js @@ -22,5 +22,13 @@ export default tseslint.config( "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "no-console": "error", }, + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], }, ); From 0dd80a86ab1e716410bc32aea805e180d8a729ff Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 02:50:49 +0800 Subject: [PATCH 06/18] feat: add @streamdown --- webapp/_webapp/bun.lock | 576 +++++++++++++++++++++++++++++++++++- webapp/_webapp/package.json | 5 + 2 files changed, 574 insertions(+), 7 deletions(-) diff --git a/webapp/_webapp/bun.lock b/webapp/_webapp/bun.lock index c4d24488..99a843cb 100644 --- a/webapp/_webapp/bun.lock +++ b/webapp/_webapp/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "webapp", @@ -14,6 +13,10 @@ "@iconify/react": "^6.0.0", "@lukemorales/query-key-factory": "^1.3.4", "@r2wc/react-to-web-component": "^2.1.0", + "@streamdown/cjk": "^1.0.1", + "@streamdown/code": "^1.0.1", + "@streamdown/math": "^1.0.1", + "@streamdown/mermaid": "^1.0.1", "@tanstack/react-query": "^5.79.0", "@types/diff": "^8.0.0", "@uidotdev/usehooks": "^2.4.1", @@ -33,6 +36,7 @@ "react-dom": "^19.1.0", "react-rnd": "^10.5.2", "semver": "^7.7.2", + "streamdown": "^2.1.0", "uuid": "^11.1.0", "zustand": "^5.0.5", }, @@ -67,8 +71,12 @@ "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@babel/runtime": ["@babel/runtime@7.27.4", "", {}, "sha512-t3yaEOuGu9NlIZ+hIeGbBjFtZT7j2cb2tg0fuaJKeGotchRjjLfrBA9Kwf8quhpP1EUuxModQg04q/mBwyg8uA=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], + "@buf/googleapis_googleapis.bufbuild_es": ["@buf/googleapis_googleapis.bufbuild_es@2.2.3-20250411203938-61b203b9a916.1", "https://buf.build/gen/npm/v1/@buf/googleapis_googleapis.bufbuild_es/-/googleapis_googleapis.bufbuild_es-2.2.3-20250411203938-61b203b9a916.1.tgz", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.3" } }, ""], "@bufbuild/protobuf": ["@bufbuild/protobuf@2.5.1", "", {}, "sha512-lut4UTvKL8tqtend0UDu7R79/n9jA7Jtxf77RNPbxtmWqfWI4qQ9bTjf7KCS4vfqLmpQbuHr1ciqJumAgJODdw=="], @@ -77,6 +85,16 @@ "@capacitor/core": ["@capacitor/core@7.4.0", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-P6NnjoHyobZgTjynlZSn27d0SUj6j38inlNxFnKZr9qwU7/r6+0Sg2nWkGkIH/pMmXHsvGD8zVe6KUq1UncIjA=="], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], + + "@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], + + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="], + + "@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], + + "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@codemirror/state": ["@codemirror/state@6.5.2", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA=="], "@codemirror/view": ["@codemirror/view@6.37.1", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-Qy4CAUwngy/VQkEz0XzMKVRcckQuqLYWKqVpDDDghBe5FSXSqfVrJn49nw3ePZHxRUz4nRmb05Lgi+9csWo4eg=="], @@ -333,6 +351,8 @@ "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], + "@internationalized/date": ["@internationalized/date@3.8.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PgVE6B6eIZtzf9Gu5HvJxRK3ufUFz9DhspELuhW/N0GuMGMTLvPQNRkHP2hTuP9lblOk+f+1xi96sPiPXANXAA=="], "@internationalized/message": ["@internationalized/message@3.1.7", "", { "dependencies": { "@swc/helpers": "^0.5.0", "intl-messageformat": "^10.1.0" } }, "sha512-gLQlhEW4iO7DEFPf/U7IrIdA3UyLGS0opeqouaFwlMObLUzwexRjbygONHDVbC9G9oFLXsLyGKYkJwqXw/QADg=="], @@ -359,6 +379,8 @@ "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -633,6 +655,28 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw=="], + "@shikijs/core": ["@shikijs/core@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ=="], + + "@shikijs/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="], + + "@shikijs/themes": ["@shikijs/themes@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw=="], + + "@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@streamdown/cjk": ["@streamdown/cjk@1.0.1", "", { "dependencies": { "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-ElDoEfad2u8iFzmgmEEab15N4mt19r47xeUIPJtHaHVyEF5baojamGo+xw3MywMj2qUsAY3LnTnKbrUtL5tGkg=="], + + "@streamdown/code": ["@streamdown/code@1.0.1", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-U9LITfQ28tZYAoY922jdtw1ryg4kgRBdURopqK9hph7G2fBUwPeHthjH7SvaV0fvFv7EqjqCzARJuWUljLe9Ag=="], + + "@streamdown/math": ["@streamdown/math@1.0.1", "", { "dependencies": { "katex": "^0.16.27", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-R9WdHbpERiRU7WeO7oT1aIbnLJ/jraDr89F7X9x2OM//Y8G8UMATRnLD/RUwg4VLr8Nu7QSIJ0Pa8lXd2meM4Q=="], + + "@streamdown/mermaid": ["@streamdown/mermaid@1.0.1", "", { "dependencies": { "mermaid": "^11.12.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-LVGbxYd6t1DKMCMqm3cpbfsdD4/EKpQelanOlJaBMKv83kbrl8syZJhVBsd/jka+CawhpeR9xsGQJzSJEpjoVw=="], + "@swc/core": ["@swc/core@1.11.29", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.21" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.11.29", "@swc/core-darwin-x64": "1.11.29", "@swc/core-linux-arm-gnueabihf": "1.11.29", "@swc/core-linux-arm64-gnu": "1.11.29", "@swc/core-linux-arm64-musl": "1.11.29", "@swc/core-linux-x64-gnu": "1.11.29", "@swc/core-linux-x64-musl": "1.11.29", "@swc/core-win32-arm64-msvc": "1.11.29", "@swc/core-win32-ia32-msvc": "1.11.29", "@swc/core-win32-x64-msvc": "1.11.29" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" } }, "sha512-g4mThMIpWbNhV8G2rWp5a5/Igv8/2UFRJx2yImrLGMgrDDYZIopqZ/z0jZxDgqNA1QDx93rpwNF7jGsxVWcMlA=="], "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.11.29", "", { "os": "darwin", "cpu": "arm64" }, "sha512-whsCX7URzbuS5aET58c75Dloby3Gtj/ITk2vc4WW6pSDQKSPDuONsIcZ7B2ng8oz0K6ttbi4p3H/PNPQLJ4maQ=="], @@ -675,24 +719,100 @@ "@types/codemirror": ["@types/codemirror@5.60.16", "", { "dependencies": { "@types/tern": "*" } }, "sha512-V/yHdamffSS075jit+fDxaOAmdP2liok8NSNJnAZfDJErzOheuygHZEhAJrfmk5TEyM32MhkZjwo/idX791yxw=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/events": ["@types/events@3.0.3", "", {}, "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g=="], "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], "@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], + "@types/lodash": ["@types/lodash@4.17.17", "", {}, "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ=="], "@types/lodash.debounce": ["@types/lodash.debounce@4.0.9", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@22.15.29", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ=="], "@types/react": ["@types/react@19.1.6", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q=="], @@ -705,6 +825,10 @@ "@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.33.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/type-utils": "8.33.0", "@typescript-eslint/utils": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.33.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.33.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ=="], @@ -727,6 +851,8 @@ "@uidotdev/usehooks": ["@uidotdev/usehooks@2.4.1", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.10.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.9", "@swc/core": "^1.11.22" }, "peerDependencies": { "vite": "^4 || ^5 || ^6" } }, "sha512-ZmkdHw3wo/o/Rk05YsXZs/DJAfY2CdQ5DUAjoWji+PEr+hYADdGMCGgEAILbiKj+CjspBTuTACBcWDrmC8AUfw=="], "acorn": ["acorn@8.14.1", "", { "bin": "bin/acorn" }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], @@ -757,6 +883,8 @@ "axios": ["axios@1.9.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw=="], @@ -779,15 +907,29 @@ "caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], + + "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], - "clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], @@ -801,14 +943,20 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], @@ -819,22 +967,106 @@ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + + "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "decimal.js": ["decimal.js@10.5.0", "", {}, "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -843,6 +1075,8 @@ "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -875,10 +1109,14 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -917,6 +1155,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], @@ -931,6 +1171,8 @@ "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -939,8 +1181,42 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore-by-default": ["ignore-by-default@1.0.1", "", {}, "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="], @@ -957,24 +1233,38 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "input-otp": ["input-otp@1.4.1", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw=="], + "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "intl-messageformat": ["intl-messageformat@10.7.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.4", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.2", "tslib": "^2.8.0" } }, "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -995,8 +1285,16 @@ "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "katex": ["katex@0.16.28", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + + "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], @@ -1007,22 +1305,128 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "markdown-to-jsx": ["markdown-to-jsx@7.7.6", "", { "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-/PWFFoKKMidk4Ut06F5hs5sluq1aJ0CGvUJWsnCK6hx/LPM8vlhvKAxtGHJ+U+V2Il2wmnfO6r81ICD3xZRVaw=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "mermaid": ["mermaid@11.12.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^0.6.3", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gRzVLUdjXBLX6zNPSnHGDoo+ZTp5zy+MZm0g3sv+3chPXY7l9gW+DnrcHcZh/jiPR6MjPKO4AEJNp4Aw6V9z5Q=="], + + "micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.3.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gSPnxgHDDqXYOBvQRq6lerrq9mjDhdtKn+7XETuXjxWcL62yZEfUdA28Ml1I2vDIPfAOIKLa0h2XDSGkInGHFQ=="], + + "micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -1035,6 +1439,8 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], "motion-dom": ["motion-dom@12.15.0", "", { "dependencies": { "motion-utils": "^12.12.1" } }, "sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA=="], @@ -1063,6 +1469,10 @@ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], + "openai": ["openai@5.0.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": "bin/cli" }, "sha512-Do6vxhbDv7cXhji/4ct1lrpZYMAOmjYbhyA9LJTuG7OfpbWMpuS+EIXkRT7R+XxpRB1OZhU/op4FU3p3uxU6gw=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -1073,10 +1483,18 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -1085,6 +1503,8 @@ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], @@ -1093,6 +1513,12 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + "postcss": ["postcss@8.5.4", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], @@ -1115,6 +1541,8 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -1145,6 +1573,36 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype-harden": ["rehype-harden@1.1.7", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw=="], + + "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], + + "remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="], + + "remark-cjk-friendly-gfm-strikethrough": ["remark-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly-gfm-strikethrough": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-bXfMZtsaomK6ysNN/UGRIcasQAYkC10NtPmP0oOHOV8YOhA2TXmwRXCku4qOzjIFxAPfish5+XS0eIug2PzNZA=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "remend": ["remend@1.1.0", "", {}, "sha512-JENGyuIhTwzUfCarW43X4r9cehoqTo9QyYxfNDZSud2AmqeuWjZ5pfybasTa4q0dxTJAj5m8NB+wR+YueAFpxQ=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], @@ -1153,12 +1611,20 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], + "rollup": ["rollup@4.41.1", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.41.1", "@rollup/rollup-android-arm64": "4.41.1", "@rollup/rollup-darwin-arm64": "4.41.1", "@rollup/rollup-darwin-x64": "4.41.1", "@rollup/rollup-freebsd-arm64": "4.41.1", "@rollup/rollup-freebsd-x64": "4.41.1", "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", "@rollup/rollup-linux-arm-musleabihf": "4.41.1", "@rollup/rollup-linux-arm64-gnu": "4.41.1", "@rollup/rollup-linux-arm64-musl": "4.41.1", "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-musl": "4.41.1", "@rollup/rollup-linux-s390x-gnu": "4.41.1", "@rollup/rollup-linux-x64-gnu": "4.41.1", "@rollup/rollup-linux-x64-musl": "4.41.1", "@rollup/rollup-win32-arm64-msvc": "4.41.1", "@rollup/rollup-win32-ia32-msvc": "4.41.1", "@rollup/rollup-win32-x64-msvc": "4.41.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw=="], + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "scriptjs": ["scriptjs@2.5.9", "", {}, "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg=="], @@ -1173,6 +1639,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], @@ -1181,12 +1649,18 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "streamdown": ["streamdown@2.1.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.1.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-u9gWd0AmjKg1d+74P44XaPlGrMeC21oDOSIhjGNEYMAttDMzCzlJO6lpTyJ9JkSinQQF65YcK4eOd3q9iTvULw=="], + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1195,13 +1669,19 @@ "style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + "sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="], "supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "tailwind-merge": ["tailwind-merge@2.5.4", "", {}, "sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q=="], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwind-variants": ["tailwind-variants@0.3.0", "", { "dependencies": { "tailwind-merge": "^2.5.4" }, "peerDependencies": { "tailwindcss": "*" } }, "sha512-ho2k5kn+LB1fT5XdNS3Clb96zieWxbStE9wNLK7D0AV64kdZMaYzAKo0fWl6fXLPY99ffF9oBJnIj5escEl/8A=="], @@ -1213,6 +1693,8 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1221,8 +1703,14 @@ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1235,10 +1723,28 @@ "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -1255,10 +1761,30 @@ "uuid": ["uuid@11.1.0", "", { "bin": "dist/esm/bin/uuid" }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-vitals": ["web-vitals@5.1.0", "", {}, "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -1281,22 +1807,30 @@ "zustand": ["zustand@5.0.5", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" } }, "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + + "@chevrotain/gast/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.2", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg=="], + "@heroui/system-rsc/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + + "@heroui/theme/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + + "@heroui/theme/tailwind-merge": ["tailwind-merge@2.5.4", "", {}, "sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q=="], + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], - "@react-aria/focus/clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - - "@react-aria/utils/clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -1309,14 +1843,36 @@ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "react-draggable/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -1327,6 +1883,8 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tailwind-variants/tailwind-merge": ["tailwind-merge@2.5.4", "", {}, "sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1337,6 +1895,10 @@ "chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/webapp/_webapp/package.json b/webapp/_webapp/package.json index faf1f0a1..cbb93b1f 100644 --- a/webapp/_webapp/package.json +++ b/webapp/_webapp/package.json @@ -31,6 +31,10 @@ "@iconify/react": "^6.0.0", "@lukemorales/query-key-factory": "^1.3.4", "@r2wc/react-to-web-component": "^2.1.0", + "@streamdown/cjk": "^1.0.1", + "@streamdown/code": "^1.0.1", + "@streamdown/math": "^1.0.1", + "@streamdown/mermaid": "^1.0.1", "@tanstack/react-query": "^5.79.0", "@types/diff": "^8.0.0", "@uidotdev/usehooks": "^2.4.1", @@ -50,6 +54,7 @@ "react-dom": "^19.1.0", "react-rnd": "^10.5.2", "semver": "^7.7.2", + "streamdown": "^2.1.0", "uuid": "^11.1.0", "zustand": "^5.0.5" }, From bca6b04cf8e60e010dc9680333b79a74629dc1a3 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 02:56:46 +0800 Subject: [PATCH 07/18] feat: expand supported models list with new free options --- internal/api/chat/list_supported_models_v2.go | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/internal/api/chat/list_supported_models_v2.go b/internal/api/chat/list_supported_models_v2.go index dfd9cb05..6b44c1ea 100644 --- a/internal/api/chat/list_supported_models_v2.go +++ b/internal/api/chat/list_supported_models_v2.go @@ -95,10 +95,50 @@ var allModels = []modelConfig{ outputPrice: 1000, requireOwnKey: true, }, + { + name: "DeepSeek: R1 0528 (free)", + slugOpenRouter: "deepseek/deepseek-r1-0528:free", + slugOpenAI: "", + totalContext: 163800, + maxOutput: 163800, + inputPrice: 0, + outputPrice: 0, + requireOwnKey: false, + }, + { + name: "OpenAI: gpt-oss-120b (free)", + slugOpenRouter: "openai/gpt-oss-120b:free", + slugOpenAI: "", + totalContext: 131072, + maxOutput: 131072, + inputPrice: 0, + outputPrice: 0, + requireOwnKey: false, + }, + { + name: "Qwen: Qwen3 Next 80B A3B Instruct (free)", + slugOpenRouter: "qwen/qwen3-next-80b-a3b-instruct:free", + slugOpenAI: "", + totalContext: 262144, + maxOutput: 262144, + inputPrice: 0, + outputPrice: 0, + requireOwnKey: false, + }, + { + name: "MoonshotAI: Kimi K2 0711 (free)", + slugOpenRouter: "moonshotai/kimi-k2:free", + slugOpenAI: "", + totalContext: 32768, + maxOutput: 32768, + inputPrice: 0, + outputPrice: 0, + requireOwnKey: false, + }, { name: "Qwen Plus (balanced)", slugOpenRouter: "qwen/qwen-plus", - slugOpenAI: "qwen-plus", // OpenAI doesn't support Qwen, use OpenRouter slug + slugOpenAI: "", // OpenAI doesn't support Qwen, use OpenRouter slug totalContext: 131100, maxOutput: 8200, inputPrice: 40, @@ -195,9 +235,12 @@ func (s *ChatServerV2) ListSupportedModels( var models []*chatv2.SupportedModel for _, config := range allModels { - // Choose the appropriate slug based on whether user has their own API key + // Choose the appropriate slug based on whether user has their own API key. + // + // Some models are only available via OpenRouter; for those, slugOpenAI may be empty. + // In that case, keep using the OpenRouter slug to avoid returning an empty model slug. slug := config.slugOpenRouter - if hasOwnAPIKey { + if hasOwnAPIKey && strings.TrimSpace(config.slugOpenAI) != "" { slug = config.slugOpenAI } From 2ed7f9cd33eb33a7c3fc0e395975e8d1d030d76b Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 03:49:55 +0800 Subject: [PATCH 08/18] feat: integrate CSRF token management in devtool --- webapp/_webapp/src/devtool/app.tsx | 42 ++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/webapp/_webapp/src/devtool/app.tsx b/webapp/_webapp/src/devtool/app.tsx index a2597d65..1c880604 100644 --- a/webapp/_webapp/src/devtool/app.tsx +++ b/webapp/_webapp/src/devtool/app.tsx @@ -5,36 +5,56 @@ import { getCookies } from "../intermediate"; import { TooltipArea } from "./tooltip"; import { DevTools } from "../views/devtools"; import { useDevtoolStore } from "../stores/devtool-store"; +import { storage } from "../libs/storage"; + +const updateCsrfTokenMeta = (csrfToken: string) => { + let metaTag = document.querySelector('meta[name="ol-csrfToken"]') as HTMLMetaElement | null; + if (!metaTag) { + metaTag = document.createElement("meta"); + metaTag.name = "ol-csrfToken"; + document.head.appendChild(metaTag); + } + metaTag.content = csrfToken; +}; const App = () => { const { token, refreshToken, setToken, setRefreshToken } = useAuthStore(); - const [projectId, setProjectId] = useState(localStorage.getItem("pd.projectId") ?? ""); - const [overleafSession, setOverleafSession] = useState(localStorage.getItem("pd.auth.overleafSession") ?? ""); - const [gclb, setGclb] = useState(localStorage.getItem("pd.auth.gclb") ?? ""); + const [projectId, setProjectId] = useState(storage.getItem("pd.projectId") ?? ""); + const [overleafSession, setOverleafSession] = useState(storage.getItem("pd.auth.overleafSession") ?? ""); + const [gclb, setGclb] = useState(storage.getItem("pd.auth.gclb") ?? ""); + const [csrfToken, setCsrfToken] = useState(storage.getItem("pd.auth.csrfToken") ?? ""); + const { showTool } = useDevtoolStore(); useEffect(() => { getCookies(window.location.hostname).then((cookies) => { - setOverleafSession(cookies.session ?? localStorage.getItem("pd.auth.overleafSession") ?? ""); - setGclb(cookies.gclb ?? localStorage.getItem("pd.auth.gclb") ?? ""); + setOverleafSession(cookies.session ?? storage.getItem("pd.auth.overleafSession") ?? ""); + setGclb(cookies.gclb ?? storage.getItem("pd.auth.gclb") ?? ""); }); }, []); const setProjectId_ = useCallback((projectId: string) => { - localStorage.setItem("pd.projectId", projectId); + storage.setItem("pd.projectId", projectId); setProjectId(projectId); }, []); const setOverleafSession_ = useCallback((overleafSession: string) => { - localStorage.setItem("pd.auth.overleafSession", overleafSession); + storage.setItem("pd.auth.overleafSession", overleafSession); setOverleafSession(overleafSession); }, []); const setGclb_ = useCallback((gclb: string) => { - localStorage.setItem("pd.auth.gclb", gclb); + storage.setItem("pd.auth.gclb", gclb); setGclb(gclb); }, []); + const setCsrfToken_ = useCallback((csrfToken: string) => { + storage.setItem("pd.auth.csrfToken", csrfToken); + setCsrfToken(csrfToken); + // Update meta tag in DOM + updateCsrfTokenMeta(csrfToken); + }, []); + return (
@@ -69,6 +89,12 @@ const App = () => { value={refreshToken} setValue={setRefreshToken} /> +
From e24566b90232afca7f8411a674ac3220bec3f7c4 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 03:50:38 +0800 Subject: [PATCH 09/18] feat: enhance model selection with disabled state handling --- webapp/_webapp/src/hooks/useLanguageModels.ts | 5 ++ webapp/_webapp/src/libs/toasts.tsx | 1 - .../chat/footer/toolbar/model-selection.tsx | 3 + .../views/chat/footer/toolbar/selection.tsx | 57 ++++++++++++++----- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/webapp/_webapp/src/hooks/useLanguageModels.ts b/webapp/_webapp/src/hooks/useLanguageModels.ts index 04b2e0c5..a45b3761 100644 --- a/webapp/_webapp/src/hooks/useLanguageModels.ts +++ b/webapp/_webapp/src/hooks/useLanguageModels.ts @@ -12,6 +12,8 @@ export type Model = { maxOutput: number; inputPrice: number; outputPrice: number; + disabled: boolean; + disabledReason?: string; }; // Extract provider from model slug (e.g., "openai/gpt-4.1" -> "openai") @@ -30,6 +32,7 @@ const fallbackModels: Model[] = [ maxOutput: 32800, inputPrice: 200, outputPrice: 800, + disabled: false, }, ]; @@ -41,6 +44,8 @@ const mapSupportedModelToModel = (supportedModel: SupportedModel): Model => ({ maxOutput: Number(supportedModel.maxOutput), inputPrice: Number(supportedModel.inputPrice), outputPrice: Number(supportedModel.outputPrice), + disabled: supportedModel.disabled, + disabledReason: supportedModel.disabledReason, }); export const useLanguageModels = () => { diff --git a/webapp/_webapp/src/libs/toasts.tsx b/webapp/_webapp/src/libs/toasts.tsx index c1129682..8b54328d 100644 --- a/webapp/_webapp/src/libs/toasts.tsx +++ b/webapp/_webapp/src/libs/toasts.tsx @@ -25,6 +25,5 @@ export function errorToast(description: string, title: string = "Error") { color: "danger", timeout: 10000, }); - console.trace(); // eslint-disable-line no-console console.error(title, description); // eslint-disable-line no-console } diff --git a/webapp/_webapp/src/views/chat/footer/toolbar/model-selection.tsx b/webapp/_webapp/src/views/chat/footer/toolbar/model-selection.tsx index 0033a8c6..33d2d7df 100644 --- a/webapp/_webapp/src/views/chat/footer/toolbar/model-selection.tsx +++ b/webapp/_webapp/src/views/chat/footer/toolbar/model-selection.tsx @@ -16,11 +16,14 @@ export function ModelSelection({ onSelectModel }: ModelSelectionProps) { title: model.name, subtitle: model.slug, value: model.slug, + disabled: model.disabled, + disabledReason: model.disabledReason, })); }, [models]); const onSelect = useCallback( (item: SelectionItem) => { + if (item.disabled) return; setModel(models.find((m) => m.slug === item.value)!); onSelectModel(); inputRef.current?.focus(); diff --git a/webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx b/webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx index 7bcba7be..ebe05022 100644 --- a/webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx +++ b/webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx @@ -10,6 +10,8 @@ export type SelectionItem = { subtitle?: string; description?: string; value: T; + disabled?: boolean; + disabledReason?: string; }; type SelectionProps = { @@ -92,23 +94,36 @@ export function Selection({ items, initialValue, onSelect, onClose }: Selecti if (e.key === "ArrowDown") { e.preventDefault(); e.stopPropagation(); - if (selectedIdx < itemCount - 1) { - scrollTo(selectedIdx + 1); - setSelectedIdx(selectedIdx + 1); + let nextIdx = selectedIdx + 1; + // Skip disabled items + while (nextIdx < itemCount && items[nextIdx]?.disabled) { + nextIdx++; + } + if (nextIdx < itemCount) { + scrollTo(nextIdx); + setSelectedIdx(nextIdx); } } if (e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); - if (selectedIdx > 0) { - scrollTo(selectedIdx - 1); - setSelectedIdx(selectedIdx - 1); + let prevIdx = selectedIdx - 1; + // Skip disabled items + while (prevIdx >= 0 && items[prevIdx]?.disabled) { + prevIdx--; + } + if (prevIdx >= 0) { + scrollTo(prevIdx); + setSelectedIdx(prevIdx); } } if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); - onSelect?.(items[selectedIdx]); + // Only select if not disabled + if (!items[selectedIdx]?.disabled) { + onSelect?.(items[selectedIdx]); + } } }; @@ -129,7 +144,7 @@ export function Selection({ items, initialValue, onSelect, onClose }: Selecti ref={scrollContainerRef} className={cn( "transition-all duration-100 absolute bottom-full left-0 right-0 mb-1 z-50 bg-white shadow-lg", - items && items.length > 0 ? "rounded-lg border border-gray-200 overflow-y-auto" : "max-h-[0px]", + items && items.length > 0 ? "rounded-lg border !border-gray-200 overflow-y-auto" : "max-h-[0px]", heightCollapseRequired || minimalistMode ? "p-0 max-h-[100px]" : "p-2 max-h-[200px]", )} > @@ -137,16 +152,18 @@ export function Selection({ items, initialValue, onSelect, onClose }: Selecti
{ + if (item.disabled) return; googleAnalytics.fireEvent(user?.id, `select_${normalizeName(item.title)}`, {}); onSelect?.(item); }} onMouseEnter={() => { - if (!isKeyboardNavigation) { + if (!isKeyboardNavigation && !item.disabled) { setSelectedIdx(idx); } }} @@ -158,6 +175,20 @@ export function Selection({ items, initialValue, onSelect, onClose }: Selecti )} > {item.title} + {item.disabled && ( + + + + )} {item.subtitle && ( ({ items, initialValue, onSelect, onClose }: Selecti )}
- {item.description && ( + {(item.description || item.disabledReason) && (
- {item.description} + {item.disabledReason || item.description}
)}
From d771effe887a567d97fefa29d2e53202ab68da66 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 03:51:17 +0800 Subject: [PATCH 10/18] feat: update styling and integrate Streamdown for markdown rendering - Added new styles for chat message components and tool cards. - Integrated Streamdown for enhanced markdown rendering, replacing the previous Markdown component. - Improved responsiveness and visual consistency across message boxes and actions. --- webapp/_webapp/src/components/markdown.tsx | 159 +++++---------------- webapp/_webapp/src/index.css | 82 +++++++---- 2 files changed, 91 insertions(+), 150 deletions(-) diff --git a/webapp/_webapp/src/components/markdown.tsx b/webapp/_webapp/src/components/markdown.tsx index 3da2beba..9a675f54 100644 --- a/webapp/_webapp/src/components/markdown.tsx +++ b/webapp/_webapp/src/components/markdown.tsx @@ -1,6 +1,9 @@ -import Markdown from "markdown-to-jsx"; -import { TextPatches } from "./text-patches"; -import { ReactNode, useMemo, memo } from "react"; +import { memo } from "react"; +import { Streamdown } from "streamdown"; +import { code } from "@streamdown/code"; +import { mermaid } from "@streamdown/mermaid"; +import { math } from "@streamdown/math"; +import { cjk } from "@streamdown/cjk"; interface MarkdownComponentProps { children: string; @@ -8,129 +11,35 @@ interface MarkdownComponentProps { animated?: boolean; } -interface ComponentProps { - children: ReactNode; - [key: string]: ReactNode | string | number | boolean | undefined; -} - -const AnimatedText = ({ children, animated }: { children: ReactNode; animated?: boolean }) => { - if (!animated) { - return children; - } - - const str = typeof children === "string" ? children : children?.toString() || ""; - - if (str.length > 0 && !str.includes("[object Object]")) { - return str.split(" ").map((word, index) => ( - - {word + " "} - - )); - } - return children; -}; - +// @ts-ignore const MarkdownComponent = memo(({ children, prevAttachment, animated }: MarkdownComponentProps) => { - const markdownOptions = useMemo( - () => ({ - overrides: { - PaperDebugger: { - component: TextPatches, - }, - span: { - component: ({ children, ...props }: ComponentProps) => ( - - {children} - - ), - }, - // p: { - // component: ({ children, ...props }: ComponentProps) => ( - //
- // {children} - //
- // ), - // }, - h1: { - component: ({ children, ...props }: ComponentProps) => ( -

- {typeof children === "string" ? {children} : children} -

- ), - }, - h2: { - component: ({ children, ...props }: ComponentProps) => ( -

- {typeof children === "string" ? {children} : children} -

- ), - }, - h3: { - component: ({ children, ...props }: ComponentProps) => ( -

- {typeof children === "string" ? {children} : children} -

- ), - }, - code: { - component: ({ children, ...props }: ComponentProps) => ( - - {typeof children === "string" ? {children} : children} - - ), - }, - pre: { - component: ({ children, ...props }: ComponentProps) => ( - - {typeof children === "string" ? {children} : children} - - ), - }, - a: { - component: ({ children, ...props }: ComponentProps) => ( - - {typeof children === "string" ? {children} : children} - - ), - }, - hr: { - component: ({ ...props }: ComponentProps) =>
, - }, - li: { - component: ({ children, ...props }: ComponentProps) => ( -
  • - {typeof children === "string" ? {children} : children} -
  • - ), - }, - ul: { - component: ({ children, ...props }: ComponentProps) => ( -
      - {typeof children === "string" ? {children} : children} -
    - ), - }, - ol: { - component: ({ children, ...props }: ComponentProps) => ( -
      - {typeof children === "string" ? {children} : children} -
    - ), - }, - }, - }), - [prevAttachment, animated], - ); - - return {children}; + return ( +

    + {children} +

    + ), + + h2: ({ children }) => ( +

    + {children} +

    + ), + + h3: ({ children }) => ( +

    + {children} +

    + ), + }} + plugins={{ code, mermaid, math, cjk }} + isAnimating={animated}> + {children} +
    + + // return {children}; }); MarkdownComponent.displayName = "MarkdownComponent"; diff --git a/webapp/_webapp/src/index.css b/webapp/_webapp/src/index.css index 50d5dce9..b29f618b 100644 --- a/webapp/_webapp/src/index.css +++ b/webapp/_webapp/src/index.css @@ -1,4 +1,5 @@ @import url("https://fonts.googleapis.com/css2?family=Exo+2:ital,wght@0,100..900;1,100..900&display=swap"); +@source "../node_modules/streamdown/dist/*.js"; @tailwind base; @tailwind components; @@ -104,8 +105,8 @@ video, canvas, audio, iframe, embed, object { /* Tool Card Begin */ .tool-card { - @apply bg-white rounded-xl border overflow-hidden max-w-xl mx-2 p-4 my-2; - border-color: var(--pd-border-color); + @apply bg-white overflow-hidden max-w-xl p-4 my-2; + border-color: var(--pd-border-color) !important; } .tool-card.narrow { @@ -127,7 +128,7 @@ video, canvas, audio, iframe, embed, object { } .tool-card.compact { - @apply px-[3px] py-[1px] my-0.5 bg-transparent text-xs border-0; + @apply px-[0px] py-[0px] my-0.5 bg-transparent text-xs border-0; } .tool-card.compact .tool-card-title { @@ -266,6 +267,10 @@ video, canvas, audio, iframe, embed, object { flex-shrink: 0; } +.pd-chat-item-container-messages { + @apply flex flex-col px-2; +} + .pd-app-tab-content { /* 填满 pd-app-body-container */ height: 100%; @@ -325,11 +330,10 @@ video, canvas, audio, iframe, embed, object { width: 100%; display: flex; flex-direction: column; - word-break: break-all; } .chat-message-entry .indicator { - @apply transition-all duration-500 fade-in noselect; + @apply transition-all duration-300 ease-in-out fade-in noselect; @apply bg-transparent px-2; text-wrap-style: balance; overflow: hidden; @@ -341,11 +345,18 @@ video, canvas, audio, iframe, embed, object { } .chat-message-entry .indicator.preparing { - @apply opacity-100 h-6; + @apply opacity-100 h-6 mb-0; } .chat-message-entry .indicator.prepared { - @apply opacity-0 h-0 -mt-2; + @apply opacity-0 m-0 p-0; + height: 0; + max-height: 0; +} + +/* prepared 后的下一个兄弟元素不需要上边距 */ +.chat-message-entry .indicator.prepared + * { + margin-top: 0; } .chat-message-entry .indicator.incomplete { @@ -355,39 +366,41 @@ video, canvas, audio, iframe, embed, object { .chat-message-entry .message-box-user-wrapper { display: flex; flex-direction: row; - align-items: center; justify-content: flex-end; gap: 4px; width: 100%; } .chat-message-entry .message-box-user { - max-width: 70%; + max-width: min(70%, 700px); + min-width: 0; + overflow-wrap: break-word; + word-wrap: break-word; @apply text-sm text-default-800 px-3 py-2 border border-transparent rounded-xl; @apply transition-all duration-500 ease-in-out; - @apply bg-gray-200 my-2; + @apply bg-gray-200; } .chat-message-entry .message-box-assistant { align-self: flex-start; - @apply text-sm text-default-800 px-2 py-2 border border-transparent rounded-xl; + max-width: min(95%, 700px); + min-width: 0; + @apply text-sm text-default-800 border border-transparent rounded-xl; @apply transition-all duration-500 ease-in-out; - @apply mb-2; } -.chat-message-entry .message-box-assistant:hover { - @apply !bg-gray-100 !delay-[2000ms]; - border-color: var(--pd-border-color); +.chat-message-entry .message-box-assistant > * { + max-width: 100%; + min-width: 0; + overflow-x: auto; } .chat-message-entry .actions { display: flex; flex-direction: row; - @apply gap-2 text-gray-400 mt-2 -ml-2 opacity-0 transition-all duration-100; -} - -.chat-message-entry .actions.actions-left { - @apply mt-0 ml-0 mr-0; + justify-content: flex-start; + align-items: flex-end; + @apply text-gray-400 opacity-0 transition-all duration-100; } .chat-message-entry:hover .actions { @@ -398,17 +411,17 @@ video, canvas, audio, iframe, embed, object { .chat-message-entry .actions .icon { cursor: pointer; font-size: 24px; - @apply hover:text-gray-500 hover:bg-gray-200 rounded-md p-1 transition-all duration-100 ml-2; + @apply hover:text-gray-500 hover:bg-gray-200 rounded-md p-1 transition-all duration-100; } .message-box-stale { @apply !bg-red-50 !border; - border-color: var(--pd-border-color-error); + border-color: var(--pd-border-color-error) !important; } .message-box-stale-description { @apply !text-sm !text-red-400 !border-t !mt-2 !pt-1 fade-in; - border-color: var(--pd-border-color-error); + border-color: var(--pd-border-color-error) !important; } .pd-message { @@ -562,7 +575,7 @@ video, canvas, audio, iframe, embed, object { .gsi-material-button:disabled { cursor: default; background-color: #ffffff61; - border-color: #1f1f1f1f; + border-color: #1f1f1f1f !important; } .gsi-material-button:disabled .gsi-material-button-contents { @@ -595,6 +608,7 @@ video, canvas, audio, iframe, embed, object { vertical-align: top; text-align: right; } + /* Embed Sidebar Styles */ #pd-embed-sidebar { background-color: var(--pd-default-bg); @@ -681,4 +695,22 @@ video, canvas, audio, iframe, embed, object { .pd-tab-items-resize-handle.resizing .resize-handle-indicator { opacity: 1 !important; -} \ No newline at end of file +} + +[data-streamdown="inline-code"] { + @apply px-1 py-0 !text-xs !bg-gray-200 rounded-md font-mono; +} + +[data-streamdown="code-block"] { + border-radius: 0.5rem; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +[data-streamdown="code-block-header"] { + @apply py-1 px-2; +} + +[data-streamdown="code-block-body"] { + @apply py-2 px-3 text-xs; +} From 59b6df1ac2b255cd6510a6c780f228f2f87ba254 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 03:51:29 +0800 Subject: [PATCH 11/18] feat: enhance AssistantMessageContainer with auto-collapse reasoning feature - Implemented auto-collapse functionality for reasoning content based on message state. - Updated GeneralToolCard to support external collapse state and auto-scroll behavior. - Refactored ErrorToolCard to utilize GeneralToolCard for consistent error display. - Improved message rendering logic for better user experience. --- .../message-entry-container/assistant.tsx | 79 ++++++++++------ .../message-entry-container/tools/error.tsx | 16 ++-- .../message-entry-container/tools/general.tsx | 92 ++++++++++++++++--- 3 files changed, 137 insertions(+), 50 deletions(-) diff --git a/webapp/_webapp/src/components/message-entry-container/assistant.tsx b/webapp/_webapp/src/components/message-entry-container/assistant.tsx index 5ceebd98..bb6700b5 100644 --- a/webapp/_webapp/src/components/message-entry-container/assistant.tsx +++ b/webapp/_webapp/src/components/message-entry-container/assistant.tsx @@ -1,11 +1,11 @@ import { cn, Tooltip } from "@heroui/react"; -import { useCallback, useMemo, useState } from "react"; +import { GeneralToolCard } from "./tools/general"; +import { useCallback, useEffect, useMemo, useState } from "react"; import googleAnalytics from "../../libs/google-analytics"; import { getProjectId } from "../../libs/helpers"; import MarkdownComponent from "../markdown"; import { useAuthStore } from "../../stores/auth-store"; import { Icon } from "@iconify/react/dist/iconify.js"; -import { GeneralToolCard } from "./tools/general"; // Helper functions const preprocessMessage = (message: string): string | undefined => { @@ -38,6 +38,24 @@ export const AssistantMessageContainer = ({ const projectId = getProjectId(); const [copySuccess, setCopySuccess] = useState(false); + // Auto-collapse reasoning when message content arrives + const [isReasoningCollapsed, setIsReasoningCollapsed] = useState(true); + + useEffect(() => { + const hasReasoning = (reasoning?.length ?? 0) > 0; + const hasMessage = (processedMessage?.length ?? 0) > 0; + + // Auto-expand when reasoning arrives + if (hasReasoning && !hasMessage) { + setIsReasoningCollapsed(false); + } + + // Auto-collapse when message content arrives + if (hasReasoning && hasMessage) { + setIsReasoningCollapsed(true); + } + }, [reasoning, processedMessage]); + const handleCopy = useCallback(() => { if (processedMessage) { googleAnalytics.fireEvent(user?.id, "messagecard_copy_message", { @@ -52,7 +70,7 @@ export const AssistantMessageContainer = ({ } }, [user?.id, projectId, processedMessage, messageId]); - const showMessage = processedMessage?.length || 0 > 0 || reasoning?.length || 0 > 0; + const showMessage = (processedMessage?.length ?? 0) > 0 || (reasoning?.length ?? 0) > 0; const staleComponent = stale &&
    This message is stale.
    ; const writingIndicator = stale || !showMessage ? null : ( @@ -69,43 +87,46 @@ export const AssistantMessageContainer = ({ ); const reasoningComponent = reasoning && ( -
    - -
    - ); - - const messageComponent = processedMessage && ( -
    - - {processedMessage || ""} - -
    - ); + setIsReasoningCollapsed(!isReasoningCollapsed)} + isLoading={preparing} + /> - const actionComponent = ( -
    - - - - - -
    ); - return ( showMessage && (
    + {/* Reasoning content */} {reasoningComponent} - {messageComponent} + + {/* Message content */} +
    + + {processedMessage || ""} + +
    + {writingIndicator} + + {/* Stale message */} {staleComponent} - {actionComponent} + + { (processedMessage?.length || 0) > 0 && +
    + + + + + +
    }
    ) ); }; + diff --git a/webapp/_webapp/src/components/message-entry-container/tools/error.tsx b/webapp/_webapp/src/components/message-entry-container/tools/error.tsx index c90da685..f1e65a63 100644 --- a/webapp/_webapp/src/components/message-entry-container/tools/error.tsx +++ b/webapp/_webapp/src/components/message-entry-container/tools/error.tsx @@ -1,4 +1,4 @@ -import { cn } from "@heroui/react"; +import { GeneralToolCard } from "./general"; type ErrorToolCardProps = { functionName: string; @@ -7,12 +7,10 @@ type ErrorToolCardProps = { }; export const ErrorToolCard = ({ functionName, errorMessage, animated }: ErrorToolCardProps) => { - return ( -
    -

    - Error in Tool "{functionName}" -

    - {errorMessage} -
    - ); + return ; }; diff --git a/webapp/_webapp/src/components/message-entry-container/tools/general.tsx b/webapp/_webapp/src/components/message-entry-container/tools/general.tsx index f521acda..3d1b104f 100644 --- a/webapp/_webapp/src/components/message-entry-container/tools/general.tsx +++ b/webapp/_webapp/src/components/message-entry-container/tools/general.tsx @@ -1,10 +1,18 @@ import { cn } from "@heroui/react"; -import { useState } from "react"; +import { useEffect, useState, useRef } from "react"; +import { Streamdown } from "streamdown"; +import { code } from "@streamdown/code"; +import { mermaid } from "@streamdown/mermaid"; +import { math } from "@streamdown/math"; +import { cjk } from "@streamdown/cjk"; type GeneralToolCardProps = { functionName: string; message: string; animated: boolean; + isCollapsed?: boolean; + onToggleCollapse?: () => void; + isLoading?: boolean; }; const shimmerStyle = { @@ -20,8 +28,40 @@ const shimmerStyle = { backgroundPositionX: "-100%", } as const; -export const GeneralToolCard = ({ functionName, message, animated }: GeneralToolCardProps) => { - const [isCollapsed, setIsCollapsed] = useState(true); +export const GeneralToolCard = ({ functionName, message, animated, isCollapsed: externalIsCollapsed, onToggleCollapse, isLoading }: GeneralToolCardProps) => { + const [internalIsCollapsed, setInternalIsCollapsed] = useState(true); + const scrollContainerRef = useRef(null); + + // Use external state if provided, otherwise use internal state + const isCollapsed = externalIsCollapsed !== undefined ? externalIsCollapsed : internalIsCollapsed; + + // Sync internal state with external state when it changes + useEffect(() => { + if (externalIsCollapsed !== undefined) { + setInternalIsCollapsed(externalIsCollapsed); + } + }, [externalIsCollapsed]); + + // Auto-scroll to bottom when message updates and is loading + useEffect(() => { + if (isLoading && scrollContainerRef.current && !isCollapsed) { + scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; + } + }, [message, isLoading, isCollapsed]); + + // Auto-collapse when loading finishes (reasoning ends) + const prevIsLoadingRef = useRef(isLoading); + useEffect(() => { + // Only collapse if it was loading before and now it's not + if (prevIsLoadingRef.current && !isLoading) { + if (onToggleCollapse && externalIsCollapsed === false) { + onToggleCollapse(); + } else if (externalIsCollapsed === undefined) { + setInternalIsCollapsed(true); + } + } + prevIsLoadingRef.current = isLoading; + }, [isLoading, onToggleCollapse, externalIsCollapsed]); // When no message, show minimal "Calling tool..." style like Preparing function if (!message) { @@ -35,10 +75,14 @@ export const GeneralToolCard = ({ functionName, message, animated }: GeneralTool } const toggleCollapse = () => { - setIsCollapsed(!isCollapsed); + if (onToggleCollapse) { + onToggleCollapse(); + } else { + setInternalIsCollapsed(!internalIsCollapsed); + } }; const pascalCase = (str: string) => { - const words = str.split("_"); + const words = str.split(/[\s_-]+/); return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); }; // When there is a message, show the compact card with collapsible content @@ -46,7 +90,7 @@ export const GeneralToolCard = ({ functionName, message, animated }: GeneralTool
    -

    {pascalCase(functionName)}

    +

    {pascalCase(functionName)}

    +
    - {message} +
    +
    + {/* Fade-out gradient at top */} +
    + + {/* Scrollable content with max height - hide scrollbar */} +
    + + {message} + +
    + + {/* Fade-out gradient at bottom */} +
    +
    +
    ); From 51ffbd64bb46792b60de2faedb0c96d85142b746 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Tue, 27 Jan 2026 04:33:36 +0800 Subject: [PATCH 12/18] feat: refactor message handling and introduce unified message store - Updated MessageCard component to utilize the new DisplayMessage type for improved message handling. - Refactored useSendMessageStream hook to streamline message streaming logic and reduce dependencies. - Introduced a unified message store to consolidate message state management, enhancing the overall architecture. - Added message converters for bidirectional transformation between API and internal message types. - Removed deprecated streaming message store and related handlers to simplify the codebase. - Enhanced chat components to leverage the new message store and improve rendering efficiency. --- .../_webapp/src/components/message-card.tsx | 53 +- .../_webapp/src/hooks/useSendMessageStream.ts | 365 +++++++---- .../stores/conversation/conversation-store.ts | 21 +- .../stores/conversation/handlers/converter.ts | 73 --- .../conversation/handlers/handleError.ts | 20 - .../handlers/handleIncompleteIndicator.ts | 6 - .../handlers/handleMessageChunk.ts | 37 -- .../handlers/handleReasoningChunk.ts | 64 +- .../handlers/handleStreamError.ts | 53 -- .../handlers/handleStreamFinalization.ts | 6 - .../handlers/handleStreamInitialization.ts | 29 - .../handlers/handleStreamPartBegin.ts | 73 --- .../handlers/handleStreamPartEnd.ts | 99 --- .../_webapp/src/stores/conversation/types.ts | 31 +- webapp/_webapp/src/stores/converters.ts | 73 +++ webapp/_webapp/src/stores/message-store.ts | 307 +++++++++ .../src/stores/streaming-message-store.ts | 50 -- .../streaming/__tests__/error-hander.test.ts | 477 ++++++++++++++ .../__tests__/message-type-handlers.test.ts | 390 +++++++++++ .../__tests__/streaming-state-machine.test.ts | 390 +++++++++++ .../src/stores/streaming/error-handler.ts | 588 +++++++++++++++++ webapp/_webapp/src/stores/streaming/index.ts | 10 + .../stores/streaming/message-type-handlers.ts | 173 +++++ .../streaming/streaming-state-machine.ts | 438 +++++++++++++ webapp/_webapp/src/stores/streaming/types.ts | 206 ++++++ webapp/_webapp/src/stores/types.ts | 46 ++ webapp/_webapp/src/types/index.ts | 43 ++ webapp/_webapp/src/types/message.ts | 299 +++++++++ .../__tests__/message-converters.test.ts | 617 ++++++++++++++++++ webapp/_webapp/src/utils/index.ts | 35 + .../_webapp/src/utils/message-converters.ts | 482 ++++++++++++++ .../_webapp/src/utils/stream-event-mapper.ts | 116 ++++ .../src/utils/stream-request-builder.ts | 113 ++++ webapp/_webapp/src/views/chat/body/index.tsx | 191 +++--- .../src/views/chat/body/status-indicator.tsx | 33 +- .../views/chat/header/chat-history-modal.tsx | 5 +- .../_webapp/src/views/chat/header/index.tsx | 20 +- webapp/_webapp/src/views/chat/helper.ts | 114 +++- webapp/_webapp/src/views/devtools/index.tsx | 188 +++--- 39 files changed, 5398 insertions(+), 936 deletions(-) delete mode 100644 webapp/_webapp/src/stores/conversation/handlers/converter.ts delete mode 100644 webapp/_webapp/src/stores/conversation/handlers/handleError.ts delete mode 100644 webapp/_webapp/src/stores/conversation/handlers/handleIncompleteIndicator.ts delete mode 100644 webapp/_webapp/src/stores/conversation/handlers/handleMessageChunk.ts delete mode 100644 webapp/_webapp/src/stores/conversation/handlers/handleStreamError.ts delete mode 100644 webapp/_webapp/src/stores/conversation/handlers/handleStreamFinalization.ts delete mode 100644 webapp/_webapp/src/stores/conversation/handlers/handleStreamInitialization.ts delete mode 100644 webapp/_webapp/src/stores/conversation/handlers/handleStreamPartBegin.ts delete mode 100644 webapp/_webapp/src/stores/conversation/handlers/handleStreamPartEnd.ts create mode 100644 webapp/_webapp/src/stores/converters.ts create mode 100644 webapp/_webapp/src/stores/message-store.ts delete mode 100644 webapp/_webapp/src/stores/streaming-message-store.ts create mode 100644 webapp/_webapp/src/stores/streaming/__tests__/error-hander.test.ts create mode 100644 webapp/_webapp/src/stores/streaming/__tests__/message-type-handlers.test.ts create mode 100644 webapp/_webapp/src/stores/streaming/__tests__/streaming-state-machine.test.ts create mode 100644 webapp/_webapp/src/stores/streaming/error-handler.ts create mode 100644 webapp/_webapp/src/stores/streaming/index.ts create mode 100644 webapp/_webapp/src/stores/streaming/message-type-handlers.ts create mode 100644 webapp/_webapp/src/stores/streaming/streaming-state-machine.ts create mode 100644 webapp/_webapp/src/stores/streaming/types.ts create mode 100644 webapp/_webapp/src/types/index.ts create mode 100644 webapp/_webapp/src/types/message.ts create mode 100644 webapp/_webapp/src/utils/__tests__/message-converters.test.ts create mode 100644 webapp/_webapp/src/utils/index.ts create mode 100644 webapp/_webapp/src/utils/message-converters.ts create mode 100644 webapp/_webapp/src/utils/stream-event-mapper.ts create mode 100644 webapp/_webapp/src/utils/stream-request-builder.ts diff --git a/webapp/_webapp/src/components/message-card.tsx b/webapp/_webapp/src/components/message-card.tsx index 0f6eff7f..ffb1fc16 100644 --- a/webapp/_webapp/src/components/message-card.tsx +++ b/webapp/_webapp/src/components/message-card.tsx @@ -1,11 +1,11 @@ import { cn } from "@heroui/react"; import { memo } from "react"; import Tools from "./message-entry-container/tools/tools"; -import { MessageEntry, MessageEntryStatus } from "../stores/conversation/types"; import { AssistantMessageContainer } from "./message-entry-container/assistant"; import { UserMessageContainer } from "./message-entry-container/user"; import { ToolCallPrepareMessageContainer } from "./message-entry-container/toolcall-prepare"; import { UnknownEntryMessageContainer } from "./message-entry-container/unknown-entry"; +import { DisplayMessage } from "../stores/types"; // Constants export const STYLES = { @@ -34,64 +34,67 @@ export const STYLES = { // Types interface MessageCardProps { - messageEntry: MessageEntry; + message: DisplayMessage; prevAttachment?: string; animated?: boolean; } -export const MessageCard = memo(({ messageEntry, prevAttachment, animated }: MessageCardProps) => { +export const MessageCard = memo(({ message, prevAttachment, animated }: MessageCardProps) => { + const isStale = message.status === "stale"; + const isPreparing = message.status === "streaming"; + const returnComponent = () => { - if (messageEntry.toolCall !== undefined) { + if (message.type === "toolCall") { return (
    ); } - if (messageEntry.assistant !== undefined) { + if (message.type === "assistant") { return ( ); } - if (messageEntry.toolCallPrepareArguments !== undefined) { + if (message.type === "toolCallPrepare") { return ( ); } - if (messageEntry.user !== undefined) { + if (message.type === "user") { return ( ); } - return ; + return ; }; return <>{returnComponent()}; diff --git a/webapp/_webapp/src/hooks/useSendMessageStream.ts b/webapp/_webapp/src/hooks/useSendMessageStream.ts index 3801787b..a4fb3da3 100644 --- a/webapp/_webapp/src/hooks/useSendMessageStream.ts +++ b/webapp/_webapp/src/hooks/useSendMessageStream.ts @@ -1,196 +1,289 @@ -import { useCallback } from "react"; -import { - ConversationType, - CreateConversationMessageStreamRequest, - IncompleteIndicator, - ReasoningChunk, - StreamFinalization, -} from "../pkg/gen/apiclient/chat/v2/chat_pb"; -import { PlainMessage } from "../query/types"; -import { useStreamingMessageStore } from "../stores/streaming-message-store"; -import { getProjectId } from "../libs/helpers"; -import { withRetrySync } from "../libs/with-retry-sync"; +/** + * useSendMessageStream Hook + * + * A React hook for sending streaming messages in a conversation. + * + * This hook has been refactored as part of Phase 5 to: + * - Focus on orchestration, delegating event handling to the state machine + * - Use extracted utilities for request building and event mapping + * - Reduce the number of hook dependencies + * - Improve testability and maintainability + * + * Architecture: + * ``` + * useSendMessageStream (orchestrator) + * │ + * ├── buildStreamRequest() → Create API request + * │ + * ├── StreamingStateMachine.handleEvent() → Handle stream events + * │ + * └── mapResponseToStreamEvent() → Map API responses to events + * ``` + * + * @example + * ```tsx + * function ChatInput() { + * const { sendMessageStream, isStreaming } = useSendMessageStream(); + * + * const handleSend = async () => { + * await sendMessageStream(message, selectedText); + * }; + * } + * ``` + */ + +import { useCallback, useMemo, useRef } from "react"; import { createConversationMessageStream } from "../query/api"; -import { handleStreamInitialization } from "../stores/conversation/handlers/handleStreamInitialization"; -import { handleStreamPartBegin } from "../stores/conversation/handlers/handleStreamPartBegin"; -import { handleMessageChunk } from "../stores/conversation/handlers/handleMessageChunk"; -import { handleStreamPartEnd } from "../stores/conversation/handlers/handleStreamPartEnd"; -import { handleStreamFinalization } from "../stores/conversation/handlers/handleStreamFinalization"; -import { handleStreamError } from "../stores/conversation/handlers/handleStreamError"; -import { - MessageChunk, - MessageTypeUserSchema, - StreamError, - StreamInitialization, - StreamPartBegin, - StreamPartEnd, -} from "../pkg/gen/apiclient/chat/v2/chat_pb"; -import { MessageEntry, MessageEntryStatus } from "../stores/conversation/types"; -import { fromJson } from "../libs/protobuf-utils"; import { useConversationStore } from "../stores/conversation/conversation-store"; import { useListConversationsQuery } from "../query"; import { logError, logWarn } from "../libs/logger"; -import { handleError } from "../stores/conversation/handlers/handleError"; -import { handleIncompleteIndicator } from "../stores/conversation/handlers/handleIncompleteIndicator"; import { useAuthStore } from "../stores/auth-store"; import { useDevtoolStore } from "../stores/devtool-store"; import { useSelectionStore } from "../stores/selection-store"; import { useSettingStore } from "../stores/setting-store"; import { useSync } from "./useSync"; import { useAdapter } from "../adapters"; -import { handleReasoningChunk } from "../stores/conversation/handlers/handleReasoningChunk"; +import { getProjectId } from "../libs/helpers"; +import { + useStreamingStateMachine, + InternalMessage, + withStreamingErrorHandler, +} from "../stores/streaming"; +import { createUserMessage } from "../types/message"; +import { buildStreamRequest, StreamRequestParams } from "../utils/stream-request-builder"; +import { mapResponseToStreamEvent } from "../utils/stream-event-mapper"; + +// ============================================================================ +// Types +// ============================================================================ /** - * Custom React hook to handle sending a message as a stream in a conversation. - * - * This hook manages the process of sending a user message to the backend as a streaming request, - * handling all intermediate streaming events (initialization, message chunks, part begin/end, finalization, and errors). - * It updates the relevant stores for streaming and finalized messages, manages conversation state, - * and ensures proper synchronization with the backend (including Overleaf authentication). - * - * Usage: - * const { sendMessageStream } = useSendMessageStream(); - * await sendMessageStream(message, selectedText); - * - * @returns {Object} An object containing the sendMessageStream function. - * @returns {Function} sendMessageStream - Function to send a message as a stream. Accepts (message: string, selectedText: string) and returns a Promise. + * Return type for the useSendMessageStream hook. */ -export function useSendMessageStream() { +export interface UseSendMessageStreamResult { + /** Function to send a message as a stream */ + sendMessageStream: (message: string, selectedText: string, parentMessageId?: string) => Promise; + /** Whether a stream is currently active */ + isStreaming: boolean; +} + +// ============================================================================ +// Hook Implementation +// ============================================================================ + +export function useSendMessageStream(): UseSendMessageStreamResult { + // External dependencies const { sync } = useSync(); const { user } = useAuthStore(); const adapter = useAdapter(); - const { currentConversation } = useConversationStore(); - // Get project ID from adapter (supports both Overleaf URL and Word document ID) + // Conversation state + const currentConversation = useConversationStore((s) => s.currentConversation); const projectId = adapter.getDocumentId?.() || getProjectId(); const { refetch: refetchConversationList } = useListConversationsQuery(projectId); - const { resetStreamingMessage, updateStreamingMessage, resetIncompleteIndicator } = useStreamingMessageStore(); - const { surroundingText: storeSurroundingText } = useSelectionStore(); - const { alwaysSyncProject } = useDevtoolStore(); - const { conversationMode } = useSettingStore(); - const sendMessageStream = useCallback( - async (message: string, selectedText: string) => { - if (!message || !message.trim()) { + // Streaming state machine + const stateMachine = useStreamingStateMachine(); + const isStreaming = stateMachine.state !== "idle"; + + // Selection and settings + const surroundingText = useSelectionStore((s) => s.surroundingText); + const alwaysSyncProject = useDevtoolStore((s) => s.alwaysSyncProject); + const conversationMode = useSettingStore((s) => s.conversationMode); + + /** + * Add the user message to the streaming state. + */ + const addUserMessageToStream = useCallback( + (message: string, selectedText: string) => { + const newUserMessage: InternalMessage = createUserMessage( + `pending-${crypto.randomUUID()}`, + message, + { + selectedText, + surrounding: surroundingText ?? undefined, + status: "streaming", + } + ); + + useStreamingStateMachine.setState((state) => ({ + streamingMessage: { + parts: [...state.streamingMessage.parts, newUserMessage], + sequence: state.streamingMessage.sequence + 1, + }, + })); + }, + [surroundingText] + ); + + /** + * Truncate conversation for message editing. + */ + const truncateConversationIfEditing = useCallback( + (parentMessageId?: string) => { + if (!parentMessageId || currentConversation.messages.length === 0) return; + + if (parentMessageId === "root") { + // Clear all messages for "root" edit + useConversationStore.getState().updateCurrentConversation((prev) => ({ + ...prev, + messages: [], + })); + return; + } + + const parentIndex = currentConversation.messages.findIndex( + (m) => m.messageId === parentMessageId + ); + + if (parentIndex !== -1) { + // Truncate messages to include only up to parentMessage + useConversationStore.getState().updateCurrentConversation((prev) => ({ + ...prev, + messages: prev.messages.slice(0, parentIndex + 1), + })); + } + }, + [currentConversation.messages] + ); + + /** + * Main send message function. + */ + type SendMessageStreamImpl = ( + message: string, + selectedText: string, + parentMessageId?: string, + options?: { isRetry?: boolean } + ) => Promise; + + // Break circular hook dependencies for retry callbacks. + const sendMessageStreamImplRef = useRef(null); + + const sendMessageStreamImpl = useCallback( + async ( + message: string, + selectedText: string, + parentMessageId?: string, + options?: { isRetry?: boolean } + ) => { + // Validate input + if (!message?.trim()) { logWarn("No message to send"); return; } message = message.trim(); - const request: PlainMessage = { - projectId: projectId, + const requestParams: StreamRequestParams = { + message, + selectedText, + projectId, conversationId: currentConversation.id, modelSlug: currentConversation.modelSlug, - userMessage: message, - userSelectedText: selectedText, - surrounding: storeSurroundingText ?? undefined, - conversationType: conversationMode === "debug" ? ConversationType.DEBUG : ConversationType.UNSPECIFIED, + surroundingText: surroundingText ?? undefined, + conversationMode: conversationMode === "debug" ? "debug" : "default", }; - resetStreamingMessage(); // ensure no stale message in the streaming messages - resetIncompleteIndicator(); - - const newMessageEntry: MessageEntry = { - messageId: "dummy", - status: MessageEntryStatus.PREPARING, - user: fromJson(MessageTypeUserSchema, { - content: message, - selectedText: selectedText, - surrounding: storeSurroundingText ?? null, - }), - }; - updateStreamingMessage((prev) => ({ - ...prev, - parts: [...prev.parts, newMessageEntry], - sequence: prev.sequence + 1, - })); + // Build the API request + const request = buildStreamRequest(requestParams); + + // Reset state machine and prepare for new stream. + // For retries, avoid resetting the state machine (it would also reset retry counters and + // can cause infinite loops where attempt never increments). Also avoid duplicating the + // user's message in the UI. + if (!options?.isRetry) { + stateMachine.reset(); + truncateConversationIfEditing(parentMessageId); + addUserMessageToStream(message, selectedText); + } + // Optional: sync project in dev mode if (import.meta.env.DEV && alwaysSyncProject) { - // Platform-aware sync (Overleaf uses WebSocket, Word uses adapter.getFullText) await sync(); } - await withRetrySync( + // Execute the stream with error handling + await withStreamingErrorHandler( () => createConversationMessageStream(request, async (response) => { - switch (response.responsePayload.case) { - case "streamInitialization": // means the user message is received by the server, can change the status to FINALIZED - handleStreamInitialization( - response.responsePayload.value as StreamInitialization, - refetchConversationList, - ); - break; - case "streamPartBegin": - handleStreamPartBegin(response.responsePayload.value as StreamPartBegin, updateStreamingMessage); - break; - case "messageChunk": - handleMessageChunk(response.responsePayload.value as MessageChunk, updateStreamingMessage); - break; - case "streamPartEnd": - handleStreamPartEnd(response.responsePayload.value as StreamPartEnd, updateStreamingMessage); - break; - case "streamFinalization": - handleStreamFinalization(response.responsePayload.value as StreamFinalization); - break; - case "streamError": - await handleStreamError( - response.responsePayload.value as StreamError, - user?.id || "", - message, - selectedText, - sync, - sendMessageStream, - updateStreamingMessage, - ); - break; - case "incompleteIndicator": - handleIncompleteIndicator(response.responsePayload.value as IncompleteIndicator); - break; - case "reasoningChunk": - handleReasoningChunk(response.responsePayload.value as ReasoningChunk, updateStreamingMessage); - break; - default: { - if (response.responsePayload.value !== undefined) { - const _typeCheck: never = response.responsePayload; - throw new Error("Unexpected response payload: " + _typeCheck); - // DO NOT delete above line, it is used to check that all cases are handled. - } - break; - } + const event = mapResponseToStreamEvent(response); + if (event) { + await stateMachine.handleEvent(event, { + refetchConversationList, + userId: user?.id || "", + currentPrompt: message, + currentSelectedText: selectedText, + sync: async () => { + try { + const result = await sync(); + return result; + } catch (e) { + logError("Failed to sync project", e); + return { success: false, error: e instanceof Error ? e : new Error(String(e)) }; + } + }, + // IMPORTANT: pass a retry-aware sender so the state machine's sync-and-retry + // recovery doesn't reset itself back to attempt 1. + sendMessageStream: (m, s) => + sendMessageStreamImplRef.current?.(m, s, parentMessageId, { isRetry: true }) ?? + Promise.resolve(), + }); } }), { sync: async () => { try { - // Platform-aware sync (Overleaf uses WebSocket, Word uses adapter.getFullText) const result = await sync(); - if (!result.success) { - logError("Failed to sync project", result.error); - } + return result; } catch (e) { logError("Failed to sync project", e); + return { success: false, error: e instanceof Error ? e : new Error(String(e)) }; } }, onGiveUp: () => { - handleError(new Error("connection error.")); + stateMachine.handleEvent({ + type: "CONNECTION_ERROR", + payload: new Error("Connection error"), + }); }, - }, + context: { + currentPrompt: message, + currentSelectedText: selectedText, + userId: user?.id, + operation: "send-message", + }, + } ); }, + // Reduced dependencies: 5 main dependencies instead of 11 [ - resetStreamingMessage, - resetIncompleteIndicator, - updateStreamingMessage, + stateMachine, currentConversation, + projectId, refetchConversationList, sync, + // These are derived/stable and won't cause re-renders user?.id, alwaysSyncProject, conversationMode, - storeSurroundingText, - projectId, - ], + surroundingText, + addUserMessageToStream, + truncateConversationIfEditing, + ] ); - return { sendMessageStream }; + // Keep ref updated for retry callbacks. + sendMessageStreamImplRef.current = sendMessageStreamImpl; + + const sendMessageStream = useCallback( + async (message: string, selectedText: string, parentMessageId?: string) => { + return sendMessageStreamImpl(message, selectedText, parentMessageId, { isRetry: false }); + }, + [sendMessageStreamImpl] + ); + + return useMemo( + () => ({ sendMessageStream, isStreaming }), + [sendMessageStream, isStreaming] + ); } diff --git a/webapp/_webapp/src/stores/conversation/conversation-store.ts b/webapp/_webapp/src/stores/conversation/conversation-store.ts index 0b8378f6..dd8f7688 100644 --- a/webapp/_webapp/src/stores/conversation/conversation-store.ts +++ b/webapp/_webapp/src/stores/conversation/conversation-store.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; import { Conversation, ConversationSchema } from "../../pkg/gen/apiclient/chat/v2/chat_pb"; import { fromJson } from "../../libs/protobuf-utils"; import { useConversationUiStore } from "./conversation-ui-store"; @@ -12,15 +13,17 @@ interface ConversationStore { setIsStreaming: (isStreaming: boolean) => void; } -export const useConversationStore = create((set, get) => ({ - currentConversation: newConversation(), - setCurrentConversation: (conversation: Conversation) => set({ currentConversation: conversation }), - updateCurrentConversation: (updater: (conversation: Conversation) => Conversation) => - set({ currentConversation: updater(get().currentConversation) }), - startFromScratch: () => set({ currentConversation: newConversation() }), - isStreaming: false, - setIsStreaming: (isStreaming: boolean) => set({ isStreaming }), -})); +export const useConversationStore = create()( + subscribeWithSelector((set, get) => ({ + currentConversation: newConversation(), + setCurrentConversation: (conversation: Conversation) => set({ currentConversation: conversation }), + updateCurrentConversation: (updater: (conversation: Conversation) => Conversation) => + set({ currentConversation: updater(get().currentConversation) }), + startFromScratch: () => set({ currentConversation: newConversation() }), + isStreaming: false, + setIsStreaming: (isStreaming: boolean) => set({ isStreaming }), + })) +); export function newConversation(): Conversation { const modelSlug = useConversationUiStore.getState().lastUsedModelSlug; diff --git a/webapp/_webapp/src/stores/conversation/handlers/converter.ts b/webapp/_webapp/src/stores/conversation/handlers/converter.ts deleted file mode 100644 index eddb3fa8..00000000 --- a/webapp/_webapp/src/stores/conversation/handlers/converter.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { fromJson } from "../../../libs/protobuf-utils"; -import { Conversation, Message, MessageSchema } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; -import { MessageEntry, MessageEntryStatus } from "../types"; -import { useStreamingMessageStore } from "../../streaming-message-store"; -import { flushSync } from "react-dom"; -import { useConversationStore } from "../conversation-store"; - -export const convertMessageEntryToMessage = (messageEntry: MessageEntry): Message | undefined => { - if (messageEntry.assistant) { - const assistantPayload: { content: string; reasoning?: string } = { - content: messageEntry.assistant.content, - }; - if (messageEntry.assistant.reasoning) { - assistantPayload.reasoning = messageEntry.assistant.reasoning; - } - return fromJson(MessageSchema, { - messageId: messageEntry.messageId, - payload: { - assistant: assistantPayload, - }, - }); - } else if (messageEntry.toolCall) { - return fromJson(MessageSchema, { - messageId: messageEntry.messageId, - payload: { - toolCall: { - name: messageEntry.toolCall.name, - args: messageEntry.toolCall.args, - result: messageEntry.toolCall.result, - error: messageEntry.toolCall.error, - }, - }, - }); - } else if (messageEntry.user) { - return fromJson(MessageSchema, { - messageId: messageEntry.messageId, - payload: { - user: { - content: messageEntry.user.content, - selectedText: messageEntry.user.selectedText ?? "", - }, - }, - }); - } - return undefined; -}; - -export const flushStreamingMessageToConversation = (conversationId?: string, modelSlug?: string) => { - const flushMessages = useStreamingMessageStore - .getState() - .streamingMessage.parts.map((part) => { - if (part.status === MessageEntryStatus.FINALIZED) { - return convertMessageEntryToMessage(part); - } else { - return null; - } - }) - .filter((part) => { - return part !== null && part !== undefined; - }) as Message[]; - - flushSync(() => { - useConversationStore.getState().updateCurrentConversation((prev: Conversation) => ({ - ...prev, - id: conversationId ?? prev.id, - modelSlug: modelSlug ?? prev.modelSlug, - messages: [...prev.messages, ...flushMessages], - })); - }); - - useStreamingMessageStore.getState().resetStreamingMessage(); - // Do not reset incomplete indicator here, it will be reset in useSendMessageStream -}; diff --git a/webapp/_webapp/src/stores/conversation/handlers/handleError.ts b/webapp/_webapp/src/stores/conversation/handlers/handleError.ts deleted file mode 100644 index 9d36100a..00000000 --- a/webapp/_webapp/src/stores/conversation/handlers/handleError.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { logError } from "../../../libs/logger"; -import { useStreamingMessageStore } from "../../streaming-message-store"; -import { MessageEntry, MessageEntryStatus } from "../types"; - -export function handleError(error?: Error) { - useStreamingMessageStore.getState().updateStreamingMessage((prev) => { - const newParts = prev.parts.map((part: MessageEntry) => { - return { - ...part, - status: part.status === MessageEntryStatus.PREPARING ? MessageEntryStatus.STALE : part.status, - }; - }); - return { - ...prev, - parts: newParts, - sequence: prev.sequence + 1, - }; - }); - logError("handleError", error); -} diff --git a/webapp/_webapp/src/stores/conversation/handlers/handleIncompleteIndicator.ts b/webapp/_webapp/src/stores/conversation/handlers/handleIncompleteIndicator.ts deleted file mode 100644 index 57513d9f..00000000 --- a/webapp/_webapp/src/stores/conversation/handlers/handleIncompleteIndicator.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IncompleteIndicator } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; -import { useStreamingMessageStore } from "../../streaming-message-store"; - -export function handleIncompleteIndicator(incompleteIndicator: IncompleteIndicator) { - useStreamingMessageStore.getState().setIncompleteIndicator(incompleteIndicator); -} diff --git a/webapp/_webapp/src/stores/conversation/handlers/handleMessageChunk.ts b/webapp/_webapp/src/stores/conversation/handlers/handleMessageChunk.ts deleted file mode 100644 index 020cfb13..00000000 --- a/webapp/_webapp/src/stores/conversation/handlers/handleMessageChunk.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { logError } from "../../../libs/logger"; -import { MessageChunk, MessageTypeAssistant } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; -import { StreamingMessage } from "../../streaming-message-store"; -import { MessageEntry, MessageEntryStatus } from "../types"; - -export function handleMessageChunk( - chunk: MessageChunk, - updateStreamingMessage: (updater: (prev: StreamingMessage) => StreamingMessage) => void, -) { - updateStreamingMessage((prevMessage) => { - const updatedParts = prevMessage.parts.map((part: MessageEntry) => { - const isTargetPart = part.messageId === chunk.messageId && part.assistant; - - if (!isTargetPart) return part; - - const updatedAssistant: MessageTypeAssistant = { - ...part.assistant!, - content: part.assistant!.content + chunk.delta, - }; - - if (part.status !== MessageEntryStatus.PREPARING) { - logError("Message chunk received for non-preparing part, this is a critical error"); - } - - return { - ...part, - assistant: updatedAssistant, - }; - }); - - return { - ...prevMessage, - parts: updatedParts, - sequence: prevMessage.sequence + 1, - }; - }); -} diff --git a/webapp/_webapp/src/stores/conversation/handlers/handleReasoningChunk.ts b/webapp/_webapp/src/stores/conversation/handlers/handleReasoningChunk.ts index 62e08a9f..5e37f30d 100644 --- a/webapp/_webapp/src/stores/conversation/handlers/handleReasoningChunk.ts +++ b/webapp/_webapp/src/stores/conversation/handlers/handleReasoningChunk.ts @@ -1,38 +1,38 @@ -import { logError } from "../../../libs/logger"; -import { ReasoningChunk, MessageTypeAssistant } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; -import { StreamingMessage } from "../../streaming-message-store"; -import { MessageEntry, MessageEntryStatus } from "../types"; +// import { logError } from "../../../libs/logger"; +// import { ReasoningChunk, MessageTypeAssistant } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; +// import { StreamingMessage } from "../../streaming-message-store"; +// import { MessageEntry, MessageEntryStatus } from "../types"; -export function handleReasoningChunk( - chunk: ReasoningChunk, - updateStreamingMessage: (updater: (prev: StreamingMessage) => StreamingMessage) => void, -) { - updateStreamingMessage((prevMessage) => { - const updatedParts = prevMessage.parts.map((part: MessageEntry) => { - const isTargetPart = part.messageId === chunk.messageId && part.assistant; +// export function handleReasoningChunk( +// chunk: ReasoningChunk, +// updateStreamingMessage: (updater: (prev: StreamingMessage) => StreamingMessage) => void, +// ) { +// updateStreamingMessage((prevMessage) => { +// const updatedParts = prevMessage.parts.map((part: MessageEntry) => { +// const isTargetPart = part.messageId === chunk.messageId && part.assistant; - if (!isTargetPart) return part; +// if (!isTargetPart) return part; - const currentReasoning = part.assistant!.reasoning ?? ""; - const updatedAssistant: MessageTypeAssistant = { - ...part.assistant!, - reasoning: currentReasoning + chunk.delta, - }; +// const currentReasoning = part.assistant!.reasoning ?? ""; +// const updatedAssistant: MessageTypeAssistant = { +// ...part.assistant!, +// reasoning: currentReasoning + chunk.delta, +// }; - if (part.status !== MessageEntryStatus.PREPARING) { - logError("Reasoning chunk received for non-preparing part, this is a critical error"); - } +// if (part.status !== MessageEntryStatus.PREPARING) { +// logError("Reasoning chunk received for non-preparing part, this is a critical error"); +// } - return { - ...part, - assistant: updatedAssistant, - }; - }); +// return { +// ...part, +// assistant: updatedAssistant, +// }; +// }); - return { - ...prevMessage, - parts: updatedParts, - sequence: prevMessage.sequence + 1, - }; - }); -} +// return { +// ...prevMessage, +// parts: updatedParts, +// sequence: prevMessage.sequence + 1, +// }; +// }); +// } diff --git a/webapp/_webapp/src/stores/conversation/handlers/handleStreamError.ts b/webapp/_webapp/src/stores/conversation/handlers/handleStreamError.ts deleted file mode 100644 index 1c7ed210..00000000 --- a/webapp/_webapp/src/stores/conversation/handlers/handleStreamError.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { MessageTypeAssistantSchema, StreamError } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; -import { errorToast } from "../../../libs/toasts"; -import { StreamingMessage } from "../../streaming-message-store"; -import { MessageEntry, MessageEntryStatus } from "../types"; -import { fromJson } from "../../../libs/protobuf-utils"; - -interface SyncResult { - success: boolean; - error?: Error; -} - -export async function handleStreamError( - streamError: StreamError, - _userId: string, // Kept for API compatibility, sync handles user internally - currentPrompt: string, - currentSelectedText: string, - sync: () => Promise, - sendMessageStream: (message: string, selectedText: string) => Promise, - updateStreamingMessage: (updater: (prev: StreamingMessage) => StreamingMessage) => void, -) { - // Append an error message to the streaming message - const updateFunc = (prev: StreamingMessage) => { - const errorMessageEntry: MessageEntry = { - messageId: "error-" + Date.now(), - status: MessageEntryStatus.STALE, - assistant: fromJson(MessageTypeAssistantSchema, { - content: `${streamError.errorMessage}`, - }), - }; - return { - ...prev, - parts: [...prev.parts, errorMessageEntry], - }; - }; - - try { - if (streamError.errorMessage.includes("project is out of date")) { - // Platform-aware sync (Overleaf uses WebSocket, Word uses adapter.getFullText) - const result = await sync(); - if (!result.success) { - throw result.error || new Error("Sync failed"); - } - // Retry sending the message after sync - await sendMessageStream(currentPrompt, currentSelectedText); - } else { - updateStreamingMessage(updateFunc); - errorToast(streamError.errorMessage, "Chat Stream Error"); - } - } catch (error) { - updateStreamingMessage(updateFunc); - errorToast(error instanceof Error ? error.message : "Unknown error", "Chat Stream Error"); - } -} diff --git a/webapp/_webapp/src/stores/conversation/handlers/handleStreamFinalization.ts b/webapp/_webapp/src/stores/conversation/handlers/handleStreamFinalization.ts deleted file mode 100644 index be08d272..00000000 --- a/webapp/_webapp/src/stores/conversation/handlers/handleStreamFinalization.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { StreamFinalization } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; -import { flushStreamingMessageToConversation } from "./converter"; - -export function handleStreamFinalization(_finalization: StreamFinalization) { - flushStreamingMessageToConversation(_finalization.conversationId); -} diff --git a/webapp/_webapp/src/stores/conversation/handlers/handleStreamInitialization.ts b/webapp/_webapp/src/stores/conversation/handlers/handleStreamInitialization.ts deleted file mode 100644 index c6b84eff..00000000 --- a/webapp/_webapp/src/stores/conversation/handlers/handleStreamInitialization.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { StreamInitialization } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; -import { useStreamingMessageStore } from "../../streaming-message-store"; -import { MessageEntryStatus } from "../types"; -import { logWarn } from "../../../libs/logger"; -import { flushStreamingMessageToConversation } from "./converter"; - -export function handleStreamInitialization(streamInit: StreamInitialization, refetchConversationList: () => void) { - useStreamingMessageStore.setState((prev) => ({ - ...prev, - streamingMessage: { - ...prev.streamingMessage, - parts: prev.streamingMessage.parts.map((part) => { - if (part.status === MessageEntryStatus.PREPARING && part.user) { - return { - ...part, - status: MessageEntryStatus.FINALIZED, - }; - } - return part; - }), - }, - })); - if (useStreamingMessageStore.getState().streamingMessage.parts.length !== 1) { - logWarn("Streaming message parts length is not 1, this may indicate some stale messages in the store"); - } - - flushStreamingMessageToConversation(streamInit.conversationId, streamInit.modelSlug); - refetchConversationList(); // Here we refetch conversation list because user may send chat message and immediately open history to view. -} diff --git a/webapp/_webapp/src/stores/conversation/handlers/handleStreamPartBegin.ts b/webapp/_webapp/src/stores/conversation/handlers/handleStreamPartBegin.ts deleted file mode 100644 index caa65b19..00000000 --- a/webapp/_webapp/src/stores/conversation/handlers/handleStreamPartBegin.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { StreamPartBegin } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; -import { StreamingMessage } from "../../streaming-message-store"; -import { MessageEntry, MessageEntryStatus } from "../types"; -import { logError } from "../../../libs/logger"; - -export function handleStreamPartBegin( - partBegin: StreamPartBegin, - updateStreamingMessage: (updater: (prev: StreamingMessage) => StreamingMessage) => void, -) { - const role = partBegin.payload?.messageType.case; - if (role === "assistant") { - const newMessageEntry: MessageEntry = { - messageId: partBegin.messageId, - status: MessageEntryStatus.PREPARING, - assistant: partBegin.payload?.messageType.value, - }; - updateStreamingMessage((prev) => { - // Skip if entry with same messageId already exists (prevents duplicate keys) - if (prev.parts.some((p) => p.messageId === partBegin.messageId)) { - return prev; - } - return { - parts: [...prev.parts, newMessageEntry], - sequence: prev.sequence + 1, - }; - }); - } else if (role === "toolCallPrepareArguments") { - const newMessageEntry: MessageEntry = { - messageId: partBegin.messageId, - status: MessageEntryStatus.PREPARING, - toolCallPrepareArguments: partBegin.payload?.messageType.value, - }; - updateStreamingMessage((prev) => { - // Skip if entry with same messageId already exists (prevents duplicate keys) - if (prev.parts.some((p) => p.messageId === partBegin.messageId)) { - return prev; - } - return { - parts: [...prev.parts, newMessageEntry], - sequence: prev.sequence + 1, - }; - }); - } else if (role === "toolCall") { - const newMessageEntry: MessageEntry = { - messageId: partBegin.messageId, - status: MessageEntryStatus.PREPARING, - toolCall: partBegin.payload?.messageType.value, - }; - updateStreamingMessage((prev) => { - // Skip if entry with same messageId already exists (prevents duplicate keys) - if (prev.parts.some((p) => p.messageId === partBegin.messageId)) { - return prev; - } - return { - parts: [...prev.parts, newMessageEntry], - sequence: prev.sequence + 1, - }; - }); - } else if (role === "system") { - // not possible - } else if (role === "user") { - // not possible - } else if (role === "unknown") { - // not possible - } else { - if (role !== undefined) { - const _typeCheck: never = role; - throw new Error("Unexpected response payload: " + _typeCheck); - // DO NOT delete above line, it is used to check that all cases are handled. - } - logError("unknown role in streamPartEnd:", role); - } -} diff --git a/webapp/_webapp/src/stores/conversation/handlers/handleStreamPartEnd.ts b/webapp/_webapp/src/stores/conversation/handlers/handleStreamPartEnd.ts deleted file mode 100644 index 46e6bb10..00000000 --- a/webapp/_webapp/src/stores/conversation/handlers/handleStreamPartEnd.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - MessageTypeAssistant, - MessageTypeToolCall, - MessageTypeToolCallPrepareArguments, - StreamPartEnd, -} from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; -import { StreamingMessage } from "../../streaming-message-store"; -import { logError } from "../../../libs/logger"; -import { MessageEntryStatus } from "../types"; - -export function handleStreamPartEnd( - partEnd: StreamPartEnd, - updateStreamingMessage: (updater: (prev: StreamingMessage) => StreamingMessage) => void, -) { - const role = partEnd.payload?.messageType.case; - switch (role) { - case "assistant": { - updateStreamingMessage((prev) => { - const newParts = prev.parts.map((part) => { - if (part.messageId === partEnd.messageId) { - const assistantMessage = partEnd.payload?.messageType.value as MessageTypeAssistant; - return { - ...part, - status: MessageEntryStatus.FINALIZED, - assistant: assistantMessage, - }; - } - return part; - }); - return { - ...prev, - parts: newParts, - sequence: prev.sequence + 1, - }; - }); - break; - } - case "toolCallPrepareArguments": { - updateStreamingMessage((prev) => { - const newParts = prev.parts.map((part) => { - if (part.messageId === partEnd.messageId) { - const toolCallPrepareArguments = partEnd.payload?.messageType.value as MessageTypeToolCallPrepareArguments; - return { - ...part, - status: MessageEntryStatus.FINALIZED, - toolCallPrepareArguments: toolCallPrepareArguments, - }; - } - return part; - }); - return { - ...prev, - parts: newParts, - sequence: prev.sequence + 1, - }; - }); - break; - } - case "toolCall": { - updateStreamingMessage((prev) => { - const newParts = prev.parts.map((part) => { - const toolCall = partEnd.payload?.messageType.value as MessageTypeToolCall; - if (part.messageId === partEnd.messageId) { - return { - ...part, - status: MessageEntryStatus.FINALIZED, - toolCall: toolCall, - }; - } - return part; - }); - return { - ...prev, - parts: newParts, - sequence: prev.sequence + 1, - }; - }); - break; - } - case "system": { - break; - } - case "unknown": { - break; - } - case "user": { - break; - } - default: { - if (role !== undefined) { - const _typeCheck: never = role; - throw new Error("Unexpected response payload: " + _typeCheck); - // DO NOT delete above line, it is used to check that all cases are handled. - } - logError("unknown role in streamPartEnd:", role); - break; - } - } -} diff --git a/webapp/_webapp/src/stores/conversation/types.ts b/webapp/_webapp/src/stores/conversation/types.ts index 273f291f..319e7fd7 100644 --- a/webapp/_webapp/src/stores/conversation/types.ts +++ b/webapp/_webapp/src/stores/conversation/types.ts @@ -1,25 +1,8 @@ -import { - MessageTypeAssistant, - MessageTypeToolCall, - MessageTypeToolCallPrepareArguments, - MessageTypeUnknown, - MessageTypeUser, -} from "../../pkg/gen/apiclient/chat/v2/chat_pb"; +/** + * Conversation Types (Backward Compatibility Layer) + * + * This file now re-exports types from the streaming module for backward compatibility. + * For new code, prefer importing directly from '../streaming'. + */ -export enum MessageEntryStatus { - PREPARING = "PREPARING", - FINALIZED = "FINALIZED", // received "part end" or "stream finalization" - INCOMPLETE = "INCOMPLETE", // received "incomplete indicator" - STALE = "STALE", // if network shutdown or server crash. -} - -export type MessageEntry = { - messageId: string; - status: MessageEntryStatus; - // roles - user?: MessageTypeUser; - assistant?: MessageTypeAssistant; - toolCallPrepareArguments?: MessageTypeToolCallPrepareArguments; - toolCall?: MessageTypeToolCall; - unknown?: MessageTypeUnknown; -}; +export type { InternalMessage, MessageStatus } from "../streaming/types"; diff --git a/webapp/_webapp/src/stores/converters.ts b/webapp/_webapp/src/stores/converters.ts new file mode 100644 index 00000000..862fcd50 --- /dev/null +++ b/webapp/_webapp/src/stores/converters.ts @@ -0,0 +1,73 @@ +/** + * Message Converters + * + * Bidirectional converters between protobuf Message, InternalMessage, and DisplayMessage types. + * These provide the bridge between API types and UI types. + */ + +import { Message } from "../pkg/gen/apiclient/chat/v2/chat_pb"; +import { InternalMessage } from "./streaming/types"; +import { DisplayMessage } from "./types"; +import { + fromApiMessage, + toDisplayMessage, +} from "../utils/message-converters"; + +// ============================================================================ +// Message → DisplayMessage (for finalized messages from server) +// ============================================================================ + +/** + * Convert a finalized Message to DisplayMessage. + * Uses the unified converter pipeline: Message → InternalMessage → DisplayMessage + * + * @returns DisplayMessage or null if the message type is not displayable + */ +export function messageToDisplayMessage(msg: Message): DisplayMessage | null { + // Use the new unified converter: API Message → InternalMessage → DisplayMessage + const internalMsg = fromApiMessage(msg); + if (!internalMsg) return null; + return toDisplayMessage(internalMsg); +} + +// ============================================================================ +// InternalMessage → DisplayMessage (for streaming messages) +// ============================================================================ + +/** + * Convert an InternalMessage to DisplayMessage. + * This is the primary converter for both API and streaming messages. + * + * @returns DisplayMessage or null if the message type is not displayable + */ +export function internalMessageToDisplayMessage(msg: InternalMessage): DisplayMessage | null { + return toDisplayMessage(msg); +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Check if a DisplayMessage should be visible in the chat. + * Filters out empty messages and system messages. + */ +export function isDisplayableMessage(msg: DisplayMessage): boolean { + if (msg.type === "user") { + return msg.content.length > 0; + } + if (msg.type === "assistant") { + return msg.content.length > 0 || (msg.reasoning?.length ?? 0) > 0; + } + if (msg.type === "toolCall" || msg.type === "toolCallPrepare") { + return true; + } + return false; +} + +/** + * Filter display messages to only include visible ones. + */ +export function filterDisplayMessages(messages: DisplayMessage[]): DisplayMessage[] { + return messages.filter(isDisplayableMessage); +} diff --git a/webapp/_webapp/src/stores/message-store.ts b/webapp/_webapp/src/stores/message-store.ts new file mode 100644 index 00000000..7707dc03 --- /dev/null +++ b/webapp/_webapp/src/stores/message-store.ts @@ -0,0 +1,307 @@ +/** + * Unified Message Store + * + * This store consolidates message state management by combining: + * - Finalized messages (from conversation-store) + * - Streaming entries (from streaming-state-machine) + * + * Benefits: + * - Single source of truth for all messages + * - Unified DisplayMessage type for UI components + * - No flushSync needed - uses natural React batching + * - Automatically synced with conversation-store and streaming-state-machine + * + * Architecture: + * - This store subscribes to useConversationStore for finalized messages + * - This store subscribes to useStreamingStateMachine for streaming entries + * - UI components only need to use this store for all message rendering + */ + +import { create } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; +import { Message, Conversation } from "../pkg/gen/apiclient/chat/v2/chat_pb"; +import { InternalMessage } from "./streaming/types"; +import { DisplayMessage } from "./types"; +import { + messageToDisplayMessage, + internalMessageToDisplayMessage, + filterDisplayMessages, +} from "./converters"; +import { useConversationStore } from "./conversation/conversation-store"; +import { useStreamingStateMachine } from "./streaming"; + +// ============================================================================ +// Store State Interface +// ============================================================================ + +interface MessageStoreState { + // Finalized messages from server (synced from conversation-store) + messages: Message[]; + + // Currently streaming entries (synced from streaming-state-machine) + streamingEntries: InternalMessage[]; + + // Conversation metadata (synced from conversation-store) + conversationId: string; + modelSlug: string; + + // Computed display messages (updated when messages or streamingEntries change) + allDisplayMessages: DisplayMessage[]; + visibleDisplayMessages: DisplayMessage[]; + + // Flag to track if subscriptions are initialized + _subscriptionsInitialized: boolean; +} + +interface MessageStoreActions { + // Message management (used by subscriptions) + setMessages: (messages: Message[]) => void; + setConversation: (conversation: Conversation) => void; + + // Streaming entry management (used by subscriptions) + setStreamingEntries: (entries: InternalMessage[]) => void; + + // Initialize subscriptions to source stores + initializeSubscriptions: () => void; + + // Reset + reset: () => void; + resetStreaming: () => void; +} + +interface MessageStoreSelectors { + // Computed selectors + getAllDisplayMessages: () => DisplayMessage[]; + getVisibleDisplayMessages: () => DisplayMessage[]; + hasStreamingMessages: () => boolean; + isWaitingForResponse: () => boolean; + hasStaleMessages: () => boolean; +} + +export type MessageStore = MessageStoreState & + MessageStoreActions & + MessageStoreSelectors; + +// ============================================================================ +// Initial State +// ============================================================================ + +const initialState: MessageStoreState = { + messages: [], + streamingEntries: [], + conversationId: "", + modelSlug: "", + allDisplayMessages: [], + visibleDisplayMessages: [], + _subscriptionsInitialized: false, +}; + +// ============================================================================ +// Helper: Compute Display Messages +// ============================================================================ + +function computeDisplayMessages( + messages: Message[], + streamingEntries: InternalMessage[] +): { all: DisplayMessage[]; visible: DisplayMessage[] } { + // Convert finalized messages + const finalizedDisplayMessages = messages + .map(messageToDisplayMessage) + .filter((m): m is DisplayMessage => m !== null); + + // Convert streaming entries + const streamingDisplayMessages = streamingEntries + .map(internalMessageToDisplayMessage) + .filter((m): m is DisplayMessage => m !== null); + + // Combine: finalized first, then streaming + const all = [...finalizedDisplayMessages, ...streamingDisplayMessages]; + const visible = filterDisplayMessages(all); + + return { all, visible }; +} + +// ============================================================================ +// Store Implementation +// ============================================================================ + +export const useMessageStore = create()( + subscribeWithSelector((set, get) => ({ + ...initialState, + + // ======================================================================== + // Message Management (synced from conversation-store) + // ======================================================================== + + setMessages: (messages: Message[]) => { + const { all, visible } = computeDisplayMessages(messages, get().streamingEntries); + set({ + messages, + allDisplayMessages: all, + visibleDisplayMessages: visible, + }); + }, + + setConversation: (conversation: Conversation) => { + const { all, visible } = computeDisplayMessages(conversation.messages, get().streamingEntries); + set({ + messages: conversation.messages, + conversationId: conversation.id, + modelSlug: conversation.modelSlug, + allDisplayMessages: all, + visibleDisplayMessages: visible, + }); + }, + + // ======================================================================== + // Streaming Entry Management (synced from streaming-state-machine) + // ======================================================================== + + setStreamingEntries: (entries: InternalMessage[]) => { + const { all, visible } = computeDisplayMessages(get().messages, entries); + set({ + streamingEntries: entries, + allDisplayMessages: all, + visibleDisplayMessages: visible, + }); + }, + + // ======================================================================== + // Subscription Initialization + // ======================================================================== + + initializeSubscriptions: () => { + if (get()._subscriptionsInitialized) return; + + // Subscribe to conversation-store for finalized messages + useConversationStore.subscribe( + (state) => state.currentConversation, + (conversation) => { + get().setConversation(conversation); + }, + { fireImmediately: true } + ); + + // Subscribe to streaming-state-machine for streaming entries + useStreamingStateMachine.subscribe( + (state) => state.streamingMessage, + (streamingMessage) => { + get().setStreamingEntries(streamingMessage.parts); + }, + { fireImmediately: true } + ); + + set({ _subscriptionsInitialized: true }); + }, + + // ======================================================================== + // Reset + // ======================================================================== + + reset: () => { + set({ + messages: [], + streamingEntries: [], + conversationId: "", + modelSlug: "", + allDisplayMessages: [], + visibleDisplayMessages: [], + // Keep subscriptions initialized + }); + }, + + resetStreaming: () => { + const { all, visible } = computeDisplayMessages(get().messages, []); + set({ + streamingEntries: [], + allDisplayMessages: all, + visibleDisplayMessages: visible, + }); + }, + + // ======================================================================== + // Computed Selectors (return cached values) + // ======================================================================== + + getAllDisplayMessages: () => { + return get().allDisplayMessages; + }, + + getVisibleDisplayMessages: () => { + return get().visibleDisplayMessages; + }, + + hasStreamingMessages: () => { + return get().streamingEntries.length > 0; + }, + + isWaitingForResponse: () => { + const state = get(); + const lastStreaming = state.streamingEntries.at(-1); + const lastFinalized = state.messages.at(-1); + + // Waiting if last streaming entry is a user message + if (lastStreaming?.role === "user") { + return true; + } + + // Waiting if last finalized is user and no streaming entries + if ( + lastFinalized?.payload?.messageType.case === "user" && + state.streamingEntries.length === 0 + ) { + return true; + } + + return false; + }, + + hasStaleMessages: () => { + return get().streamingEntries.some( + (entry) => entry.status === "stale" + ); + }, + })) +); + +// ============================================================================ +// Convenience Selectors +// ============================================================================ + +export const selectAllDisplayMessages = (state: MessageStore) => + state.allDisplayMessages; + +export const selectVisibleDisplayMessages = (state: MessageStore) => + state.visibleDisplayMessages; + +export const selectHasStreamingMessages = (state: MessageStore) => + state.hasStreamingMessages(); + +export const selectIsWaitingForResponse = (state: MessageStore) => + state.isWaitingForResponse(); + +export const selectHasStaleMessages = (state: MessageStore) => + state.hasStaleMessages(); + +export const selectConversationId = (state: MessageStore) => state.conversationId; + +export const selectModelSlug = (state: MessageStore) => state.modelSlug; + +// ============================================================================ +// Store Initialization +// ============================================================================ + +/** + * Initialize the message store subscriptions. + * This should be called once at app startup to sync the message store + * with the conversation store and streaming state machine. + * + * Can be called multiple times safely - will only initialize once. + */ +export function initializeMessageStore(): void { + useMessageStore.getState().initializeSubscriptions(); +} + +// Auto-initialize when this module is first imported +// This ensures subscriptions are set up before any component renders +initializeMessageStore(); diff --git a/webapp/_webapp/src/stores/streaming-message-store.ts b/webapp/_webapp/src/stores/streaming-message-store.ts deleted file mode 100644 index a7c12f08..00000000 --- a/webapp/_webapp/src/stores/streaming-message-store.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Store "every streaming messages" occurred in the stream. - -import { create } from "zustand"; -import { MessageEntry } from "./conversation/types"; -import { flushSync } from "react-dom"; -import { IncompleteIndicator } from "../pkg/gen/apiclient/chat/v2/chat_pb"; -import { SetterResetterStore } from "./types"; - -export type StreamingMessage = { - parts: MessageEntry[]; - sequence: number; -}; - -type CoreState = { - streamingMessage: StreamingMessage; - incompleteIndicator: IncompleteIndicator | null; -}; - -type StreamingMessageState = SetterResetterStore; - -export const useStreamingMessageStore = create((set) => ({ - streamingMessage: { parts: [], sequence: 0 }, - setStreamingMessage: (message) => set({ streamingMessage: message }), - resetStreamingMessage: () => - set({ - streamingMessage: { parts: [], sequence: 0 }, - }), - updateStreamingMessage: (updater) => { - // force React to synchronously flush any pending updates and - // re-render the component immediately after each store update, rather than batching them together. - flushSync(() => { - set((state) => { - const newState = updater(state.streamingMessage); - return { streamingMessage: newState }; - }); - }); - }, - - incompleteIndicator: null, - setIncompleteIndicator: (incompleteIndicator) => { - set({ incompleteIndicator }); - }, - resetIncompleteIndicator: () => set({ incompleteIndicator: null }), - updateIncompleteIndicator: (updater) => { - set((state) => { - const newState = updater(state.incompleteIndicator); - return { incompleteIndicator: newState }; - }); - }, -})); diff --git a/webapp/_webapp/src/stores/streaming/__tests__/error-hander.test.ts b/webapp/_webapp/src/stores/streaming/__tests__/error-hander.test.ts new file mode 100644 index 00000000..652ccb52 --- /dev/null +++ b/webapp/_webapp/src/stores/streaming/__tests__/error-hander.test.ts @@ -0,0 +1,477 @@ +/** + * Unit Tests for Streaming Error Handler + * + * Tests error handling, recovery strategies, and retry logic. + */ + +import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"; +import { + createStreamingError, + getRecoveryStrategy, + StreamingErrorHandler, + isRetryableError, + handleStreamingError, + withStreamingErrorHandler, +} from "../error-handler"; +import { StreamingError, StreamingErrorCode, RecoveryStrategy } from "../types"; + +// Mock toast and logger +const mockErrorToast = mock(() => {}); +mock.module("../../../libs/toasts", () => ({ + errorToast: mockErrorToast, +})); + +const mockLogError = mock(() => {}); +const mockLogWarn = mock(() => {}); +const mockLogInfo = mock(() => {}); +mock.module("../../../libs/logger", () => ({ + logError: mockLogError, + logWarn: mockLogWarn, + logInfo: mockLogInfo, +})); + +describe("Error Handler", () => { + beforeEach(() => { + mockErrorToast.mockClear(); + mockLogError.mockClear(); + mockLogWarn.mockClear(); + mockLogInfo.mockClear(); + }); + + describe("createStreamingError", () => { + it("should create error from string", () => { + const error = createStreamingError("Something went wrong"); + + expect(error.message).toBe("Something went wrong"); + expect(error.code).toBe("UNKNOWN"); + expect(error.retryable).toBe(false); + expect(error.timestamp).toBeGreaterThan(0); + }); + + it("should detect PROJECT_OUT_OF_DATE from message", () => { + const error = createStreamingError("project is out of date"); + + expect(error.code).toBe("PROJECT_OUT_OF_DATE"); + expect(error.retryable).toBe(true); + }); + + it("should detect NETWORK_ERROR from message", () => { + const error = createStreamingError("Network connection failed"); + + expect(error.code).toBe("NETWORK_ERROR"); + expect(error.retryable).toBe(true); + }); + + it("should detect TIMEOUT from message", () => { + const error = createStreamingError("Request timed out"); + + expect(error.code).toBe("TIMEOUT"); + expect(error.retryable).toBe(true); + }); + + it("should detect RATE_LIMITED from message", () => { + const error = createStreamingError("Rate limit exceeded, too many requests"); + + expect(error.code).toBe("RATE_LIMITED"); + expect(error.retryable).toBe(true); + }); + + it("should detect AUTHENTICATION_ERROR from message", () => { + const error = createStreamingError("Unauthorized: invalid token"); + + expect(error.code).toBe("AUTHENTICATION_ERROR"); + expect(error.retryable).toBe(false); + }); + + it("should detect SERVER_ERROR from message", () => { + const error = createStreamingError("Internal server error"); + + expect(error.code).toBe("SERVER_ERROR"); + expect(error.retryable).toBe(true); + }); + + it("should create error from Error object", () => { + const originalError = new Error("Network error occurred"); + const error = createStreamingError(originalError); + + expect(error.message).toBe("Network error occurred"); + expect(error.code).toBe("NETWORK_ERROR"); + expect(error.originalError).toBe(originalError); + }); + + it("should create error from RequestError with code", () => { + // Simulate a protobuf RequestError + const requestError = { + code: 10, // PROJECT_OUT_OF_DATE + message: "Project version mismatch", + }; + + const error = createStreamingError(requestError); + + expect(error.message).toBe("Project version mismatch"); + expect(error.originalError).toBe(requestError); + }); + + it("should handle null/undefined gracefully", () => { + const error = createStreamingError(null); + + expect(error.code).toBe("UNKNOWN"); + expect(error.message).toBe("null"); + }); + + it("should use default code when message doesn't match known patterns", () => { + // When error message doesn't match any known pattern, + // detectErrorCodeFromMessage returns UNKNOWN (not the default code) + // This is because the string "Something" doesn't contain any error keywords + const error = createStreamingError("Something", "SERVER_ERROR"); + + // The function detects from message first, defaultCode is only used for unknown error types + expect(error.code).toBe("UNKNOWN"); + }); + }); + + describe("getRecoveryStrategy", () => { + const testCases: Array<{ + code: StreamingErrorCode; + expectedType: RecoveryStrategy["type"]; + }> = [ + { code: "PROJECT_OUT_OF_DATE", expectedType: "sync-and-retry" }, + { code: "NETWORK_ERROR", expectedType: "retry" }, + { code: "TIMEOUT", expectedType: "retry" }, + { code: "RATE_LIMITED", expectedType: "retry" }, + { code: "SERVER_ERROR", expectedType: "retry" }, + { code: "INVALID_RESPONSE", expectedType: "show-error" }, + { code: "AUTHENTICATION_ERROR", expectedType: "show-error" }, + { code: "UNKNOWN", expectedType: "show-error" }, + ]; + + for (const { code, expectedType } of testCases) { + it(`should return ${expectedType} strategy for ${code}`, () => { + const error: StreamingError = { + code, + message: "Test error", + retryable: false, + timestamp: Date.now(), + }; + + const strategy = getRecoveryStrategy(error); + + expect(strategy.type).toBe(expectedType); + }); + } + + it("should have correct maxRetries for retry strategies", () => { + const networkError: StreamingError = { + code: "NETWORK_ERROR", + message: "Test", + retryable: true, + timestamp: Date.now(), + }; + + const strategy = getRecoveryStrategy(networkError); + + if (strategy.type === "retry") { + expect(strategy.maxRetries).toBe(3); + expect(strategy.backoff).toBe("exponential"); + expect(strategy.delayMs).toBe(1000); + } + }); + + it("should have correct config for sync-and-retry strategy", () => { + const error: StreamingError = { + code: "PROJECT_OUT_OF_DATE", + message: "Test", + retryable: true, + timestamp: Date.now(), + }; + + const strategy = getRecoveryStrategy(error); + + if (strategy.type === "sync-and-retry") { + expect(strategy.maxRetries).toBe(2); + } + }); + }); + + describe("isRetryableError", () => { + it("should return true for retryable errors", () => { + expect(isRetryableError("Network connection failed")).toBe(true); + expect(isRetryableError("project is out of date")).toBe(true); + expect(isRetryableError("Request timed out")).toBe(true); + expect(isRetryableError(new Error("Server error"))).toBe(true); + }); + + it("should return false for non-retryable errors", () => { + expect(isRetryableError("Unauthorized")).toBe(false); + expect(isRetryableError("Unknown error")).toBe(false); + }); + }); + + describe("StreamingErrorHandler", () => { + describe("handle()", () => { + it("should handle retry strategy successfully", async () => { + let retryCount = 0; + const handler = new StreamingErrorHandler({ + sync: async () => ({ success: true }), + retryOperation: async () => { + retryCount++; + if (retryCount < 2) { + throw new Error("Network error"); + } + }, + }); + + const resolution = await handler.handle("Network error", { + retryCount: 0, + maxRetries: 3, + currentPrompt: "test", + currentSelectedText: "", + operation: "send-message", + }); + + expect(resolution.handled).toBe(true); + expect(resolution.success).toBe(true); + }); + + it("should handle sync-and-retry strategy successfully", async () => { + let syncCalled = false; + let retryCalled = false; + + const handler = new StreamingErrorHandler({ + sync: async () => { + syncCalled = true; + return { success: true }; + }, + retryOperation: async () => { + retryCalled = true; + }, + }); + + const resolution = await handler.handle("project is out of date", { + retryCount: 0, + maxRetries: 2, + currentPrompt: "test", + currentSelectedText: "", + operation: "send-message", + }); + + expect(syncCalled).toBe(true); + expect(retryCalled).toBe(true); + expect(resolution.handled).toBe(true); + expect(resolution.success).toBe(true); + }); + + it("should fail after max retry attempts", async () => { + const handler = new StreamingErrorHandler({ + sync: async () => ({ success: true }), + retryOperation: async () => { + throw new Error("Network error"); + }, + }); + + const resolution = await handler.handle("Network error", { + retryCount: 3, // Already at max + maxRetries: 3, + currentPrompt: "test", + currentSelectedText: "", + operation: "send-message", + }); + + expect(resolution.handled).toBe(true); + expect(resolution.success).toBe(false); + }); + + it("should show error for non-retryable errors", async () => { + const handler = new StreamingErrorHandler({ + sync: async () => ({ success: true }), + retryOperation: async () => {}, + }); + + const resolution = await handler.handle("Unauthorized", { + retryCount: 0, + maxRetries: 3, + currentPrompt: "test", + currentSelectedText: "", + operation: "send-message", + }); + + expect(resolution.handled).toBe(true); + expect(resolution.success).toBe(false); + expect(resolution.strategy.type).toBe("show-error"); + }); + + it("should call onShowError callback when provided", async () => { + let shownMessage = ""; + const handler = new StreamingErrorHandler({ + sync: async () => ({ success: true }), + retryOperation: async () => {}, + onShowError: (message) => { + shownMessage = message; + }, + }); + + await handler.handle("Unauthorized access", { + retryCount: 0, + maxRetries: 3, + currentPrompt: "test", + currentSelectedText: "", + operation: "send-message", + }); + + expect(shownMessage).toContain("Authentication"); + }); + + it("should handle sync failure in sync-and-retry", async () => { + const handler = new StreamingErrorHandler({ + sync: async () => ({ success: false, error: new Error("Sync failed") }), + retryOperation: async () => {}, + }); + + // This will eventually fail after retries + const resolution = await handler.handle("project is out of date", { + retryCount: 1, // One attempt left + maxRetries: 2, + currentPrompt: "test", + currentSelectedText: "", + operation: "send-message", + }); + + expect(resolution.success).toBe(false); + }); + }); + }); + + describe("handleStreamingError", () => { + it("should show error for non-retryable errors without retry function", async () => { + const resolution = await handleStreamingError("Unknown error", {}); + + expect(resolution.handled).toBe(true); + expect(resolution.success).toBe(false); + }); + + it("should attempt retry when retry function provided", async () => { + let retryCalled = false; + const resolution = await handleStreamingError("Network error", { + retry: async () => { + retryCalled = true; + }, + }); + + expect(retryCalled).toBe(true); + expect(resolution.handled).toBe(true); + }); + }); + + describe("withStreamingErrorHandler", () => { + it("should return result on success", async () => { + const result = await withStreamingErrorHandler( + async () => "success", + { sync: async () => ({ success: true }) } + ); + + expect(result).toBe("success"); + }); + + it("should handle PROJECT_OUT_OF_DATE with sync and retry", async () => { + let syncCalled = false; + let attemptCount = 0; + + const result = await withStreamingErrorHandler( + async () => { + attemptCount++; + if (attemptCount === 1) { + throw new Error("project is out of date"); + } + return "success after retry"; + }, + { + sync: async () => { + syncCalled = true; + return { success: true }; + }, + } + ); + + expect(syncCalled).toBe(true); + expect(attemptCount).toBe(2); + expect(result).toBe("success after retry"); + }); + + it("should return undefined and call onGiveUp on persistent failure", async () => { + let gaveUp = false; + + const result = await withStreamingErrorHandler( + async () => { + throw new Error("project is out of date"); + }, + { + sync: async () => ({ success: true }), + onGiveUp: () => { + gaveUp = true; + }, + } + ); + + expect(result).toBeUndefined(); + expect(gaveUp).toBe(true); + }); + + it("should show error for non-PROJECT_OUT_OF_DATE errors", async () => { + let gaveUp = false; + + const result = await withStreamingErrorHandler( + async () => { + throw new Error("Unknown error"); + }, + { + sync: async () => ({ success: true }), + onGiveUp: () => { + gaveUp = true; + }, + } + ); + + expect(result).toBeUndefined(); + expect(gaveUp).toBe(true); + }); + }); + + describe("Backoff Calculations", () => { + it("should calculate exponential backoff correctly", async () => { + const delays: number[] = []; + const originalSetTimeout = setTimeout; + + // Mock setTimeout to capture delays + globalThis.setTimeout = ((fn: () => void, delay: number) => { + delays.push(delay); + fn(); // Execute immediately for testing + return 0 as any; + }) as any; + + const handler = new StreamingErrorHandler({ + sync: async () => ({ success: true }), + retryOperation: async () => { + throw new Error("Network error"); + }, + }); + + await handler.handle("Network error", { + retryCount: 0, + maxRetries: 3, + currentPrompt: "test", + currentSelectedText: "", + operation: "send-message", + }); + + globalThis.setTimeout = originalSetTimeout; + + // Exponential backoff: 1000, 2000, 4000 + expect(delays.length).toBeGreaterThanOrEqual(1); + if (delays.length >= 3) { + expect(delays[0]).toBe(1000); // 1000 * 2^0 + expect(delays[1]).toBe(2000); // 1000 * 2^1 + expect(delays[2]).toBe(4000); // 1000 * 2^2 + } + }); + }); +}); diff --git a/webapp/_webapp/src/stores/streaming/__tests__/message-type-handlers.test.ts b/webapp/_webapp/src/stores/streaming/__tests__/message-type-handlers.test.ts new file mode 100644 index 00000000..7acd08c6 --- /dev/null +++ b/webapp/_webapp/src/stores/streaming/__tests__/message-type-handlers.test.ts @@ -0,0 +1,390 @@ +/** + * Unit Tests for Message Type Handlers + * + * Tests the handler registry and individual message type handlers. + */ + +import { describe, it, expect } from "bun:test"; +import { + getMessageTypeHandler, + isValidMessageRole, + messageTypeHandlers, +} from "../message-type-handlers"; +import { InternalMessage, MessageRole } from "../types"; + +// Mock protobuf types +const createMockStreamPartBegin = ( + messageId: string, + role: MessageRole, + value: Record +) => ({ + messageId, + payload: { + messageType: { + case: role, + value, + }, + }, +}); + +const createMockStreamPartEnd = ( + messageId: string, + role: MessageRole, + value: Record +) => ({ + messageId, + payload: { + messageType: { + case: role, + value, + }, + }, +}); + +describe("Message Type Handlers", () => { + describe("isValidMessageRole", () => { + it("should return true for valid roles", () => { + const validRoles: MessageRole[] = [ + "assistant", + "toolCallPrepareArguments", + "toolCall", + "user", + "system", + "unknown", + ]; + + for (const role of validRoles) { + expect(isValidMessageRole(role)).toBe(true); + } + }); + + it("should return false for invalid roles", () => { + expect(isValidMessageRole("invalid")).toBe(false); + expect(isValidMessageRole("")).toBe(false); + expect(isValidMessageRole(null)).toBe(false); + expect(isValidMessageRole(undefined)).toBe(false); + expect(isValidMessageRole(123)).toBe(false); + }); + }); + + describe("getMessageTypeHandler", () => { + it("should return correct handler for each role", () => { + const roles: MessageRole[] = [ + "assistant", + "toolCallPrepareArguments", + "toolCall", + "user", + "system", + "unknown", + ]; + + for (const role of roles) { + const handler = getMessageTypeHandler(role); + expect(handler).toBeDefined(); + expect(typeof handler.onPartBegin).toBe("function"); + expect(typeof handler.onPartEnd).toBe("function"); + } + }); + + it("should return NoOpHandler for undefined role", () => { + const handler = getMessageTypeHandler(undefined); + expect(handler.onPartBegin({} as any)).toBeNull(); + }); + }); + + describe("AssistantHandler", () => { + const handler = messageTypeHandlers.assistant; + + describe("onPartBegin", () => { + it("should create assistant message from StreamPartBegin", () => { + const partBegin = createMockStreamPartBegin("msg-1", "assistant", { + content: "Hello", + reasoning: "Thinking...", + modelSlug: "gpt-4", + }); + + const result = handler.onPartBegin(partBegin as any); + + expect(result).not.toBeNull(); + expect(result!.id).toBe("msg-1"); + expect(result!.role).toBe("assistant"); + expect(result!.status).toBe("streaming"); + if (result!.role === "assistant") { + expect(result!.data.content).toBe("Hello"); + expect(result!.data.reasoning).toBe("Thinking..."); + expect(result!.data.modelSlug).toBe("gpt-4"); + } + }); + }); + + describe("onPartEnd", () => { + it("should finalize assistant message", () => { + const existingMessage: InternalMessage = { + id: "msg-1", + role: "assistant", + status: "streaming", + data: { content: "Hello", reasoning: "" }, + }; + + const partEnd = createMockStreamPartEnd("msg-1", "assistant", { + content: "Hello World!", + reasoning: "Done thinking", + modelSlug: "gpt-4", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + + expect(result).not.toBeNull(); + expect(result!.status).toBe("complete"); + if (result!.role === "assistant") { + expect(result!.data.content).toBe("Hello World!"); + expect(result!.data.reasoning).toBe("Done thinking"); + } + }); + + it("should return null for non-assistant messages", () => { + const existingMessage: InternalMessage = { + id: "msg-1", + role: "user", + status: "streaming", + data: { content: "Hello" }, + }; + + const partEnd = createMockStreamPartEnd("msg-1", "assistant", { + content: "Response", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + expect(result).toBeNull(); + }); + }); + }); + + describe("ToolCallPrepareHandler", () => { + const handler = messageTypeHandlers.toolCallPrepareArguments; + + describe("onPartBegin", () => { + it("should create toolCallPrepare message from StreamPartBegin", () => { + const partBegin = createMockStreamPartBegin( + "tool-prep-1", + "toolCallPrepareArguments", + { + name: "search", + args: '{"query":', + } + ); + + const result = handler.onPartBegin(partBegin as any); + + expect(result).not.toBeNull(); + expect(result!.id).toBe("tool-prep-1"); + expect(result!.role).toBe("toolCallPrepare"); + expect(result!.status).toBe("streaming"); + if (result!.role === "toolCallPrepare") { + expect(result!.data.name).toBe("search"); + expect(result!.data.args).toBe('{"query":'); + } + }); + }); + + describe("onPartEnd", () => { + it("should finalize toolCallPrepare message", () => { + const existingMessage: InternalMessage = { + id: "tool-prep-1", + role: "toolCallPrepare", + status: "streaming", + data: { name: "search", args: "" }, + }; + + const partEnd = createMockStreamPartEnd( + "tool-prep-1", + "toolCallPrepareArguments", + { + name: "search", + args: '{"query": "test"}', + } + ); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + + expect(result).not.toBeNull(); + expect(result!.status).toBe("complete"); + if (result!.role === "toolCallPrepare") { + expect(result!.data.args).toBe('{"query": "test"}'); + } + }); + + it("should return null for non-toolCallPrepare messages", () => { + const existingMessage: InternalMessage = { + id: "msg-1", + role: "assistant", + status: "streaming", + data: { content: "Hello" }, + }; + + const partEnd = createMockStreamPartEnd( + "msg-1", + "toolCallPrepareArguments", + { + name: "search", + args: "{}", + } + ); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + expect(result).toBeNull(); + }); + }); + }); + + describe("ToolCallHandler", () => { + const handler = messageTypeHandlers.toolCall; + + describe("onPartBegin", () => { + it("should create toolCall message from StreamPartBegin", () => { + const partBegin = createMockStreamPartBegin("tool-1", "toolCall", { + name: "search", + args: '{"query": "test"}', + result: "", + error: "", + }); + + const result = handler.onPartBegin(partBegin as any); + + expect(result).not.toBeNull(); + expect(result!.id).toBe("tool-1"); + expect(result!.role).toBe("toolCall"); + expect(result!.status).toBe("streaming"); + if (result!.role === "toolCall") { + expect(result!.data.name).toBe("search"); + expect(result!.data.args).toBe('{"query": "test"}'); + } + }); + }); + + describe("onPartEnd", () => { + it("should finalize toolCall message with result", () => { + const existingMessage: InternalMessage = { + id: "tool-1", + role: "toolCall", + status: "streaming", + data: { name: "search", args: "{}", result: "", error: "" }, + }; + + const partEnd = createMockStreamPartEnd("tool-1", "toolCall", { + name: "search", + args: '{"query": "test"}', + result: "Found 3 results", + error: "", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + + expect(result).not.toBeNull(); + expect(result!.status).toBe("complete"); + if (result!.role === "toolCall") { + expect(result!.data.result).toBe("Found 3 results"); + expect(result!.data.error).toBe(""); + } + }); + + it("should finalize toolCall message with error", () => { + const existingMessage: InternalMessage = { + id: "tool-1", + role: "toolCall", + status: "streaming", + data: { name: "search", args: "{}", result: "", error: "" }, + }; + + const partEnd = createMockStreamPartEnd("tool-1", "toolCall", { + name: "search", + args: "{}", + result: "", + error: "Tool not found", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + + expect(result).not.toBeNull(); + if (result!.role === "toolCall") { + expect(result!.data.error).toBe("Tool not found"); + } + }); + + it("should return null for non-toolCall messages", () => { + const existingMessage: InternalMessage = { + id: "msg-1", + role: "assistant", + status: "streaming", + data: { content: "Hello" }, + }; + + const partEnd = createMockStreamPartEnd("msg-1", "toolCall", { + name: "search", + args: "{}", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + expect(result).toBeNull(); + }); + }); + }); + + describe("NoOpHandler (user, system, unknown)", () => { + const noOpRoles: MessageRole[] = ["user", "system", "unknown"]; + + for (const role of noOpRoles) { + describe(`${role} handler`, () => { + const handler = messageTypeHandlers[role]; + + it("should return null on partBegin", () => { + const partBegin = createMockStreamPartBegin("msg-1", role, { + content: "test", + }); + + const result = handler.onPartBegin(partBegin as any); + expect(result).toBeNull(); + }); + + it("should return null on partEnd", () => { + const existingMessage: InternalMessage = { + id: "msg-1", + role: "user", + status: "streaming", + data: { content: "test" }, + }; + + const partEnd = createMockStreamPartEnd("msg-1", role, { + content: "test", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + expect(result).toBeNull(); + }); + }); + } + }); + + describe("Handler Registry", () => { + it("should have handlers for all valid roles", () => { + const roles: MessageRole[] = [ + "assistant", + "toolCallPrepareArguments", + "toolCall", + "user", + "system", + "unknown", + ]; + + for (const role of roles) { + expect(messageTypeHandlers[role]).toBeDefined(); + } + }); + + it("should return same handler instance for same role", () => { + const handler1 = getMessageTypeHandler("assistant"); + const handler2 = getMessageTypeHandler("assistant"); + expect(handler1).toBe(handler2); + }); + }); +}); diff --git a/webapp/_webapp/src/stores/streaming/__tests__/streaming-state-machine.test.ts b/webapp/_webapp/src/stores/streaming/__tests__/streaming-state-machine.test.ts new file mode 100644 index 00000000..7acd08c6 --- /dev/null +++ b/webapp/_webapp/src/stores/streaming/__tests__/streaming-state-machine.test.ts @@ -0,0 +1,390 @@ +/** + * Unit Tests for Message Type Handlers + * + * Tests the handler registry and individual message type handlers. + */ + +import { describe, it, expect } from "bun:test"; +import { + getMessageTypeHandler, + isValidMessageRole, + messageTypeHandlers, +} from "../message-type-handlers"; +import { InternalMessage, MessageRole } from "../types"; + +// Mock protobuf types +const createMockStreamPartBegin = ( + messageId: string, + role: MessageRole, + value: Record +) => ({ + messageId, + payload: { + messageType: { + case: role, + value, + }, + }, +}); + +const createMockStreamPartEnd = ( + messageId: string, + role: MessageRole, + value: Record +) => ({ + messageId, + payload: { + messageType: { + case: role, + value, + }, + }, +}); + +describe("Message Type Handlers", () => { + describe("isValidMessageRole", () => { + it("should return true for valid roles", () => { + const validRoles: MessageRole[] = [ + "assistant", + "toolCallPrepareArguments", + "toolCall", + "user", + "system", + "unknown", + ]; + + for (const role of validRoles) { + expect(isValidMessageRole(role)).toBe(true); + } + }); + + it("should return false for invalid roles", () => { + expect(isValidMessageRole("invalid")).toBe(false); + expect(isValidMessageRole("")).toBe(false); + expect(isValidMessageRole(null)).toBe(false); + expect(isValidMessageRole(undefined)).toBe(false); + expect(isValidMessageRole(123)).toBe(false); + }); + }); + + describe("getMessageTypeHandler", () => { + it("should return correct handler for each role", () => { + const roles: MessageRole[] = [ + "assistant", + "toolCallPrepareArguments", + "toolCall", + "user", + "system", + "unknown", + ]; + + for (const role of roles) { + const handler = getMessageTypeHandler(role); + expect(handler).toBeDefined(); + expect(typeof handler.onPartBegin).toBe("function"); + expect(typeof handler.onPartEnd).toBe("function"); + } + }); + + it("should return NoOpHandler for undefined role", () => { + const handler = getMessageTypeHandler(undefined); + expect(handler.onPartBegin({} as any)).toBeNull(); + }); + }); + + describe("AssistantHandler", () => { + const handler = messageTypeHandlers.assistant; + + describe("onPartBegin", () => { + it("should create assistant message from StreamPartBegin", () => { + const partBegin = createMockStreamPartBegin("msg-1", "assistant", { + content: "Hello", + reasoning: "Thinking...", + modelSlug: "gpt-4", + }); + + const result = handler.onPartBegin(partBegin as any); + + expect(result).not.toBeNull(); + expect(result!.id).toBe("msg-1"); + expect(result!.role).toBe("assistant"); + expect(result!.status).toBe("streaming"); + if (result!.role === "assistant") { + expect(result!.data.content).toBe("Hello"); + expect(result!.data.reasoning).toBe("Thinking..."); + expect(result!.data.modelSlug).toBe("gpt-4"); + } + }); + }); + + describe("onPartEnd", () => { + it("should finalize assistant message", () => { + const existingMessage: InternalMessage = { + id: "msg-1", + role: "assistant", + status: "streaming", + data: { content: "Hello", reasoning: "" }, + }; + + const partEnd = createMockStreamPartEnd("msg-1", "assistant", { + content: "Hello World!", + reasoning: "Done thinking", + modelSlug: "gpt-4", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + + expect(result).not.toBeNull(); + expect(result!.status).toBe("complete"); + if (result!.role === "assistant") { + expect(result!.data.content).toBe("Hello World!"); + expect(result!.data.reasoning).toBe("Done thinking"); + } + }); + + it("should return null for non-assistant messages", () => { + const existingMessage: InternalMessage = { + id: "msg-1", + role: "user", + status: "streaming", + data: { content: "Hello" }, + }; + + const partEnd = createMockStreamPartEnd("msg-1", "assistant", { + content: "Response", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + expect(result).toBeNull(); + }); + }); + }); + + describe("ToolCallPrepareHandler", () => { + const handler = messageTypeHandlers.toolCallPrepareArguments; + + describe("onPartBegin", () => { + it("should create toolCallPrepare message from StreamPartBegin", () => { + const partBegin = createMockStreamPartBegin( + "tool-prep-1", + "toolCallPrepareArguments", + { + name: "search", + args: '{"query":', + } + ); + + const result = handler.onPartBegin(partBegin as any); + + expect(result).not.toBeNull(); + expect(result!.id).toBe("tool-prep-1"); + expect(result!.role).toBe("toolCallPrepare"); + expect(result!.status).toBe("streaming"); + if (result!.role === "toolCallPrepare") { + expect(result!.data.name).toBe("search"); + expect(result!.data.args).toBe('{"query":'); + } + }); + }); + + describe("onPartEnd", () => { + it("should finalize toolCallPrepare message", () => { + const existingMessage: InternalMessage = { + id: "tool-prep-1", + role: "toolCallPrepare", + status: "streaming", + data: { name: "search", args: "" }, + }; + + const partEnd = createMockStreamPartEnd( + "tool-prep-1", + "toolCallPrepareArguments", + { + name: "search", + args: '{"query": "test"}', + } + ); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + + expect(result).not.toBeNull(); + expect(result!.status).toBe("complete"); + if (result!.role === "toolCallPrepare") { + expect(result!.data.args).toBe('{"query": "test"}'); + } + }); + + it("should return null for non-toolCallPrepare messages", () => { + const existingMessage: InternalMessage = { + id: "msg-1", + role: "assistant", + status: "streaming", + data: { content: "Hello" }, + }; + + const partEnd = createMockStreamPartEnd( + "msg-1", + "toolCallPrepareArguments", + { + name: "search", + args: "{}", + } + ); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + expect(result).toBeNull(); + }); + }); + }); + + describe("ToolCallHandler", () => { + const handler = messageTypeHandlers.toolCall; + + describe("onPartBegin", () => { + it("should create toolCall message from StreamPartBegin", () => { + const partBegin = createMockStreamPartBegin("tool-1", "toolCall", { + name: "search", + args: '{"query": "test"}', + result: "", + error: "", + }); + + const result = handler.onPartBegin(partBegin as any); + + expect(result).not.toBeNull(); + expect(result!.id).toBe("tool-1"); + expect(result!.role).toBe("toolCall"); + expect(result!.status).toBe("streaming"); + if (result!.role === "toolCall") { + expect(result!.data.name).toBe("search"); + expect(result!.data.args).toBe('{"query": "test"}'); + } + }); + }); + + describe("onPartEnd", () => { + it("should finalize toolCall message with result", () => { + const existingMessage: InternalMessage = { + id: "tool-1", + role: "toolCall", + status: "streaming", + data: { name: "search", args: "{}", result: "", error: "" }, + }; + + const partEnd = createMockStreamPartEnd("tool-1", "toolCall", { + name: "search", + args: '{"query": "test"}', + result: "Found 3 results", + error: "", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + + expect(result).not.toBeNull(); + expect(result!.status).toBe("complete"); + if (result!.role === "toolCall") { + expect(result!.data.result).toBe("Found 3 results"); + expect(result!.data.error).toBe(""); + } + }); + + it("should finalize toolCall message with error", () => { + const existingMessage: InternalMessage = { + id: "tool-1", + role: "toolCall", + status: "streaming", + data: { name: "search", args: "{}", result: "", error: "" }, + }; + + const partEnd = createMockStreamPartEnd("tool-1", "toolCall", { + name: "search", + args: "{}", + result: "", + error: "Tool not found", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + + expect(result).not.toBeNull(); + if (result!.role === "toolCall") { + expect(result!.data.error).toBe("Tool not found"); + } + }); + + it("should return null for non-toolCall messages", () => { + const existingMessage: InternalMessage = { + id: "msg-1", + role: "assistant", + status: "streaming", + data: { content: "Hello" }, + }; + + const partEnd = createMockStreamPartEnd("msg-1", "toolCall", { + name: "search", + args: "{}", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + expect(result).toBeNull(); + }); + }); + }); + + describe("NoOpHandler (user, system, unknown)", () => { + const noOpRoles: MessageRole[] = ["user", "system", "unknown"]; + + for (const role of noOpRoles) { + describe(`${role} handler`, () => { + const handler = messageTypeHandlers[role]; + + it("should return null on partBegin", () => { + const partBegin = createMockStreamPartBegin("msg-1", role, { + content: "test", + }); + + const result = handler.onPartBegin(partBegin as any); + expect(result).toBeNull(); + }); + + it("should return null on partEnd", () => { + const existingMessage: InternalMessage = { + id: "msg-1", + role: "user", + status: "streaming", + data: { content: "test" }, + }; + + const partEnd = createMockStreamPartEnd("msg-1", role, { + content: "test", + }); + + const result = handler.onPartEnd(partEnd as any, existingMessage); + expect(result).toBeNull(); + }); + }); + } + }); + + describe("Handler Registry", () => { + it("should have handlers for all valid roles", () => { + const roles: MessageRole[] = [ + "assistant", + "toolCallPrepareArguments", + "toolCall", + "user", + "system", + "unknown", + ]; + + for (const role of roles) { + expect(messageTypeHandlers[role]).toBeDefined(); + } + }); + + it("should return same handler instance for same role", () => { + const handler1 = getMessageTypeHandler("assistant"); + const handler2 = getMessageTypeHandler("assistant"); + expect(handler1).toBe(handler2); + }); + }); +}); diff --git a/webapp/_webapp/src/stores/streaming/error-handler.ts b/webapp/_webapp/src/stores/streaming/error-handler.ts new file mode 100644 index 00000000..5d2d39a5 --- /dev/null +++ b/webapp/_webapp/src/stores/streaming/error-handler.ts @@ -0,0 +1,588 @@ +/** + * Streaming Error Handler + * + * Centralized error handling for all streaming-related errors. + * Provides a unified strategy for error recovery, retry logic, and user notification. + * + * Benefits: + * - Single source of truth for error handling logic + * - Configurable recovery strategies per error type + * - Eliminates duplicate retry logic across files + * - Testable error handling with explicit strategies + */ + +import { ErrorCode, Error as RequestError } from "../../pkg/gen/apiclient/shared/v1/shared_pb"; +import { logError, logWarn, logInfo } from "../../libs/logger"; +import { errorToast } from "../../libs/toasts"; +import { + StreamingError, + StreamingErrorCode, + RecoveryStrategy, + ErrorContext, + ErrorResolution, +} from "./types"; + +// ============================================================================ +// Error Code Mapping +// ============================================================================ + +/** + * Maps protobuf ErrorCode to our StreamingErrorCode. + */ +function mapErrorCode(code?: ErrorCode): StreamingErrorCode { + if (code === undefined) return "UNKNOWN"; + + switch (code) { + case ErrorCode.PROJECT_OUT_OF_DATE: + return "PROJECT_OUT_OF_DATE"; + case ErrorCode.INVALID_TOKEN: + case ErrorCode.INVALID_ACTOR: + case ErrorCode.INVALID_CREDENTIAL: + return "AUTHENTICATION_ERROR"; + case ErrorCode.PERMISSION_DENIED: + return "AUTHENTICATION_ERROR"; + case ErrorCode.BAD_REQUEST: + case ErrorCode.INVALID_LLM_RESPONSE: + return "INVALID_RESPONSE"; + case ErrorCode.INTERNAL: + return "SERVER_ERROR"; + case ErrorCode.RECORD_NOT_FOUND: + case ErrorCode.INVALID_USER: + return "INVALID_RESPONSE"; + default: + return "UNKNOWN"; + } +} + +/** + * Creates a StreamingError from various error sources. + */ +export function createStreamingError( + error: Error | RequestError | string | unknown, + defaultCode: StreamingErrorCode = "UNKNOWN" +): StreamingError { + const timestamp = Date.now(); + + // Handle string errors + if (typeof error === "string") { + const code = detectErrorCodeFromMessage(error); + return { + code, + message: error, + retryable: isRetryableCode(code), + timestamp, + }; + } + + // Handle RequestError from protobuf + if (error && typeof error === "object" && "code" in error && "message" in error) { + const requestError = error as RequestError; + const code = mapErrorCode(requestError.code); + return { + code, + message: requestError.message, + originalError: error, + retryable: isRetryableCode(code), + timestamp, + }; + } + + // Handle standard Error + if (error instanceof Error) { + const code = detectErrorCodeFromMessage(error.message); + return { + code, + message: error.message, + originalError: error, + retryable: isRetryableCode(code), + timestamp, + }; + } + + // Fallback for unknown error types + return { + code: defaultCode, + message: String(error) || "An unknown error occurred", + originalError: error, + retryable: isRetryableCode(defaultCode), + timestamp, + }; +} + +/** + * Detects error code from error message content. + */ +function detectErrorCodeFromMessage(message: string): StreamingErrorCode { + const lowerMessage = message.toLowerCase(); + + if (lowerMessage.includes("project is out of date") || lowerMessage.includes("out of date")) { + return "PROJECT_OUT_OF_DATE"; + } + if (lowerMessage.includes("network") || lowerMessage.includes("connection") || lowerMessage.includes("fetch")) { + return "NETWORK_ERROR"; + } + if (lowerMessage.includes("timeout") || lowerMessage.includes("timed out")) { + return "TIMEOUT"; + } + if (lowerMessage.includes("rate limit") || lowerMessage.includes("too many requests")) { + return "RATE_LIMITED"; + } + if (lowerMessage.includes("unauthorized") || lowerMessage.includes("authentication") || lowerMessage.includes("token")) { + return "AUTHENTICATION_ERROR"; + } + if (lowerMessage.includes("server error") || lowerMessage.includes("internal")) { + return "SERVER_ERROR"; + } + + return "UNKNOWN"; +} + +/** + * Determines if an error code is retryable. + */ +function isRetryableCode(code: StreamingErrorCode): boolean { + switch (code) { + case "PROJECT_OUT_OF_DATE": + case "NETWORK_ERROR": + case "TIMEOUT": + case "SERVER_ERROR": + return true; + case "RATE_LIMITED": + return true; // Retryable with backoff + case "INVALID_RESPONSE": + case "AUTHENTICATION_ERROR": + case "UNKNOWN": + return false; + } +} + +// ============================================================================ +// Recovery Strategy Configuration +// ============================================================================ + +/** + * Default recovery strategies per error code. + */ +const DEFAULT_STRATEGIES: Record = { + PROJECT_OUT_OF_DATE: { + type: "sync-and-retry", + maxRetries: 3, + }, + NETWORK_ERROR: { + type: "retry", + maxRetries: 3, + backoff: "exponential", + delayMs: 1000, + }, + TIMEOUT: { + type: "retry", + maxRetries: 2, + backoff: "linear", + delayMs: 2000, + }, + RATE_LIMITED: { + type: "retry", + maxRetries: 3, + backoff: "exponential", + delayMs: 5000, + }, + SERVER_ERROR: { + type: "retry", + maxRetries: 2, + backoff: "exponential", + delayMs: 2000, + }, + INVALID_RESPONSE: { + type: "show-error", + dismissable: true, + message: "Received an invalid response. Please try again.", + }, + AUTHENTICATION_ERROR: { + type: "show-error", + dismissable: false, + message: "Authentication failed. Please sign in again.", + }, + UNKNOWN: { + type: "show-error", + dismissable: true, + }, +}; + +/** + * Gets the recovery strategy for a given error. + */ +export function getRecoveryStrategy(error: StreamingError): RecoveryStrategy { + return DEFAULT_STRATEGIES[error.code]; +} + +// ============================================================================ +// Streaming Error Handler Class +// ============================================================================ + +/** + * Handles and synchronizes the stream of errors. + */ +export interface StreamingErrorHandlerDeps { + /** Sync function to synchronize project state */ + sync: () => Promise<{ success: boolean; error?: Error }>; + /** Function to retry the failed operation */ + retryOperation: () => Promise; + /** Callback when error handling completes */ + onComplete?: (resolution: ErrorResolution) => void; + /** Callback when an error message should be displayed */ + onShowError?: (message: string, title?: string) => void; +} + +/** + * StreamingErrorHandler - Centralized error handling for streaming operations. + * + * Usage: + * ```typescript + * const handler = new StreamingErrorHandler({ + * sync: () => syncProject(), + * retryOperation: () => sendMessage(prompt, selectedText), + * }); + * + * try { + * await sendMessage(prompt, selectedText); + * } catch (error) { + * const resolution = await handler.handle(error, { + * retryCount: 0, + * maxRetries: 3, + * currentPrompt: prompt, + * currentSelectedText: selectedText, + * operation: "send-message", + * }); + * if (!resolution.success) { + * // Handle final failure + * } + * } + * ``` + */ +export class StreamingErrorHandler { + private deps: StreamingErrorHandlerDeps; + + constructor(deps: StreamingErrorHandlerDeps) { + this.deps = deps; + } + + /** + * Handles a streaming error with appropriate recovery strategy. + */ + async handle( + error: Error | RequestError | string | unknown, + context: ErrorContext + ): Promise { + const streamingError = createStreamingError(error); + const strategy = getRecoveryStrategy(streamingError); + + logError(`Streaming error [${streamingError.code}] ${strategy.type}:`, streamingError.message, context); + + switch (strategy.type) { + case "retry": + return this.handleRetry(streamingError, context, strategy); + + case "sync-and-retry": + return this.handleSyncAndRetry(streamingError, context, strategy); + + case "show-error": + return this.handleShowError(streamingError, strategy); + + case "abort": + return this.handleAbort(streamingError, strategy); + + default: { + // Exhaustive check + const _exhaustive: never = strategy; + logError("Unknown recovery strategy:", _exhaustive); + return { + handled: false, + success: false, + strategy, + message: "Unknown recovery strategy", + }; + } + } + } + + /** + * Handle simple retry with backoff. + */ + private async handleRetry( + error: StreamingError, + context: ErrorContext, + strategy: Extract + ): Promise { + // Increment retry count at the beginning + const currentAttempt = context.retryCount + 1; + + if (currentAttempt > strategy.maxRetries) { + logWarn(`Max retry attempts (${strategy.maxRetries}) reached for ${error.code}`); + this.showErrorToUser(error, "Retry failed after multiple attempts"); + return { + handled: true, + success: false, + strategy, + message: `Max retries exceeded for ${error.code}`, + }; + } + + const delay = this.calculateDelay(context.retryCount, strategy); + logInfo(`Retrying in ${delay}ms (attempt ${currentAttempt}/${strategy.maxRetries})`); + + await this.sleep(delay); + + try { + await this.deps.retryOperation(); + return { + handled: true, + success: true, + strategy, + message: `Retry successful on attempt ${currentAttempt}`, + }; + } catch (retryError) { + // Recursive retry with incremented count + return this.handle(retryError, { + ...context, + retryCount: currentAttempt, + }); + } + } + + /** + * Handle sync-then-retry for project out of date errors. + */ + private async handleSyncAndRetry( + error: StreamingError, + context: ErrorContext, + strategy: Extract + ): Promise { + // Increment retry count at the beginning + const currentAttempt = context.retryCount + 1; + console.log("handleSyncAndRetry called, currentAttempt:", currentAttempt); + if (currentAttempt > strategy.maxRetries) { + logWarn(`Max sync-and-retry attempts (${strategy.maxRetries}) reached`); + this.showErrorToUser(error, "Project sync failed"); + return { + handled: true, + success: false, + strategy, + message: "Max sync-and-retry attempts exceeded", + }; + } + + logInfo(`Syncing project before retry (attempt ${currentAttempt}/${strategy.maxRetries})`); + + try { + const syncResult = await this.deps.sync(); + if (!syncResult.success) { + throw syncResult.error || new Error("Sync failed"); + } + + await this.deps.retryOperation(); + return { + handled: true, + success: true, + strategy, + message: "Sync and retry successful", + }; + } catch (retryError) { + // Recursive retry with incremented count + return this.handle(retryError, { + ...context, + retryCount: currentAttempt, + }); + } + } + + /** + * Handle show error to user. + */ + private handleShowError( + error: StreamingError, + strategy: Extract + ): ErrorResolution { + const message = strategy.message || error.message; + this.showErrorToUser(error, message); + + return { + handled: true, + success: false, + strategy, + message, + }; + } + + /** + * Handle abort - stop processing and optionally cleanup. + */ + private handleAbort( + error: StreamingError, + strategy: Extract + ): ErrorResolution { + logError("Aborting due to error:", error.message); + + return { + handled: true, + success: false, + strategy, + message: `Operation aborted: ${error.message}`, + }; + } + + /** + * Shows error to user via toast. + */ + private showErrorToUser(error: StreamingError, message: string): void { + if (this.deps.onShowError) { + this.deps.onShowError(message, getErrorTitle(error.code)); + } else { + errorToast(message, getErrorTitle(error.code)); + } + } + + /** + * Calculate delay based on retry strategy. + */ + private calculateDelay( + retryCount: number, + strategy: Extract + ): number { + if (strategy.backoff === "exponential") { + // Exponential backoff: delay * 2^retryCount + return strategy.delayMs * Math.pow(2, retryCount); + } + // Linear backoff: delay * (retryCount + 1) + return strategy.delayMs * (retryCount + 1); + } + + /** + * Sleep for a given number of milliseconds. + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Get a user-friendly error title based on error code. + */ +function getErrorTitle(code: StreamingErrorCode): string { + switch (code) { + case "PROJECT_OUT_OF_DATE": + return "Project Out of Date"; + case "NETWORK_ERROR": + return "Network Error"; + case "TIMEOUT": + return "Request Timeout"; + case "RATE_LIMITED": + return "Rate Limited"; + case "INVALID_RESPONSE": + return "Invalid Response"; + case "AUTHENTICATION_ERROR": + return "Authentication Error"; + case "SERVER_ERROR": + return "Server Error"; + case "UNKNOWN": + return "Error"; + } +} + +/** + * Quick helper to check if an error is retryable. + */ +export function isRetryableError(error: Error | RequestError | string | unknown): boolean { + const streamingError = createStreamingError(error); + return streamingError.retryable; +} + +/** + * Quick helper to handle an error with default behavior. + * Use this for simple error handling without creating a full handler instance. + */ +export async function handleStreamingError( + error: Error | RequestError | string | unknown, + options: { + sync?: () => Promise<{ success: boolean; error?: Error }>; + retry?: () => Promise; + context?: Partial; + } +): Promise { + const currentAttempt = options.context?.retryCount || 0; + const streamingError = createStreamingError(error); + const strategy = getRecoveryStrategy(streamingError); + + // If not retryable or no retry function, just show the error + if (!streamingError.retryable || (!options.retry && !options.sync)) { + errorToast(streamingError.message, getErrorTitle(streamingError.code)); + return { + handled: true, + success: false, + strategy, + message: streamingError.message, + }; + } + + // Create handler and delegate + const handler = new StreamingErrorHandler({ + sync: options.sync || (async () => ({ success: true })), + retryOperation: options.retry || (async () => {}), + }); + + return handler.handle(error, { + retryCount: currentAttempt, + maxRetries: 3, + currentPrompt: "", + currentSelectedText: "", + operation: "other", + ...options.context, + }); +} + +/** + * Wrap an async operation with automatic error handling. + * This is a replacement for withRetrySync that uses the new error handler. + */ +export async function withStreamingErrorHandler( + operation: () => Promise, + options: { + sync: () => Promise<{ success: boolean; error?: Error }>; + onGiveUp?: () => void; + context?: Partial; + } +): Promise { + try { + return await operation(); + } catch (error) { + const streamingError = createStreamingError(error); + const strategy = getRecoveryStrategy(streamingError); + + // Handle sync-and-retry for project out of date + if (streamingError.code === "PROJECT_OUT_OF_DATE") { + try { + logInfo("Project out of date, syncing and retrying..."); + const syncResult = await options.sync(); + if (!syncResult.success) { + throw syncResult.error || new Error("Sync failed"); + } + return await operation(); + } catch (retryError) { + errorToast("Retry failed", "Operation failed after retry"); + logError("Retry after sync failed:", retryError); + options.onGiveUp?.(); + return undefined; + } + } + + // Handle other errors + const message = (strategy.type === "show-error" && strategy.message) || streamingError.message; + errorToast(message, getErrorTitle(streamingError.code)); + logError("Operation failed:", error); + options.onGiveUp?.(); + return undefined; + } +} diff --git a/webapp/_webapp/src/stores/streaming/index.ts b/webapp/_webapp/src/stores/streaming/index.ts new file mode 100644 index 00000000..efe1a5c3 --- /dev/null +++ b/webapp/_webapp/src/stores/streaming/index.ts @@ -0,0 +1,10 @@ +/** + * Streaming Module + * + * Exports all streaming-related functionality from a single entry point. + */ + +export * from "./types"; +export * from "./message-type-handlers"; +export * from "./streaming-state-machine"; +export * from "./error-handler"; diff --git a/webapp/_webapp/src/stores/streaming/message-type-handlers.ts b/webapp/_webapp/src/stores/streaming/message-type-handlers.ts new file mode 100644 index 00000000..08dbdbc6 --- /dev/null +++ b/webapp/_webapp/src/stores/streaming/message-type-handlers.ts @@ -0,0 +1,173 @@ +/** + * Message Type Handlers Registry + * + * This module provides a registry of handlers for different message types, + * eliminating the duplicate switch/if-else statements spread across multiple files. + * + * Benefits: + * - Adding a new message type only requires adding one handler + * - Type-safe handling with exhaustive checking + * - Clear separation of concerns for each message type + */ + +import { + MessageTypeAssistant, + MessageTypeToolCall, + MessageTypeToolCallPrepareArguments, + StreamPartBegin, + StreamPartEnd, + } from "../../pkg/gen/apiclient/chat/v2/chat_pb"; + import { + InternalMessage, + MessageRole, + MessageTypeHandler, + MessageTypeHandlerRegistry, + } from "./types"; + import { + createAssistantMessage, + createToolCallMessage, + createToolCallPrepareMessage, + } from "../../types/message"; + + // ============================================================================ + // Handler Implementations + // ============================================================================ + + /** + * Handler for assistant messages. + */ + class AssistantHandler implements MessageTypeHandler { + onPartBegin(partBegin: StreamPartBegin): InternalMessage | null { + const assistant = partBegin.payload?.messageType.value as MessageTypeAssistant; + return createAssistantMessage(partBegin.messageId, assistant.content, { + reasoning: assistant.reasoning, + modelSlug: assistant.modelSlug, + status: "streaming", + }); + } + + onPartEnd(partEnd: StreamPartEnd, existingMessage: InternalMessage): InternalMessage | null { + if (existingMessage.role !== "assistant") return null; + const assistant = partEnd.payload?.messageType.value as MessageTypeAssistant; + return { + ...existingMessage, + status: "complete", + data: { + content: assistant.content, + reasoning: assistant.reasoning, + modelSlug: assistant.modelSlug, + }, + }; + } + } + + /** + * Handler for tool call preparation (arguments streaming). + */ + class ToolCallPrepareHandler implements MessageTypeHandler { + onPartBegin(partBegin: StreamPartBegin): InternalMessage | null { + const toolCallPrepare = partBegin.payload?.messageType.value as MessageTypeToolCallPrepareArguments; + return createToolCallPrepareMessage(partBegin.messageId, toolCallPrepare.name, toolCallPrepare.args, { + status: "streaming", + }); + } + + onPartEnd(partEnd: StreamPartEnd, existingMessage: InternalMessage): InternalMessage | null { + if (existingMessage.role !== "toolCallPrepare") return null; + const toolCallPrepare = partEnd.payload?.messageType.value as MessageTypeToolCallPrepareArguments; + return { + ...existingMessage, + status: "complete", + data: { + name: toolCallPrepare.name, + args: toolCallPrepare.args, + }, + }; + } + } + + /** + * Handler for completed tool calls. + */ + class ToolCallHandler implements MessageTypeHandler { + onPartBegin(partBegin: StreamPartBegin): InternalMessage | null { + const toolCall = partBegin.payload?.messageType.value as MessageTypeToolCall; + return createToolCallMessage(partBegin.messageId, toolCall.name, toolCall.args, { + result: toolCall.result, + error: toolCall.error, + status: "streaming", + }); + } + + onPartEnd(partEnd: StreamPartEnd, existingMessage: InternalMessage): InternalMessage | null { + if (existingMessage.role !== "toolCall") return null; + const toolCall = partEnd.payload?.messageType.value as MessageTypeToolCall; + return { + ...existingMessage, + status: "complete", + data: { + name: toolCall.name, + args: toolCall.args, + result: toolCall.result, + error: toolCall.error, + }, + }; + } + } + + /** + * No-op handler for message types that don't require streaming handling. + * Used for system, user, and unknown message types. + */ + class NoOpHandler implements MessageTypeHandler { + onPartBegin(_partBegin: StreamPartBegin): InternalMessage | null { + return null; + } + + onPartEnd(_partEnd: StreamPartEnd, _existingMessage: InternalMessage): InternalMessage | null { + return null; + } + } + + // ============================================================================ + // Handler Registry + // ============================================================================ + + /** + * Registry mapping message roles to their handlers. + * This eliminates the need for switch/if-else statements when handling different message types. + */ + export const messageTypeHandlers: MessageTypeHandlerRegistry = { + assistant: new AssistantHandler(), + toolCallPrepareArguments: new ToolCallPrepareHandler(), + toolCall: new ToolCallHandler(), + user: new NoOpHandler(), + system: new NoOpHandler(), + unknown: new NoOpHandler(), + }; + + /** + * Get the handler for a specific message role. + * Returns NoOpHandler for undefined/null roles. + */ + export function getMessageTypeHandler(role: MessageRole | undefined): MessageTypeHandler { + if (!role) { + return new NoOpHandler(); + } + return messageTypeHandlers[role] || new NoOpHandler(); + } + + /** + * Type guard to check if a role is a valid MessageRole. + */ + export function isValidMessageRole(role: unknown): role is MessageRole { + return ( + role === "assistant" || + role === "toolCallPrepareArguments" || + role === "toolCall" || + role === "user" || + role === "system" || + role === "unknown" + ); + } + \ No newline at end of file diff --git a/webapp/_webapp/src/stores/streaming/streaming-state-machine.ts b/webapp/_webapp/src/stores/streaming/streaming-state-machine.ts new file mode 100644 index 00000000..a10dabfe --- /dev/null +++ b/webapp/_webapp/src/stores/streaming/streaming-state-machine.ts @@ -0,0 +1,438 @@ +/** + * Streaming State Machine + * + * This module consolidates all streaming event handling into a single cohesive state machine, + * replacing the 9+ fragmented handler files with a centralized, type-safe implementation. + * + * Benefits: + * - Single point of control for all state transitions + * - Clear state machine pattern with explicit states + * - Type-safe event handling with exhaustive checking + * - All related logic in one place for easier debugging + */ + +import { create } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; +import { + IncompleteIndicator, + Message, + Conversation, +} from "../../pkg/gen/apiclient/chat/v2/chat_pb"; +import { logError, logWarn } from "../../libs/logger"; +import { useConversationStore } from "../conversation/conversation-store"; +import { createStreamingError, getRecoveryStrategy, StreamingErrorHandler } from "./error-handler"; +import { getMessageTypeHandler, isValidMessageRole } from "./message-type-handlers"; +import { + InternalMessage, + MessageRole, + StreamEvent, + StreamHandlerContext, + StreamingMessage, + StreamState, +} from "./types"; +import { toApiMessage, createAssistantMessage } from "../../utils/message-converters"; + +// ============================================================================ +// Store State Interface +// ============================================================================ + +interface StreamingStateMachineState { + // Current streaming state + state: StreamState; + + /** + * Retry counter specifically for sync-and-retry recovery. + * + * IMPORTANT: This must survive "retry" re-stream attempts; do NOT rely on callers + * passing the correct retryCount (they often reset state for a new stream). + */ + syncRetryCount: number; + + // Streaming message data + streamingMessage: StreamingMessage; + + // Incomplete indicator from server + incompleteIndicator: IncompleteIndicator | null; + + // Actions + handleEvent: (event: StreamEvent, context?: Partial) => Promise; + reset: () => void; + getStreamingMessage: () => StreamingMessage; + getIncompleteIndicator: () => IncompleteIndicator | null; +} + +// ============================================================================ +// Initial State +// ============================================================================ + +const initialState = { + state: "idle" as StreamState, + syncRetryCount: 0, + streamingMessage: { parts: [], sequence: 0 }, + incompleteIndicator: null, +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Flush finalized streaming messages to the conversation store. + */ +function flushStreamingMessageToConversation( + streamingMessage: StreamingMessage, + conversationId?: string, + modelSlug?: string, +) { + const flushMessages = streamingMessage.parts + .filter((part) => part.status === "complete") + .map((part) => toApiMessage(part)) + .filter((part): part is Message => part !== null && part !== undefined); + + useConversationStore.getState().updateCurrentConversation((prev: Conversation) => ({ + ...prev, + id: conversationId ?? prev.id, + modelSlug: modelSlug ?? prev.modelSlug, + messages: [...prev.messages, ...flushMessages], + })); +} + +// ============================================================================ +// State Machine Store +// ============================================================================ + +export const useStreamingStateMachine = create()( + subscribeWithSelector((set, get) => ({ + ...initialState, + + handleEvent: async (event: StreamEvent, context?: Partial) => { + switch (event.type) { + // ======================================================================== + // INIT - User message acknowledged by server + // ======================================================================== + case "INIT": { + // Finalize the user message (mark as received by server) + set((state) => ({ + state: "receiving", + syncRetryCount: 0, + streamingMessage: { + ...state.streamingMessage, + parts: state.streamingMessage.parts.map((part) => { + if (part.status === "streaming" && part.role === "user") { + return { ...part, status: "complete" as const }; + } + return part; + }), + }, + })); + + if (get().streamingMessage.parts.length !== 1) { + logWarn("Streaming message parts length is not 1, this may indicate stale messages"); + } + + // Flush to conversation store + flushStreamingMessageToConversation( + get().streamingMessage, + event.payload.conversationId, + event.payload.modelSlug, + ); + + // Reset after flush + set({ streamingMessage: { parts: [], sequence: 0 } }); + + // Refetch conversation list + context?.refetchConversationList?.(); + break; + } + + // ======================================================================== + // PART_BEGIN - New message part started + // ======================================================================== + case "PART_BEGIN": { + const role = event.payload.payload?.messageType.case as MessageRole | undefined; + + if (!role || !isValidMessageRole(role)) { + logError("Unknown role in streamPartBegin:", role); + break; + } + + const handler = getMessageTypeHandler(role); + const newMessage = handler.onPartBegin(event.payload); + + if (newMessage) { + set((state) => { + // Skip if entry with same id already exists + if (state.streamingMessage.parts.some((p) => p.id === newMessage.id)) { + return state; + } + return { + state: "receiving", + streamingMessage: { + parts: [...state.streamingMessage.parts, newMessage], + sequence: state.streamingMessage.sequence + 1, + }, + }; + }); + } + break; + } + + // ======================================================================== + // CHUNK - Message content chunk received + // ======================================================================== + case "CHUNK": { + set((state) => { + const updatedParts = state.streamingMessage.parts.map((part) => { + const isTargetPart = + part.id === event.payload.messageId && part.role === "assistant"; + + if (!isTargetPart) return part; + + if (part.status !== "streaming") { + logError("Message chunk received for non-streaming part"); + } + + if (part.role !== "assistant") return part; + + return { + ...part, + data: { + ...part.data, + content: part.data.content + event.payload.delta, + }, + }; + }); + + return { + streamingMessage: { + parts: updatedParts, + sequence: state.streamingMessage.sequence + 1, + }, + }; + }); + break; + } + + // ======================================================================== + // REASONING_CHUNK - Reasoning content chunk received + // ======================================================================== + case "REASONING_CHUNK": { + set((state) => { + const updatedParts = state.streamingMessage.parts.map((part) => { + const isTargetPart = + part.id === event.payload.messageId && part.role === "assistant"; + + if (!isTargetPart) return part; + + if (part.status !== "streaming") { + logError("Reasoning chunk received for non-streaming part"); + } + + if (part.role !== "assistant") return part; + + const currentReasoning = part.data.reasoning ?? ""; + return { + ...part, + data: { + ...part.data, + reasoning: currentReasoning + event.payload.delta, + }, + }; + }); + + return { + streamingMessage: { + parts: updatedParts, + sequence: state.streamingMessage.sequence + 1, + }, + }; + }); + break; + } + + // ======================================================================== + // PART_END - Message part completed + // ======================================================================== + case "PART_END": { + const role = event.payload.payload?.messageType.case as MessageRole | undefined; + + if (!role || !isValidMessageRole(role)) { + logError("Unknown role in streamPartEnd:", role); + break; + } + + const handler = getMessageTypeHandler(role); + + set((state) => { + const updatedParts = state.streamingMessage.parts.map((part) => { + if (part.id !== event.payload.messageId) { + return part; + } + + const updatedMessage = handler.onPartEnd(event.payload, part); + if (!updatedMessage) return part; + + return updatedMessage; + }); + + return { + streamingMessage: { + parts: updatedParts, + sequence: state.streamingMessage.sequence + 1, + }, + }; + }); + break; + } + + // ======================================================================== + // FINALIZE - Stream completed + // ======================================================================== + case "FINALIZE": { + set({ state: "finalizing" }); + + // Flush remaining messages to conversation store + flushStreamingMessageToConversation(get().streamingMessage, event.payload.conversationId); + + // Reset streaming state + set({ + state: "idle", + streamingMessage: { parts: [], sequence: 0 }, + }); + break; + } + + // ======================================================================== + // ERROR - Stream error from server + // ======================================================================== + case "ERROR": { + const errorMessage = event.payload.errorMessage; + const streamingError = createStreamingError(errorMessage); + const strategy = getRecoveryStrategy(streamingError); + + // Check if this error can be recovered with sync-and-retry + if ( + streamingError.retryable && + strategy.type === "sync-and-retry" && + context?.sync && + context?.sendMessageStream && + context?.currentPrompt !== undefined && + context?.currentSelectedText !== undefined + ) { + const currentPrompt = context.currentPrompt; + const currentSelectedText = context.currentSelectedText; + const sendMessageStream = context.sendMessageStream; + const retryCount = get().syncRetryCount; + + const handler = new StreamingErrorHandler({ + sync: context.sync, + retryOperation: () => sendMessageStream(currentPrompt, currentSelectedText), + }); + + const resolution = await handler.handle(errorMessage, { + retryCount, + maxRetries: strategy.maxRetries || 2, // Use strategy's maxRetries to prevent infinite retry + currentPrompt: context.currentPrompt, + currentSelectedText: context.currentSelectedText, + userId: context.userId, + operation: "send-message", + }); + + if (resolution.success) { + // Recovery succeeded; reset counter for next time + set({ syncRetryCount: 0 }); + return; // Successfully recovered + } + + // Recovery failed; advance counter so we eventually stop instead of looping. + set({ syncRetryCount: retryCount + 1 }); + // Fall through to error state if recovery failed + } + + // Add error message to streaming parts + const errorEntry: InternalMessage = createAssistantMessage( + "error-" + Date.now(), + errorMessage, + { status: "stale" } + ); + + set((state) => ({ + state: "error", + streamingMessage: { + ...state.streamingMessage, + parts: [...state.streamingMessage.parts, errorEntry], + }, + })); + + // Error handler already shows toast if needed, but show one for non-retryable errors + if (!streamingError.retryable) { + // Error is already handled by StreamingErrorHandler which shows toast + } + break; + } + + // ======================================================================== + // CONNECTION_ERROR - Network/connection error + // ======================================================================== + case "CONNECTION_ERROR": { + // Mark all streaming messages as stale + set((state) => ({ + state: "error", + streamingMessage: { + parts: state.streamingMessage.parts.map((part) => ({ + ...part, + status: part.status === "streaming" ? "stale" as const : part.status, + })), + sequence: state.streamingMessage.sequence + 1, + }, + })); + + logError("Connection error:", event.payload); + break; + } + + // ======================================================================== + // INCOMPLETE - Incomplete indicator received + // ======================================================================== + case "INCOMPLETE": { + set({ incompleteIndicator: event.payload }); + break; + } + + default: { + // Exhaustive type checking + const _exhaustive: never = event; + logError("Unhandled event type:", _exhaustive); + } + } + }, + + reset: () => { + set(initialState); + }, + + getStreamingMessage: () => get().streamingMessage, + + getIncompleteIndicator: () => get().incompleteIndicator, +})) +); + +// ============================================================================ +// Convenience Selectors +// ============================================================================ + +/** + * Select the streaming message from the state machine. + */ +export const selectStreamingMessage = (state: StreamingStateMachineState) => state.streamingMessage; + +/** + * Select the incomplete indicator from the state machine. + */ +export const selectIncompleteIndicator = (state: StreamingStateMachineState) => + state.incompleteIndicator; + +/** + * Select the current stream state. + */ +export const selectStreamState = (state: StreamingStateMachineState) => state.state; diff --git a/webapp/_webapp/src/stores/streaming/types.ts b/webapp/_webapp/src/stores/streaming/types.ts new file mode 100644 index 00000000..d90be8a1 --- /dev/null +++ b/webapp/_webapp/src/stores/streaming/types.ts @@ -0,0 +1,206 @@ +/** + * Streaming State Machine Types + * + * This file defines all types used by the streaming state machine, + * consolidating the fragmented type definitions across multiple handler files. + */ + +import { + IncompleteIndicator, + MessageChunk, + ReasoningChunk, + StreamError, + StreamFinalization, + StreamInitialization, + StreamPartBegin, + StreamPartEnd, + } from "../../pkg/gen/apiclient/chat/v2/chat_pb"; + import { InternalMessage, MessageStatus } from "../../types/message"; + + // Re-export InternalMessage for convenience + export type { InternalMessage, MessageStatus }; + + // ============================================================================ + // Stream State + // ============================================================================ + + /** + * Represents the current state of the streaming process. + */ + export type StreamState = "idle" | "receiving" | "thinking" | "finalizing" | "error"; + + // ============================================================================ + // Streaming Message State + // ============================================================================ + + /** + * The current streaming message state. + * Now uses InternalMessage instead of the legacy MessageEntry. + */ + export type StreamingMessage = { + parts: InternalMessage[]; + sequence: number; + }; + + // ============================================================================ + // Stream Events + // ============================================================================ + + /** + * Union type representing all possible stream events. + * This enables type-safe, exhaustive event handling in the state machine. + */ + export type StreamEvent = + | { type: "INIT"; payload: StreamInitialization } + | { type: "PART_BEGIN"; payload: StreamPartBegin } + | { type: "CHUNK"; payload: MessageChunk } + | { type: "REASONING_CHUNK"; payload: ReasoningChunk } + | { type: "PART_END"; payload: StreamPartEnd } + | { type: "FINALIZE"; payload: StreamFinalization } + | { type: "ERROR"; payload: StreamError } + | { type: "INCOMPLETE"; payload: IncompleteIndicator } + | { type: "CONNECTION_ERROR"; payload: Error }; + + /** + * Extract the payload type for a given event type. + */ + export type StreamEventPayload = Extract< + StreamEvent, + { type: T } + >["payload"]; + + // ============================================================================ + // Message Roles + // ============================================================================ + + /** + * All possible message roles from the protobuf MessagePayload. + */ + export type MessageRole = + | "assistant" + | "toolCallPrepareArguments" + | "toolCall" + | "user" + | "system" + | "unknown"; + + // ============================================================================ + // Handler Interfaces + // ============================================================================ + + /** + * Context provided to stream event handlers. + */ + export interface StreamHandlerContext { + /** Callback to refetch the conversation list */ + refetchConversationList: () => void; + /** User ID for error handling */ + userId: string; + /** Current prompt for retry scenarios */ + currentPrompt: string; + /** Current selected text for retry scenarios */ + currentSelectedText: string; + /** Sync function for project synchronization */ + sync: () => Promise<{ success: boolean; error?: Error }>; + /** Send message function for retry scenarios */ + sendMessageStream: (message: string, selectedText: string) => Promise; + } + + /** + * Interface for message type-specific handlers. + * Implementations handle the creation and finalization of specific message types. + */ + export interface MessageTypeHandler { + /** + * Called when a stream part begins for this message type. + * @returns A new InternalMessage or null if this type should be ignored. + */ + onPartBegin(partBegin: StreamPartBegin): InternalMessage | null; + + /** + * Called when a stream part ends for this message type. + * @returns Updated InternalMessage or null to skip. + */ + onPartEnd(partEnd: StreamPartEnd, existingMessage: InternalMessage): InternalMessage | null; + } + + /** + * Registry type for message type handlers. + */ + export type MessageTypeHandlerRegistry = Record; + + // ============================================================================ + // Error Handling Types (Phase 4) + // ============================================================================ + + /** + * Error codes that the streaming system can handle. + * Mapped from the protobuf ErrorCode enum for convenience. + */ + export type StreamingErrorCode = + | "PROJECT_OUT_OF_DATE" + | "NETWORK_ERROR" + | "TIMEOUT" + | "INVALID_RESPONSE" + | "RATE_LIMITED" + | "AUTHENTICATION_ERROR" + | "SERVER_ERROR" + | "UNKNOWN"; + + /** + * Represents a streaming error with additional context. + */ + export interface StreamingError { + /** Error code for categorization */ + code: StreamingErrorCode; + /** Human-readable error message */ + message: string; + /** Original error object if available */ + originalError?: Error | unknown; + /** Whether this error is retryable */ + retryable: boolean; + /** Timestamp when the error occurred */ + timestamp: number; + } + + /** + * Recovery strategy types for different error scenarios. + */ + export type RecoveryStrategy = + | { type: "retry"; maxRetries: number; backoff: "exponential" | "linear"; delayMs: number } + | { type: "sync-and-retry"; maxRetries: number } + | { type: "show-error"; dismissable: boolean; message?: string } + | { type: "abort"; cleanup?: boolean }; + + /** + * Context provided to the error handler for making recovery decisions. + */ + export interface ErrorContext { + /** Number of retry attempts already made */ + retryCount: number; + /** Maximum retries allowed */ + maxRetries: number; + /** Current prompt being sent */ + currentPrompt: string; + /** Current selected text */ + currentSelectedText: string; + /** User ID for logging/analytics */ + userId?: string; + /** Operation that failed */ + operation: "send-message" | "sync" | "fetch-conversation" | "other"; + } + + /** + * Result of error handling - what action was taken. + */ + export interface ErrorResolution { + /** Whether the error was handled (recovery attempted) */ + handled: boolean; + /** Whether recovery was successful */ + success: boolean; + /** Strategy that was applied */ + strategy: RecoveryStrategy; + /** Additional message for logging/display */ + message?: string; + } + \ No newline at end of file diff --git a/webapp/_webapp/src/stores/types.ts b/webapp/_webapp/src/stores/types.ts index 824eec09..93df44c8 100644 --- a/webapp/_webapp/src/stores/types.ts +++ b/webapp/_webapp/src/stores/types.ts @@ -12,3 +12,49 @@ export type Updater = { export type SetterResetterStore = T & Setter & Resetter & Updater; export type SetterStore = T & Setter; + +// ============================================================================ +// DisplayMessage Types - Unified message type for UI rendering +// ============================================================================ + +/** + * Status of a display message. + */ +export type DisplayMessageStatus = "streaming" | "complete" | "error" | "stale"; + +/** + * Type of display message. + */ +export type DisplayMessageType = "user" | "assistant" | "toolCall" | "toolCallPrepare" | "error"; + +/** + * Unified message type for UI rendering. + * All UI components should use this type instead of Message or MessageEntry directly. + * This provides a single consistent interface regardless of whether the message + * is finalized or still streaming. + */ +export interface DisplayMessage { + /** Unique message identifier */ + id: string; + + /** Message type */ + type: DisplayMessageType; + + /** Current status */ + status: DisplayMessageStatus; + + /** Main content (text for user/assistant, empty for tool calls) */ + content: string; + + /** Reasoning content (for assistant messages with thinking) */ + reasoning?: string; + + // Tool call specific fields + toolName?: string; + toolArgs?: string; + toolResult?: string; + toolError?: string; + + // User message specific fields + selectedText?: string; +} diff --git a/webapp/_webapp/src/types/index.ts b/webapp/_webapp/src/types/index.ts new file mode 100644 index 00000000..e1c53936 --- /dev/null +++ b/webapp/_webapp/src/types/index.ts @@ -0,0 +1,43 @@ +/** + * Types Module + * + * Re-exports all public types used throughout the application. + */ + +// Internal Message Types (canonical format) +export type { + InternalMessage, + UserMessage, + AssistantMessage, + ToolCallMessage, + ToolCallPrepareMessage, + SystemMessage, + UnknownMessage, + MessageStatus, + MessageRole, + UserMessageData, + AssistantMessageData, + ToolCallData, + ToolCallPrepareData, + SystemMessageData, + UnknownMessageData, + } from "./message"; + + // Type Guards + export { + isUserMessage, + isAssistantMessage, + isToolCallMessage, + isToolCallPrepareMessage, + isSystemMessage, + isUnknownMessage, + } from "./message"; + + // Factory Functions + export { + createUserMessage, + createAssistantMessage, + createToolCallMessage, + createToolCallPrepareMessage, + } from "./message"; + \ No newline at end of file diff --git a/webapp/_webapp/src/types/message.ts b/webapp/_webapp/src/types/message.ts new file mode 100644 index 00000000..a4050f74 --- /dev/null +++ b/webapp/_webapp/src/types/message.ts @@ -0,0 +1,299 @@ +/** + * Canonical Internal Message Types + * + * This file defines the single internal message format used throughout the app. + * All components should use these types instead of working with protobuf types directly. + * + * Data Flow: + * 1. API Response → InternalMessage (via fromApiMessage) + * 2. InternalMessage → API Request (via toApiMessage) + * 3. InternalMessage → DisplayMessage (trivial 1:1 mapping in most cases) + * + * Benefits: + * - Single format reduces confusion + * - Clear boundary between API types and internal types + * - Reduces the number of data transformations + */ + +// ============================================================================ +// Message Status +// ============================================================================ + +/** + * Status of a message during its lifecycle. + * Replaces MessageEntryStatus with clearer semantics. + */ +export type MessageStatus = "streaming" | "complete" | "error" | "stale"; + +// ============================================================================ +// Message Roles +// ============================================================================ + +/** + * All possible message roles. + */ +export type MessageRole = + | "user" + | "assistant" + | "toolCall" + | "toolCallPrepare" + | "system" + | "unknown"; + +// ============================================================================ +// Role-Specific Data +// ============================================================================ + +/** + * Data specific to user messages. + */ +export interface UserMessageData { + content: string; + selectedText?: string; + surrounding?: string; +} + +/** + * Data specific to assistant messages. + */ +export interface AssistantMessageData { + content: string; + reasoning?: string; + modelSlug?: string; +} + +/** + * Data specific to tool call messages. + */ +export interface ToolCallData { + name: string; + args: string; + result?: string; + error?: string; +} + +/** + * Data specific to tool call preparation messages. + */ +export interface ToolCallPrepareData { + name: string; + args: string; +} + +/** + * Data specific to system messages. + */ +export interface SystemMessageData { + content: string; +} + +/** + * Data specific to unknown messages. + */ +export interface UnknownMessageData { + description: string; +} + +// ============================================================================ +// Internal Message Type +// ============================================================================ + +/** + * Base properties shared by all message types. + */ +interface InternalMessageBase { + /** Unique message identifier */ + id: string; + /** Current status of the message */ + status: MessageStatus; + /** Optional timestamp (milliseconds since epoch) */ + timestamp?: number; +} + +/** + * User message. + */ +export interface UserMessage extends InternalMessageBase { + role: "user"; + data: UserMessageData; +} + +/** + * Assistant message. + */ +export interface AssistantMessage extends InternalMessageBase { + role: "assistant"; + data: AssistantMessageData; +} + +/** + * Tool call message. + */ +export interface ToolCallMessage extends InternalMessageBase { + role: "toolCall"; + data: ToolCallData; +} + +/** + * Tool call preparation message. + */ +export interface ToolCallPrepareMessage extends InternalMessageBase { + role: "toolCallPrepare"; + data: ToolCallPrepareData; +} + +/** + * System message. + */ +export interface SystemMessage extends InternalMessageBase { + role: "system"; + data: SystemMessageData; +} + +/** + * Unknown message type. + */ +export interface UnknownMessage extends InternalMessageBase { + role: "unknown"; + data: UnknownMessageData; +} + +/** + * Union type representing all internal message types. + * This is the canonical format used throughout the application. + */ +export type InternalMessage = + | UserMessage + | AssistantMessage + | ToolCallMessage + | ToolCallPrepareMessage + | SystemMessage + | UnknownMessage; + +// ============================================================================ +// Type Guards +// ============================================================================ + +export function isUserMessage(msg: InternalMessage): msg is UserMessage { + return msg.role === "user"; +} + +export function isAssistantMessage(msg: InternalMessage): msg is AssistantMessage { + return msg.role === "assistant"; +} + +export function isToolCallMessage(msg: InternalMessage): msg is ToolCallMessage { + return msg.role === "toolCall"; +} + +export function isToolCallPrepareMessage(msg: InternalMessage): msg is ToolCallPrepareMessage { + return msg.role === "toolCallPrepare"; +} + +export function isSystemMessage(msg: InternalMessage): msg is SystemMessage { + return msg.role === "system"; +} + +export function isUnknownMessage(msg: InternalMessage): msg is UnknownMessage { + return msg.role === "unknown"; +} + +// ============================================================================ +// Factory Functions +// ============================================================================ + +/** + * Create a new user message. + */ +export function createUserMessage( + id: string, + content: string, + options?: { + selectedText?: string; + surrounding?: string; + status?: MessageStatus; + } +): UserMessage { + return { + id, + role: "user", + status: options?.status ?? "streaming", + data: { + content, + selectedText: options?.selectedText, + surrounding: options?.surrounding, + }, + }; +} + +/** + * Create a new assistant message. + */ +export function createAssistantMessage( + id: string, + content: string, + options?: { + reasoning?: string; + modelSlug?: string; + status?: MessageStatus; + } +): AssistantMessage { + return { + id, + role: "assistant", + status: options?.status ?? "streaming", + data: { + content, + reasoning: options?.reasoning, + modelSlug: options?.modelSlug, + }, + }; +} + +/** + * Create a new tool call message. + */ +export function createToolCallMessage( + id: string, + name: string, + args: string, + options?: { + result?: string; + error?: string; + status?: MessageStatus; + } +): ToolCallMessage { + return { + id, + role: "toolCall", + status: options?.status ?? "streaming", + data: { + name, + args, + result: options?.result, + error: options?.error, + }, + }; +} + +/** + * Create a new tool call prepare message. + */ +export function createToolCallPrepareMessage( + id: string, + name: string, + args: string, + options?: { + status?: MessageStatus; + } +): ToolCallPrepareMessage { + return { + id, + role: "toolCallPrepare", + status: options?.status ?? "streaming", + data: { + name, + args, + }, + }; +} diff --git a/webapp/_webapp/src/utils/__tests__/message-converters.test.ts b/webapp/_webapp/src/utils/__tests__/message-converters.test.ts new file mode 100644 index 00000000..1d53de6d --- /dev/null +++ b/webapp/_webapp/src/utils/__tests__/message-converters.test.ts @@ -0,0 +1,617 @@ +/** + * Unit Tests for Message Converters + * + * Tests bidirectional conversion between API types, InternalMessage, and DisplayMessage. + */ + +import { describe, it, expect } from "bun:test"; +import { + fromApiMessage, + toApiMessage, + fromStreamPartBegin, + applyStreamPartEnd, + toDisplayMessage, + fromDisplayMessage, +} from "../message-converters"; +import { InternalMessage, MessageStatus } from "../../types/message"; +import { DisplayMessage } from "../../stores/types"; + +describe("Message Converters", () => { + describe("fromApiMessage", () => { + it("should convert user message from API", () => { + const apiMessage = { + messageId: "user-1", + timestamp: BigInt(1000), + payload: { + messageType: { + case: "user" as const, + value: { + content: "Hello", + selectedText: "selected", + surrounding: "context", + }, + }, + }, + }; + + const result = fromApiMessage(apiMessage as any); + + expect(result).not.toBeNull(); + expect(result!.id).toBe("user-1"); + expect(result!.role).toBe("user"); + expect(result!.status).toBe("complete"); + if (result!.role === "user") { + expect(result!.data.content).toBe("Hello"); + expect(result!.data.selectedText).toBe("selected"); + expect(result!.data.surrounding).toBe("context"); + } + }); + + it("should convert assistant message from API", () => { + const apiMessage = { + messageId: "assistant-1", + timestamp: BigInt(2000), + payload: { + messageType: { + case: "assistant" as const, + value: { + content: "Response", + reasoning: "Thinking...", + modelSlug: "gpt-4", + }, + }, + }, + }; + + const result = fromApiMessage(apiMessage as any); + + expect(result).not.toBeNull(); + expect(result!.id).toBe("assistant-1"); + expect(result!.role).toBe("assistant"); + if (result!.role === "assistant") { + expect(result!.data.content).toBe("Response"); + expect(result!.data.reasoning).toBe("Thinking..."); + expect(result!.data.modelSlug).toBe("gpt-4"); + } + }); + + it("should convert toolCall message from API", () => { + const apiMessage = { + messageId: "tool-1", + payload: { + messageType: { + case: "toolCall" as const, + value: { + name: "search", + args: '{"query": "test"}', + result: "Found 3 results", + error: "", + }, + }, + }, + }; + + const result = fromApiMessage(apiMessage as any); + + expect(result).not.toBeNull(); + expect(result!.role).toBe("toolCall"); + if (result!.role === "toolCall") { + expect(result!.data.name).toBe("search"); + expect(result!.data.result).toBe("Found 3 results"); + } + }); + + it("should convert toolCallPrepareArguments message from API", () => { + const apiMessage = { + messageId: "prep-1", + payload: { + messageType: { + case: "toolCallPrepareArguments" as const, + value: { + name: "search", + args: '{"query":', + }, + }, + }, + }; + + const result = fromApiMessage(apiMessage as any); + + expect(result).not.toBeNull(); + expect(result!.role).toBe("toolCallPrepare"); + }); + + it("should convert system message from API", () => { + const apiMessage = { + messageId: "sys-1", + payload: { + messageType: { + case: "system" as const, + value: { + content: "System message", + }, + }, + }, + }; + + const result = fromApiMessage(apiMessage as any); + + expect(result).not.toBeNull(); + expect(result!.role).toBe("system"); + }); + + it("should convert unknown message from API", () => { + const apiMessage = { + messageId: "unknown-1", + payload: { + messageType: { + case: "unknown" as const, + value: { + description: "Unknown message type", + }, + }, + }, + }; + + const result = fromApiMessage(apiMessage as any); + + expect(result).not.toBeNull(); + expect(result!.role).toBe("unknown"); + }); + + it("should return null for messages without payload", () => { + const apiMessage = { messageId: "empty-1" }; + const result = fromApiMessage(apiMessage as any); + expect(result).toBeNull(); + }); + + it("should respect status override", () => { + const apiMessage = { + messageId: "user-1", + payload: { + messageType: { + case: "user" as const, + value: { content: "Hello" }, + }, + }, + }; + + const result = fromApiMessage(apiMessage as any, "streaming"); + expect(result!.status).toBe("streaming"); + }); + }); + + describe("toApiMessage", () => { + it("should convert user InternalMessage to API", () => { + const internal: InternalMessage = { + id: "user-1", + role: "user", + status: "complete", + data: { + content: "Hello", + selectedText: "selected", + }, + }; + + const result = toApiMessage(internal); + + expect(result).toBeDefined(); + expect(result!.messageId).toBe("user-1"); + }); + + it("should convert assistant InternalMessage to API", () => { + const internal: InternalMessage = { + id: "assistant-1", + role: "assistant", + status: "complete", + data: { + content: "Response", + reasoning: "Thinking", + modelSlug: "gpt-4", + }, + }; + + const result = toApiMessage(internal); + + expect(result).toBeDefined(); + expect(result!.messageId).toBe("assistant-1"); + }); + + it("should convert toolCall InternalMessage to API", () => { + const internal: InternalMessage = { + id: "tool-1", + role: "toolCall", + status: "complete", + data: { + name: "search", + args: "{}", + result: "Found", + error: "", + }, + }; + + const result = toApiMessage(internal); + + expect(result).toBeDefined(); + expect(result!.messageId).toBe("tool-1"); + }); + + it("should handle optional fields in assistant message", () => { + const internal: InternalMessage = { + id: "assistant-1", + role: "assistant", + status: "complete", + data: { + content: "Response", + // No reasoning or modelSlug + }, + }; + + const result = toApiMessage(internal); + + expect(result).toBeDefined(); + }); + }); + + describe("fromStreamPartBegin", () => { + it("should create assistant message from StreamPartBegin", () => { + const partBegin = { + messageId: "msg-1", + payload: { + messageType: { + case: "assistant" as const, + value: { + content: "Hello", + reasoning: "Thinking", + modelSlug: "gpt-4", + }, + }, + }, + }; + + const result = fromStreamPartBegin(partBegin as any); + + expect(result).not.toBeNull(); + expect(result!.id).toBe("msg-1"); + expect(result!.role).toBe("assistant"); + expect(result!.status).toBe("streaming"); + }); + + it("should create toolCall message from StreamPartBegin", () => { + const partBegin = { + messageId: "tool-1", + payload: { + messageType: { + case: "toolCall" as const, + value: { + name: "search", + args: "{}", + result: "", + error: "", + }, + }, + }, + }; + + const result = fromStreamPartBegin(partBegin as any); + + expect(result).not.toBeNull(); + expect(result!.role).toBe("toolCall"); + }); + + it("should return null for user messages", () => { + const partBegin = { + messageId: "user-1", + payload: { + messageType: { + case: "user" as const, + value: { content: "Hello" }, + }, + }, + }; + + const result = fromStreamPartBegin(partBegin as any); + expect(result).toBeNull(); + }); + + it("should return null for system messages", () => { + const partBegin = { + messageId: "sys-1", + payload: { + messageType: { + case: "system" as const, + value: { content: "System" }, + }, + }, + }; + + const result = fromStreamPartBegin(partBegin as any); + expect(result).toBeNull(); + }); + + it("should return null when payload is missing", () => { + const partBegin = { messageId: "msg-1" }; + const result = fromStreamPartBegin(partBegin as any); + expect(result).toBeNull(); + }); + }); + + describe("applyStreamPartEnd", () => { + it("should update assistant message from StreamPartEnd", () => { + const existing: InternalMessage = { + id: "msg-1", + role: "assistant", + status: "streaming", + data: { content: "Hello" }, + }; + + const partEnd = { + messageId: "msg-1", + payload: { + messageType: { + case: "assistant" as const, + value: { + content: "Hello World!", + reasoning: "Done", + modelSlug: "gpt-4", + }, + }, + }, + }; + + const result = applyStreamPartEnd(partEnd as any, existing); + + expect(result).not.toBeNull(); + expect(result!.status).toBe("complete"); + if (result!.role === "assistant") { + expect(result!.data.content).toBe("Hello World!"); + expect(result!.data.reasoning).toBe("Done"); + } + }); + + it("should update toolCall message from StreamPartEnd", () => { + const existing: InternalMessage = { + id: "tool-1", + role: "toolCall", + status: "streaming", + data: { name: "search", args: "{}", result: "", error: "" }, + }; + + const partEnd = { + messageId: "tool-1", + payload: { + messageType: { + case: "toolCall" as const, + value: { + name: "search", + args: '{"q": "test"}', + result: "Found!", + error: "", + }, + }, + }, + }; + + const result = applyStreamPartEnd(partEnd as any, existing); + + expect(result).not.toBeNull(); + expect(result!.status).toBe("complete"); + if (result!.role === "toolCall") { + expect(result!.data.result).toBe("Found!"); + } + }); + + it("should return null for role mismatch", () => { + const existing: InternalMessage = { + id: "msg-1", + role: "user", + status: "streaming", + data: { content: "Hello" }, + }; + + const partEnd = { + messageId: "msg-1", + payload: { + messageType: { + case: "assistant" as const, + value: { content: "Response" }, + }, + }, + }; + + const result = applyStreamPartEnd(partEnd as any, existing); + expect(result).toBeNull(); + }); + }); + + describe("toDisplayMessage", () => { + it("should convert user message to DisplayMessage", () => { + const internal: InternalMessage = { + id: "user-1", + role: "user", + status: "complete", + data: { + content: "Hello", + selectedText: "selected", + }, + }; + + const result = toDisplayMessage(internal); + + expect(result).not.toBeNull(); + expect(result!.id).toBe("user-1"); + expect(result!.type).toBe("user"); + expect(result!.content).toBe("Hello"); + expect(result!.selectedText).toBe("selected"); + }); + + it("should convert assistant message to DisplayMessage", () => { + const internal: InternalMessage = { + id: "assistant-1", + role: "assistant", + status: "streaming", + data: { + content: "Response", + reasoning: "Thinking", + }, + }; + + const result = toDisplayMessage(internal); + + expect(result).not.toBeNull(); + expect(result!.type).toBe("assistant"); + expect(result!.status).toBe("streaming"); + expect(result!.reasoning).toBe("Thinking"); + }); + + it("should convert toolCall message to DisplayMessage", () => { + const internal: InternalMessage = { + id: "tool-1", + role: "toolCall", + status: "complete", + data: { + name: "search", + args: '{"q": "test"}', + result: "Found!", + error: "", + }, + }; + + const result = toDisplayMessage(internal); + + expect(result).not.toBeNull(); + expect(result!.type).toBe("toolCall"); + expect(result!.toolName).toBe("search"); + expect(result!.toolResult).toBe("Found!"); + }); + + it("should return null for system messages", () => { + const internal: InternalMessage = { + id: "sys-1", + role: "system", + status: "complete", + data: { content: "System" }, + }; + + const result = toDisplayMessage(internal); + expect(result).toBeNull(); + }); + + it("should return null for unknown messages", () => { + const internal: InternalMessage = { + id: "unknown-1", + role: "unknown", + status: "complete", + data: { description: "Unknown" }, + }; + + const result = toDisplayMessage(internal); + expect(result).toBeNull(); + }); + }); + + describe("fromDisplayMessage", () => { + it("should convert user DisplayMessage to InternalMessage", () => { + const display: DisplayMessage = { + id: "user-1", + type: "user", + status: "complete", + content: "Hello", + selectedText: "selected", + }; + + const result = fromDisplayMessage(display); + + expect(result.id).toBe("user-1"); + expect(result.role).toBe("user"); + if (result.role === "user") { + expect(result.data.content).toBe("Hello"); + expect(result.data.selectedText).toBe("selected"); + } + }); + + it("should convert assistant DisplayMessage to InternalMessage", () => { + const display: DisplayMessage = { + id: "assistant-1", + type: "assistant", + status: "streaming", + content: "Response", + reasoning: "Thinking", + }; + + const result = fromDisplayMessage(display); + + expect(result.role).toBe("assistant"); + expect(result.status).toBe("streaming"); + if (result.role === "assistant") { + expect(result.data.reasoning).toBe("Thinking"); + } + }); + + it("should convert toolCall DisplayMessage to InternalMessage", () => { + const display: DisplayMessage = { + id: "tool-1", + type: "toolCall", + status: "complete", + content: "", + toolName: "search", + toolArgs: "{}", + toolResult: "Found!", + }; + + const result = fromDisplayMessage(display); + + expect(result.role).toBe("toolCall"); + if (result.role === "toolCall") { + expect(result.data.name).toBe("search"); + expect(result.data.result).toBe("Found!"); + } + }); + + it("should convert error DisplayMessage to unknown InternalMessage", () => { + const display: DisplayMessage = { + id: "error-1", + type: "error", + status: "error", + content: "Something went wrong", + }; + + const result = fromDisplayMessage(display); + + expect(result.role).toBe("unknown"); + if (result.role === "unknown") { + expect(result.data.description).toBe("Something went wrong"); + } + }); + + + }); + + describe("Round-trip conversion", () => { + it("should maintain data integrity through InternalMessage → DisplayMessage → InternalMessage", () => { + const original: InternalMessage = { + id: "assistant-1", + role: "assistant", + status: "complete", + data: { + content: "Hello World", + reasoning: "Deep thought", + modelSlug: "gpt-4", + }, + }; + + const display = toDisplayMessage(original); + expect(display).not.toBeNull(); + + const restored = fromDisplayMessage(display!); + + expect(restored.id).toBe(original.id); + expect(restored.role).toBe(original.role); + expect(restored.status).toBe(original.status); + if (restored.role === "assistant") { + expect(restored.data.content).toBe(original.data.content); + expect(restored.data.reasoning).toBe(original.data.reasoning); + } + }); + }); +}); diff --git a/webapp/_webapp/src/utils/index.ts b/webapp/_webapp/src/utils/index.ts new file mode 100644 index 00000000..cfc2e26e --- /dev/null +++ b/webapp/_webapp/src/utils/index.ts @@ -0,0 +1,35 @@ +/** + * Utils Module + * + * Re-exports all utility functions. + */ + +// Message Converters +export { + // API ↔ InternalMessage + fromApiMessage, + toApiMessage, + // Stream Events → InternalMessage + fromStreamPartBegin, + applyStreamPartEnd, + // InternalMessage ↔ DisplayMessage + toDisplayMessage, + fromDisplayMessage, + } from "./message-converters"; + + // Stream Request Builder + export { + buildStreamRequest, + validateStreamRequestParams, + type StreamRequestParams, + } from "./stream-request-builder"; + + // Stream Event Mapper + export { + mapResponseToStreamEvent, + isFinalizeEvent, + isErrorEvent, + isInitEvent, + isChunkEvent, + } from "./stream-event-mapper"; + \ No newline at end of file diff --git a/webapp/_webapp/src/utils/message-converters.ts b/webapp/_webapp/src/utils/message-converters.ts new file mode 100644 index 00000000..efcf77cb --- /dev/null +++ b/webapp/_webapp/src/utils/message-converters.ts @@ -0,0 +1,482 @@ +/** + * Message Converters + * + * Bidirectional converters between API types (protobuf Message) and internal types (InternalMessage). + * These provide the only two transformations needed: + * + * 1. API Response → InternalMessage (fromApiMessage) + * 2. InternalMessage → API Request (toApiMessage) + * + * Benefits: + * - Clear boundary between API types and internal types + * - Reduces the number of data transformations from 5+ to 2 + * - All conversion logic in one place + */ + +import { + Message, + MessageSchema, + StreamPartBegin, + StreamPartEnd, + } from "../pkg/gen/apiclient/chat/v2/chat_pb"; + import { fromJson } from "../libs/protobuf-utils"; + import { + InternalMessage, + MessageStatus, + createAssistantMessage, + createToolCallMessage, + createToolCallPrepareMessage, + } from "../types/message"; + + // ============================================================================ + // API Response → InternalMessage + // ============================================================================ + + /** + * Convert a protobuf Message to InternalMessage. + * This is used when receiving finalized messages from the server. + * + * @param msg The protobuf Message from the API + * @param status Optional status override (default: "complete") + * @returns InternalMessage or null if message type is not recognized + */ + export function fromApiMessage(msg: Message, status: MessageStatus = "complete"): InternalMessage | null { + const messageType = msg.payload?.messageType; + + if (!messageType) return null; + + switch (messageType.case) { + case "user": + return { + id: msg.messageId, + role: "user", + status, + timestamp: Number(msg.timestamp) || undefined, + data: { + content: messageType.value.content, + selectedText: messageType.value.selectedText, + surrounding: messageType.value.surrounding, + }, + }; + + case "assistant": + return { + id: msg.messageId, + role: "assistant", + status, + timestamp: Number(msg.timestamp) || undefined, + data: { + content: messageType.value.content, + reasoning: messageType.value.reasoning, + modelSlug: messageType.value.modelSlug, + }, + }; + + case "toolCall": + return { + id: msg.messageId, + role: "toolCall", + status, + timestamp: Number(msg.timestamp) || undefined, + data: { + name: messageType.value.name, + args: messageType.value.args, + result: messageType.value.result, + error: messageType.value.error, + }, + }; + + case "toolCallPrepareArguments": + return { + id: msg.messageId, + role: "toolCallPrepare", + status, + timestamp: Number(msg.timestamp) || undefined, + data: { + name: messageType.value.name, + args: messageType.value.args, + }, + }; + + case "system": + return { + id: msg.messageId, + role: "system", + status, + timestamp: Number(msg.timestamp) || undefined, + data: { + content: messageType.value.content, + }, + }; + + case "unknown": + return { + id: msg.messageId, + role: "unknown", + status, + timestamp: Number(msg.timestamp) || undefined, + data: { + description: messageType.value.description, + }, + }; + + default: + return null; + } + } + + // ============================================================================ + // InternalMessage → API Request + // ============================================================================ + + import { JsonValue } from "@bufbuild/protobuf"; + + /** + * Convert an InternalMessage to a protobuf Message. + * This is used when sending messages to the server or storing in conversation state. + * + * @param msg The internal message + * @returns Protobuf Message or undefined if conversion fails + */ + export function toApiMessage(msg: InternalMessage): Message | undefined { + switch (msg.role) { + case "user": + return fromJson(MessageSchema, { + messageId: msg.id, + payload: { + user: { + content: msg.data.content, + selectedText: msg.data.selectedText ?? "", + }, + }, + } as unknown as JsonValue); + + case "assistant": { + const assistantPayload: { content: string; reasoning?: string; modelSlug?: string } = { + content: msg.data.content, + }; + if (msg.data.reasoning) { + assistantPayload.reasoning = msg.data.reasoning; + } + if (msg.data.modelSlug) { + assistantPayload.modelSlug = msg.data.modelSlug; + } + return fromJson(MessageSchema, { + messageId: msg.id, + payload: { + assistant: assistantPayload, + }, + } as unknown as JsonValue); + } + + case "toolCall": + return fromJson(MessageSchema, { + messageId: msg.id, + payload: { + toolCall: { + name: msg.data.name, + args: msg.data.args, + result: msg.data.result ?? "", + error: msg.data.error ?? "", + }, + }, + } as unknown as JsonValue); + + case "toolCallPrepare": + return fromJson(MessageSchema, { + messageId: msg.id, + payload: { + toolCallPrepareArguments: { + name: msg.data.name, + args: msg.data.args, + }, + }, + } as unknown as JsonValue); + + case "system": + return fromJson(MessageSchema, { + messageId: msg.id, + payload: { + system: { + content: msg.data.content, + }, + }, + } as unknown as JsonValue); + + case "unknown": + return fromJson(MessageSchema, { + messageId: msg.id, + payload: { + unknown: { + description: msg.data.description, + }, + }, + } as unknown as JsonValue); + + default: + return undefined; + } + } + + // ============================================================================ + // Stream Events → InternalMessage + // ============================================================================ + + /** + * Create an InternalMessage from a StreamPartBegin event. + * Used during streaming to initialize a new message entry. + * + * @param partBegin The stream part begin event + * @returns InternalMessage or null if message type should be ignored + */ + export function fromStreamPartBegin(partBegin: StreamPartBegin): InternalMessage | null { + const messageType = partBegin.payload?.messageType; + + if (!messageType) return null; + + switch (messageType.case) { + case "assistant": + return createAssistantMessage( + partBegin.messageId, + messageType.value.content, + { + reasoning: messageType.value.reasoning, + modelSlug: messageType.value.modelSlug, + status: "streaming", + } + ); + + case "toolCall": + return createToolCallMessage( + partBegin.messageId, + messageType.value.name, + messageType.value.args, + { + result: messageType.value.result, + error: messageType.value.error, + status: "streaming", + } + ); + + case "toolCallPrepareArguments": + return createToolCallPrepareMessage( + partBegin.messageId, + messageType.value.name, + messageType.value.args, + { status: "streaming" } + ); + + // User, system, and unknown messages are not handled via streaming + case "user": + case "system": + case "unknown": + return null; + + default: + return null; + } + } + + /** + * Get the updated data from a StreamPartEnd event. + * Used to finalize a streaming message with complete data. + * + * @param partEnd The stream part end event + * @param existingMessage The existing message to update + * @returns Updated InternalMessage or null if update should be skipped + */ + export function applyStreamPartEnd( + partEnd: StreamPartEnd, + existingMessage: InternalMessage + ): InternalMessage | null { + const messageType = partEnd.payload?.messageType; + + if (!messageType) return null; + + switch (messageType.case) { + case "assistant": + if (existingMessage.role !== "assistant") return null; + return { + ...existingMessage, + status: "complete", + data: { + ...existingMessage.data, + content: messageType.value.content, + reasoning: messageType.value.reasoning, + modelSlug: messageType.value.modelSlug, + }, + }; + + case "toolCall": + if (existingMessage.role !== "toolCall") return null; + return { + ...existingMessage, + status: "complete", + data: { + name: messageType.value.name, + args: messageType.value.args, + result: messageType.value.result, + error: messageType.value.error, + }, + }; + + case "toolCallPrepareArguments": + if (existingMessage.role !== "toolCallPrepare") return null; + return { + ...existingMessage, + status: "complete", + data: { + name: messageType.value.name, + args: messageType.value.args, + }, + }; + + // User, system, and unknown messages are not handled via streaming + case "user": + case "system": + case "unknown": + return null; + + default: + return null; + } + } + + // ============================================================================ + // InternalMessage ↔ DisplayMessage + // ============================================================================ + + import { DisplayMessage, DisplayMessageStatus } from "../stores/types"; + + /** + * Convert InternalMessage to DisplayMessage. + * This is a simple 1:1 mapping in most cases. + * + * @param msg The internal message + * @returns DisplayMessage or null if message should not be displayed + */ + export function toDisplayMessage(msg: InternalMessage): DisplayMessage | null { + switch (msg.role) { + case "user": + return { + id: msg.id, + type: "user", + status: msg.status as DisplayMessageStatus, + content: msg.data.content, + selectedText: msg.data.selectedText, + }; + + case "assistant": + return { + id: msg.id, + type: "assistant", + status: msg.status as DisplayMessageStatus, + content: msg.data.content, + reasoning: msg.data.reasoning, + }; + + case "toolCall": + return { + id: msg.id, + type: "toolCall", + status: msg.status as DisplayMessageStatus, + content: "", + toolName: msg.data.name, + toolArgs: msg.data.args, + toolResult: msg.data.result, + toolError: msg.data.error, + }; + + case "toolCallPrepare": + return { + id: msg.id, + type: "toolCallPrepare", + status: msg.status as DisplayMessageStatus, + content: "", + toolName: msg.data.name, + toolArgs: msg.data.args, + }; + + // System and unknown messages are typically not displayed + case "system": + case "unknown": + return null; + + default: + return null; + } + } + + /** + * Convert DisplayMessage back to InternalMessage. + * Used for backward compatibility with components that need InternalMessage. + * + * @param msg The display message + * @returns InternalMessage + */ + export function fromDisplayMessage(msg: DisplayMessage): InternalMessage { + const status = msg.status as MessageStatus; + + switch (msg.type) { + case "user": + return { + id: msg.id, + role: "user", + status, + data: { + content: msg.content, + selectedText: msg.selectedText, + }, + }; + + case "assistant": + return { + id: msg.id, + role: "assistant", + status, + data: { + content: msg.content, + reasoning: msg.reasoning, + }, + }; + + case "toolCall": + return { + id: msg.id, + role: "toolCall", + status, + data: { + name: msg.toolName ?? "", + args: msg.toolArgs ?? "", + result: msg.toolResult, + error: msg.toolError, + }, + }; + + case "toolCallPrepare": + return { + id: msg.id, + role: "toolCallPrepare", + status, + data: { + name: msg.toolName ?? "", + args: msg.toolArgs ?? "", + }, + }; + + case "error": + default: + return { + id: msg.id, + role: "unknown", + status, + data: { + description: msg.content, + }, + }; + } + } + + // Re-export factory functions for convenience + export { createAssistantMessage, createToolCallMessage, createToolCallPrepareMessage }; + \ No newline at end of file diff --git a/webapp/_webapp/src/utils/stream-event-mapper.ts b/webapp/_webapp/src/utils/stream-event-mapper.ts new file mode 100644 index 00000000..39c40b00 --- /dev/null +++ b/webapp/_webapp/src/utils/stream-event-mapper.ts @@ -0,0 +1,116 @@ +/** + * Stream Event Mapper + * + * Maps API streaming responses to typed StreamEvent objects for the state machine. + * This extracts the mapping logic from useSendMessageStream hook, + * providing a pure, testable function. + */ + +import { + CreateConversationMessageStreamResponse, + IncompleteIndicator, + MessageChunk, + ReasoningChunk, + StreamError, + StreamFinalization, + StreamInitialization, + StreamPartBegin, + StreamPartEnd, + } from "../pkg/gen/apiclient/chat/v2/chat_pb"; + import { StreamEvent } from "../stores/streaming"; + import { logError } from "../libs/logger"; + + // ============================================================================ + // Event Mapper + // ============================================================================ + + /** + * Maps the API response payload to a StreamEvent for the state machine. + * + * This is a pure function that converts the API response to a typed event + * that can be handled by the streaming state machine. + * + * @param response - The streaming response from the API + * @returns A StreamEvent or null if the response type is not recognized + * + * @example + * ```ts + * createConversationMessageStream(request, (response) => { + * const event = mapResponseToStreamEvent(response); + * if (event) { + * machine.handleEvent(event); + * } + * }); + * ``` + */ + export function mapResponseToStreamEvent( + response: CreateConversationMessageStreamResponse + ): StreamEvent | null { + const { case: payloadCase, value } = response.responsePayload; + + switch (payloadCase) { + case "streamInitialization": + return { type: "INIT", payload: value as StreamInitialization }; + + case "streamPartBegin": + return { type: "PART_BEGIN", payload: value as StreamPartBegin }; + + case "messageChunk": + return { type: "CHUNK", payload: value as MessageChunk }; + + case "reasoningChunk": + return { type: "REASONING_CHUNK", payload: value as ReasoningChunk }; + + case "streamPartEnd": + return { type: "PART_END", payload: value as StreamPartEnd }; + + case "streamFinalization": + return { type: "FINALIZE", payload: value as StreamFinalization }; + + case "streamError": + return { type: "ERROR", payload: value as StreamError }; + + case "incompleteIndicator": + return { type: "INCOMPLETE", payload: value as IncompleteIndicator }; + + default: + // Log unexpected payload types for debugging + if (value !== undefined) { + logError("Unexpected response payload type:", payloadCase, response.responsePayload); + } + return null; + } + } + + // ============================================================================ + // Event Type Guards + // ============================================================================ + + /** + * Check if an event is a finalization event. + */ + export function isFinalizeEvent(event: StreamEvent): event is { type: "FINALIZE"; payload: StreamFinalization } { + return event.type === "FINALIZE"; + } + + /** + * Check if an event is an error event. + */ + export function isErrorEvent(event: StreamEvent): event is { type: "ERROR"; payload: StreamError } { + return event.type === "ERROR"; + } + + /** + * Check if an event is an initialization event. + */ + export function isInitEvent(event: StreamEvent): event is { type: "INIT"; payload: StreamInitialization } { + return event.type === "INIT"; + } + + /** + * Check if an event is a content chunk event. + */ + export function isChunkEvent(event: StreamEvent): event is { type: "CHUNK"; payload: MessageChunk } { + return event.type === "CHUNK"; + } + \ No newline at end of file diff --git a/webapp/_webapp/src/utils/stream-request-builder.ts b/webapp/_webapp/src/utils/stream-request-builder.ts new file mode 100644 index 00000000..00ca309d --- /dev/null +++ b/webapp/_webapp/src/utils/stream-request-builder.ts @@ -0,0 +1,113 @@ +/** + * Stream Request Builder + * + * Provides a pure, testable function for building streaming request objects. + * This extracts the request-building logic from useSendMessageStream hook, + * making it easier to test and reducing hook complexity. + */ + +import { + ConversationType, + CreateConversationMessageStreamRequest, + } from "../pkg/gen/apiclient/chat/v2/chat_pb"; + import { PlainMessage } from "../query/types"; + + // ============================================================================ + // Types + // ============================================================================ + + /** + * Parameters required to build a stream request. + */ + export interface StreamRequestParams { + /** User message content */ + message: string; + /** Selected text from the document */ + selectedText: string; + /** Project/document ID */ + projectId: string; + /** Current conversation ID */ + conversationId: string; + /** Model slug for the conversation */ + modelSlug: string; + /** Surrounding text context */ + surroundingText?: string; + /** Conversation mode (debug or default) */ + conversationMode: "debug" | "default"; + /** Parent message ID for message editing/branching */ + } + + // ============================================================================ + // Request Builder + // ============================================================================ + + /** + * Build a stream request from the given parameters. + * + * This is a pure function that creates the request object needed for + * the streaming API call. Extracting this logic makes it: + * - Easy to test in isolation + * - Reusable across different contexts + * - Clear what data is needed to make a request + * + * @param params - The parameters for building the request + * @returns The request object ready for the API call + * + * @example + * ```ts + * const request = buildStreamRequest({ + * message: "Hello", + * selectedText: "", + * projectId: "123", + * conversationId: "456", + * modelSlug: "gpt-4", + * conversationMode: "default", + * }); + * await createConversationMessageStream(request, onMessage); + * ``` + */ + export function buildStreamRequest( + params: StreamRequestParams + ): PlainMessage { + return { + projectId: params.projectId, + conversationId: params.conversationId, + modelSlug: params.modelSlug, + userMessage: params.message, + userSelectedText: params.selectedText, + surrounding: params.surroundingText ?? undefined, + conversationType: + params.conversationMode === "debug" + ? ConversationType.DEBUG + : ConversationType.UNSPECIFIED, + }; + } + + /** + * Validate stream request parameters. + * + * @param params - The parameters to validate + * @returns An object with `valid` boolean and optional `error` message + */ + export function validateStreamRequestParams( + params: Partial + ): { valid: boolean; error?: string } { + if (!params.message || !params.message.trim()) { + return { valid: false, error: "Message cannot be empty" }; + } + + if (!params.projectId) { + return { valid: false, error: "Project ID is required" }; + } + + if (!params.conversationId) { + return { valid: false, error: "Conversation ID is required" }; + } + + if (!params.modelSlug) { + return { valid: false, error: "Model slug is required" }; + } + + return { valid: true }; + } + \ No newline at end of file diff --git a/webapp/_webapp/src/views/chat/body/index.tsx b/webapp/_webapp/src/views/chat/body/index.tsx index 7010d737..b2669f24 100644 --- a/webapp/_webapp/src/views/chat/body/index.tsx +++ b/webapp/_webapp/src/views/chat/body/index.tsx @@ -1,13 +1,19 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { MessageCard } from "../../../components/message-card"; -import { Conversation, Message } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; -import { filterVisibleMessages, getPrevUserMessage, isEmptyConversation, messageToMessageEntry } from "../helper"; +import { Conversation } from "../../../pkg/gen/apiclient/chat/v2/chat_pb"; +import { + isEmptyConversation, + getPrevUserSelectedText, + findLastUserMessageIndex, +} from "../helper"; import { StatusIndicator } from "./status-indicator"; import { EmptyView } from "./empty-view"; -import { useStreamingMessageStore } from "../../../stores/streaming-message-store"; +import { useMessageStore } from "../../../stores/message-store"; import { useSettingStore } from "../../../stores/setting-store"; import { useConversationStore } from "../../../stores/conversation/conversation-store"; +import { useStreamingStateMachine } from "../../../stores/streaming"; import { getConversation } from "../../../query/api"; +import { DisplayMessage } from "../../../stores/types"; interface ChatBodyProps { conversation?: Conversation; @@ -23,117 +29,100 @@ export const ChatBody = ({ conversation }: ChatBodyProps) => { const setCurrentConversation = useConversationStore((s) => s.setCurrentConversation); const chatContainerRef = useRef(null); const lastUserMsgRef = useRef(null); - const expanderRef = useRef(null); - const streamingMessage = useStreamingMessageStore((s) => s.streamingMessage); - const visibleMessages = useMemo(() => filterVisibleMessages(conversation), [conversation]); const [reloadSuccess, setReloadSuccess] = useState(ReloadStatus.Default); const conversationMode = useSettingStore((s) => s.conversationMode); const isDebugMode = conversationMode === "debug"; - // Scroll to the top of the last user message - useEffect(() => { - if (expanderRef.current) { - expanderRef.current.style.height = "1000px"; - } - - const chatContainerHeight = chatContainerRef.current?.clientHeight ?? 0; - const expanderViewOffset = - (expanderRef.current?.getBoundingClientRect().top ?? 0) - - (chatContainerRef.current?.getBoundingClientRect().y ?? 0); - - let expanderHeight: number; - if (expanderViewOffset < 0) { - expanderHeight = 0; // The expander's position is absolute and renders independently from stream markdown. When stream markdown renders, the expander may scroll above the chatContainer due to user scrolling, causing expander.y < 0. In this case, we don't need the expander. - } else { - expanderHeight = chatContainerHeight - expanderViewOffset; - } - - if (expanderRef.current) { - const lastUserMsgHeight = lastUserMsgRef.current?.clientHeight ?? 0; - expanderRef.current.style.height = chatContainerHeight - lastUserMsgHeight - 8 + "px"; - } - - if (lastUserMsgRef.current && chatContainerRef.current) { - const container = chatContainerRef.current; - const target = lastUserMsgRef.current; - container.scrollTo({ - top: target.offsetTop, - behavior: "smooth", - }); - } else { - if (expanderRef.current) { - expanderRef.current.style.height = (expanderHeight < 0 ? 0 : expanderHeight) + "px"; - } - } - }, [visibleMessages.length]); - - const finalizedMessageCards = useMemo( - () => - visibleMessages.map((message: Message, index: number) => ( -
    - -
    - )), - [visibleMessages], + // Use the unified message store to get all display messages + const allDisplayMessages = useMessageStore((s) => s.allDisplayMessages); + + // Filter visible messages (non-empty user/assistant, all tool calls) + const visibleMessages = useMemo(() => { + return allDisplayMessages.filter((msg: DisplayMessage) => { + if (msg.type === "user") return msg.content.length > 0; + if (msg.type === "assistant") return msg.content.length > 0 || (msg.reasoning?.length ?? 0) > 0; + if (msg.type === "toolCall" || msg.type === "toolCallPrepare") return true; + return false; + }); + }, [allDisplayMessages]); + + // Find the last user message index for scroll behavior + const lastUserMessageIndex = useMemo( + () => findLastUserMessageIndex(visibleMessages), + [visibleMessages] ); - const streamingMessageCards = useMemo( + // Get the last user message ID to track when it changes + const lastUserMessageId = useMemo(() => { + if (lastUserMessageIndex === -1) return null; + return visibleMessages[lastUserMessageIndex]?.id ?? null; + }, [visibleMessages, lastUserMessageIndex]); + + // Scroll the last user message to the top of the viewport (container only) + const scrollToLastUserMessage = useCallback(() => { + if (!lastUserMsgRef.current || !chatContainerRef.current) return; + + const container = chatContainerRef.current; + const target = lastUserMsgRef.current; + + container.scrollTo({ + top: target.offsetTop, + behavior: "smooth", + }); + }, []); + + // Auto-scroll only when a new user message is added + useEffect(() => { + if (!lastUserMessageId) return; + + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + scrollToLastUserMessage(); + }); + }, [lastUserMessageId, scrollToLastUserMessage]); + + // Render all messages using the unified DisplayMessage array + const messageCards = useMemo( () => - streamingMessage.parts.map((entry) => ( - - )), - [streamingMessage.parts], + visibleMessages.map((msg: DisplayMessage, index: number) => { + const isStreaming = msg.status === "streaming"; + const isLastUserMsg = index === lastUserMessageIndex; + + return ( +
    + +
    + ); + }), + [visibleMessages, lastUserMessageIndex] ); if (isEmptyConversation()) { return ; } - const expander = ( -