0 || hasCoarsePointer || hasNoHoverInput,
+ hasHoverInput,
+ hasCoarsePointer,
+ };
+}
+
+function samePointerInteraction(left: PointerInteraction, right: PointerInteraction): boolean {
+ return (
+ left.hasTouchInput === right.hasTouchInput &&
+ left.hasHoverInput === right.hasHoverInput &&
+ left.hasCoarsePointer === right.hasCoarsePointer
+ );
+}
+
+// usePointerInteraction 暴露设备交互能力,用于区分 hover-first 与 touch-first UI 行为。
+export function usePointerInteraction(): PointerInteraction {
+ const [interaction, setInteraction] = React.useState(DEFAULT_POINTER_INTERACTION);
+
+ React.useEffect(() => {
+ const queries = POINTER_INTERACTION_QUERIES.map((query) => window.matchMedia(query));
+ const update = () => {
+ const next = readPointerInteraction();
+ setInteraction((current) => (samePointerInteraction(current, next) ? current : next));
+ };
+
+ update();
+ for (const query of queries) {
+ query.addEventListener("change", update);
+ }
+ return () => {
+ for (const query of queries) {
+ query.removeEventListener("change", update);
+ }
+ };
+ }, []);
+
+ return interaction;
+}
From 247ef9cdb871b2265638199595b01dde87eca38f Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Wed, 17 Jun 2026 15:58:19 +0800
Subject: [PATCH 17/31] feat: implement OpenAI responses instructions handling
and previous response ID routes
---
.../conversation/service_message_send.go | 7 ++-
.../service_stateful_responses.go | 62 +++++++++++++++++--
.../service_stateful_responses_test.go | 55 ++++++++++++++++
backend/internal/infra/llm/client.go | 7 ++-
.../internal/infra/llm/openai_responses.go | 9 ++-
.../infra/llm/openrouter_responses.go | 2 +-
.../internal/infra/llm/request_params_test.go | 24 +++++++
7 files changed, 156 insertions(+), 10 deletions(-)
diff --git a/backend/internal/application/conversation/service_message_send.go b/backend/internal/application/conversation/service_message_send.go
index bea865f0..bb34a062 100644
--- a/backend/internal/application/conversation/service_message_send.go
+++ b/backend/internal/application/conversation/service_message_send.go
@@ -699,6 +699,7 @@ func (s *Service) sendMessageInternal(
Options: filteredOptions,
}
fullLLMMessages := llmMessages
+ applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &generateInput)
statefulContextConfig := buildPromptContextConfigSignature(cfg)
statefulContextState := buildPromptContextStateSignature(stableFullContextAttachments, prefixMemories)
statefulPrefixFingerprint := buildPromptStateFingerprint(promptStateFingerprintInput{
@@ -994,6 +995,7 @@ func (s *Service) sendMessageInternal(
_ = s.repo.UpdateConversationLastResponseID(ctx, input.ConversationID, "")
generateInput.PreviousResponseID = ""
generateInput.Messages = fullLLMMessages
+ applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &generateInput)
estimatedPromptTokens = estimatePromptTokens(fullLLMMessages)
initialPromptShape = summarizePromptShape("full_retry", generateInput.Messages, fullLLMMessages, "")
if traceRecorder != nil {
@@ -1102,12 +1104,14 @@ func (s *Service) sendMessageInternal(
followUpInput.Tools = nil
followUpInput.DisableTools = true
followUpInput.PreviousResponseID = ""
- } else if routeConfig.Endpoint == llm.EndpointResponses && strings.TrimSpace(upstreamOutput.ResponseID) != "" {
+ applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &followUpInput)
+ } else if routeConfig.Endpoint == llm.EndpointResponses && supportsPreviousResponseIDRoute(route) && strings.TrimSpace(upstreamOutput.ResponseID) != "" {
followUpInput.PreviousResponseID = strings.TrimSpace(upstreamOutput.ResponseID)
followUpInput.Messages = []llm.Message{{Role: "tool", ToolResults: toolResult.ToolResults}}
} else {
followUpInput.Messages = llmMessages
followUpInput.PreviousResponseID = ""
+ applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &followUpInput)
}
nextOutput, nextErr := runGenerate(followUpInput)
@@ -1139,6 +1143,7 @@ func (s *Service) sendMessageInternal(
finalInput.Tools = nil
finalInput.DisableTools = true
finalInput.PreviousResponseID = ""
+ applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &finalInput)
nextOutput, nextErr := runGenerate(finalInput)
if handleCanceledGeneration(nextErr) {
return nil, retErr
diff --git a/backend/internal/application/conversation/service_stateful_responses.go b/backend/internal/application/conversation/service_stateful_responses.go
index c08ab78e..068d9211 100644
--- a/backend/internal/application/conversation/service_stateful_responses.go
+++ b/backend/internal/application/conversation/service_stateful_responses.go
@@ -44,15 +44,18 @@ func resolvePreviousResponseID(route *channel.ResolvedRoute, branchReason string
if responseID == "" || !strings.EqualFold(strings.TrimSpace(branchReason), "default") {
return ""
}
- if route == nil || !llm.SupportsPreviousResponseID(route.Protocol) {
- return ""
- }
- if !isOfficialOpenAIBaseURL(route.BaseURL) {
+ if !supportsPreviousResponseIDRoute(route) {
return ""
}
return responseID
}
+func supportsPreviousResponseIDRoute(route *channel.ResolvedRoute) bool {
+ return route != nil &&
+ llm.SupportsPreviousResponseID(route.Protocol) &&
+ isOfficialOpenAIBaseURL(route.BaseURL)
+}
+
func isOfficialOpenAIBaseURL(raw string) bool {
value := strings.TrimSpace(raw)
if value == "" {
@@ -78,6 +81,57 @@ func buildStatefulResponseMessages(messages []llm.Message) []llm.Message {
return nil
}
+func applyOpenAIResponsesInstructions(route *channel.ResolvedRoute, endpoint string, input *llm.GenerateInput) {
+ if input == nil || endpoint != llm.EndpointResponses || !supportsPreviousResponseIDRoute(route) {
+ return
+ }
+ instructions, messages := extractOpenAIResponsesInstructions(input.Messages)
+ if strings.TrimSpace(instructions) == "" {
+ return
+ }
+ input.Instructions = instructions
+ input.Messages = messages
+}
+
+func extractOpenAIResponsesInstructions(messages []llm.Message) (string, []llm.Message) {
+ if len(messages) == 0 {
+ return "", nil
+ }
+ var builder strings.Builder
+ result := make([]llm.Message, 0, len(messages))
+ for _, message := range messages {
+ if message.Role != "system" {
+ result = append(result, message)
+ continue
+ }
+ text := strings.TrimSpace(systemInstructionText(message))
+ if text == "" {
+ continue
+ }
+ if builder.Len() > 0 {
+ builder.WriteString("\n\n")
+ }
+ builder.WriteString(text)
+ }
+ if builder.Len() == 0 {
+ return "", cloneLLMMessages(messages)
+ }
+ return builder.String(), result
+}
+
+func systemInstructionText(message llm.Message) string {
+ if strings.TrimSpace(message.Content) != "" || len(message.Parts) == 0 {
+ return message.Content
+ }
+ parts := make([]string, 0, len(message.Parts))
+ for _, part := range message.Parts {
+ if strings.TrimSpace(part.Text) != "" {
+ parts = append(parts, strings.TrimSpace(part.Text))
+ }
+ }
+ return strings.Join(parts, "\n\n")
+}
+
func shouldRetryWithoutPreviousResponseID(err error) bool {
if err == nil {
return false
diff --git a/backend/internal/application/conversation/service_stateful_responses_test.go b/backend/internal/application/conversation/service_stateful_responses_test.go
index 882361b6..5d0c5a04 100644
--- a/backend/internal/application/conversation/service_stateful_responses_test.go
+++ b/backend/internal/application/conversation/service_stateful_responses_test.go
@@ -50,6 +50,27 @@ func TestResolvePreviousResponseIDOnlyEnablesKnownSafeRoutes(t *testing.T) {
})
}
+func TestSupportsPreviousResponseIDRouteOnlyAllowsOfficialOpenAIResponses(t *testing.T) {
+ if !supportsPreviousResponseIDRoute(&channel.ResolvedRoute{
+ Protocol: llm.AdapterOpenAIResponses,
+ BaseURL: "https://api.openai.com/v1",
+ }) {
+ t.Fatalf("expected official OpenAI Responses route to support previous_response_id")
+ }
+ if supportsPreviousResponseIDRoute(&channel.ResolvedRoute{
+ Protocol: llm.AdapterOpenAIResponses,
+ BaseURL: "http://host.docker.internal:42113/v1",
+ }) {
+ t.Fatalf("expected custom Responses-compatible route to disable previous_response_id")
+ }
+ if supportsPreviousResponseIDRoute(&channel.ResolvedRoute{
+ Protocol: llm.AdapterOpenAIChatCompletions,
+ BaseURL: "https://api.openai.com/v1",
+ }) {
+ t.Fatalf("expected non-Responses route to disable previous_response_id")
+ }
+}
+
func TestBuildStatefulResponseMessagesKeepsLatestUserOnly(t *testing.T) {
messages := []llm.Message{
{Role: "system", Content: "behavior"},
@@ -68,6 +89,40 @@ func TestBuildStatefulResponseMessagesKeepsLatestUserOnly(t *testing.T) {
}
}
+func TestApplyOpenAIResponsesInstructionsOnlyForOfficialRoute(t *testing.T) {
+ official := &channel.ResolvedRoute{
+ Protocol: llm.AdapterOpenAIResponses,
+ BaseURL: "https://api.openai.com/v1",
+ }
+ input := llm.GenerateInput{
+ Messages: []llm.Message{
+ {Role: "system", Content: "platform policy"},
+ {Role: "user", Content: "hello"},
+ {Role: "system", Content: "final synthesis only"},
+ {Role: "tool", ToolResults: []llm.ToolResult{{ToolCallID: "call_1", OutputJSON: `{"ok":true}`}}},
+ },
+ }
+
+ applyOpenAIResponsesInstructions(official, llm.EndpointResponses, &input)
+
+ if input.Instructions != "platform policy\n\nfinal synthesis only" {
+ t.Fatalf("expected extracted instructions, got %q", input.Instructions)
+ }
+ if len(input.Messages) != 2 || input.Messages[0].Role != "user" || input.Messages[1].Role != "tool" {
+ t.Fatalf("expected system messages removed from input, got %#v", input.Messages)
+ }
+
+ custom := &channel.ResolvedRoute{
+ Protocol: llm.AdapterOpenAIResponses,
+ BaseURL: "https://reverse.example.com/v1",
+ }
+ compatInput := llm.GenerateInput{Messages: []llm.Message{{Role: "system", Content: "policy"}, {Role: "user", Content: "hello"}}}
+ applyOpenAIResponsesInstructions(custom, llm.EndpointResponses, &compatInput)
+ if compatInput.Instructions != "" || len(compatInput.Messages) != 2 {
+ t.Fatalf("expected custom route to keep system messages, got %#v", compatInput)
+ }
+}
+
func TestResolveStatefulPreviousResponseIDRequiresMatchingFingerprint(t *testing.T) {
route := &channel.ResolvedRoute{
Protocol: llm.AdapterOpenAIResponses,
diff --git a/backend/internal/infra/llm/client.go b/backend/internal/infra/llm/client.go
index 1a2ec78e..0fc3cec9 100644
--- a/backend/internal/infra/llm/client.go
+++ b/backend/internal/infra/llm/client.go
@@ -126,13 +126,16 @@ type GenerateInput struct {
RequestID string
ConversationID uint
Messages []Message
- Tools []ToolDefinition
+ // Instructions 映射 OpenAI Responses API 顶层 instructions 字段。
+ // 仅由确认支持该字段的 adapter 使用;普通兼容路由继续通过 messages 承载系统提示。
+ Instructions string
+ Tools []ToolDefinition
// DisableTools 表示本轮调用必须只生成文本,adapter 不再声明 MCP 或厂商原生工具。
DisableTools bool
// Options 承载本次调用的自由 JSON 参数。系统字段(model/messages/input/stream)
// 由 adapter 固定构造;Options 只表达采样、推理、工具、缓存和厂商原生扩展。
Options map[string]interface{}
- // PreviousResponseID 供 OpenAI/xAI Responses API 实现有状态会话。
+ // PreviousResponseID 供 OpenAI Responses API 实现有状态会话。
// 非空时:仅在 input 中发送本轮新消息,服务端从存储状态续接历史。
// 空串时:退回全量发送模式,适用于所有 adapter。
PreviousResponseID string
diff --git a/backend/internal/infra/llm/openai_responses.go b/backend/internal/infra/llm/openai_responses.go
index eaab072b..9ebf8d37 100644
--- a/backend/internal/infra/llm/openai_responses.go
+++ b/backend/internal/infra/llm/openai_responses.go
@@ -47,6 +47,9 @@ func buildResponsesRequestBody(
"input": items,
"stream": stream,
}
+ if instructions := strings.TrimSpace(input.Instructions); adapter == AdapterOpenAIResponses && instructions != "" {
+ payload["instructions"] = instructions
+ }
if maxTokens := modelParamInt(input.Options, "max_output_tokens"); maxTokens > 0 {
payload["max_output_tokens"] = maxTokens
}
@@ -71,7 +74,7 @@ func buildResponsesRequestBody(
if streamOptions := responsesStreamOptions(providerStreamOptions); stream && len(streamOptions) > 0 {
payload["stream_options"] = streamOptions
}
- applyProviderOptions(payload, input.Options, responsesProtectedProviderOptionKeys(adapter)...)
+ applyProviderOptions(payload, input.Options, responsesProtectedProviderOptionKeys(adapter, strings.TrimSpace(input.Instructions) != "")...)
if supportsResponsesIncludeDefaults(adapter) {
defaultIncludes := responsesDefaultIncludeValues(adapter, stream, nativeTools)
appendResponseInclude(payload, responseIncludeValues(input.Options, defaultIncludes...)...)
@@ -81,7 +84,7 @@ func buildResponsesRequestBody(
return payload
}
-func responsesProtectedProviderOptionKeys(adapter string) []string {
+func responsesProtectedProviderOptionKeys(adapter string, hasManagedInstructions bool) []string {
keys := []string{
"contents",
"include",
@@ -100,6 +103,8 @@ func responsesProtectedProviderOptionKeys(adapter string) []string {
}
if adapter != AdapterOpenAIResponses {
keys = append(keys, "instructions", "metadata", "prompt")
+ } else if hasManagedInstructions {
+ keys = append(keys, "instructions")
}
return keys
}
diff --git a/backend/internal/infra/llm/openrouter_responses.go b/backend/internal/infra/llm/openrouter_responses.go
index d2fd02f6..a81c90ea 100644
--- a/backend/internal/infra/llm/openrouter_responses.go
+++ b/backend/internal/infra/llm/openrouter_responses.go
@@ -66,7 +66,7 @@ func buildOpenRouterResponsesRequestBody(
if streamOptions := responsesStreamOptions(providerStreamOptions); stream && len(streamOptions) > 0 {
payload["stream_options"] = streamOptions
}
- applyProviderOptions(payload, input.Options, responsesProtectedProviderOptionKeys(AdapterOpenRouterResponses)...)
+ applyProviderOptions(payload, input.Options, responsesProtectedProviderOptionKeys(AdapterOpenRouterResponses, false)...)
if include := responseIncludeValues(input.Options); len(include) > 0 {
appendResponseInclude(payload, include...)
}
diff --git a/backend/internal/infra/llm/request_params_test.go b/backend/internal/infra/llm/request_params_test.go
index 6d6588c3..61ca6036 100644
--- a/backend/internal/infra/llm/request_params_test.go
+++ b/backend/internal/infra/llm/request_params_test.go
@@ -495,6 +495,30 @@ func TestBuildOpenAIResponsesNestedProviderOptionsMergeAndOfficialFields(t *test
}
}
+func TestBuildOpenAIResponsesUsesManagedInstructions(t *testing.T) {
+ payload := mustBuildRequestBody(t, AdapterOpenAIResponses, "gpt-5", EndpointResponses, GenerateInput{
+ Messages: []Message{{Role: "user", Content: "hello"}},
+ Instructions: "managed developer instructions",
+ Options: map[string]interface{}{
+ "instructions": "provider override",
+ "metadata": map[string]interface{}{"trace": "ok"},
+ "prompt": map[string]interface{}{"id": "pmpt_123"},
+ },
+ }, true)
+
+ if payload["instructions"] != "managed developer instructions" {
+ t.Fatalf("expected managed instructions, got %#v", payload["instructions"])
+ }
+ metadata, ok := payload["metadata"].(map[string]interface{})
+ if !ok || metadata["trace"] != "ok" {
+ t.Fatalf("expected metadata to remain available, got %#v", payload["metadata"])
+ }
+ prompt, ok := payload["prompt"].(map[string]interface{})
+ if !ok || prompt["id"] != "pmpt_123" {
+ t.Fatalf("expected prompt to remain available, got %#v", payload["prompt"])
+ }
+}
+
func TestBuildXAIResponsesOmitsUnsupportedSystemMetadata(t *testing.T) {
payload := mustBuildRequestBody(t, AdapterXAIResponses, "grok-4.3", EndpointResponses, GenerateInput{
RequestID: "req-123",
From 8ca43fb79579b76237e9e22413ff30bd890864e2 Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Wed, 17 Jun 2026 16:03:07 +0800
Subject: [PATCH 18/31] refactor: update instructions field documentation for
clarity on adapter usage
---
backend/internal/infra/llm/client.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/internal/infra/llm/client.go b/backend/internal/infra/llm/client.go
index 0fc3cec9..98d52c17 100644
--- a/backend/internal/infra/llm/client.go
+++ b/backend/internal/infra/llm/client.go
@@ -126,8 +126,8 @@ type GenerateInput struct {
RequestID string
ConversationID uint
Messages []Message
- // Instructions 映射 OpenAI Responses API 顶层 instructions 字段。
- // 仅由确认支持该字段的 adapter 使用;普通兼容路由继续通过 messages 承载系统提示。
+ // Instructions 承载可映射到上游原生指令字段的系统/开发者指令。
+ // 不支持原生指令字段的 adapter 应继续通过 messages 承载系统提示。
Instructions string
Tools []ToolDefinition
// DisableTools 表示本轮调用必须只生成文本,adapter 不再声明 MCP 或厂商原生工具。
From db5fdb9314d46d9491b81097a003cfabafac1f50 Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Wed, 17 Jun 2026 16:26:32 +0800
Subject: [PATCH 19/31] feat: enhance model option filtering and input
generation for text tasks
---
.../conversation/model_option_policy.go | 39 ++++++++++--
.../conversation/model_option_policy_test.go | 61 +++++++++++++++++++
.../service_generation_support.go | 7 +--
.../conversation/service_metadata.go | 5 +-
.../conversation/service_text_task_route.go | 47 ++++++++++++++
.../service_text_task_route_test.go | 55 +++++++++++++++++
6 files changed, 202 insertions(+), 12 deletions(-)
diff --git a/backend/internal/application/conversation/model_option_policy.go b/backend/internal/application/conversation/model_option_policy.go
index 204d6d7f..80e8d3dd 100644
--- a/backend/internal/application/conversation/model_option_policy.go
+++ b/backend/internal/application/conversation/model_option_policy.go
@@ -39,10 +39,6 @@ type modelOptionPolicyConfig struct {
}
func filterModelOptions(options map[string]interface{}, protocol string, cfg modelOptionPolicyConfig) map[string]interface{} {
- if len(options) == 0 {
- return nil
- }
-
mode := strings.TrimSpace(cfg.Mode)
if mode == "" {
mode = modelOptionPolicyAllowlist
@@ -52,8 +48,11 @@ func filterModelOptions(options map[string]interface{}, protocol string, cfg mod
}
protocolKey := modelOptionPolicyProtocolKey(protocol)
- nativeTools := nativeProviderToolsFromOption(protocolKey, options["tools"], cfg.ModelCapabilitiesJSON)
- policyOptions := cloneModelOptionMap(options)
+ policyOptions := mergeModelOptionDefaults(modelCapabilityDefaultOptions(cfg.ModelCapabilitiesJSON), options)
+ if len(policyOptions) == 0 {
+ return nil
+ }
+ nativeTools := nativeProviderToolsFromOption(protocolKey, policyOptions["tools"], cfg.ModelCapabilitiesJSON)
delete(policyOptions, "tools")
denied := append([][]string{}, hardDeniedModelOptionPaths...)
@@ -85,6 +84,34 @@ func filterModelOptions(options map[string]interface{}, protocol string, cfg mod
return filtered
}
+// modelCapabilityDefaultOptions 提取管理员在模型能力 JSON 中声明的默认请求参数。
+func modelCapabilityDefaultOptions(raw string) map[string]interface{} {
+ value := strings.TrimSpace(raw)
+ if value == "" {
+ return nil
+ }
+ var config struct {
+ DefaultOptions map[string]interface{} `json:"defaultOptions"`
+ }
+ if err := json.Unmarshal([]byte(value), &config); err != nil {
+ return nil
+ }
+ return cloneModelOptionMap(config.DefaultOptions)
+}
+
+// mergeModelOptionDefaults 以能力默认值为基础合并本次显式参数,显式参数优先。
+func mergeModelOptionDefaults(defaults map[string]interface{}, options map[string]interface{}) map[string]interface{} {
+ merged := cloneModelOptionMap(defaults)
+ if merged == nil {
+ merged = make(map[string]interface{}, len(options))
+ }
+ mergeModelOptionMap(merged, options)
+ if len(merged) == 0 {
+ return nil
+ }
+ return merged
+}
+
// nativeProviderToolsFromOption 将用户 options.tools 收敛为当前协议允许的官方原生工具。
// 普通参数白名单不处理 tools,避免用户通过自由 JSON 绕过官方工具控制。
func nativeProviderToolsFromOption(protocolKey string, raw interface{}, capabilitiesJSON string) []map[string]interface{} {
diff --git a/backend/internal/application/conversation/model_option_policy_test.go b/backend/internal/application/conversation/model_option_policy_test.go
index 0e86244c..b7cc6edf 100644
--- a/backend/internal/application/conversation/model_option_policy_test.go
+++ b/backend/internal/application/conversation/model_option_policy_test.go
@@ -68,6 +68,67 @@ func TestFilterModelOptionsAllowlistUsesDefaultAndProtocolPaths(t *testing.T) {
}
}
+func TestFilterModelOptionsAppliesCapabilityDefaultOptions(t *testing.T) {
+ filtered := filterModelOptions(map[string]interface{}{
+ "reasoning": map[string]interface{}{
+ "effort": "high",
+ },
+ }, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{
+ Mode: modelOptionPolicyAllowlist,
+ AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(),
+ DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(),
+ ModelCapabilitiesJSON: `{
+ "defaultOptions": {
+ "reasoning": {"effort": "medium", "summary": "auto"},
+ "text": {"verbosity": "low"},
+ "model": "blocked"
+ }
+ }`,
+ })
+
+ reasoning := filtered["reasoning"].(map[string]interface{})
+ if reasoning["effort"] != "high" || reasoning["summary"] != "auto" {
+ t.Fatalf("expected explicit option to override default and default summary to remain, got %#v", reasoning)
+ }
+ text := filtered["text"].(map[string]interface{})
+ if text["verbosity"] != "low" {
+ t.Fatalf("expected default text verbosity to pass, got %#v", filtered)
+ }
+ if _, ok := filtered["model"]; ok {
+ t.Fatalf("expected hard-denied default option to be removed, got %#v", filtered)
+ }
+}
+
+func TestFilterModelOptionsOnlyInjectsDefaultToolsFromDefaultOptions(t *testing.T) {
+ allowedOnly := filterModelOptions(nil, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{
+ Mode: modelOptionPolicyAllowlist,
+ AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(),
+ DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(),
+ ModelCapabilitiesJSON: `{"nativeToolKeys":["openai.web_search_preview"]}`,
+ })
+ if _, ok := allowedOnly["tools"]; ok {
+ t.Fatalf("expected nativeToolKeys to allow but not inject tools, got %#v", allowedOnly)
+ }
+
+ withDefault := filterModelOptions(nil, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{
+ Mode: modelOptionPolicyAllowlist,
+ AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(),
+ DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(),
+ ModelCapabilitiesJSON: `{
+ "defaultOptions": {
+ "tools": [{"type": "web_search_preview", "search_context_size": "low"}]
+ }
+ }`,
+ })
+ tools, ok := withDefault["tools"].([]map[string]interface{})
+ if !ok || len(tools) != 1 {
+ t.Fatalf("expected default tool to be injected through capability defaults, got %#v", withDefault)
+ }
+ if tools[0]["type"] != "web_search_preview" || tools[0]["search_context_size"] != "low" {
+ t.Fatalf("expected default tool parameters to pass, got %#v", tools[0])
+ }
+}
+
func TestFilterModelOptionsRejectsUnsupportedOpenAIServiceTier(t *testing.T) {
for _, serviceTier := range []string{"auto", "scale", "unknown"} {
t.Run(serviceTier, func(t *testing.T) {
diff --git a/backend/internal/application/conversation/service_generation_support.go b/backend/internal/application/conversation/service_generation_support.go
index b8a44c8e..80dc55d1 100644
--- a/backend/internal/application/conversation/service_generation_support.go
+++ b/backend/internal/application/conversation/service_generation_support.go
@@ -151,15 +151,14 @@ func (s *Service) callCompactLLM(ctx context.Context, platformModelName string,
AttributionTitle: attributionTitle,
}
startedAt := time.Now()
- out, err := s.llmClient.Generate(ctx, routeConfig, llm.GenerateInput{
- Messages: llmMsgs,
- })
+ generateInput := buildTextTaskGenerateInput(route, s.cfg.Snapshot(), llmMsgs)
+ out, err := s.llmClient.Generate(ctx, routeConfig, generateInput)
if err != nil {
return "", fmt.Errorf("compact llm generate: %w", err)
}
text := strings.TrimSpace(out.Text)
if billingCtx, ok := ctx.Value(basicServiceBillingContextKey{}).(basicServiceBillingContext); ok {
- s.recordBasicServiceUsage(ctx, billingCtx.UserID, billingCtx.ConversationID, "compact", "上下文压缩", code, route.BindingCode, route.Protocol, route.UpstreamName, route.UpstreamModel, "5m", out.Usage, llmMsgs, text, time.Since(startedAt).Milliseconds())
+ s.recordBasicServiceUsage(ctx, billingCtx.UserID, billingCtx.ConversationID, "compact", "上下文压缩", code, route.BindingCode, route.Protocol, route.UpstreamName, route.UpstreamModel, "5m", out.Usage, generateInput.Messages, text, time.Since(startedAt).Milliseconds())
}
return text, nil
}
diff --git a/backend/internal/application/conversation/service_metadata.go b/backend/internal/application/conversation/service_metadata.go
index 6f690b05..cb9c5120 100644
--- a/backend/internal/application/conversation/service_metadata.go
+++ b/backend/internal/application/conversation/service_metadata.go
@@ -401,7 +401,8 @@ func (s *Service) callConversationMetadataLLM(ctx context.Context, configuredMod
AttributionTitle: attributionTitle,
}
startedAt := time.Now()
- out, generateErr := s.llmClient.Generate(ctx, routeConfig, llm.GenerateInput{Messages: messages})
+ generateInput := buildTextTaskGenerateInput(route, s.cfg.Snapshot(), messages)
+ out, generateErr := s.llmClient.Generate(ctx, routeConfig, generateInput)
if generateErr != nil {
lastErr = fmt.Errorf("metadata llm generate: %w", generateErr)
continue
@@ -409,7 +410,7 @@ func (s *Service) callConversationMetadataLLM(ctx context.Context, configuredMod
return &conversationMetadataLLMResult{
Text: strings.TrimSpace(out.Text),
Usage: out.Usage,
- Messages: messages,
+ Messages: generateInput.Messages,
PlatformModelName: route.PlatformModelName,
RoutedBindingCode: route.BindingCode,
ProviderProtocol: route.Protocol,
diff --git a/backend/internal/application/conversation/service_text_task_route.go b/backend/internal/application/conversation/service_text_task_route.go
index 657883e8..ec33a0e0 100644
--- a/backend/internal/application/conversation/service_text_task_route.go
+++ b/backend/internal/application/conversation/service_text_task_route.go
@@ -6,6 +6,8 @@ import (
"strings"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
)
const textTaskFollowModel = "follow"
@@ -121,3 +123,48 @@ func (s *Service) resolveTextTaskModel(ctx context.Context, configured string, c
}
return strings.TrimSpace(route.PlatformModelName)
}
+
+// buildTextTaskGenerateInput 为标题、标签、压缩等内部文本任务统一应用模型能力策略。
+func buildTextTaskGenerateInput(route *channel.ResolvedRoute, cfg config.Config, messages []llm.Message) llm.GenerateInput {
+ if route == nil {
+ return llm.GenerateInput{Messages: cloneLLMMessages(messages)}
+ }
+ input := llm.GenerateInput{
+ Messages: normalizeTextTaskSystemMessages(route, messages),
+ Options: filterModelOptions(nil, route.Protocol, modelOptionPolicyConfig{
+ Mode: cfg.ModelOptionPolicyMode,
+ AllowedPathsJSON: cfg.ModelOptionAllowedPaths,
+ DeniedPathsJSON: cfg.ModelOptionDeniedPaths,
+ ModelCapabilitiesJSON: route.ModelCapabilitiesJSON,
+ }),
+ }
+ applyOpenAIResponsesInstructions(route, llm.DefaultEndpointForAdapter(route.Protocol), &input)
+ return input
+}
+
+// normalizeTextTaskSystemMessages 按模型能力决定内部文本任务是否需要把 system 指令降级进 user 消息。
+func normalizeTextTaskSystemMessages(route *channel.ResolvedRoute, messages []llm.Message) []llm.Message {
+ if route == nil || !shouldInlineSystemPromptToUser(*route) {
+ return cloneLLMMessages(messages)
+ }
+ var systemText strings.Builder
+ remaining := make([]llm.Message, 0, len(messages))
+ for _, message := range messages {
+ if message.Role != "system" {
+ remaining = append(remaining, message)
+ continue
+ }
+ text := strings.TrimSpace(systemInstructionText(message))
+ if text == "" {
+ continue
+ }
+ if systemText.Len() > 0 {
+ systemText.WriteString("\n\n")
+ }
+ systemText.WriteString(text)
+ }
+ if systemText.Len() == 0 {
+ return remaining
+ }
+ return inlineSystemPromptIntoLatestUserMessage(remaining, systemText.String())
+}
diff --git a/backend/internal/application/conversation/service_text_task_route_test.go b/backend/internal/application/conversation/service_text_task_route_test.go
index 0ff028e0..de0a82a2 100644
--- a/backend/internal/application/conversation/service_text_task_route_test.go
+++ b/backend/internal/application/conversation/service_text_task_route_test.go
@@ -3,9 +3,12 @@ package conversation
import (
"context"
"errors"
+ "strings"
"testing"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
)
type textTaskRouteResolverStub struct {
@@ -91,3 +94,55 @@ func TestResolveTextTaskRouteCandidatesFollowFallsBackWhenCurrentRouteFails(t *t
t.Fatalf("expected default route after current route failure, got %#v", routes)
}
}
+
+func TestBuildTextTaskGenerateInputAppliesDefaultsAndInstructions(t *testing.T) {
+ route := &channel.ResolvedRoute{
+ Protocol: llm.AdapterOpenAIResponses,
+ BaseURL: "https://api.openai.com/v1",
+ ModelCapabilitiesJSON: `{"defaultOptions":{"reasoning":{"effort":"medium"}}}`,
+ }
+ input := buildTextTaskGenerateInput(route, config.Config{
+ ModelOptionPolicyMode: modelOptionPolicyAllowlist,
+ ModelOptionAllowedPaths: `{
+ "openai_responses": ["reasoning.effort"]
+ }`,
+ ModelOptionDeniedPaths: config.DefaultModelOptionDeniedPathsJSON(),
+ }, []llm.Message{
+ {Role: "system", Content: "summarize carefully"},
+ {Role: "user", Content: "hello"},
+ })
+
+ if input.Instructions != "summarize carefully" {
+ t.Fatalf("expected official Responses instructions, got %q", input.Instructions)
+ }
+ if len(input.Messages) != 1 || input.Messages[0].Role != "user" {
+ t.Fatalf("expected system message to be removed from input, got %#v", input.Messages)
+ }
+ reasoning := input.Options["reasoning"].(map[string]interface{})
+ if reasoning["effort"] != "medium" {
+ t.Fatalf("expected default reasoning effort, got %#v", input.Options)
+ }
+}
+
+func TestBuildTextTaskGenerateInputInlinesSystemWhenCapabilitiesDisableSystemPrompt(t *testing.T) {
+ route := &channel.ResolvedRoute{
+ Protocol: llm.AdapterOpenAIResponses,
+ BaseURL: "https://api.openai.com/v1",
+ ModelCapabilitiesJSON: `{"supportsSystemPrompt":false}`,
+ }
+ input := buildTextTaskGenerateInput(route, config.Config{}, []llm.Message{
+ {Role: "system", Content: "title only"},
+ {Role: "user", Content: "hello"},
+ })
+
+ if input.Instructions != "" {
+ t.Fatalf("expected no native instructions for inline-user capability, got %q", input.Instructions)
+ }
+ if len(input.Messages) != 1 || input.Messages[0].Role != "user" {
+ t.Fatalf("expected one inlined user message, got %#v", input.Messages)
+ }
+ content := input.Messages[0].Content
+ if !strings.Contains(content, "
") || !strings.Contains(content, "title only") || !strings.Contains(content, "hello") {
+ t.Fatalf("expected system prompt to be inlined into user message, got %q", content)
+ }
+}
From 3c238eb10107600989afb0819153a9f4014d73b4 Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Wed, 17 Jun 2026 17:00:53 +0800
Subject: [PATCH 20/31] feat: add support for locked option paths and enhance
filtering logic
---
backend/README.md | 1 +
.../conversation/model_option_policy.go | 36 +++++-
.../conversation/model_option_policy_test.go | 61 ++++++++++
.../models/models-capabilities-config.tsx | 68 +++++++++--
.../chat/components/sections/chat-input.tsx | 1 +
.../components/sections/chat-model-config.tsx | 109 ++++++++++++++----
.../chat/hooks/use-chat-model-options.ts | 20 ++++
frontend/features/chat/types/chat-runtime.ts | 2 +
.../i18n/messages/en-US/admin-models.json | 2 +
frontend/i18n/messages/en-US/chat.json | 5 +-
.../i18n/messages/zh-CN/admin-models.json | 2 +
frontend/i18n/messages/zh-CN/chat.json | 5 +-
12 files changed, 272 insertions(+), 40 deletions(-)
diff --git a/backend/README.md b/backend/README.md
index 2c1f4367..b0a61bf4 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -224,6 +224,7 @@ OCR 引擎配置由后台文件设置管理,当前支持 RapidOCR、Tesseract
模型能力 JSON 支持:
- `defaultOptions`:写入用户侧默认参数 JSON,并作为请求参数来源。
+- `lockedOptionPaths`:声明不可由用户覆盖的参数路径;对应值仍从 `defaultOptions` 读取,后端发送前会恢复为管理员默认值。
- `optionControls`:定义用户参数配置对话框的可视化控件,不会单独传给上游。
- `nativeToolKeys`:定义当前模型允许的厂商官方原生工具,例如 OpenAI、xAI、Google 和 Anthropic 的原生搜索、代码执行或图片生成能力。
- `image.stream`:仅对图像类模型能力生效;未配置时保持默认流式,显式写 `false` 时关闭图像流式调用。
diff --git a/backend/internal/application/conversation/model_option_policy.go b/backend/internal/application/conversation/model_option_policy.go
index 80e8d3dd..f8af93cd 100644
--- a/backend/internal/application/conversation/model_option_policy.go
+++ b/backend/internal/application/conversation/model_option_policy.go
@@ -48,7 +48,12 @@ func filterModelOptions(options map[string]interface{}, protocol string, cfg mod
}
protocolKey := modelOptionPolicyProtocolKey(protocol)
- policyOptions := mergeModelOptionDefaults(modelCapabilityDefaultOptions(cfg.ModelCapabilitiesJSON), options)
+ defaultOptions := modelCapabilityDefaultOptions(cfg.ModelCapabilitiesJSON)
+ policyOptions := mergeModelOptionDefaults(
+ defaultOptions,
+ options,
+ modelCapabilityLockedOptionPaths(cfg.ModelCapabilitiesJSON),
+ )
if len(policyOptions) == 0 {
return nil
}
@@ -99,13 +104,38 @@ func modelCapabilityDefaultOptions(raw string) map[string]interface{} {
return cloneModelOptionMap(config.DefaultOptions)
}
-// mergeModelOptionDefaults 以能力默认值为基础合并本次显式参数,显式参数优先。
-func mergeModelOptionDefaults(defaults map[string]interface{}, options map[string]interface{}) map[string]interface{} {
+func modelCapabilityLockedOptionPaths(raw string) [][]string {
+ value := strings.TrimSpace(raw)
+ if value == "" {
+ return nil
+ }
+ var config struct {
+ LockedOptionPaths []string `json:"lockedOptionPaths"`
+ }
+ if err := json.Unmarshal([]byte(value), &config); err != nil {
+ return nil
+ }
+ paths := make([][]string, 0, len(config.LockedOptionPaths))
+ for _, value := range config.LockedOptionPaths {
+ if path := splitModelOptionPath(value); len(path) > 0 {
+ paths = append(paths, path)
+ }
+ }
+ return paths
+}
+
+// mergeModelOptionDefaults 以能力默认值为基础合并本次显式参数,并对锁定路径恢复默认值。
+func mergeModelOptionDefaults(defaults map[string]interface{}, options map[string]interface{}, lockedPaths [][]string) map[string]interface{} {
merged := cloneModelOptionMap(defaults)
if merged == nil {
merged = make(map[string]interface{}, len(options))
}
mergeModelOptionMap(merged, options)
+ for _, path := range lockedPaths {
+ if value, ok := readModelOptionPath(defaults, path); ok {
+ writeModelOptionPath(merged, path, cloneModelOptionValue(value))
+ }
+ }
if len(merged) == 0 {
return nil
}
diff --git a/backend/internal/application/conversation/model_option_policy_test.go b/backend/internal/application/conversation/model_option_policy_test.go
index b7cc6edf..562f6584 100644
--- a/backend/internal/application/conversation/model_option_policy_test.go
+++ b/backend/internal/application/conversation/model_option_policy_test.go
@@ -99,6 +99,41 @@ func TestFilterModelOptionsAppliesCapabilityDefaultOptions(t *testing.T) {
}
}
+func TestFilterModelOptionsAppliesLockedCapabilityDefaultOptions(t *testing.T) {
+ filtered := filterModelOptions(map[string]interface{}{
+ "reasoning": map[string]interface{}{
+ "effort": "high",
+ },
+ "text": map[string]interface{}{
+ "verbosity": "high",
+ },
+ }, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{
+ Mode: modelOptionPolicyAllowlist,
+ AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(),
+ DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(),
+ ModelCapabilitiesJSON: `{
+ "defaultOptions": {
+ "reasoning": {"effort": "low"},
+ "text": {"verbosity": "low"},
+ "previous_response_id": "resp_blocked"
+ },
+ "lockedOptionPaths": ["reasoning.effort", "text.verbosity", "previous_response_id"]
+ }`,
+ })
+
+ reasoning := filtered["reasoning"].(map[string]interface{})
+ if reasoning["effort"] != "low" {
+ t.Fatalf("expected locked default reasoning effort to override explicit option, got %#v", reasoning)
+ }
+ text := filtered["text"].(map[string]interface{})
+ if text["verbosity"] != "low" {
+ t.Fatalf("expected locked default text verbosity to override explicit option, got %#v", text)
+ }
+ if _, ok := filtered["previous_response_id"]; ok {
+ t.Fatalf("expected hard-denied locked default option to be removed, got %#v", filtered)
+ }
+}
+
func TestFilterModelOptionsOnlyInjectsDefaultToolsFromDefaultOptions(t *testing.T) {
allowedOnly := filterModelOptions(nil, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{
Mode: modelOptionPolicyAllowlist,
@@ -129,6 +164,32 @@ func TestFilterModelOptionsOnlyInjectsDefaultToolsFromDefaultOptions(t *testing.
}
}
+func TestFilterModelOptionsInjectsLockedDefaultToolsThroughCapabilities(t *testing.T) {
+ filtered := filterModelOptions(map[string]interface{}{
+ "tools": []interface{}{
+ map[string]interface{}{"type": "web_search_preview", "search_context_size": "high"},
+ },
+ }, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{
+ Mode: modelOptionPolicyAllowlist,
+ AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(),
+ DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(),
+ ModelCapabilitiesJSON: `{
+ "defaultOptions": {
+ "tools": [{"type": "web_search_preview", "search_context_size": "low"}]
+ },
+ "lockedOptionPaths": ["tools"]
+ }`,
+ })
+
+ tools, ok := filtered["tools"].([]map[string]interface{})
+ if !ok || len(tools) != 1 {
+ t.Fatalf("expected locked default tool to be injected through capabilities, got %#v", filtered)
+ }
+ if tools[0]["type"] != "web_search_preview" || tools[0]["search_context_size"] != "low" {
+ t.Fatalf("expected locked default tool to override explicit tool parameters, got %#v", tools[0])
+ }
+}
+
func TestFilterModelOptionsRejectsUnsupportedOpenAIServiceTier(t *testing.T) {
for _, serviceTier := range []string{"auto", "scale", "unknown"} {
t.Run(serviceTier, func(t *testing.T) {
diff --git a/frontend/features/admin/components/sections/models/models-capabilities-config.tsx b/frontend/features/admin/components/sections/models/models-capabilities-config.tsx
index d6178350..ddb9b1e2 100644
--- a/frontend/features/admin/components/sections/models/models-capabilities-config.tsx
+++ b/frontend/features/admin/components/sections/models/models-capabilities-config.tsx
@@ -77,6 +77,7 @@ type ParameterRow = {
type: CapabilityControlType;
options: string;
defaultValue: string;
+ locked: boolean;
};
type NativeToolOption = {
@@ -90,7 +91,7 @@ type NativeToolOption = {
protocols: string[];
};
-type CapabilityRowErrors = Record>>;
+type CapabilityRowErrors = Record>>;
type NativeToolRow = {
id: string;
@@ -250,6 +251,17 @@ function buildDefaultOptions(rows: ParameterRow[]): Record {
return options;
}
+function parseLockedOptionPaths(value: unknown): Set {
+ if (!Array.isArray(value)) {
+ return new Set();
+ }
+ return new Set(
+ value
+ .map((item) => (typeof item === "string" ? optionPathSegments(item).join(".") : ""))
+ .filter(Boolean),
+ );
+}
+
function normalizeControlType(value: unknown): CapabilityControlType {
return CAPABILITY_CONTROL_TYPES.includes(value as CapabilityControlType)
? (value as CapabilityControlType)
@@ -289,11 +301,13 @@ function parseOptionControls(value: unknown): ParameterRow[] {
type: normalizeControlType(item.type),
options,
defaultValue: "",
+ locked: false,
}];
});
}
-function parseParameterRows(defaultOptions: unknown, optionControls: unknown): ParameterRow[] {
+function parseParameterRows(defaultOptions: unknown, optionControls: unknown, lockedOptionPaths: unknown): ParameterRow[] {
+ const lockedPaths = parseLockedOptionPaths(lockedOptionPaths);
const defaultsByPath = new Map();
flattenDefaultOptions(defaultOptions).forEach((item) => {
defaultsByPath.set(item.path, { value: item.value, rawValue: item.rawValue });
@@ -304,6 +318,7 @@ function parseParameterRows(defaultOptions: unknown, optionControls: unknown): P
return {
...row,
defaultValue: defaultItem?.value ?? "",
+ locked: lockedPaths.has(row.path),
};
});
defaultsByPath.forEach((item, path) => {
@@ -315,6 +330,7 @@ function parseParameterRows(defaultOptions: unknown, optionControls: unknown): P
type: inferControlType(item.rawValue),
options: "",
defaultValue: item.value,
+ locked: lockedPaths.has(path),
});
});
return rows;
@@ -361,6 +377,16 @@ function buildOptionControls(rows: ParameterRow[]): Record[] {
});
}
+function buildLockedOptionPaths(rows: ParameterRow[]): string[] {
+ return Array.from(new Set(rows.flatMap((row) => {
+ if (!row.locked || !row.defaultValue.trim()) {
+ return [];
+ }
+ const path = optionPathSegments(row.path);
+ return path.length > 0 ? [path.join(".")] : [];
+ })));
+}
+
function markDuplicatePathErrors(
rows: T[],
errors: CapabilityRowErrors,
@@ -396,6 +422,9 @@ function validateParameterRows(rows: ParameterRow[], t: (key: string) => string)
if (row.type === "select" && parseControlOptions(row.options).length === 0) {
errors[row.id] = { ...(errors[row.id] ?? {}), options: t("sheet.capabilitiesQuick.selectOptionsRequired") };
}
+ if (row.locked && !row.defaultValue.trim()) {
+ errors[row.id] = { ...(errors[row.id] ?? {}), defaultValue: t("sheet.capabilitiesQuick.lockedDefaultRequired") };
+ }
});
markDuplicatePathErrors(rows, errors, t("sheet.capabilitiesQuick.duplicatePath"));
return errors;
@@ -859,6 +888,7 @@ function buildCapabilitiesJSON(
}
const defaultOptions = buildDefaultOptions(parameterRows);
const optionControls = buildOptionControls(parameterRows);
+ const lockedOptionPaths = buildLockedOptionPaths(parameterRows);
if (Object.keys(defaultOptions).length > 0) {
payload.defaultOptions = defaultOptions;
} else {
@@ -869,6 +899,11 @@ function buildCapabilitiesJSON(
} else {
delete payload.optionControls;
}
+ if (lockedOptionPaths.length > 0) {
+ payload.lockedOptionPaths = lockedOptionPaths;
+ } else {
+ delete payload.lockedOptionPaths;
+ }
const nativeTools = buildNativeTools(nativeToolRows);
if (nativeTools.length > 0) {
payload.nativeTools = nativeTools;
@@ -1071,7 +1106,7 @@ export function ModelCapabilitiesQuickConfig({
toast.error(t("sheet.capabilitiesQuick.invalidJSON"));
return false;
}
- setParameterRows(parseParameterRows(payload.defaultOptions, payload.optionControls));
+ setParameterRows(parseParameterRows(payload.defaultOptions, payload.optionControls, payload.lockedOptionPaths));
const nextNativeToolRows = parseNativeToolRows(payload, nativeTools, routeProtocols);
setNativeToolRows(nextNativeToolRows);
setExpandedNativeToolID("");
@@ -1153,6 +1188,7 @@ export function ModelCapabilitiesQuickConfig({
type: "text",
options: "",
defaultValue: "",
+ locked: false,
}, ...prev]);
}
@@ -1181,7 +1217,7 @@ export function ModelCapabilitiesQuickConfig({
toast.error(t("sheet.capabilitiesQuick.invalidJSON"));
return;
}
- setParameterRows(parseParameterRows(payload.defaultOptions, payload.optionControls));
+ setParameterRows(parseParameterRows(payload.defaultOptions, payload.optionControls, payload.lockedOptionPaths));
setNativeToolRows(parseNativeToolRows(payload, nativeTools, routeProtocols));
setExpandedNativeToolID("");
setParameterErrors({});
@@ -1351,17 +1387,29 @@ export function ModelCapabilitiesQuickConfig({
/>
{rowErrors.options ? {rowErrors.options}
: null}
-