From acf9653897eeac4906698d73fe3000da8dfb32f1 Mon Sep 17 00:00:00 2001 From: kah-seng <71002797+kah-seng@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:29:50 +0800 Subject: [PATCH 01/11] Add customModels to setting-store.ts --- webapp/_webapp/src/stores/setting-store.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/_webapp/src/stores/setting-store.ts b/webapp/_webapp/src/stores/setting-store.ts index 407b36c5..c9e3b647 100644 --- a/webapp/_webapp/src/stores/setting-store.ts +++ b/webapp/_webapp/src/stores/setting-store.ts @@ -63,6 +63,7 @@ const defaultSettings: PlainMessage = { fullDocumentRag: false, showedOnboarding: true, openaiApiKey: "", + customModels: [], }; export const useSettingStore = create()((set, get) => ({ From 3f6871cc19657fb8266f2de0f93bd4270e5a1110 Mon Sep 17 00:00:00 2001 From: kah-seng <71002797+kah-seng@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:58:02 +0800 Subject: [PATCH 02/11] Select models by ID instead of slug --- .../create_conversation_message_stream_v2.go | 18 +++++++--- internal/api/chat/list_supported_models_v2.go | 2 ++ pkg/gen/api/chat/v2/chat.pb.go | 33 +++++++++++++++---- proto/chat/v2/chat.proto | 2 ++ webapp/_webapp/src/hooks/useLanguageModels.ts | 16 ++++++--- .../_webapp/src/hooks/useSendMessageStream.ts | 4 +++ .../src/pkg/gen/apiclient/chat/v2/chat_pb.ts | 16 ++++++++- .../conversation/conversation-ui-store.ts | 6 ++++ .../src/utils/stream-request-builder.ts | 4 ++- .../chat/footer/toolbar/model-selection.tsx | 10 +++++- .../views/chat/footer/toolbar/selection.tsx | 2 ++ 11 files changed, 95 insertions(+), 18 deletions(-) diff --git a/internal/api/chat/create_conversation_message_stream_v2.go b/internal/api/chat/create_conversation_message_stream_v2.go index 85f68c99..5516a3f5 100644 --- a/internal/api/chat/create_conversation_message_stream_v2.go +++ b/internal/api/chat/create_conversation_message_stream_v2.go @@ -2,6 +2,7 @@ package chat import ( "context" + "fmt" "paperdebugger/internal/api/mapper" "paperdebugger/internal/libs/contextutil" "paperdebugger/internal/libs/shared" @@ -281,14 +282,21 @@ func (s *ChatServerV2) CreateConversationMessageStream( var llmProvider *models.LLMProviderConfig var customModel *models.CustomModel customModel = nil - for i := range settings.CustomModels { - if settings.CustomModels[i].Slug == modelSlug { - customModel = &settings.CustomModels[i] + + customModelID := req.GetCustomModelId() + if customModelID != "" { + for i := range settings.CustomModels { + if settings.CustomModels[i].Id.Hex() == customModelID { + customModel = &settings.CustomModels[i] + break + } } + if customModel == nil { + return s.sendStreamError(stream, fmt.Errorf("Failed to get custom model")) + } + modelSlug = customModel.Slug } - // Usage is the same as ChatCompletion, just passing the stream parameter - if customModel == nil { // User did not specify API key for this model llmProvider = &models.LLMProviderConfig{ diff --git a/internal/api/chat/list_supported_models_v2.go b/internal/api/chat/list_supported_models_v2.go index 5db6fb6f..ac1c4e6b 100644 --- a/internal/api/chat/list_supported_models_v2.go +++ b/internal/api/chat/list_supported_models_v2.go @@ -222,7 +222,9 @@ func (s *ChatServerV2) ListSupportedModels( var models []*chatv2.SupportedModel for _, model := range settings.CustomModels { + modelID := model.Id.Hex() models = append(models, &chatv2.SupportedModel{ + Id: &modelID, Name: model.Name, Slug: model.Slug, TotalContext: int64(model.ContextWindow), diff --git a/pkg/gen/api/chat/v2/chat.pb.go b/pkg/gen/api/chat/v2/chat.pb.go index 2f66c2e6..22fc2786 100644 --- a/pkg/gen/api/chat/v2/chat.pb.go +++ b/pkg/gen/api/chat/v2/chat.pb.go @@ -1035,6 +1035,7 @@ type SupportedModel struct { 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 IsCustom bool `protobuf:"varint,9,opt,name=is_custom,json=isCustom,proto3" json:"is_custom,omitempty"` + Id *string `protobuf:"bytes,10,opt,name=id,proto3,oneof" json:"id,omitempty"` // Custom model unique ID (empty for built-in models) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1132,6 +1133,13 @@ func (x *SupportedModel) GetIsCustom() bool { return false } +func (x *SupportedModel) GetId() string { + if x != nil && x.Id != nil { + return *x.Id + } + return "" +} + type ListSupportedModelsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1635,6 +1643,7 @@ type CreateConversationMessageStreamRequest struct { UserSelectedText *string `protobuf:"bytes,5,opt,name=user_selected_text,json=userSelectedText,proto3,oneof" json:"user_selected_text,omitempty"` ConversationType *ConversationType `protobuf:"varint,6,opt,name=conversation_type,json=conversationType,proto3,enum=chat.v2.ConversationType,oneof" json:"conversation_type,omitempty"` Surrounding *string `protobuf:"bytes,8,opt,name=surrounding,proto3,oneof" json:"surrounding,omitempty"` + CustomModelId *string `protobuf:"bytes,9,opt,name=custom_model_id,json=customModelId,proto3,oneof" json:"custom_model_id,omitempty"` // Selected custom model ID unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1718,6 +1727,13 @@ func (x *CreateConversationMessageStreamRequest) GetSurrounding() string { return "" } +func (x *CreateConversationMessageStreamRequest) GetCustomModelId() string { + if x != nil && x.CustomModelId != nil { + return *x.CustomModelId + } + return "" +} + // Response for streaming a message within an existing conversation type CreateConversationMessageStreamResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2069,7 +2085,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\"\xbb\x02\n" + + "\x1aDeleteConversationResponse\"\xd7\x02\n" + "\x0eSupportedModel\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + "\x04slug\x18\x02 \x01(\tR\x04slug\x12#\n" + @@ -2081,8 +2097,11 @@ const file_chat_v2_chat_proto_rawDesc = "" + "\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\x01\x12\x1b\n" + - "\tis_custom\x18\t \x01(\bR\bisCustomB\x12\n" + - "\x10_disabled_reason\"\x1c\n" + + "\tis_custom\x18\t \x01(\bR\bisCustom\x12\x13\n" + + "\x02id\x18\n" + + " \x01(\tH\x01R\x02id\x88\x01\x01B\x12\n" + + "\x10_disabled_reasonB\x05\n" + + "\x03_id\"\x1c\n" + "\x1aListSupportedModelsRequest\"N\n" + "\x1bListSupportedModelsResponse\x12/\n" + "\x06models\x18\x01 \x03(\v2\x17.chat.v2.SupportedModelR\x06models\"^\n" + @@ -2113,7 +2132,7 @@ const file_chat_v2_chat_proto_rawDesc = "" + "\x12StreamFinalization\x12'\n" + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\"2\n" + "\vStreamError\x12#\n" + - "\rerror_message\x18\x01 \x01(\tR\ferrorMessage\"\xaf\x03\n" + + "\rerror_message\x18\x01 \x01(\tR\ferrorMessage\"\xf0\x03\n" + "&CreateConversationMessageStreamRequest\x12\x1d\n" + "\n" + "project_id\x18\x01 \x01(\tR\tprojectId\x12,\n" + @@ -2123,11 +2142,13 @@ const file_chat_v2_chat_proto_rawDesc = "" + "\fuser_message\x18\x04 \x01(\tR\vuserMessage\x121\n" + "\x12user_selected_text\x18\x05 \x01(\tH\x01R\x10userSelectedText\x88\x01\x01\x12K\n" + "\x11conversation_type\x18\x06 \x01(\x0e2\x19.chat.v2.ConversationTypeH\x02R\x10conversationType\x88\x01\x01\x12%\n" + - "\vsurrounding\x18\b \x01(\tH\x03R\vsurrounding\x88\x01\x01B\x12\n" + + "\vsurrounding\x18\b \x01(\tH\x03R\vsurrounding\x88\x01\x01\x12+\n" + + "\x0fcustom_model_id\x18\t \x01(\tH\x04R\rcustomModelId\x88\x01\x01B\x12\n" + "\x10_conversation_idB\x15\n" + "\x13_user_selected_textB\x14\n" + "\x12_conversation_typeB\x0e\n" + - "\f_surrounding\"\xfd\x04\n" + + "\f_surroundingB\x12\n" + + "\x10_custom_model_id\"\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" + diff --git a/proto/chat/v2/chat.proto b/proto/chat/v2/chat.proto index 4ec44427..a9754a1e 100644 --- a/proto/chat/v2/chat.proto +++ b/proto/chat/v2/chat.proto @@ -137,6 +137,7 @@ message SupportedModel { 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 bool is_custom = 9; + optional string id = 10; // Custom model unique ID (empty for built-in models) } message ListSupportedModelsRequest { @@ -223,6 +224,7 @@ message CreateConversationMessageStreamRequest { optional string user_selected_text = 5; optional ConversationType conversation_type = 6; optional string surrounding = 8; + optional string custom_model_id = 9; // Selected custom model ID } // Response for streaming a message within an existing conversation diff --git a/webapp/_webapp/src/hooks/useLanguageModels.ts b/webapp/_webapp/src/hooks/useLanguageModels.ts index 676104b3..f836de17 100644 --- a/webapp/_webapp/src/hooks/useLanguageModels.ts +++ b/webapp/_webapp/src/hooks/useLanguageModels.ts @@ -5,6 +5,7 @@ import { useListSupportedModelsQuery } from "../query"; import { useConversationUiStore } from "../stores/conversation/conversation-ui-store"; export type Model = { + id?: string; name: string; slug: string; provider: string; @@ -39,6 +40,7 @@ const fallbackModels: Model[] = [ ]; const mapSupportedModelToModel = (supportedModel: SupportedModel): Model => ({ + id: supportedModel.id || undefined, name: supportedModel.name, slug: supportedModel.slug, provider: extractProvider(supportedModel.slug), @@ -53,7 +55,7 @@ const mapSupportedModelToModel = (supportedModel: SupportedModel): Model => ({ export const useLanguageModels = () => { const { currentConversation, setCurrentConversation } = useConversationStore(); - const { setLastUsedModelSlug } = useConversationUiStore(); + const { lastUsedCustomModelId, setLastUsedModelSlug, setLastUsedCustomModelId } = useConversationUiStore(); const { data: supportedModelsResponse } = useListSupportedModelsQuery(); const models: Model[] = useMemo(() => { @@ -64,19 +66,25 @@ export const useLanguageModels = () => { }, [supportedModelsResponse]); const currentModel = useMemo(() => { - const model = models.find((m) => m.slug === currentConversation.modelSlug); + if (lastUsedCustomModelId) { + const customModel = models.find((m) => m.isCustom && m.id === lastUsedCustomModelId); + if (customModel) return customModel; + } + + const model = models.find((m) => !m.isCustom && m.slug === currentConversation.modelSlug); return model || models[0]; - }, [models, currentConversation.modelSlug]); + }, [models, currentConversation.modelSlug, lastUsedCustomModelId]); const setModel = useCallback( (model: Model) => { setLastUsedModelSlug(model.slug); + setLastUsedCustomModelId(model.isCustom ? (model.id ?? "") : ""); setCurrentConversation({ ...currentConversation, modelSlug: model.slug, }); }, - [setCurrentConversation, currentConversation, setLastUsedModelSlug], + [setCurrentConversation, currentConversation, setLastUsedModelSlug, setLastUsedCustomModelId], ); return { models, currentModel, setModel }; diff --git a/webapp/_webapp/src/hooks/useSendMessageStream.ts b/webapp/_webapp/src/hooks/useSendMessageStream.ts index 17aaa795..4d2056d3 100644 --- a/webapp/_webapp/src/hooks/useSendMessageStream.ts +++ b/webapp/_webapp/src/hooks/useSendMessageStream.ts @@ -41,6 +41,7 @@ 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 { useConversationUiStore } from "../stores/conversation/conversation-ui-store"; import { useSync } from "./useSync"; import { useAdapter } from "../adapters"; import { getProjectId } from "../libs/helpers"; @@ -86,6 +87,7 @@ export function useSendMessageStream(): UseSendMessageStreamResult { const surroundingText = useSelectionStore((s) => s.surroundingText); const alwaysSyncProject = useDevtoolStore((s) => s.alwaysSyncProject); const conversationMode = useSettingStore((s) => s.conversationMode); + const lastUsedCustomModelId = useConversationUiStore((s) => s.lastUsedCustomModelId); /** * Add the user message to the streaming state. @@ -165,6 +167,7 @@ export function useSendMessageStream(): UseSendMessageStreamResult { projectId, conversationId: currentConversation.id, modelSlug: currentConversation.modelSlug, + customModelId: lastUsedCustomModelId || undefined, surroundingText: surroundingText ?? undefined, conversationMode: conversationMode === "debug" ? "debug" : "default", }; @@ -251,6 +254,7 @@ export function useSendMessageStream(): UseSendMessageStreamResult { alwaysSyncProject, conversationMode, surroundingText, + lastUsedCustomModelId, addUserMessageToStream, truncateConversationIfEditing, ], 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 f270c782..2a79b357 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("ChJjaGF0L3YyL2NoYXQucHJvdG8SB2NoYXQudjIiUAoTTWVzc2FnZVR5cGVUb29sQ2FsbBIMCgRuYW1lGAEgASgJEgwKBGFyZ3MYAiABKAkSDgoGcmVzdWx0GAMgASgJEg0KBWVycm9yGAQgASgJIkEKI01lc3NhZ2VUeXBlVG9vbENhbGxQcmVwYXJlQXJndW1lbnRzEgwKBG5hbWUYASABKAkSDAoEYXJncxgCIAEoCSIkChFNZXNzYWdlVHlwZVN5c3RlbRIPCgdjb250ZW50GAEgASgJImEKFE1lc3NhZ2VUeXBlQXNzaXN0YW50Eg8KB2NvbnRlbnQYASABKAkSEgoKbW9kZWxfc2x1ZxgCIAEoCRIWCglyZWFzb25pbmcYAyABKAlIAIgBAUIMCgpfcmVhc29uaW5nInoKD01lc3NhZ2VUeXBlVXNlchIPCgdjb250ZW50GAEgASgJEhoKDXNlbGVjdGVkX3RleHQYAiABKAlIAIgBARIYCgtzdXJyb3VuZGluZxgHIAEoCUgBiAEBQhAKDl9zZWxlY3RlZF90ZXh0Qg4KDF9zdXJyb3VuZGluZyIpChJNZXNzYWdlVHlwZVVua25vd24SEwoLZGVzY3JpcHRpb24YASABKAki5AIKDk1lc3NhZ2VQYXlsb2FkEiwKBnN5c3RlbRgBIAEoCzIaLmNoYXQudjIuTWVzc2FnZVR5cGVTeXN0ZW1IABIoCgR1c2VyGAIgASgLMhguY2hhdC52Mi5NZXNzYWdlVHlwZVVzZXJIABIyCglhc3Npc3RhbnQYAyABKAsyHS5jaGF0LnYyLk1lc3NhZ2VUeXBlQXNzaXN0YW50SAASUwobdG9vbF9jYWxsX3ByZXBhcmVfYXJndW1lbnRzGAQgASgLMiwuY2hhdC52Mi5NZXNzYWdlVHlwZVRvb2xDYWxsUHJlcGFyZUFyZ3VtZW50c0gAEjEKCXRvb2xfY2FsbBgFIAEoCzIcLmNoYXQudjIuTWVzc2FnZVR5cGVUb29sQ2FsbEgAEi4KB3Vua25vd24YBiABKAsyGy5jaGF0LnYyLk1lc3NhZ2VUeXBlVW5rbm93bkgAQg4KDG1lc3NhZ2VfdHlwZSJaCgdNZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSKAoHcGF5bG9hZBgCIAEoCzIXLmNoYXQudjIuTWVzc2FnZVBheWxvYWQSEQoJdGltZXN0YW1wGAMgASgDImEKDENvbnZlcnNhdGlvbhIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgptb2RlbF9zbHVnGAMgASgJEiIKCG1lc3NhZ2VzGAQgAygLMhAuY2hhdC52Mi5NZXNzYWdlIkIKGExpc3RDb252ZXJzYXRpb25zUmVxdWVzdBIXCgpwcm9qZWN0X2lkGAEgASgJSACIAQFCDQoLX3Byb2plY3RfaWQiSQoZTGlzdENvbnZlcnNhdGlvbnNSZXNwb25zZRIsCg1jb252ZXJzYXRpb25zGAEgAygLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iMQoWR2V0Q29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiRgoXR2V0Q29udmVyc2F0aW9uUmVzcG9uc2USKwoMY29udmVyc2F0aW9uGAEgASgLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iQwoZVXBkYXRlQ29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDQoFdGl0bGUYAiABKAkiSQoaVXBkYXRlQ29udmVyc2F0aW9uUmVzcG9uc2USKwoMY29udmVyc2F0aW9uGAEgASgLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iNAoZRGVsZXRlQ29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiHAoaRGVsZXRlQ29udmVyc2F0aW9uUmVzcG9uc2Ui2QEKDlN1cHBvcnRlZE1vZGVsEgwKBG5hbWUYASABKAkSDAoEc2x1ZxgCIAEoCRIVCg10b3RhbF9jb250ZXh0GAMgASgDEhIKCm1heF9vdXRwdXQYBCABKAMSEwoLaW5wdXRfcHJpY2UYBSABKAMSFAoMb3V0cHV0X3ByaWNlGAYgASgDEhAKCGRpc2FibGVkGAcgASgIEhwKD2Rpc2FibGVkX3JlYXNvbhgIIAEoCUgAiAEBEhEKCWlzX2N1c3RvbRgJIAEoCEISChBfZGlzYWJsZWRfcmVhc29uIhwKGkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXF1ZXN0IkYKG0xpc3RTdXBwb3J0ZWRNb2RlbHNSZXNwb25zZRInCgZtb2RlbHMYASADKAsyFy5jaGF0LnYyLlN1cHBvcnRlZE1vZGVsIkMKFFN0cmVhbUluaXRpYWxpemF0aW9uEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRISCgptb2RlbF9zbHVnGAIgASgJIk8KD1N0cmVhbVBhcnRCZWdpbhISCgptZXNzYWdlX2lkGAEgASgJEigKB3BheWxvYWQYAyABKAsyFy5jaGF0LnYyLk1lc3NhZ2VQYXlsb2FkIjEKDE1lc3NhZ2VDaHVuaxISCgptZXNzYWdlX2lkGAEgASgJEg0KBWRlbHRhGAIgASgJIjMKDlJlYXNvbmluZ0NodW5rEhIKCm1lc3NhZ2VfaWQYASABKAkSDQoFZGVsdGEYAiABKAkiOgoTSW5jb21wbGV0ZUluZGljYXRvchIOCgZyZWFzb24YASABKAkSEwoLcmVzcG9uc2VfaWQYAiABKAkiTQoNU3RyZWFtUGFydEVuZBISCgptZXNzYWdlX2lkGAEgASgJEigKB3BheWxvYWQYAyABKAsyFy5jaGF0LnYyLk1lc3NhZ2VQYXlsb2FkIi0KElN0cmVhbUZpbmFsaXphdGlvbhIXCg9jb252ZXJzYXRpb25faWQYASABKAkiJAoLU3RyZWFtRXJyb3ISFQoNZXJyb3JfbWVzc2FnZRgBIAEoCSLLAgomQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbVJlcXVlc3QSEgoKcHJvamVjdF9pZBgBIAEoCRIcCg9jb252ZXJzYXRpb25faWQYAiABKAlIAIgBARISCgptb2RlbF9zbHVnGAMgASgJEhQKDHVzZXJfbWVzc2FnZRgEIAEoCRIfChJ1c2VyX3NlbGVjdGVkX3RleHQYBSABKAlIAYgBARI5ChFjb252ZXJzYXRpb25fdHlwZRgGIAEoDjIZLmNoYXQudjIuQ29udmVyc2F0aW9uVHlwZUgCiAEBEhgKC3N1cnJvdW5kaW5nGAggASgJSAOIAQFCEgoQX2NvbnZlcnNhdGlvbl9pZEIVChNfdXNlcl9zZWxlY3RlZF90ZXh0QhQKEl9jb252ZXJzYXRpb25fdHlwZUIOCgxfc3Vycm91bmRpbmci8wMKJ0NyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW1SZXNwb25zZRI+ChVzdHJlYW1faW5pdGlhbGl6YXRpb24YASABKAsyHS5jaGF0LnYyLlN0cmVhbUluaXRpYWxpemF0aW9uSAASNQoRc3RyZWFtX3BhcnRfYmVnaW4YAiABKAsyGC5jaGF0LnYyLlN0cmVhbVBhcnRCZWdpbkgAEi4KDW1lc3NhZ2VfY2h1bmsYAyABKAsyFS5jaGF0LnYyLk1lc3NhZ2VDaHVua0gAEjwKFGluY29tcGxldGVfaW5kaWNhdG9yGAQgASgLMhwuY2hhdC52Mi5JbmNvbXBsZXRlSW5kaWNhdG9ySAASMQoPc3RyZWFtX3BhcnRfZW5kGAUgASgLMhYuY2hhdC52Mi5TdHJlYW1QYXJ0RW5kSAASOgoTc3RyZWFtX2ZpbmFsaXphdGlvbhgGIAEoCzIbLmNoYXQudjIuU3RyZWFtRmluYWxpemF0aW9uSAASLAoMc3RyZWFtX2Vycm9yGAcgASgLMhQuY2hhdC52Mi5TdHJlYW1FcnJvckgAEjIKD3JlYXNvbmluZ19jaHVuaxgIIAEoCzIXLmNoYXQudjIuUmVhc29uaW5nQ2h1bmtIAEISChByZXNwb25zZV9wYXlsb2FkIj4KFkdldENpdGF0aW9uS2V5c1JlcXVlc3QSEAoIc2VudGVuY2UYASABKAkSEgoKcHJvamVjdF9pZBgCIAEoCSIwChdHZXRDaXRhdGlvbktleXNSZXNwb25zZRIVCg1jaXRhdGlvbl9rZXlzGAEgAygJKlIKEENvbnZlcnNhdGlvblR5cGUSIQodQ09OVkVSU0FUSU9OX1RZUEVfVU5TUEVDSUZJRUQQABIbChdDT05WRVJTQVRJT05fVFlQRV9ERUJVRxABMqcICgtDaGF0U2VydmljZRKDAQoRTGlzdENvbnZlcnNhdGlvbnMSIS5jaGF0LnYyLkxpc3RDb252ZXJzYXRpb25zUmVxdWVzdBoiLmNoYXQudjIuTGlzdENvbnZlcnNhdGlvbnNSZXNwb25zZSIngtPkkwIhEh8vX3BkL2FwaS92Mi9jaGF0cy9jb252ZXJzYXRpb25zEo8BCg9HZXRDb252ZXJzYXRpb24SHy5jaGF0LnYyLkdldENvbnZlcnNhdGlvblJlcXVlc3QaIC5jaGF0LnYyLkdldENvbnZlcnNhdGlvblJlc3BvbnNlIjmC0+STAjMSMS9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMve2NvbnZlcnNhdGlvbl9pZH0SwgEKH0NyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW0SLy5jaGF0LnYyLkNyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW1SZXF1ZXN0GjAuY2hhdC52Mi5DcmVhdGVDb252ZXJzYXRpb25NZXNzYWdlU3RyZWFtUmVzcG9uc2UiOoLT5JMCNDoBKiIvL19wZC9hcGkvdjIvY2hhdHMvY29udmVyc2F0aW9ucy9tZXNzYWdlcy9zdHJlYW0wARKbAQoSVXBkYXRlQ29udmVyc2F0aW9uEiIuY2hhdC52Mi5VcGRhdGVDb252ZXJzYXRpb25SZXF1ZXN0GiMuY2hhdC52Mi5VcGRhdGVDb252ZXJzYXRpb25SZXNwb25zZSI8gtPkkwI2OgEqMjEvX3BkL2FwaS92Mi9jaGF0cy9jb252ZXJzYXRpb25zL3tjb252ZXJzYXRpb25faWR9EpgBChJEZWxldGVDb252ZXJzYXRpb24SIi5jaGF0LnYyLkRlbGV0ZUNvbnZlcnNhdGlvblJlcXVlc3QaIy5jaGF0LnYyLkRlbGV0ZUNvbnZlcnNhdGlvblJlc3BvbnNlIjmC0+STAjMqMS9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMve2NvbnZlcnNhdGlvbl9pZH0SggEKE0xpc3RTdXBwb3J0ZWRNb2RlbHMSIy5jaGF0LnYyLkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXF1ZXN0GiQuY2hhdC52Mi5MaXN0U3VwcG9ydGVkTW9kZWxzUmVzcG9uc2UiIILT5JMCGhIYL19wZC9hcGkvdjIvY2hhdHMvbW9kZWxzEn0KD0dldENpdGF0aW9uS2V5cxIfLmNoYXQudjIuR2V0Q2l0YXRpb25LZXlzUmVxdWVzdBogLmNoYXQudjIuR2V0Q2l0YXRpb25LZXlzUmVzcG9uc2UiJ4LT5JMCIRIfL19wZC9hcGkvdjIvY2hhdHMvY2l0YXRpb24ta2V5c0J/Cgtjb20uY2hhdC52MkIJQ2hhdFByb3RvUAFaKHBhcGVyZGVidWdnZXIvcGtnL2dlbi9hcGkvY2hhdC92MjtjaGF0djKiAgNDWFiqAgdDaGF0LlYyygIHQ2hhdFxWMuICE0NoYXRcVjJcR1BCTWV0YWRhdGHqAghDaGF0OjpWMmIGcHJvdG8z", [file_google_api_annotations]); + fileDesc("ChJjaGF0L3YyL2NoYXQucHJvdG8SB2NoYXQudjIiUAoTTWVzc2FnZVR5cGVUb29sQ2FsbBIMCgRuYW1lGAEgASgJEgwKBGFyZ3MYAiABKAkSDgoGcmVzdWx0GAMgASgJEg0KBWVycm9yGAQgASgJIkEKI01lc3NhZ2VUeXBlVG9vbENhbGxQcmVwYXJlQXJndW1lbnRzEgwKBG5hbWUYASABKAkSDAoEYXJncxgCIAEoCSIkChFNZXNzYWdlVHlwZVN5c3RlbRIPCgdjb250ZW50GAEgASgJImEKFE1lc3NhZ2VUeXBlQXNzaXN0YW50Eg8KB2NvbnRlbnQYASABKAkSEgoKbW9kZWxfc2x1ZxgCIAEoCRIWCglyZWFzb25pbmcYAyABKAlIAIgBAUIMCgpfcmVhc29uaW5nInoKD01lc3NhZ2VUeXBlVXNlchIPCgdjb250ZW50GAEgASgJEhoKDXNlbGVjdGVkX3RleHQYAiABKAlIAIgBARIYCgtzdXJyb3VuZGluZxgHIAEoCUgBiAEBQhAKDl9zZWxlY3RlZF90ZXh0Qg4KDF9zdXJyb3VuZGluZyIpChJNZXNzYWdlVHlwZVVua25vd24SEwoLZGVzY3JpcHRpb24YASABKAki5AIKDk1lc3NhZ2VQYXlsb2FkEiwKBnN5c3RlbRgBIAEoCzIaLmNoYXQudjIuTWVzc2FnZVR5cGVTeXN0ZW1IABIoCgR1c2VyGAIgASgLMhguY2hhdC52Mi5NZXNzYWdlVHlwZVVzZXJIABIyCglhc3Npc3RhbnQYAyABKAsyHS5jaGF0LnYyLk1lc3NhZ2VUeXBlQXNzaXN0YW50SAASUwobdG9vbF9jYWxsX3ByZXBhcmVfYXJndW1lbnRzGAQgASgLMiwuY2hhdC52Mi5NZXNzYWdlVHlwZVRvb2xDYWxsUHJlcGFyZUFyZ3VtZW50c0gAEjEKCXRvb2xfY2FsbBgFIAEoCzIcLmNoYXQudjIuTWVzc2FnZVR5cGVUb29sQ2FsbEgAEi4KB3Vua25vd24YBiABKAsyGy5jaGF0LnYyLk1lc3NhZ2VUeXBlVW5rbm93bkgAQg4KDG1lc3NhZ2VfdHlwZSJaCgdNZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSKAoHcGF5bG9hZBgCIAEoCzIXLmNoYXQudjIuTWVzc2FnZVBheWxvYWQSEQoJdGltZXN0YW1wGAMgASgDImEKDENvbnZlcnNhdGlvbhIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgptb2RlbF9zbHVnGAMgASgJEiIKCG1lc3NhZ2VzGAQgAygLMhAuY2hhdC52Mi5NZXNzYWdlIkIKGExpc3RDb252ZXJzYXRpb25zUmVxdWVzdBIXCgpwcm9qZWN0X2lkGAEgASgJSACIAQFCDQoLX3Byb2plY3RfaWQiSQoZTGlzdENvbnZlcnNhdGlvbnNSZXNwb25zZRIsCg1jb252ZXJzYXRpb25zGAEgAygLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iMQoWR2V0Q29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiRgoXR2V0Q29udmVyc2F0aW9uUmVzcG9uc2USKwoMY29udmVyc2F0aW9uGAEgASgLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iQwoZVXBkYXRlQ29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDQoFdGl0bGUYAiABKAkiSQoaVXBkYXRlQ29udmVyc2F0aW9uUmVzcG9uc2USKwoMY29udmVyc2F0aW9uGAEgASgLMhUuY2hhdC52Mi5Db252ZXJzYXRpb24iNAoZRGVsZXRlQ29udmVyc2F0aW9uUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiHAoaRGVsZXRlQ29udmVyc2F0aW9uUmVzcG9uc2Ui8QEKDlN1cHBvcnRlZE1vZGVsEgwKBG5hbWUYASABKAkSDAoEc2x1ZxgCIAEoCRIVCg10b3RhbF9jb250ZXh0GAMgASgDEhIKCm1heF9vdXRwdXQYBCABKAMSEwoLaW5wdXRfcHJpY2UYBSABKAMSFAoMb3V0cHV0X3ByaWNlGAYgASgDEhAKCGRpc2FibGVkGAcgASgIEhwKD2Rpc2FibGVkX3JlYXNvbhgIIAEoCUgAiAEBEhEKCWlzX2N1c3RvbRgJIAEoCBIPCgJpZBgKIAEoCUgBiAEBQhIKEF9kaXNhYmxlZF9yZWFzb25CBQoDX2lkIhwKGkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXF1ZXN0IkYKG0xpc3RTdXBwb3J0ZWRNb2RlbHNSZXNwb25zZRInCgZtb2RlbHMYASADKAsyFy5jaGF0LnYyLlN1cHBvcnRlZE1vZGVsIkMKFFN0cmVhbUluaXRpYWxpemF0aW9uEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRISCgptb2RlbF9zbHVnGAIgASgJIk8KD1N0cmVhbVBhcnRCZWdpbhISCgptZXNzYWdlX2lkGAEgASgJEigKB3BheWxvYWQYAyABKAsyFy5jaGF0LnYyLk1lc3NhZ2VQYXlsb2FkIjEKDE1lc3NhZ2VDaHVuaxISCgptZXNzYWdlX2lkGAEgASgJEg0KBWRlbHRhGAIgASgJIjMKDlJlYXNvbmluZ0NodW5rEhIKCm1lc3NhZ2VfaWQYASABKAkSDQoFZGVsdGEYAiABKAkiOgoTSW5jb21wbGV0ZUluZGljYXRvchIOCgZyZWFzb24YASABKAkSEwoLcmVzcG9uc2VfaWQYAiABKAkiTQoNU3RyZWFtUGFydEVuZBISCgptZXNzYWdlX2lkGAEgASgJEigKB3BheWxvYWQYAyABKAsyFy5jaGF0LnYyLk1lc3NhZ2VQYXlsb2FkIi0KElN0cmVhbUZpbmFsaXphdGlvbhIXCg9jb252ZXJzYXRpb25faWQYASABKAkiJAoLU3RyZWFtRXJyb3ISFQoNZXJyb3JfbWVzc2FnZRgBIAEoCSL9AgomQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbVJlcXVlc3QSEgoKcHJvamVjdF9pZBgBIAEoCRIcCg9jb252ZXJzYXRpb25faWQYAiABKAlIAIgBARISCgptb2RlbF9zbHVnGAMgASgJEhQKDHVzZXJfbWVzc2FnZRgEIAEoCRIfChJ1c2VyX3NlbGVjdGVkX3RleHQYBSABKAlIAYgBARI5ChFjb252ZXJzYXRpb25fdHlwZRgGIAEoDjIZLmNoYXQudjIuQ29udmVyc2F0aW9uVHlwZUgCiAEBEhgKC3N1cnJvdW5kaW5nGAggASgJSAOIAQESHAoPY3VzdG9tX21vZGVsX2lkGAkgASgJSASIAQFCEgoQX2NvbnZlcnNhdGlvbl9pZEIVChNfdXNlcl9zZWxlY3RlZF90ZXh0QhQKEl9jb252ZXJzYXRpb25fdHlwZUIOCgxfc3Vycm91bmRpbmdCEgoQX2N1c3RvbV9tb2RlbF9pZCLzAwonQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbVJlc3BvbnNlEj4KFXN0cmVhbV9pbml0aWFsaXphdGlvbhgBIAEoCzIdLmNoYXQudjIuU3RyZWFtSW5pdGlhbGl6YXRpb25IABI1ChFzdHJlYW1fcGFydF9iZWdpbhgCIAEoCzIYLmNoYXQudjIuU3RyZWFtUGFydEJlZ2luSAASLgoNbWVzc2FnZV9jaHVuaxgDIAEoCzIVLmNoYXQudjIuTWVzc2FnZUNodW5rSAASPAoUaW5jb21wbGV0ZV9pbmRpY2F0b3IYBCABKAsyHC5jaGF0LnYyLkluY29tcGxldGVJbmRpY2F0b3JIABIxCg9zdHJlYW1fcGFydF9lbmQYBSABKAsyFi5jaGF0LnYyLlN0cmVhbVBhcnRFbmRIABI6ChNzdHJlYW1fZmluYWxpemF0aW9uGAYgASgLMhsuY2hhdC52Mi5TdHJlYW1GaW5hbGl6YXRpb25IABIsCgxzdHJlYW1fZXJyb3IYByABKAsyFC5jaGF0LnYyLlN0cmVhbUVycm9ySAASMgoPcmVhc29uaW5nX2NodW5rGAggASgLMhcuY2hhdC52Mi5SZWFzb25pbmdDaHVua0gAQhIKEHJlc3BvbnNlX3BheWxvYWQiPgoWR2V0Q2l0YXRpb25LZXlzUmVxdWVzdBIQCghzZW50ZW5jZRgBIAEoCRISCgpwcm9qZWN0X2lkGAIgASgJIjAKF0dldENpdGF0aW9uS2V5c1Jlc3BvbnNlEhUKDWNpdGF0aW9uX2tleXMYASADKAkqUgoQQ29udmVyc2F0aW9uVHlwZRIhCh1DT05WRVJTQVRJT05fVFlQRV9VTlNQRUNJRklFRBAAEhsKF0NPTlZFUlNBVElPTl9UWVBFX0RFQlVHEAEypwgKC0NoYXRTZXJ2aWNlEoMBChFMaXN0Q29udmVyc2F0aW9ucxIhLmNoYXQudjIuTGlzdENvbnZlcnNhdGlvbnNSZXF1ZXN0GiIuY2hhdC52Mi5MaXN0Q29udmVyc2F0aW9uc1Jlc3BvbnNlIieC0+STAiESHy9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMSjwEKD0dldENvbnZlcnNhdGlvbhIfLmNoYXQudjIuR2V0Q29udmVyc2F0aW9uUmVxdWVzdBogLmNoYXQudjIuR2V0Q29udmVyc2F0aW9uUmVzcG9uc2UiOYLT5JMCMxIxL19wZC9hcGkvdjIvY2hhdHMvY29udmVyc2F0aW9ucy97Y29udmVyc2F0aW9uX2lkfRLCAQofQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbRIvLmNoYXQudjIuQ3JlYXRlQ29udmVyc2F0aW9uTWVzc2FnZVN0cmVhbVJlcXVlc3QaMC5jaGF0LnYyLkNyZWF0ZUNvbnZlcnNhdGlvbk1lc3NhZ2VTdHJlYW1SZXNwb25zZSI6gtPkkwI0OgEqIi8vX3BkL2FwaS92Mi9jaGF0cy9jb252ZXJzYXRpb25zL21lc3NhZ2VzL3N0cmVhbTABEpsBChJVcGRhdGVDb252ZXJzYXRpb24SIi5jaGF0LnYyLlVwZGF0ZUNvbnZlcnNhdGlvblJlcXVlc3QaIy5jaGF0LnYyLlVwZGF0ZUNvbnZlcnNhdGlvblJlc3BvbnNlIjyC0+STAjY6ASoyMS9fcGQvYXBpL3YyL2NoYXRzL2NvbnZlcnNhdGlvbnMve2NvbnZlcnNhdGlvbl9pZH0SmAEKEkRlbGV0ZUNvbnZlcnNhdGlvbhIiLmNoYXQudjIuRGVsZXRlQ29udmVyc2F0aW9uUmVxdWVzdBojLmNoYXQudjIuRGVsZXRlQ29udmVyc2F0aW9uUmVzcG9uc2UiOYLT5JMCMyoxL19wZC9hcGkvdjIvY2hhdHMvY29udmVyc2F0aW9ucy97Y29udmVyc2F0aW9uX2lkfRKCAQoTTGlzdFN1cHBvcnRlZE1vZGVscxIjLmNoYXQudjIuTGlzdFN1cHBvcnRlZE1vZGVsc1JlcXVlc3QaJC5jaGF0LnYyLkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXNwb25zZSIggtPkkwIaEhgvX3BkL2FwaS92Mi9jaGF0cy9tb2RlbHMSfQoPR2V0Q2l0YXRpb25LZXlzEh8uY2hhdC52Mi5HZXRDaXRhdGlvbktleXNSZXF1ZXN0GiAuY2hhdC52Mi5HZXRDaXRhdGlvbktleXNSZXNwb25zZSIngtPkkwIhEh8vX3BkL2FwaS92Mi9jaGF0cy9jaXRhdGlvbi1rZXlzQn8KC2NvbS5jaGF0LnYyQglDaGF0UHJvdG9QAVoocGFwZXJkZWJ1Z2dlci9wa2cvZ2VuL2FwaS9jaGF0L3YyO2NoYXR2MqICA0NYWKoCB0NoYXQuVjLKAgdDaGF0XFYy4gITQ2hhdFxWMlxHUEJNZXRhZGF0YeoCCENoYXQ6OlYyYgZwcm90bzM", [file_google_api_annotations]); /** * @generated from message chat.v2.MessageTypeToolCall @@ -474,6 +474,13 @@ export type SupportedModel = Message$1<"chat.v2.SupportedModel"> & { * @generated from field: bool is_custom = 9; */ isCustom: boolean; + + /** + * Custom model unique ID (empty for built-in models) + * + * @generated from field: optional string id = 10; + */ + id?: string; }; /** @@ -749,6 +756,13 @@ export type CreateConversationMessageStreamRequest = Message$1<"chat.v2.CreateCo * @generated from field: optional string surrounding = 8; */ surrounding?: string; + + /** + * Selected custom model ID + * + * @generated from field: optional string custom_model_id = 9; + */ + customModelId?: string; }; /** diff --git a/webapp/_webapp/src/stores/conversation/conversation-ui-store.ts b/webapp/_webapp/src/stores/conversation/conversation-ui-store.ts index 8728c1fd..19a33de1 100644 --- a/webapp/_webapp/src/stores/conversation/conversation-ui-store.ts +++ b/webapp/_webapp/src/stores/conversation/conversation-ui-store.ts @@ -64,6 +64,9 @@ interface ConversationUiStore { lastUsedModelSlug: string; setLastUsedModelSlug: (lastUsedModelSlug: string) => void; + lastUsedCustomModelId: string; + setLastUsedCustomModelId: (lastUsedCustomModelId: string) => void; + resetPosition: () => void; } @@ -120,6 +123,9 @@ export const useConversationUiStore = create()( lastUsedModelSlug: "openai/gpt-4.1", setLastUsedModelSlug: (lastUsedModelSlug: string) => set({ lastUsedModelSlug }), + lastUsedCustomModelId: "", + setLastUsedCustomModelId: (lastUsedCustomModelId: string) => set({ lastUsedCustomModelId }), + resetPosition: () => { set({ floatingX: 100, diff --git a/webapp/_webapp/src/utils/stream-request-builder.ts b/webapp/_webapp/src/utils/stream-request-builder.ts index 7e132f47..6c9cef26 100644 --- a/webapp/_webapp/src/utils/stream-request-builder.ts +++ b/webapp/_webapp/src/utils/stream-request-builder.ts @@ -31,7 +31,8 @@ export interface StreamRequestParams { surroundingText?: string; /** Conversation mode (debug or default) */ conversationMode: "debug" | "default"; - /** Parent message ID for message editing/branching */ + /** User-specified custom model ID for the conversation */ + customModelId?: string; } // ============================================================================ @@ -68,6 +69,7 @@ export function buildStreamRequest(params: StreamRequestParams): PlainMessage) => { if (item.disabled) return; - setModel(models.find((m) => m.slug === item.value)!); + + if (item.isCustom) { + setModel(models.find((m) => m.id === item.id)!); + } else { + 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 2e91bf5b..796b47d8 100644 --- a/webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx +++ b/webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx @@ -12,6 +12,8 @@ export type SelectionItem = { value: T; disabled?: boolean; disabledReason?: string; + id?: string; + isCustom: boolean; }; type SelectionProps = { From 0f9dd22bcf89c919af71c5133b0551f1c81daf4b Mon Sep 17 00:00:00 2001 From: kah-seng <71002797+kah-seng@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:11:12 +0800 Subject: [PATCH 03/11] Add loading spinner --- .../views/chat/footer/toolbar/selection.tsx | 2 +- .../settings/sections/api-key-settings.tsx | 103 +++++++++++------- 2 files changed, 66 insertions(+), 39 deletions(-) diff --git a/webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx b/webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx index 796b47d8..5eec50bc 100644 --- a/webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx +++ b/webapp/_webapp/src/views/chat/footer/toolbar/selection.tsx @@ -13,7 +13,7 @@ export type SelectionItem = { disabled?: boolean; disabledReason?: string; id?: string; - isCustom: boolean; + isCustom?: boolean; }; type SelectionProps = { diff --git a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx index 4d5c854d..3ed585a4 100644 --- a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx +++ b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx @@ -105,6 +105,7 @@ type CustomModelSectionProps = NewCustomModelSectionProps | ExistingCustomModelS const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModelSectionProps) => { const id = customModel?.id || ""; const [isEditing, setIsEditing] = useState(isNew); + const [isSaving, setIsSaving] = useState(false); const [baseUrl, setBaseUrl] = useState(customModel?.baseUrl || ""); const [slug, setSlug] = useState(customModel?.slug ?? ""); const [apiKey, setApiKey] = useState(customModel?.apiKey || ""); @@ -126,6 +127,8 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel const errorInputClassName = "!border-red-500 focus:!border-red-500"; const handleOnChange = async (isDelete: boolean) => { + if (isSaving) return; + if ( modelName.trim().length < 1 || slug.trim().length < 1 || @@ -139,32 +142,39 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel return; } - await onChange( - { - id: id, - name: modelName.trim(), - baseUrl: baseUrl.trim(), - slug: slug.trim(), - apiKey: apiKey.trim(), - contextWindow: contextWindow, - maxOutput: maxOutput, - inputPrice: inputPrice, - outputPrice: outputPrice, - }, - isDelete, - ); + const isSaveAction = !isDelete; + if (isSaveAction) setIsSaving(true); - if (isNew) { - setModelName(""); - setBaseUrl(""); - setSlug(""); - setApiKey(""); - setContextWindow(0); - setMaxOutput(0); - setInputPrice(0); - setOutputPrice(0); - } else { - setIsEditing(false); + try { + await onChange( + { + id: id, + name: modelName.trim(), + baseUrl: baseUrl.trim(), + slug: slug.trim(), + apiKey: apiKey.trim(), + contextWindow: contextWindow, + maxOutput: maxOutput, + inputPrice: inputPrice, + outputPrice: outputPrice, + }, + isDelete, + ); + + if (isNew) { + setModelName(""); + setBaseUrl(""); + setSlug(""); + setApiKey(""); + setContextWindow(0); + setMaxOutput(0); + setInputPrice(0); + setOutputPrice(0); + } else if (isSaveAction) { + setIsEditing(false); + } + } finally { + if (isSaveAction) setIsSaving(false); } }; @@ -176,7 +186,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel value={modelName} placeholder="My Model" type="text" - disabled={!isEditing} + disabled={!isEditing || isSaving} onChange={(e) => { setIsModelNameValid(true); setModelName(e.target.value); @@ -185,8 +195,16 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel {isNew ? ( - ) : ( @@ -200,13 +218,22 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel setIsEditing(true); } }} - className="p-1 hover:bg-default-100 rounded" + disabled={isSaving} + className="p-1 hover:bg-default-100 rounded disabled:opacity-60" > - + - @@ -221,7 +248,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel value={slug} placeholder="e.g., gemini-2.5-flash" type="text" - disabled={!isEditing} + disabled={!isEditing || isSaving} onChange={(e) => { setIsSlugValid(true); setSlug(e.target.value); @@ -236,7 +263,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel value={baseUrl} placeholder="An OpenAI-compatible endpoint" type="text" - disabled={!isEditing} + disabled={!isEditing || isSaving} onChange={(e) => { setIsBaseUrlValid(true); setBaseUrl(e.target.value); @@ -251,7 +278,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel value={apiKey} placeholder="Your API Key" type={!isEditing && !isNew ? "password" : "text"} - disabled={!isEditing} + disabled={!isEditing || isSaving} onChange={(e) => { setIsApiKeyValid(true); setApiKey(e.target.value); @@ -278,7 +305,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel type="number" min={0} step="1" - disabled={!isEditing} + disabled={!isEditing || isSaving} onChange={(e) => setContextWindow(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} /> @@ -291,7 +318,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel type="number" min={0} step="1" - disabled={!isEditing} + disabled={!isEditing || isSaving} onChange={(e) => setMaxOutput(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} /> @@ -304,7 +331,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel type="number" min={0} step="1" - disabled={!isEditing} + disabled={!isEditing || isSaving} onChange={(e) => setInputPrice(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} /> @@ -318,7 +345,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel min={0} step="1" pattern="[0-9]*" - disabled={!isEditing} + disabled={!isEditing || isSaving} onChange={(e) => setOutputPrice(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} /> From 2ff12f92e05f4aca4345fe358e131bb074331754 Mon Sep 17 00:00:00 2001 From: kah-seng <71002797+kah-seng@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:34:12 +0800 Subject: [PATCH 04/11] Fix Copilot comments --- .../create_conversation_message_stream_v2.go | 2 +- .../chat/footer/toolbar/model-selection.tsx | 10 +-- .../settings/sections/api-key-settings.tsx | 62 +++++++++++-------- 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/internal/api/chat/create_conversation_message_stream_v2.go b/internal/api/chat/create_conversation_message_stream_v2.go index 5516a3f5..d1dfb3e6 100644 --- a/internal/api/chat/create_conversation_message_stream_v2.go +++ b/internal/api/chat/create_conversation_message_stream_v2.go @@ -292,7 +292,7 @@ func (s *ChatServerV2) CreateConversationMessageStream( } } if customModel == nil { - return s.sendStreamError(stream, fmt.Errorf("Failed to get custom model")) + return s.sendStreamError(stream, fmt.Errorf("custom model not found: %q", customModelID)) } modelSlug = customModel.Slug } 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 18ad0a9d..135dec8f 100644 --- a/webapp/_webapp/src/views/chat/footer/toolbar/model-selection.tsx +++ b/webapp/_webapp/src/views/chat/footer/toolbar/model-selection.tsx @@ -27,12 +27,12 @@ export function ModelSelection({ onSelectModel }: ModelSelectionProps) { (item: SelectionItem) => { if (item.disabled) return; - if (item.isCustom) { - setModel(models.find((m) => m.id === item.id)!); - } else { - setModel(models.find((m) => m.slug === item.value)!); - } + const selectedModel = item.isCustom + ? ((item.id ? models.find((m) => m.id === item.id) : undefined) ?? models.find((m) => m.slug === item.value)) + : models.find((m) => m.slug === item.value); + if (!selectedModel) return; + setModel(selectedModel); onSelectModel(); inputRef.current?.focus(); }, diff --git a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx index 3ed585a4..126f0d5d 100644 --- a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx +++ b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx @@ -105,7 +105,8 @@ type CustomModelSectionProps = NewCustomModelSectionProps | ExistingCustomModelS const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModelSectionProps) => { const id = customModel?.id || ""; const [isEditing, setIsEditing] = useState(isNew); - const [isSaving, setIsSaving] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [processingAction, setProcessingAction] = useState<"save" | "delete" | null>(null); const [baseUrl, setBaseUrl] = useState(customModel?.baseUrl || ""); const [slug, setSlug] = useState(customModel?.slug ?? ""); const [apiKey, setApiKey] = useState(customModel?.apiKey || ""); @@ -127,13 +128,13 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel const errorInputClassName = "!border-red-500 focus:!border-red-500"; const handleOnChange = async (isDelete: boolean) => { - if (isSaving) return; + if (isProcessing) return; + + const isSaveAction = !isDelete; if ( - modelName.trim().length < 1 || - slug.trim().length < 1 || - baseUrl.trim().length < 1 || - apiKey.trim().length < 1 + isSaveAction && + (modelName.trim().length < 1 || slug.trim().length < 1 || baseUrl.trim().length < 1 || apiKey.trim().length < 1) ) { setIsModelNameValid(modelName.trim().length > 0); setIsSlugValid(slug.trim().length > 0); @@ -142,8 +143,8 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel return; } - const isSaveAction = !isDelete; - if (isSaveAction) setIsSaving(true); + setIsProcessing(true); + setProcessingAction(isDelete ? "delete" : "save"); try { await onChange( @@ -174,7 +175,8 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel setIsEditing(false); } } finally { - if (isSaveAction) setIsSaving(false); + setIsProcessing(false); + setProcessingAction(null); } }; @@ -186,7 +188,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel value={modelName} placeholder="My Model" type="text" - disabled={!isEditing || isSaving} + disabled={!isEditing || isProcessing} onChange={(e) => { setIsModelNameValid(true); setModelName(e.target.value); @@ -197,13 +199,13 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel @@ -218,23 +220,33 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel setIsEditing(true); } }} - disabled={isSaving} + disabled={isProcessing} className="p-1 hover:bg-default-100 rounded disabled:opacity-60" > @@ -248,7 +260,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel value={slug} placeholder="e.g., gemini-2.5-flash" type="text" - disabled={!isEditing || isSaving} + disabled={!isEditing || isProcessing} onChange={(e) => { setIsSlugValid(true); setSlug(e.target.value); @@ -263,7 +275,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel value={baseUrl} placeholder="An OpenAI-compatible endpoint" type="text" - disabled={!isEditing || isSaving} + disabled={!isEditing || isProcessing} onChange={(e) => { setIsBaseUrlValid(true); setBaseUrl(e.target.value); @@ -278,7 +290,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel value={apiKey} placeholder="Your API Key" type={!isEditing && !isNew ? "password" : "text"} - disabled={!isEditing || isSaving} + disabled={!isEditing || isProcessing} onChange={(e) => { setIsApiKeyValid(true); setApiKey(e.target.value); @@ -305,7 +317,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel type="number" min={0} step="1" - disabled={!isEditing || isSaving} + disabled={!isEditing || isProcessing} onChange={(e) => setContextWindow(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} /> @@ -318,7 +330,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel type="number" min={0} step="1" - disabled={!isEditing || isSaving} + disabled={!isEditing || isProcessing} onChange={(e) => setMaxOutput(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} /> @@ -331,7 +343,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel type="number" min={0} step="1" - disabled={!isEditing || isSaving} + disabled={!isEditing || isProcessing} onChange={(e) => setInputPrice(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} /> @@ -345,7 +357,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel min={0} step="1" pattern="[0-9]*" - disabled={!isEditing || isSaving} + disabled={!isEditing || isProcessing} onChange={(e) => setOutputPrice(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} /> From 6508989c9d616d3f7ee8debec8d2fede95b21491 Mon Sep 17 00:00:00 2001 From: kah-seng <71002797+kah-seng@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:54:33 +0800 Subject: [PATCH 05/11] Show error when same slug and name --- .../settings/sections/api-key-settings.tsx | 106 +++++++++++++----- 1 file changed, 76 insertions(+), 30 deletions(-) diff --git a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx index 126f0d5d..e8133c55 100644 --- a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx +++ b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { Icon } from "@iconify/react"; import { Modal } from "../../../components/modal"; import { SettingsSectionContainer, SettingsSectionTitle } from "./components"; @@ -18,6 +18,16 @@ export const ApiKeySettings = () => { customModels: otherCustomModels, }); } else { + const hasDuplicate = otherCustomModels.some( + (model) => + model.name.trim().toLowerCase() === newModel.name.trim().toLowerCase() && + model.slug.trim().toLowerCase() === newModel.slug.trim().toLowerCase(), + ); + + if (hasDuplicate) { + throw new Error("A model with the same name and slug already exists."); + } + await updateSettings({ customModels: [ ...otherCustomModels, @@ -90,13 +100,13 @@ type CustomModel = { type NewCustomModelSectionProps = { isNew: true; - onChange: (model: CustomModel, isDelete: boolean) => void; + onChange: (model: CustomModel, isDelete: boolean) => Promise; model?: never; }; type ExistingCustomModelSectionProps = { isNew: false; - onChange: (model: CustomModel, isDelete: boolean) => void; + onChange: (model: CustomModel, isDelete: boolean) => Promise; model: CustomModel; }; @@ -110,15 +120,16 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel const [baseUrl, setBaseUrl] = useState(customModel?.baseUrl || ""); const [slug, setSlug] = useState(customModel?.slug ?? ""); const [apiKey, setApiKey] = useState(customModel?.apiKey || ""); - const [contextWindow, setContextWindow] = useState(customModel?.contextWindow || 0); - const [maxOutput, setMaxOutput] = useState(customModel?.maxOutput || 0); - const [inputPrice, setInputPrice] = useState(customModel?.inputPrice || 0); - const [outputPrice, setOutputPrice] = useState(customModel?.outputPrice || 0); + const [contextWindow, setContextWindow] = useState(customModel?.contextWindow ?? 0); + const [maxOutput, setMaxOutput] = useState(customModel?.maxOutput ?? 4000); + const [inputPrice, setInputPrice] = useState(customModel?.inputPrice ?? 0); + const [outputPrice, setOutputPrice] = useState(customModel?.outputPrice ?? 0); const [modelName, setModelName] = useState(customModel?.name || ""); const [isModelNameValid, setIsModelNameValid] = useState(true); const [isSlugValid, setIsSlugValid] = useState(true); const [isBaseUrlValid, setIsBaseUrlValid] = useState(true); const [isApiKeyValid, setIsApiKeyValid] = useState(true); + const [submitError, setSubmitError] = useState(null); const borderedInputClassName = "rnd-cancel px-2 py-1 border !border-gray-200 dark:!border-default-200 rounded-md"; const baseClassName = "bg-transparent p-1 focus:outline-none disabled:opacity-70"; @@ -127,22 +138,49 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel const detailInputClassName = `${baseClassName} ${isEditing || isNew ? borderedInputClassName : ""} flex-1 noselect focus:outline-none text-xs text-default-700 placeholder:text-default-400`; const errorInputClassName = "!border-red-500 focus:!border-red-500"; + useEffect(() => { + if (isNew || !customModel) return; + if (isEditing) return; + + setModelName(customModel.name || ""); + setBaseUrl(customModel.baseUrl || ""); + setSlug(customModel.slug || ""); + setApiKey(customModel.apiKey || ""); + setContextWindow(customModel.contextWindow ?? 0); + setMaxOutput(customModel.maxOutput ?? 4000); + setInputPrice(customModel.inputPrice ?? 0); + setOutputPrice(customModel.outputPrice ?? 0); + }, [isNew, isEditing, customModel]); + const handleOnChange = async (isDelete: boolean) => { if (isProcessing) return; const isSaveAction = !isDelete; - if ( - isSaveAction && - (modelName.trim().length < 1 || slug.trim().length < 1 || baseUrl.trim().length < 1 || apiKey.trim().length < 1) - ) { - setIsModelNameValid(modelName.trim().length > 0); - setIsSlugValid(slug.trim().length > 0); - setIsBaseUrlValid(baseUrl.trim().length > 0); - setIsApiKeyValid(apiKey.trim().length > 0); - return; + if (isSaveAction) { + // Input validation + const missingFields: string[] = []; + if (modelName.trim().length < 1) missingFields.push("Model Name"); + if (slug.trim().length < 1) missingFields.push("Slug"); + if (baseUrl.trim().length < 1) missingFields.push("Base URL"); + if (apiKey.trim().length < 1) missingFields.push("API Key"); + + if (missingFields.length > 0) { + setIsModelNameValid(modelName.trim().length > 0); + setIsSlugValid(slug.trim().length > 0); + setIsBaseUrlValid(baseUrl.trim().length > 0); + setIsApiKeyValid(apiKey.trim().length > 0); + setSubmitError(`Please fill in required fields: ${missingFields.join(", ")}.`); + return; + } + + if (maxOutput < 1) { + setSubmitError("Max Output cannot be less than 1."); + return; + } } + setSubmitError(null); setIsProcessing(true); setProcessingAction(isDelete ? "delete" : "save"); @@ -168,12 +206,14 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel setSlug(""); setApiKey(""); setContextWindow(0); - setMaxOutput(0); + setMaxOutput(4000); setInputPrice(0); setOutputPrice(0); } else if (isSaveAction) { setIsEditing(false); } + } catch (error) { + setSubmitError(error instanceof Error ? error.message : "Failed to save new model."); } finally { setIsProcessing(false); setProcessingAction(null); @@ -191,6 +231,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel disabled={!isEditing || isProcessing} onChange={(e) => { setIsModelNameValid(true); + setSubmitError(null); setModelName(e.target.value); }} > @@ -263,6 +304,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel disabled={!isEditing || isProcessing} onChange={(e) => { setIsSlugValid(true); + setSubmitError(null); setSlug(e.target.value); }} /> @@ -278,6 +320,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel disabled={!isEditing || isProcessing} onChange={(e) => { setIsBaseUrlValid(true); + setSubmitError(null); setBaseUrl(e.target.value); }} /> @@ -293,11 +336,25 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel disabled={!isEditing || isProcessing} onChange={(e) => { setIsApiKeyValid(true); + setSubmitError(null); setApiKey(e.target.value); }} /> +
+ + setMaxOutput(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} + /> +
+ -
- - setMaxOutput(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} - /> -
-
+ + {submitError &&
{submitError}
}
); }; From 7eca4d7438a5e00f87a93fc00fa440544eeb0267 Mon Sep 17 00:00:00 2001 From: kah-seng <71002797+kah-seng@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:20:13 +0800 Subject: [PATCH 06/11] Add tooltips --- .../settings/sections/api-key-settings.tsx | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx index e8133c55..ccdb3fb0 100644 --- a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx +++ b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx @@ -295,7 +295,30 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel
- + + Slugs are unique, short identifiers for AI models in API calls. +
+ Common examples: +
+ - gemini-2.5-flash +
+ - MiniMax-M2.5 +
+ - glm-4.7 +
+ - gpt-5.1 +
+ - openai/gpt-5.1 (OpenRouter) +
+
+ } + placement="bottom" + delay={100} + > + +
- + + Only OpenAI-compatible endpoints are supported currently. +
+ Common examples: +
+ - https://api.anthropic.com/v1/ +
+ - https://api.openai.com/v1 +
+ - https://generativelanguage.googleapis.com/v1beta/openai/ +
+
+ } + placement="bottom" + delay={100} + > + +
- +
- + + + Date: Thu, 9 Apr 2026 00:30:41 +0800 Subject: [PATCH 07/11] Add temp, parallel tools, store fields --- .../settings/sections/api-key-settings.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx index ccdb3fb0..71fd5c24 100644 --- a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx +++ b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx @@ -41,6 +41,9 @@ export const ApiKeySettings = () => { maxOutput: newModel.maxOutput, inputPrice: newModel.inputPrice, outputPrice: newModel.outputPrice, + temperature: newModel.temperature, + parallelToolCalls: newModel.parallelToolCalls, + store: newModel.store, }, ], }); @@ -75,6 +78,9 @@ export const ApiKeySettings = () => { maxOutput: m.maxOutput, inputPrice: m.inputPrice, outputPrice: m.outputPrice, + temperature: m.temperature, + parallelToolCalls: m.parallelToolCalls, + store: m.store, }} /> @@ -94,6 +100,9 @@ type CustomModel = { apiKey: string; contextWindow: number; maxOutput: number; + temperature: number; + parallelToolCalls: boolean; + store: boolean; inputPrice: number; outputPrice: number; }; @@ -122,6 +131,9 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel const [apiKey, setApiKey] = useState(customModel?.apiKey || ""); const [contextWindow, setContextWindow] = useState(customModel?.contextWindow ?? 0); const [maxOutput, setMaxOutput] = useState(customModel?.maxOutput ?? 4000); + const [temperature, setTemperature] = useState(customModel?.temperature ?? 0.7); + const [parallelToolCalls, setParallelToolCalls] = useState(customModel?.parallelToolCalls ?? true); + const [store, setStore] = useState(customModel?.store ?? false); const [inputPrice, setInputPrice] = useState(customModel?.inputPrice ?? 0); const [outputPrice, setOutputPrice] = useState(customModel?.outputPrice ?? 0); const [modelName, setModelName] = useState(customModel?.name || ""); @@ -150,6 +162,9 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel setMaxOutput(customModel.maxOutput ?? 4000); setInputPrice(customModel.inputPrice ?? 0); setOutputPrice(customModel.outputPrice ?? 0); + setTemperature(customModel.temperature ?? 0.7); + setParallelToolCalls(customModel.parallelToolCalls ?? true); + setStore(customModel.store ?? false); }, [isNew, isEditing, customModel]); const handleOnChange = async (isDelete: boolean) => { @@ -196,6 +211,9 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel maxOutput: maxOutput, inputPrice: inputPrice, outputPrice: outputPrice, + temperature: temperature, + parallelToolCalls: parallelToolCalls, + store: store, }, isDelete, ); @@ -209,6 +227,9 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel setMaxOutput(4000); setInputPrice(0); setOutputPrice(0); + setTemperature(0.7); + setParallelToolCalls(true); + setStore(false); } else if (isSaveAction) { setIsEditing(false); } @@ -403,6 +424,44 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel />
+
+ + setTemperature(e.target.value === "" ? 0 : Number(e.target.value))} + /> +
+ +
+ + setParallelToolCalls(e.target.checked)} + /> +
+ +
+ + setStore(e.target.checked)} + /> +
+ Date: Fri, 10 Apr 2026 01:07:40 +0800 Subject: [PATCH 08/11] Add tooltips --- .../settings/sections/api-key-settings.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx index 71fd5c24..b9c9af3c 100644 --- a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx +++ b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx @@ -425,7 +425,13 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel
- + + +
- + + +
- + + + Date: Fri, 10 Apr 2026 11:36:45 +0800 Subject: [PATCH 09/11] Allow user to configure temp, parallel tools, store params --- .../create_conversation_message_stream_v2.go | 4 +- internal/api/mapper/user.go | 42 ++++++---- internal/models/user.go | 21 +++-- .../services/toolkit/client/completion_v2.go | 8 +- .../toolkit/client/get_citation_keys.go | 2 +- .../client/get_conversation_title_v2.go | 4 +- internal/services/toolkit/client/utils_v2.go | 24 ++++-- pkg/gen/api/user/v1/user.pb.go | 56 +++++++++---- proto/user/v1/user.proto | 3 + .../src/pkg/gen/apiclient/user/v1/user_pb.ts | 17 +++- .../settings/sections/api-key-settings.tsx | 84 +++++++++++-------- 11 files changed, 172 insertions(+), 93 deletions(-) diff --git a/internal/api/chat/create_conversation_message_stream_v2.go b/internal/api/chat/create_conversation_message_stream_v2.go index d1dfb3e6..3537ec79 100644 --- a/internal/api/chat/create_conversation_message_stream_v2.go +++ b/internal/api/chat/create_conversation_message_stream_v2.go @@ -321,7 +321,7 @@ func (s *ChatServerV2) CreateConversationMessageStream( } } - openaiChatHistory, inappChatHistory, err := s.aiClientV2.ChatCompletionStreamV2(ctx, stream, conversation.ID.Hex(), modelSlug, conversation.OpenaiChatHistoryCompletion, llmProvider) + openaiChatHistory, inappChatHistory, err := s.aiClientV2.ChatCompletionStreamV2(ctx, stream, conversation.ID.Hex(), modelSlug, conversation.OpenaiChatHistoryCompletion, llmProvider, customModel) if err != nil { return s.sendStreamError(stream, err) } @@ -347,7 +347,7 @@ func (s *ChatServerV2) CreateConversationMessageStream( for i, bsonMsg := range conversation.InappChatHistory { protoMessages[i] = mapper.BSONToChatMessageV2(bsonMsg) } - title, err := s.aiClientV2.GetConversationTitleV2(ctx, protoMessages, llmProvider, modelSlug) + title, err := s.aiClientV2.GetConversationTitleV2(ctx, protoMessages, llmProvider, modelSlug, customModel) if err != nil { s.logger.Error("Failed to get conversation title", "error", err, "conversationID", conversation.ID.Hex()) return diff --git a/internal/api/mapper/user.go b/internal/api/mapper/user.go index ea9a4f7b..09d31aa7 100644 --- a/internal/api/mapper/user.go +++ b/internal/api/mapper/user.go @@ -19,15 +19,18 @@ func MapProtoSettingsToModel(settings *userv1.Settings) *models.Settings { } customModels[i] = models.CustomModel{ - Id: id, - Slug: m.Slug, - Name: m.Name, - BaseUrl: m.BaseUrl, - APIKey: m.ApiKey, - ContextWindow: m.ContextWindow, - MaxOutput: m.MaxOutput, - InputPrice: m.InputPrice, - OutputPrice: m.OutputPrice, + Id: id, + Slug: m.Slug, + Name: m.Name, + BaseUrl: m.BaseUrl, + APIKey: m.ApiKey, + ContextWindow: m.ContextWindow, + MaxOutput: m.MaxOutput, + InputPrice: m.InputPrice, + OutputPrice: m.OutputPrice, + Temperature: m.Temperature, + ParallelToolCalls: m.ParallelToolCalls, + Store: m.Store, } } @@ -47,15 +50,18 @@ func MapModelSettingsToProto(settings *models.Settings) *userv1.Settings { customModels := make([]*userv1.CustomModel, len(settings.CustomModels)) for i, m := range settings.CustomModels { customModels[i] = &userv1.CustomModel{ - Id: m.Id.Hex(), - Slug: m.Slug, - Name: m.Name, - BaseUrl: m.BaseUrl, - ApiKey: m.APIKey, - ContextWindow: m.ContextWindow, - MaxOutput: m.MaxOutput, - InputPrice: m.InputPrice, - OutputPrice: m.OutputPrice, + Id: m.Id.Hex(), + Slug: m.Slug, + Name: m.Name, + BaseUrl: m.BaseUrl, + ApiKey: m.APIKey, + ContextWindow: m.ContextWindow, + MaxOutput: m.MaxOutput, + InputPrice: m.InputPrice, + OutputPrice: m.OutputPrice, + Temperature: m.Temperature, + ParallelToolCalls: m.ParallelToolCalls, + Store: m.Store, } } diff --git a/internal/models/user.go b/internal/models/user.go index 413be929..a0350301 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -3,15 +3,18 @@ package models import "go.mongodb.org/mongo-driver/v2/bson" type CustomModel struct { - Id bson.ObjectID `bson:"_id"` - Slug string `bson:"slug"` - Name string `bson:"name"` - BaseUrl string `bson:"base_url"` - APIKey string `bson:"api_key"` - ContextWindow int32 `bson:"context_window"` - MaxOutput int32 `bson:"max_output"` - InputPrice int32 `bson:"input_price"` - OutputPrice int32 `bson:"output_price"` + Id bson.ObjectID `bson:"_id"` + Slug string `bson:"slug"` + Name string `bson:"name"` + BaseUrl string `bson:"base_url"` + APIKey string `bson:"api_key"` + ContextWindow int32 `bson:"context_window"` + MaxOutput int32 `bson:"max_output"` + InputPrice int32 `bson:"input_price"` + OutputPrice int32 `bson:"output_price"` + Temperature float32 `bson:"temperature"` + ParallelToolCalls bool `bson:"parallel_tool_calls"` + Store bool `bson:"store"` } type Settings struct { diff --git a/internal/services/toolkit/client/completion_v2.go b/internal/services/toolkit/client/completion_v2.go index 47caaad4..7266d669 100644 --- a/internal/services/toolkit/client/completion_v2.go +++ b/internal/services/toolkit/client/completion_v2.go @@ -25,8 +25,8 @@ import ( // 1. The full chat history sent to the language model (including any tool call results). // 2. The incremental chat history visible to the user (including tool call results and assistant responses). // 3. An error, if any occurred during the process. -func (a *AIClientV2) ChatCompletionV2(ctx context.Context, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig) (OpenAIChatHistory, AppChatHistory, error) { - openaiChatHistory, inappChatHistory, err := a.ChatCompletionStreamV2(ctx, nil, "", modelSlug, messages, llmProvider) +func (a *AIClientV2) ChatCompletionV2(ctx context.Context, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig, customModel *models.CustomModel) (OpenAIChatHistory, AppChatHistory, error) { + openaiChatHistory, inappChatHistory, err := a.ChatCompletionStreamV2(ctx, nil, "", modelSlug, messages, llmProvider, customModel) if err != nil { return nil, nil, err } @@ -54,7 +54,7 @@ func (a *AIClientV2) ChatCompletionV2(ctx context.Context, modelSlug string, mes // - If tool calls are required, it handles them and appends the results to the chat history, then continues the loop. // - If no tool calls are needed, it appends the assistant's response and exits the loop. // - Finally, it returns the updated chat histories and any error encountered. -func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream chatv2.ChatService_CreateConversationMessageStreamServer, conversationId string, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig) (OpenAIChatHistory, AppChatHistory, error) { +func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream chatv2.ChatService_CreateConversationMessageStreamServer, conversationId string, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig, customModel *models.CustomModel) (OpenAIChatHistory, AppChatHistory, error) { openaiChatHistory := messages inappChatHistory := AppChatHistory{} @@ -66,7 +66,7 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream }() oaiClient := a.GetOpenAIClient(llmProvider) - params := getDefaultParamsV2(modelSlug, a.toolCallHandler.Registry, llmProvider.IsCustomModel) + params := getDefaultParamsV2(modelSlug, a.toolCallHandler.Registry, customModel) for { params.Messages = openaiChatHistory diff --git a/internal/services/toolkit/client/get_citation_keys.go b/internal/services/toolkit/client/get_citation_keys.go index 1995d590..2344d49d 100644 --- a/internal/services/toolkit/client/get_citation_keys.go +++ b/internal/services/toolkit/client/get_citation_keys.go @@ -244,7 +244,7 @@ func (a *AIClientV2) GetCitationKeys(ctx context.Context, sentence string, userI _, resp, err := a.ChatCompletionV2(ctx, "gpt-5.2", OpenAIChatHistory{ openai.SystemMessage("You are a helpful assistant that suggests relevant citation keys."), openai.UserMessage(message), - }, llmProvider) + }, llmProvider, nil) if err != nil { return nil, err diff --git a/internal/services/toolkit/client/get_conversation_title_v2.go b/internal/services/toolkit/client/get_conversation_title_v2.go index a58617d7..27840c7c 100644 --- a/internal/services/toolkit/client/get_conversation_title_v2.go +++ b/internal/services/toolkit/client/get_conversation_title_v2.go @@ -13,7 +13,7 @@ import ( "github.com/samber/lo" ) -func (a *AIClientV2) GetConversationTitleV2(ctx context.Context, inappChatHistory []*chatv2.Message, llmProvider *models.LLMProviderConfig, modelSlug string) (string, error) { +func (a *AIClientV2) GetConversationTitleV2(ctx context.Context, inappChatHistory []*chatv2.Message, llmProvider *models.LLMProviderConfig, modelSlug string, customModel *models.CustomModel) (string, error) { messages := lo.Map(inappChatHistory, func(message *chatv2.Message, _ int) string { if _, ok := message.Payload.MessageType.(*chatv2.MessagePayload_Assistant); ok { return fmt.Sprintf("Assistant: %s", message.Payload.GetAssistant().GetContent()) @@ -38,7 +38,7 @@ func (a *AIClientV2) GetConversationTitleV2(ctx context.Context, inappChatHistor _, resp, err := a.ChatCompletionV2(ctx, modelToUse, OpenAIChatHistory{ openai.SystemMessage("You are a helpful assistant that generates a title for a conversation."), openai.UserMessage(message), - }, llmProvider) + }, llmProvider, customModel) if err != nil { return "", err } diff --git a/internal/services/toolkit/client/utils_v2.go b/internal/services/toolkit/client/utils_v2.go index 7890c34c..884b91eb 100644 --- a/internal/services/toolkit/client/utils_v2.go +++ b/internal/services/toolkit/client/utils_v2.go @@ -10,6 +10,7 @@ import ( "paperdebugger/internal/libs/cfg" "paperdebugger/internal/libs/db" "paperdebugger/internal/libs/logger" + "paperdebugger/internal/models" "paperdebugger/internal/services" "paperdebugger/internal/services/toolkit/registry" filetools "paperdebugger/internal/services/toolkit/tools/files" @@ -53,7 +54,7 @@ func appendAssistantTextResponseV2(openaiChatHistory *OpenAIChatHistory, inappCh }) } -func getDefaultParamsV2(modelSlug string, toolRegistry *registry.ToolRegistryV2, isCustomModel bool) openaiv3.ChatCompletionNewParams { +func getDefaultParamsV2(modelSlug string, toolRegistry *registry.ToolRegistryV2, customModel *models.CustomModel) openaiv3.ChatCompletionNewParams { var reasoningModels = []string{ "gpt-5", "gpt-5-mini", @@ -67,15 +68,22 @@ func getDefaultParamsV2(modelSlug string, toolRegistry *registry.ToolRegistryV2, "codex-mini-latest", } - // Other model providers generally do not support the Store param - if isCustomModel { - return openaiv3.ChatCompletionNewParams{ - Model: modelSlug, - Temperature: openaiv3.Float(0.7), - MaxCompletionTokens: openaiv3.Int(4000), + if customModel != nil { + params := openaiv3.ChatCompletionNewParams{ + Model: customModel.Slug, + Temperature: openaiv3.Float(float64(customModel.Temperature)), + MaxCompletionTokens: openaiv3.Int(int64(customModel.MaxOutput)), Tools: toolRegistry.GetTools(), - ParallelToolCalls: openaiv3.Bool(true), + ParallelToolCalls: openaiv3.Bool(customModel.ParallelToolCalls), } + + // Store param should only be included if it is true + // Some providers like Gemini might not support the param at all even if false + if customModel.Store { + params.Store = openaiv3.Bool(customModel.Store) + } + + return params } for _, model := range reasoningModels { diff --git a/pkg/gen/api/user/v1/user.pb.go b/pkg/gen/api/user/v1/user.pb.go index 1f727599..ef08a101 100644 --- a/pkg/gen/api/user/v1/user.pb.go +++ b/pkg/gen/api/user/v1/user.pb.go @@ -616,18 +616,21 @@ func (*DeletePromptResponse) Descriptor() ([]byte, []int) { } type CustomModel struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"` - Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` - BaseUrl string `protobuf:"bytes,4,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` - ApiKey string `protobuf:"bytes,5,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` - ContextWindow int32 `protobuf:"varint,6,opt,name=context_window,json=contextWindow,proto3" json:"context_window,omitempty"` - MaxOutput int32 `protobuf:"varint,7,opt,name=max_output,json=maxOutput,proto3" json:"max_output,omitempty"` - InputPrice int32 `protobuf:"varint,8,opt,name=input_price,json=inputPrice,proto3" json:"input_price,omitempty"` - OutputPrice int32 `protobuf:"varint,9,opt,name=output_price,json=outputPrice,proto3" json:"output_price,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + BaseUrl string `protobuf:"bytes,4,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` + ApiKey string `protobuf:"bytes,5,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` + ContextWindow int32 `protobuf:"varint,6,opt,name=context_window,json=contextWindow,proto3" json:"context_window,omitempty"` + MaxOutput int32 `protobuf:"varint,7,opt,name=max_output,json=maxOutput,proto3" json:"max_output,omitempty"` + InputPrice int32 `protobuf:"varint,8,opt,name=input_price,json=inputPrice,proto3" json:"input_price,omitempty"` + OutputPrice int32 `protobuf:"varint,9,opt,name=output_price,json=outputPrice,proto3" json:"output_price,omitempty"` + Temperature float32 `protobuf:"fixed32,10,opt,name=temperature,proto3" json:"temperature,omitempty"` + ParallelToolCalls bool `protobuf:"varint,11,opt,name=parallel_tool_calls,json=parallelToolCalls,proto3" json:"parallel_tool_calls,omitempty"` + Store bool `protobuf:"varint,12,opt,name=store,proto3" json:"store,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CustomModel) Reset() { @@ -723,6 +726,27 @@ func (x *CustomModel) GetOutputPrice() int32 { return 0 } +func (x *CustomModel) GetTemperature() float32 { + if x != nil { + return x.Temperature + } + return 0 +} + +func (x *CustomModel) GetParallelToolCalls() bool { + if x != nil { + return x.ParallelToolCalls + } + return false +} + +func (x *CustomModel) GetStore() bool { + if x != nil { + return x.Store + } + return false +} + type Settings struct { state protoimpl.MessageState `protogen:"open.v1"` ShowShortcutsAfterSelection bool `protobuf:"varint,1,opt,name=show_shortcuts_after_selection,json=showShortcutsAfterSelection,proto3" json:"show_shortcuts_after_selection,omitempty"` @@ -1269,7 +1293,7 @@ const file_user_v1_user_proto_rawDesc = "" + "\x06prompt\x18\x01 \x01(\v2\x0f.user.v1.PromptR\x06prompt\"2\n" + "\x13DeletePromptRequest\x12\x1b\n" + "\tprompt_id\x18\x01 \x01(\tR\bpromptId\"\x16\n" + - "\x14DeletePromptResponse\"\x83\x02\n" + + "\x14DeletePromptResponse\"\xeb\x02\n" + "\vCustomModel\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04slug\x18\x02 \x01(\tR\x04slug\x12\x12\n" + @@ -1281,7 +1305,11 @@ const file_user_v1_user_proto_rawDesc = "" + "max_output\x18\a \x01(\x05R\tmaxOutput\x12\x1f\n" + "\vinput_price\x18\b \x01(\x05R\n" + "inputPrice\x12!\n" + - "\foutput_price\x18\t \x01(\x05R\voutputPrice\"\x8f\x03\n" + + "\foutput_price\x18\t \x01(\x05R\voutputPrice\x12 \n" + + "\vtemperature\x18\n" + + " \x01(\x02R\vtemperature\x12.\n" + + "\x13parallel_tool_calls\x18\v \x01(\bR\x11parallelToolCalls\x12\x14\n" + + "\x05store\x18\f \x01(\bR\x05store\"\x8f\x03\n" + "\bSettings\x12C\n" + "\x1eshow_shortcuts_after_selection\x18\x01 \x01(\bR\x1bshowShortcutsAfterSelection\x12F\n" + " full_width_paper_debugger_button\x18\x02 \x01(\bR\x1cfullWidthPaperDebuggerButton\x12<\n" + diff --git a/proto/user/v1/user.proto b/proto/user/v1/user.proto index 9f45503a..fa5606fa 100644 --- a/proto/user/v1/user.proto +++ b/proto/user/v1/user.proto @@ -124,6 +124,9 @@ message CustomModel { int32 max_output = 7; int32 input_price = 8; int32 output_price = 9; + float temperature = 10; + bool parallel_tool_calls = 11; + bool store = 12; } message Settings { 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 5ff1e27a..38ffdd34 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 @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file user/v1/user.proto. */ export const file_user_v1_user: GenFile = /*@__PURE__*/ - fileDesc("ChJ1c2VyL3YxL3VzZXIucHJvdG8SB3VzZXIudjEiQAoEVXNlchIKCgJpZBgBIAEoCRINCgVlbWFpbBgCIAEoCRIMCgRuYW1lGAMgASgJEg8KB3BpY3R1cmUYBCABKAkiEAoOR2V0VXNlclJlcXVlc3QiLgoPR2V0VXNlclJlc3BvbnNlEhsKBHVzZXIYASABKAsyDS51c2VyLnYxLlVzZXIirAEKBlByb21wdBIKCgJpZBgBIAEoCRIuCgpjcmVhdGVkX2F0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBINCgV0aXRsZRgEIAEoCRIPCgdjb250ZW50GAUgASgJEhYKDmlzX3VzZXJfcHJvbXB0GAYgASgIIhQKEkxpc3RQcm9tcHRzUmVxdWVzdCI3ChNMaXN0UHJvbXB0c1Jlc3BvbnNlEiAKB3Byb21wdHMYASADKAsyDy51c2VyLnYxLlByb21wdCI1ChNDcmVhdGVQcm9tcHRSZXF1ZXN0Eg0KBXRpdGxlGAEgASgJEg8KB2NvbnRlbnQYAiABKAkiNwoUQ3JlYXRlUHJvbXB0UmVzcG9uc2USHwoGcHJvbXB0GAEgASgLMg8udXNlci52MS5Qcm9tcHQiSAoTVXBkYXRlUHJvbXB0UmVxdWVzdBIRCglwcm9tcHRfaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDwoHY29udGVudBgDIAEoCSI3ChRVcGRhdGVQcm9tcHRSZXNwb25zZRIfCgZwcm9tcHQYASABKAsyDy51c2VyLnYxLlByb21wdCIoChNEZWxldGVQcm9tcHRSZXF1ZXN0EhEKCXByb21wdF9pZBgBIAEoCSIWChREZWxldGVQcm9tcHRSZXNwb25zZSKvAQoLQ3VzdG9tTW9kZWwSCgoCaWQYASABKAkSDAoEc2x1ZxgCIAEoCRIMCgRuYW1lGAMgASgJEhAKCGJhc2VfdXJsGAQgASgJEg8KB2FwaV9rZXkYBSABKAkSFgoOY29udGV4dF93aW5kb3cYBiABKAUSEgoKbWF4X291dHB1dBgHIAEoBRITCgtpbnB1dF9wcmljZRgIIAEoBRIUCgxvdXRwdXRfcHJpY2UYCSABKAUi+wEKCFNldHRpbmdzEiYKHnNob3dfc2hvcnRjdXRzX2FmdGVyX3NlbGVjdGlvbhgBIAEoCBIoCiBmdWxsX3dpZHRoX3BhcGVyX2RlYnVnZ2VyX2J1dHRvbhgCIAEoCBIiChplbmFibGVfY2l0YXRpb25fc3VnZ2VzdGlvbhgDIAEoCBIZChFmdWxsX2RvY3VtZW50X3JhZxgEIAEoCBIZChFzaG93ZWRfb25ib2FyZGluZxgFIAEoCBIWCg5vcGVuYWlfYXBpX2tleRgGIAEoCRIrCg1jdXN0b21fbW9kZWxzGAcgAygLMhQudXNlci52MS5DdXN0b21Nb2RlbCIUChJHZXRTZXR0aW5nc1JlcXVlc3QiOgoTR2V0U2V0dGluZ3NSZXNwb25zZRIjCghzZXR0aW5ncxgBIAEoCzIRLnVzZXIudjEuU2V0dGluZ3MiPAoVVXBkYXRlU2V0dGluZ3NSZXF1ZXN0EiMKCHNldHRpbmdzGAEgASgLMhEudXNlci52MS5TZXR0aW5ncyI9ChZVcGRhdGVTZXR0aW5nc1Jlc3BvbnNlEiMKCHNldHRpbmdzGAEgASgLMhEudXNlci52MS5TZXR0aW5ncyIWChRSZXNldFNldHRpbmdzUmVxdWVzdCI8ChVSZXNldFNldHRpbmdzUmVzcG9uc2USIwoIc2V0dGluZ3MYASABKAsyES51c2VyLnYxLlNldHRpbmdzIhwKGkdldFVzZXJJbnN0cnVjdGlvbnNSZXF1ZXN0IjMKG0dldFVzZXJJbnN0cnVjdGlvbnNSZXNwb25zZRIUCgxpbnN0cnVjdGlvbnMYASABKAkiNQodVXBzZXJ0VXNlckluc3RydWN0aW9uc1JlcXVlc3QSFAoMaW5zdHJ1Y3Rpb25zGAEgASgJIjYKHlVwc2VydFVzZXJJbnN0cnVjdGlvbnNSZXNwb25zZRIUCgxpbnN0cnVjdGlvbnMYASABKAkygwoKC1VzZXJTZXJ2aWNlEl0KB0dldFVzZXISFy51c2VyLnYxLkdldFVzZXJSZXF1ZXN0GhgudXNlci52MS5HZXRVc2VyUmVzcG9uc2UiH4LT5JMCGRIXL19wZC9hcGkvdjEvdXNlcnMvQHNlbGYScQoLTGlzdFByb21wdHMSGy51c2VyLnYxLkxpc3RQcm9tcHRzUmVxdWVzdBocLnVzZXIudjEuTGlzdFByb21wdHNSZXNwb25zZSIngtPkkwIhEh8vX3BkL2FwaS92MS91c2Vycy9Ac2VsZi9wcm9tcHRzEncKDENyZWF0ZVByb21wdBIcLnVzZXIudjEuQ3JlYXRlUHJvbXB0UmVxdWVzdBodLnVzZXIudjEuQ3JlYXRlUHJvbXB0UmVzcG9uc2UiKoLT5JMCJDoBKiIfL19wZC9hcGkvdjEvdXNlcnMvQHNlbGYvcHJvbXB0cxKDAQoMVXBkYXRlUHJvbXB0EhwudXNlci52MS5VcGRhdGVQcm9tcHRSZXF1ZXN0Gh0udXNlci52MS5VcGRhdGVQcm9tcHRSZXNwb25zZSI2gtPkkwIwOgEqGisvX3BkL2FwaS92MS91c2Vycy9Ac2VsZi9wcm9tcHRzL3twcm9tcHRfaWR9Eo4BChNHZXRVc2VySW5zdHJ1Y3Rpb25zEiMudXNlci52MS5HZXRVc2VySW5zdHJ1Y3Rpb25zUmVxdWVzdBokLnVzZXIudjEuR2V0VXNlckluc3RydWN0aW9uc1Jlc3BvbnNlIiyC0+STAiYSJC9fcGQvYXBpL3YxL3VzZXJzL0BzZWxmL2luc3RydWN0aW9ucxKaAQoWVXBzZXJ0VXNlckluc3RydWN0aW9ucxImLnVzZXIudjEuVXBzZXJ0VXNlckluc3RydWN0aW9uc1JlcXVlc3QaJy51c2VyLnYxLlVwc2VydFVzZXJJbnN0cnVjdGlvbnNSZXNwb25zZSIvgtPkkwIpOgEqIiQvX3BkL2FwaS92MS91c2Vycy9Ac2VsZi9pbnN0cnVjdGlvbnMSgAEKDERlbGV0ZVByb21wdBIcLnVzZXIudjEuRGVsZXRlUHJvbXB0UmVxdWVzdBodLnVzZXIudjEuRGVsZXRlUHJvbXB0UmVzcG9uc2UiM4LT5JMCLSorL19wZC9hcGkvdjEvdXNlcnMvQHNlbGYvcHJvbXB0cy97cHJvbXB0X2lkfRJyCgtHZXRTZXR0aW5ncxIbLnVzZXIudjEuR2V0U2V0dGluZ3NSZXF1ZXN0GhwudXNlci52MS5HZXRTZXR0aW5nc1Jlc3BvbnNlIiiC0+STAiISIC9fcGQvYXBpL3YxL3VzZXJzL0BzZWxmL3NldHRpbmdzEn4KDlVwZGF0ZVNldHRpbmdzEh4udXNlci52MS5VcGRhdGVTZXR0aW5nc1JlcXVlc3QaHy51c2VyLnYxLlVwZGF0ZVNldHRpbmdzUmVzcG9uc2UiK4LT5JMCJToBKhogL19wZC9hcGkvdjEvdXNlcnMvQHNlbGYvc2V0dGluZ3MSfgoNUmVzZXRTZXR0aW5ncxIdLnVzZXIudjEuUmVzZXRTZXR0aW5nc1JlcXVlc3QaHi51c2VyLnYxLlJlc2V0U2V0dGluZ3NSZXNwb25zZSIugtPkkwIoIiYvX3BkL2FwaS92MS91c2Vycy9Ac2VsZi9zZXR0aW5ncy9yZXNldEJ/Cgtjb20udXNlci52MUIJVXNlclByb3RvUAFaKHBhcGVyZGVidWdnZXIvcGtnL2dlbi9hcGkvdXNlci92MTt1c2VydjGiAgNVWFiqAgdVc2VyLlYxygIHVXNlclxWMeICE1VzZXJcVjFcR1BCTWV0YWRhdGHqAghVc2VyOjpWMWIGcHJvdG8z", [file_google_api_annotations, file_google_protobuf_timestamp]); + fileDesc("ChJ1c2VyL3YxL3VzZXIucHJvdG8SB3VzZXIudjEiQAoEVXNlchIKCgJpZBgBIAEoCRINCgVlbWFpbBgCIAEoCRIMCgRuYW1lGAMgASgJEg8KB3BpY3R1cmUYBCABKAkiEAoOR2V0VXNlclJlcXVlc3QiLgoPR2V0VXNlclJlc3BvbnNlEhsKBHVzZXIYASABKAsyDS51c2VyLnYxLlVzZXIirAEKBlByb21wdBIKCgJpZBgBIAEoCRIuCgpjcmVhdGVkX2F0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBINCgV0aXRsZRgEIAEoCRIPCgdjb250ZW50GAUgASgJEhYKDmlzX3VzZXJfcHJvbXB0GAYgASgIIhQKEkxpc3RQcm9tcHRzUmVxdWVzdCI3ChNMaXN0UHJvbXB0c1Jlc3BvbnNlEiAKB3Byb21wdHMYASADKAsyDy51c2VyLnYxLlByb21wdCI1ChNDcmVhdGVQcm9tcHRSZXF1ZXN0Eg0KBXRpdGxlGAEgASgJEg8KB2NvbnRlbnQYAiABKAkiNwoUQ3JlYXRlUHJvbXB0UmVzcG9uc2USHwoGcHJvbXB0GAEgASgLMg8udXNlci52MS5Qcm9tcHQiSAoTVXBkYXRlUHJvbXB0UmVxdWVzdBIRCglwcm9tcHRfaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDwoHY29udGVudBgDIAEoCSI3ChRVcGRhdGVQcm9tcHRSZXNwb25zZRIfCgZwcm9tcHQYASABKAsyDy51c2VyLnYxLlByb21wdCIoChNEZWxldGVQcm9tcHRSZXF1ZXN0EhEKCXByb21wdF9pZBgBIAEoCSIWChREZWxldGVQcm9tcHRSZXNwb25zZSLwAQoLQ3VzdG9tTW9kZWwSCgoCaWQYASABKAkSDAoEc2x1ZxgCIAEoCRIMCgRuYW1lGAMgASgJEhAKCGJhc2VfdXJsGAQgASgJEg8KB2FwaV9rZXkYBSABKAkSFgoOY29udGV4dF93aW5kb3cYBiABKAUSEgoKbWF4X291dHB1dBgHIAEoBRITCgtpbnB1dF9wcmljZRgIIAEoBRIUCgxvdXRwdXRfcHJpY2UYCSABKAUSEwoLdGVtcGVyYXR1cmUYCiABKAISGwoTcGFyYWxsZWxfdG9vbF9jYWxscxgLIAEoCBINCgVzdG9yZRgMIAEoCCL7AQoIU2V0dGluZ3MSJgoec2hvd19zaG9ydGN1dHNfYWZ0ZXJfc2VsZWN0aW9uGAEgASgIEigKIGZ1bGxfd2lkdGhfcGFwZXJfZGVidWdnZXJfYnV0dG9uGAIgASgIEiIKGmVuYWJsZV9jaXRhdGlvbl9zdWdnZXN0aW9uGAMgASgIEhkKEWZ1bGxfZG9jdW1lbnRfcmFnGAQgASgIEhkKEXNob3dlZF9vbmJvYXJkaW5nGAUgASgIEhYKDm9wZW5haV9hcGlfa2V5GAYgASgJEisKDWN1c3RvbV9tb2RlbHMYByADKAsyFC51c2VyLnYxLkN1c3RvbU1vZGVsIhQKEkdldFNldHRpbmdzUmVxdWVzdCI6ChNHZXRTZXR0aW5nc1Jlc3BvbnNlEiMKCHNldHRpbmdzGAEgASgLMhEudXNlci52MS5TZXR0aW5ncyI8ChVVcGRhdGVTZXR0aW5nc1JlcXVlc3QSIwoIc2V0dGluZ3MYASABKAsyES51c2VyLnYxLlNldHRpbmdzIj0KFlVwZGF0ZVNldHRpbmdzUmVzcG9uc2USIwoIc2V0dGluZ3MYASABKAsyES51c2VyLnYxLlNldHRpbmdzIhYKFFJlc2V0U2V0dGluZ3NSZXF1ZXN0IjwKFVJlc2V0U2V0dGluZ3NSZXNwb25zZRIjCghzZXR0aW5ncxgBIAEoCzIRLnVzZXIudjEuU2V0dGluZ3MiHAoaR2V0VXNlckluc3RydWN0aW9uc1JlcXVlc3QiMwobR2V0VXNlckluc3RydWN0aW9uc1Jlc3BvbnNlEhQKDGluc3RydWN0aW9ucxgBIAEoCSI1Ch1VcHNlcnRVc2VySW5zdHJ1Y3Rpb25zUmVxdWVzdBIUCgxpbnN0cnVjdGlvbnMYASABKAkiNgoeVXBzZXJ0VXNlckluc3RydWN0aW9uc1Jlc3BvbnNlEhQKDGluc3RydWN0aW9ucxgBIAEoCTKDCgoLVXNlclNlcnZpY2USXQoHR2V0VXNlchIXLnVzZXIudjEuR2V0VXNlclJlcXVlc3QaGC51c2VyLnYxLkdldFVzZXJSZXNwb25zZSIfgtPkkwIZEhcvX3BkL2FwaS92MS91c2Vycy9Ac2VsZhJxCgtMaXN0UHJvbXB0cxIbLnVzZXIudjEuTGlzdFByb21wdHNSZXF1ZXN0GhwudXNlci52MS5MaXN0UHJvbXB0c1Jlc3BvbnNlIieC0+STAiESHy9fcGQvYXBpL3YxL3VzZXJzL0BzZWxmL3Byb21wdHMSdwoMQ3JlYXRlUHJvbXB0EhwudXNlci52MS5DcmVhdGVQcm9tcHRSZXF1ZXN0Gh0udXNlci52MS5DcmVhdGVQcm9tcHRSZXNwb25zZSIqgtPkkwIkOgEqIh8vX3BkL2FwaS92MS91c2Vycy9Ac2VsZi9wcm9tcHRzEoMBCgxVcGRhdGVQcm9tcHQSHC51c2VyLnYxLlVwZGF0ZVByb21wdFJlcXVlc3QaHS51c2VyLnYxLlVwZGF0ZVByb21wdFJlc3BvbnNlIjaC0+STAjA6ASoaKy9fcGQvYXBpL3YxL3VzZXJzL0BzZWxmL3Byb21wdHMve3Byb21wdF9pZH0SjgEKE0dldFVzZXJJbnN0cnVjdGlvbnMSIy51c2VyLnYxLkdldFVzZXJJbnN0cnVjdGlvbnNSZXF1ZXN0GiQudXNlci52MS5HZXRVc2VySW5zdHJ1Y3Rpb25zUmVzcG9uc2UiLILT5JMCJhIkL19wZC9hcGkvdjEvdXNlcnMvQHNlbGYvaW5zdHJ1Y3Rpb25zEpoBChZVcHNlcnRVc2VySW5zdHJ1Y3Rpb25zEiYudXNlci52MS5VcHNlcnRVc2VySW5zdHJ1Y3Rpb25zUmVxdWVzdBonLnVzZXIudjEuVXBzZXJ0VXNlckluc3RydWN0aW9uc1Jlc3BvbnNlIi+C0+STAik6ASoiJC9fcGQvYXBpL3YxL3VzZXJzL0BzZWxmL2luc3RydWN0aW9ucxKAAQoMRGVsZXRlUHJvbXB0EhwudXNlci52MS5EZWxldGVQcm9tcHRSZXF1ZXN0Gh0udXNlci52MS5EZWxldGVQcm9tcHRSZXNwb25zZSIzgtPkkwItKisvX3BkL2FwaS92MS91c2Vycy9Ac2VsZi9wcm9tcHRzL3twcm9tcHRfaWR9EnIKC0dldFNldHRpbmdzEhsudXNlci52MS5HZXRTZXR0aW5nc1JlcXVlc3QaHC51c2VyLnYxLkdldFNldHRpbmdzUmVzcG9uc2UiKILT5JMCIhIgL19wZC9hcGkvdjEvdXNlcnMvQHNlbGYvc2V0dGluZ3MSfgoOVXBkYXRlU2V0dGluZ3MSHi51c2VyLnYxLlVwZGF0ZVNldHRpbmdzUmVxdWVzdBofLnVzZXIudjEuVXBkYXRlU2V0dGluZ3NSZXNwb25zZSIrgtPkkwIlOgEqGiAvX3BkL2FwaS92MS91c2Vycy9Ac2VsZi9zZXR0aW5ncxJ+Cg1SZXNldFNldHRpbmdzEh0udXNlci52MS5SZXNldFNldHRpbmdzUmVxdWVzdBoeLnVzZXIudjEuUmVzZXRTZXR0aW5nc1Jlc3BvbnNlIi6C0+STAigiJi9fcGQvYXBpL3YxL3VzZXJzL0BzZWxmL3NldHRpbmdzL3Jlc2V0Qn8KC2NvbS51c2VyLnYxQglVc2VyUHJvdG9QAVoocGFwZXJkZWJ1Z2dlci9wa2cvZ2VuL2FwaS91c2VyL3YxO3VzZXJ2MaICA1VYWKoCB1VzZXIuVjHKAgdVc2VyXFYx4gITVXNlclxWMVxHUEJNZXRhZGF0YeoCCFVzZXI6OlYxYgZwcm90bzM", [file_google_api_annotations, file_google_protobuf_timestamp]); /** * @generated from message user.v1.User @@ -310,6 +310,21 @@ export type CustomModel = Message<"user.v1.CustomModel"> & { * @generated from field: int32 output_price = 9; */ outputPrice: number; + + /** + * @generated from field: float temperature = 10; + */ + temperature: number; + + /** + * @generated from field: bool parallel_tool_calls = 11; + */ + parallelToolCalls: boolean; + + /** + * @generated from field: bool store = 12; + */ + store: boolean; }; /** diff --git a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx index b9c9af3c..c967b0fe 100644 --- a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx +++ b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx @@ -62,29 +62,31 @@ export const ApiKeySettings = () => { content={
- {Array.from(settings?.customModels || []).map((m) => ( - -
- -
- ))} + {Array.from(settings?.customModels || []) + .sort((m1, m2) => m1.name.localeCompare(m2.name)) + .map((m) => ( + +
+ +
+ ))}
} /> @@ -258,7 +260,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel > {isNew ? ( - +
+ } placement="bottom" delay={100} > - + -
} placement="bottom" @@ -502,7 +520,17 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel {isNew ? "Optional Fields" : "More"}} + title={ + + + + } classNames={{ trigger: "px-1 py-0 min-h-0", content: "pt-1 pb-1", From 549b5fbf94e29beff208f726e4db142a08b34cc5 Mon Sep 17 00:00:00 2001 From: kah-seng <71002797+kah-seng@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:08:44 +0800 Subject: [PATCH 11/11] Fix Copilot comments --- .../_webapp/src/views/settings/sections/api-key-settings.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx index 094938af..c58d6dce 100644 --- a/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx +++ b/webapp/_webapp/src/views/settings/sections/api-key-settings.tsx @@ -441,7 +441,7 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel className={detailInputClassName} value={String(maxOutput)} type="number" - min={0} + min={1} step="1" disabled={!isEditing || isProcessing} onChange={(e) => setMaxOutput(e.target.value === "" ? 0 : Math.trunc(Number(e.target.value)))} @@ -487,7 +487,6 @@ const CustomModelSection = ({ isNew, onChange, model: customModel }: CustomModel