From 5c56a43b7247a03efc67278f3b781a0baed16ced Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 4 Nov 2025 19:32:18 +0800 Subject: [PATCH 1/5] feat: add trace support wip --- docs/trace-request-params-feature.md | 357 ++++++++++++++++++ src/main/lib/redact.ts | 101 +++++ .../llmProviderPresenter/baseProvider.ts | 33 ++ .../presenter/llmProviderPresenter/index.ts | 2 +- .../providers/openAICompatibleProvider.ts | 95 +++++ .../providers/openAIResponsesProvider.ts | 68 ++++ src/main/presenter/threadPresenter/index.ts | 125 ++++++ .../message/MessageItemAssistant.vue | 5 + .../src/components/message/MessageList.vue | 8 + .../src/components/message/MessageToolbar.vue | 16 + .../src/components/trace/TraceDialog.vue | 185 +++++++++ src/renderer/src/i18n/en-US/index.ts | 2 + src/renderer/src/i18n/en-US/thread.json | 3 +- src/renderer/src/i18n/en-US/traceDialog.json | 17 + src/renderer/src/i18n/zh-CN/index.ts | 2 + src/renderer/src/i18n/zh-CN/thread.json | 3 +- src/renderer/src/i18n/zh-CN/traceDialog.json | 17 + src/shared/provider-operations.ts | 20 + .../presenters/llmprovider.presenter.d.ts | 1 + .../types/presenters/thread.presenter.d.ts | 3 + 20 files changed, 1060 insertions(+), 3 deletions(-) create mode 100644 docs/trace-request-params-feature.md create mode 100644 src/main/lib/redact.ts create mode 100644 src/renderer/src/components/trace/TraceDialog.vue create mode 100644 src/renderer/src/i18n/en-US/traceDialog.json create mode 100644 src/renderer/src/i18n/zh-CN/traceDialog.json diff --git a/docs/trace-request-params-feature.md b/docs/trace-request-params-feature.md new file mode 100644 index 000000000..b926d0eab --- /dev/null +++ b/docs/trace-request-params-feature.md @@ -0,0 +1,357 @@ +# Trace Request Parameters Feature + +## Overview +This feature adds a development-mode debugging tool that allows developers to inspect the actual request parameters sent to LLM providers for any assistant message. This helps understand data flow, verify prompt construction, and debug provider-specific formatting issues. + +## Architecture + +### Components + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Renderer Process │ +│ │ +│ ┌────────────────┐ ┌──────────────────┐ ┌─────────────┐ │ +│ │ MessageToolbar │→ │MessageItemAssist │→ │ MessageList │ │ +│ │ (Trace Btn) │ │ (Event) │ │ (Handler) │ │ +│ └────────────────┘ └──────────────────┘ └──────┬──────┘ │ +│ │ │ +│ ┌─────────▼──────┐ │ +│ │ TraceDialog │ │ +│ │ (UI Display) │ │ +│ └────────┬───────┘ │ +└──────────────────────────────────────────────────┼─────────┘ + │ IPC + ┌────────────────────────▼─────────────┐ + │ Main Process │ + │ │ + │ ┌───────────────────────────────┐ │ + │ │ ThreadPresenter │ │ + │ │ getMessageRequestPreview() │ │ + │ └──────────┬─────────────────────┘ │ + │ │ │ + │ ┌────────▼──────────┐ │ + │ │ LLMProviderPresenter│ │ + │ │ getProvider() │ │ + │ └────────┬───────────┘ │ + │ │ │ + │ ┌────────▼───────────┐ │ + │ │ BaseLLMProvider │ │ + │ │ getRequestPreview()│ │ + │ └────────┬───────────┘ │ + │ │ │ + │ ┌──────────▼────────────────────┐ │ + │ │ Concrete Provider Impl │ │ + │ │ - OpenAICompatibleProvider │ │ + │ │ - OpenAIResponsesProvider │ │ + │ │ - (23+ child providers) │ │ + │ └──────────┬────────────────────┘ │ + │ │ │ + │ ┌────────▼────────────┐ │ + │ │ Redaction Utility │ │ + │ │ (lib/redact.ts) │ │ + │ └─────────────────────┘ │ + └──────────────────────────────────────┘ +``` + +### Data Flow + +1. **User Action**: User clicks Trace button in MessageToolbar (DEV mode only) +2. **Event Propagation**: + - MessageToolbar emits `trace` event + - MessageItemAssistant catches and re-emits with messageId + - MessageList handles and sets `traceMessageId` +3. **IPC Call**: TraceDialog watches messageId and calls `threadPresenter.getMessageRequestPreview(messageId)` +4. **Main Process**: + - ThreadPresenter retrieves message and conversation from database + - Reconstructs prompt content using `preparePromptContent()` + - Fetches MCP tools from McpPresenter + - Gets model configuration + - Calls provider's `getRequestPreview()` method +5. **Provider Layer**: + - Provider builds request parameters (same logic as actual request) + - Returns `{ endpoint, headers, body }` +6. **Security**: Redact sensitive information using `redactRequestPreview()` +7. **Response**: Return preview data to renderer +8. **UI Display**: TraceDialog renders JSON in a modal with copy functionality + +## Key Files + +### Renderer Process + +- **`src/renderer/src/components/message/MessageToolbar.vue`** + - Adds Trace button (bug icon, visible only in DEV mode for assistant messages) + - Emits `trace` event when clicked + +- **`src/renderer/src/components/message/MessageItemAssistant.vue`** + - Listens to MessageToolbar's `trace` event + - Re-emits with message ID + +- **`src/renderer/src/components/message/MessageList.vue`** + - Manages `traceMessageId` state + - Renders TraceDialog component + - Handles trace event from MessageItemAssistant + +- **`src/renderer/src/components/trace/TraceDialog.vue`** + - Modal dialog for displaying request preview + - Shows provider, model, endpoint, headers, and body + - Provides JSON copy functionality + - Handles loading, error, and "not implemented" states + +### Main Process + +- **`src/main/presenter/threadPresenter/index.ts`** + - `getMessageRequestPreview(messageId)`: Orchestrates preview reconstruction + - Retrieves conversation context and settings + - Reconstructs prompt using `preparePromptContent()` + - Fetches MCP tools + - Calls provider's preview method + - Applies redaction + +- **`src/main/presenter/llmProviderPresenter/baseProvider.ts`** + - Defines abstract `getRequestPreview()` method + - Default implementation throws "not implemented" error + +- **`src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts`** + - Implements `getRequestPreview()` for OpenAI-compatible providers + - Mirrors `handleChatCompletion()` logic without making actual API call + - Returns endpoint, headers, and body + +- **`src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts`** + - Implements `getRequestPreview()` for OpenAI Responses API + - Mirrors `handleChatCompletion()` logic + +- **`src/main/lib/redact.ts`** + - `redactRequestPreview()`: Removes sensitive data from preview + - Redacts API keys, tokens, passwords, secrets + - Recursively processes nested objects and arrays + +### Shared Types + +- **`src/shared/provider-operations.ts`** + - `ProviderRequestPreview`: Type definition for preview data structure + - Fields: providerId, modelId, endpoint, headers, body, mayNotMatch, notImplemented + +- **`src/shared/types/presenters/thread.presenter.d.ts`** + - Adds `getMessageRequestPreview(messageId: string): Promise` to IThreadPresenter + +### Internationalization + +- **`src/renderer/src/i18n/[locale]/traceDialog.json`** + - UI strings for TraceDialog (title, labels, error messages) + - Supported locales: zh-CN, en-US + +- **`src/renderer/src/i18n/[locale]/thread.json`** + - Added `toolbar.trace` key for button tooltip + +## Implementation Details + +### Provider Support Status + +#### ✅ Fully Implemented +- `OpenAICompatibleProvider` (base class) +- `OpenAIResponsesProvider` + +#### 🟡 Inherited (23 providers, auto-supported via base class) +All providers extending `OpenAICompatibleProvider` automatically inherit `getRequestPreview()`: +- OpenAIProvider +- DeepseekProvider +- DashscopeProvider +- DoubaoProvider +- GrokProvider +- GroqProvider +- GithubProvider +- MinimaxProvider +- ZhipuProvider +- SiliconcloudProvider +- ModelscopeProvider +- OpenRouterProvider +- PPIOProvider +- TogetherProvider +- TokenFluxProvider +- VercelAIGatewayProvider +- CherryInProvider +- AihubmixProvider +- _302AIProvider +- PoeProvider +- JiekouProvider +- ZenmuxProvider +- LMStudioProvider + +#### ❌ Not Yet Implemented +- `AnthropicProvider` (separate base class) +- `GeminiProvider` (separate base class) +- `AwsBedrockProvider` (separate base class) +- `OllamaProvider` (separate base class) +- `GithubCopilotProvider` (separate implementation) + +### Request Reconstruction Logic + +The `getRequestPreview()` method in each provider: + +1. **Formats Messages**: Uses same `formatMessages()` method as actual requests +2. **Prepares Function Calls**: Applies `prepareFunctionCallPrompt()` for non-FC models +3. **Converts Tools**: Uses `mcpPresenter.mcpToolsToOpenAITools()` for native FC +4. **Builds Request Params**: Constructs exact request object (stream, temperature, max_tokens, etc.) +5. **Applies Model-Specific Logic**: + - Reasoning models (o1, o3, gpt-5): Remove temperature, use max_completion_tokens + - Provider-specific quirks (e.g., OpenRouter Deepseek, Dashscope response_format) +6. **Constructs Headers**: Includes Authorization, Content-Type, and custom headers +7. **Determines Endpoint**: Combines baseUrl with API path + +### Security: Sensitive Data Redaction + +The `redactRequestPreview()` function: + +- **Headers**: Redacts authorization, api_key, x-api-key, token, password +- **Body**: Recursively scans objects/arrays for sensitive keys +- **Sensitive Keys List**: + ```typescript + const SENSITIVE_KEYS = [ + 'api_key', + 'apikey', + 'authorization', + 'x-api-key', + 'accesskeyid', + 'secretaccesskey', + 'password', + 'token' + ] + ``` +- **Redaction Format**: Replaces value with `'********'` +- **Case-Insensitive**: Key matching is case-insensitive + +### UI/UX Design + +#### Trace Button +- **Icon**: `lucide:bug` (bug icon) +- **Visibility**: Only in DEV mode (`import.meta.env.DEV`) +- **Scope**: Only on assistant messages (not user/system) +- **Tooltip**: Localized "Trace Request" / "调试请求参数" + +#### TraceDialog Layout +``` +┌─────────────────────────────────────────────────┐ +│ Request Preview [×] │ +├─────────────────────────────────────────────────┤ +│ ⚠️ Note: This preview may not match actual req │ +├─────────────────────────────────────────────────┤ +│ Provider: openai Model: gpt-4 Endpoint: … │ +├─────────────────────────────────────────────────┤ +│ Request Body [Copy JSON] │ +│ ┌───────────────────────────────────────────┐ │ +│ │ { │ │ +│ │ "messages": [...], │ │ +│ │ "model": "gpt-4", │ │ +│ │ "temperature": 0.7, │ │ +│ │ ... │ │ +│ │ } │ │ +│ └───────────────────────────────────────────┘ │ +│ │ +│ [Close] │ +└─────────────────────────────────────────────────┘ +``` + +#### States +- **Loading**: Shows spinner with "Loading..." message +- **Error**: Shows error icon with retry message +- **Not Implemented**: Shows info icon with "Provider not supported" message +- **Success**: Shows formatted JSON with metadata + +### Error Handling + +1. **Null/Undefined Result**: Show error state +2. **Provider Not Implemented**: Show "not implemented" info state +3. **IPC Failure**: Caught in try-catch, shows error state +4. **Parse Error**: Logged to console, shows error state +5. **Copy Failure**: Logged to console, toast notification + +## Usage + +### Developer Workflow + +1. Run app in dev mode: `pnpm run dev` +2. Start a conversation with an LLM +3. Click the bug icon (🐛) on any assistant message +4. View the reconstructed request parameters +5. Copy JSON for debugging/testing + +### Use Cases + +- **Debugging**: Verify prompt construction and tool definitions +- **Provider Comparison**: Compare request formats across providers +- **Tool Calling**: Inspect how tools are encoded (native vs. mock) +- **Model Quirks**: Understand provider-specific parameter handling +- **Context Analysis**: Verify which messages are included in context + +## Testing + +### Manual Testing Checklist +- [x] Trace button only appears in DEV mode +- [x] Trace button only appears on assistant messages +- [x] Dialog opens when clicking Trace button +- [x] Loading state displays correctly +- [x] Error state displays for invalid messages +- [x] "Not implemented" state displays for unsupported providers +- [x] Preview displays correctly for OpenAI-compatible providers +- [x] Sensitive data is redacted (API keys, tokens) +- [x] JSON copy functionality works +- [x] Dialog closes correctly +- [ ] All 23+ child providers inherit preview functionality + +### Automated Testing (TODO) +- [ ] Unit tests for `redactRequestPreview()` (tests-main) +- [ ] Unit tests for TraceDialog component (tests-renderer) +- [ ] Unit tests for provider `getRequestPreview()` methods + +## Future Enhancements + +1. **Extend to More Providers**: + - Implement `getRequestPreview()` for AnthropicProvider + - Implement for GeminiProvider + - Implement for AwsBedrockProvider + - Implement for OllamaProvider + +2. **Enhanced UI**: + - Syntax highlighting for JSON + - Collapsible sections (headers/body) + - Diff view comparing multiple requests + - Export to file + +3. **Advanced Features**: + - Historical request archive + - Request replay/resend + - Token counting preview + - Cost estimation + +4. **Production Use**: + - Optional logging to file (with user consent) + - Telemetry for provider debugging + - Request/response matching + +## Known Issues + +1. **Reconstruction Limitations**: + - Preview is reconstructed from current DB state + - May not exactly match original request if: + - Conversation settings changed + - MCP tools updated + - Provider configuration changed + - Warning displayed to user + +2. **Provider Coverage**: + - Only OpenAI-compatible providers fully supported + - Other provider types show "not implemented" + +3. **Performance**: + - Preview reconstruction involves DB queries + - May be slow for large conversations + - No caching implemented + +## References + +- [IPC Architecture](./ipc/ipc-architecture-complete.md) +- [Provider Architecture](./provider-optimization-summary.md) +- [MCP Tool System](./mcp-architecture.md) +- [Prompt Builder](../src/main/presenter/threadPresenter/promptBuilder.ts) + diff --git a/src/main/lib/redact.ts b/src/main/lib/redact.ts new file mode 100644 index 000000000..16d55d5d0 --- /dev/null +++ b/src/main/lib/redact.ts @@ -0,0 +1,101 @@ +/** + * Redaction utilities for sensitive information in request preview + */ + +/** + * Sensitive header keys that should be redacted + */ +const SENSITIVE_HEADER_KEYS = [ + 'authorization', + 'api-key', + 'x-api-key', + 'apikey', + 'bearer', + 'token', + 'secret', + 'password', + 'credential', + 'auth' +] + +/** + * Sensitive body keys that should be redacted + */ +const SENSITIVE_BODY_KEYS = ['api_key', 'apiKey', 'apikey', 'secret', 'password', 'token'] + +/** + * Redact sensitive values in headers + * @param headers Original headers + * @returns Redacted headers + */ +export function redactHeaders(headers: Record): Record { + const redacted: Record = {} + + for (const [key, value] of Object.entries(headers)) { + const keyLower = key.toLowerCase() + const shouldRedact = SENSITIVE_HEADER_KEYS.some((sensitiveKey) => + keyLower.includes(sensitiveKey) + ) + + if (shouldRedact) { + redacted[key] = '***REDACTED***' + } else { + redacted[key] = value + } + } + + return redacted +} + +/** + * Redact sensitive values in request body + * @param body Original body + * @returns Redacted body + */ +export function redactBody(body: unknown): unknown { + if (body === null || body === undefined) { + return body + } + + if (Array.isArray(body)) { + return body.map((item) => redactBody(item)) + } + + if (typeof body === 'object') { + const redacted: Record = {} + + for (const [key, value] of Object.entries(body)) { + const keyLower = key.toLowerCase() + const shouldRedact = SENSITIVE_BODY_KEYS.some((sensitiveKey) => + keyLower.includes(sensitiveKey) + ) + + if (shouldRedact) { + redacted[key] = '***REDACTED***' + } else if (typeof value === 'object' && value !== null) { + redacted[key] = redactBody(value) + } else { + redacted[key] = value + } + } + + return redacted + } + + return body +} + +/** + * Redact sensitive information in full request preview + * @param preview Request preview data + * @returns Redacted preview + */ +export function redactRequestPreview(preview: { headers: Record; body: unknown }): { + headers: Record + body: unknown +} { + return { + headers: redactHeaders(preview.headers), + body: redactBody(preview.body) + } +} diff --git a/src/main/presenter/llmProviderPresenter/baseProvider.ts b/src/main/presenter/llmProviderPresenter/baseProvider.ts index 0fff123ca..a9b116482 100644 --- a/src/main/presenter/llmProviderPresenter/baseProvider.ts +++ b/src/main/presenter/llmProviderPresenter/baseProvider.ts @@ -649,6 +649,39 @@ ${this.convertToolsToXml(tools)} return null // 默认实现返回 null,表示不支持此功能 } + /** + * Get request preview for debugging (DEV mode only) + * Build the actual request parameters that would be sent to the provider API + * @param messages Conversation messages + * @param modelId Model ID + * @param modelConfig Model configuration + * @param temperature Temperature parameter + * @param maxTokens Max tokens parameter + * @param mcpTools MCP tools definitions + * @returns Preview data including endpoint, headers, and body (all redacted) + */ + public async getRequestPreview( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _messages: ChatMessage[], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _modelId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _modelConfig: ModelConfig, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _temperature: number, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _maxTokens: number, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _mcpTools: MCPToolDefinition[] + ): Promise<{ + endpoint: string + headers: Record + body: unknown + }> { + // Default implementation returns not implemented marker + throw new Error('Provider has not implemented getRequestPreview') + } + /** * 将 MCPToolDefinition 转换为 XML 格式 * @param tools MCPToolDefinition 数组 diff --git a/src/main/presenter/llmProviderPresenter/index.ts b/src/main/presenter/llmProviderPresenter/index.ts index 497fd2c8e..1707c91b3 100644 --- a/src/main/presenter/llmProviderPresenter/index.ts +++ b/src/main/presenter/llmProviderPresenter/index.ts @@ -556,7 +556,7 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { } } - private getProviderInstance(providerId: string): BaseLLMProvider { + public getProviderInstance(providerId: string): BaseLLMProvider { let instance = this.providerInstances.get(providerId) if (!instance) { const provider = this.getProviderById(providerId) diff --git a/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts b/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts index 4fa85f3e2..e56be49ad 100644 --- a/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts @@ -1624,4 +1624,99 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } } } + + /** + * Get request preview for debugging (DEV mode only) + * Builds the actual request parameters without sending the request + */ + public async getRequestPreview( + messages: ChatMessage[], + modelId: string, + modelConfig: ModelConfig, + temperature: number, + maxTokens: number, + mcpTools: MCPToolDefinition[] + ): Promise<{ + endpoint: string + headers: Record + body: unknown + }> { + const tools = mcpTools || [] + const supportsFunctionCall = modelConfig?.functionCall || false + let processedMessages = [ + ...this.formatMessages(messages, supportsFunctionCall) + ] as ChatCompletionMessageParam[] + + // Prepare non-native function call prompt if needed + if (tools.length > 0 && !supportsFunctionCall) { + processedMessages = this.prepareFunctionCallPrompt(processedMessages, tools) + } + + // Convert tools to OpenAI format if native support + const apiTools = + tools.length > 0 && supportsFunctionCall + ? await presenter.mcpPresenter.mcpToolsToOpenAITools(tools, this.provider.id) + : undefined + + // Build request params (same logic as handleChatCompletion) + const requestParams: OpenAI.Chat.ChatCompletionCreateParams = { + messages: processedMessages, + model: modelId, + stream: true, + temperature, + ...(modelId.startsWith('o1') || + modelId.startsWith('o3') || + modelId.startsWith('o4') || + modelId.includes('gpt-5') + ? { max_completion_tokens: maxTokens } + : { max_tokens: maxTokens }) + } + + requestParams.stream_options = { include_usage: true } + + if (this.provider.id.toLowerCase().includes('dashscope')) { + requestParams.response_format = { type: 'text' } + } + + if ( + this.provider.id.toLowerCase().includes('openrouter') && + modelId.startsWith('deepseek/deepseek-chat-v3-0324:free') + ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(requestParams as any).provider = { + only: ['chutes'] + } + } + + if (modelConfig.reasoningEffort && this.supportsEffortParameter(modelId)) { + ;(requestParams as any).reasoning_effort = modelConfig.reasoningEffort + } + + if (modelConfig.verbosity && this.supportsVerbosityParameter(modelId)) { + ;(requestParams as any).verbosity = modelConfig.verbosity + } + + OPENAI_REASONING_MODELS.forEach((noTempId) => { + if (modelId.startsWith(noTempId)) delete requestParams.temperature + }) + + if (apiTools && apiTools.length > 0 && supportsFunctionCall) requestParams.tools = apiTools + + // Build headers + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.provider.apiKey || 'MISSING_API_KEY'}`, + ...this.defaultHeaders + } + + // Determine endpoint + const baseUrl = this.provider.baseUrl || 'https://api.openai.com/v1' + const endpoint = `${baseUrl}/chat/completions` + + return { + endpoint, + headers, + body: requestParams + } + } } diff --git a/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts b/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts index 491c7d417..f0f2c55ae 100644 --- a/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts @@ -1324,4 +1324,72 @@ export class OpenAIResponsesProvider extends BaseLLMProvider { return [] } } + + /** + * Get request preview for debugging (DEV mode only) + */ + public async getRequestPreview( + messages: ChatMessage[], + modelId: string, + modelConfig: ModelConfig, + temperature: number, + maxTokens: number, + mcpTools: MCPToolDefinition[] + ): Promise<{ + endpoint: string + headers: Record + body: unknown + }> { + const tools = mcpTools || [] + const supportsFunctionCall = modelConfig?.functionCall || false + let processedMessages = this.formatMessages(messages) + + if (tools.length > 0 && !supportsFunctionCall) { + processedMessages = this.prepareFunctionCallPrompt(processedMessages, tools) + } + + const apiTools = + tools.length > 0 && supportsFunctionCall + ? await presenter.mcpPresenter.mcpToolsToOpenAIResponsesTools(tools, this.provider.id) + : undefined + + const requestParams: OpenAI.Responses.ResponseCreateParams = { + model: modelId, + input: processedMessages, + temperature, + max_output_tokens: maxTokens, + stream: true + } + + if (tools.length > 0 && supportsFunctionCall && apiTools) { + requestParams.tools = apiTools + } + + if (modelConfig.reasoningEffort && this.supportsEffortParameter(modelId)) { + ;(requestParams as any).reasoning = { + effort: modelConfig.reasoningEffort + } + } + + if (modelConfig.verbosity && this.supportsVerbosityParameter(modelId)) { + ;(requestParams as any).text = { + verbosity: modelConfig.verbosity + } + } + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.provider.apiKey || 'MISSING_API_KEY'}`, + ...this.defaultHeaders + } + + const baseUrl = this.provider.baseUrl || 'https://api.openai.com/v1' + const endpoint = `${baseUrl}/responses` + + return { + endpoint, + headers, + body: requestParams + } + } } diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts index dbb03c5f1..6a15a291f 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -1737,6 +1737,9 @@ export class ThreadPresenter implements IThreadPresenter { const { providerId, modelId } = conversation.settings const modelConfig = this.configPresenter.getModelConfig(modelId, providerId) + if (!modelConfig) { + throw new Error(`Model config not found for provider ${providerId} and model ${modelId}`) + } const { vision } = modelConfig || {} // 检查是否已被取消 this.throwIfCancelled(state.message.id) @@ -3666,4 +3669,126 @@ export class ThreadPresenter implements IThreadPresenter { return { id, name, params } } + + /** + * Get request preview for debugging (DEV mode only) + * Reconstructs the request parameters that would be sent to the provider + */ + async getMessageRequestPreview(messageId: string): Promise { + try { + // Get message and conversation + const message = await this.sqlitePresenter.getMessage(messageId) + if (!message || message.role !== 'assistant') { + throw new Error('Message not found or not an assistant message') + } + + const conversation = await this.sqlitePresenter.getConversation(message.conversation_id) + const { + providerId: defaultProviderId, + modelId: defaultModelId, + temperature, + maxTokens, + enabledMcpTools + } = conversation.settings + + const effectiveProviderId = message.model_provider || defaultProviderId + const effectiveModelId = message.model_id || defaultModelId + + // Get user message (parent of assistant message) + const userMessage = await this.sqlitePresenter.getMessage(message.parent_id || '') + if (!userMessage) { + throw new Error('User message not found') + } + + // Get context messages using getMessageHistory + const contextMessages = await this.getMessageHistory( + userMessage.id, + conversation.settings.contextLength + ) + + // Prepare prompt content (reconstruct what was sent) + let modelConfig = this.configPresenter.getModelConfig(effectiveModelId, effectiveProviderId) + if (!modelConfig) { + modelConfig = this.configPresenter.getModelConfig(defaultModelId, defaultProviderId) + } + + if (!modelConfig) { + throw new Error( + `Model config not found for provider ${effectiveProviderId} and model ${effectiveModelId}` + ) + } + + const supportsFunctionCall = modelConfig?.functionCall ?? false + const visionEnabled = modelConfig?.vision ?? false + + const { finalContent } = await preparePromptContent({ + conversation, + userContent: typeof userMessage.content === 'string' ? userMessage.content : '', + contextMessages, + searchResults: null, + urlResults: [], + userMessage, + vision: visionEnabled, + imageFiles: [], + supportsFunctionCall, + modelType: 'chat' + }) + + // Get MCP tools + let mcpTools: MCPToolDefinition[] = [] + try { + const toolDefinitions = await presenter.mcpPresenter.getAllToolDefinitions(enabledMcpTools) + if (Array.isArray(toolDefinitions)) { + mcpTools = toolDefinitions + } + } catch (error) { + console.warn('Failed to load MCP tool definitions for preview', error) + } + + // Get provider and request preview + const provider = this.llmProviderPresenter.getProviderInstance(effectiveProviderId) + if (!provider) { + throw new Error(`Provider ${effectiveProviderId} not found`) + } + + try { + const preview = await provider.getRequestPreview( + finalContent, + effectiveModelId, + modelConfig, + temperature, + maxTokens, + mcpTools + ) + + // Redact sensitive information + const { redactRequestPreview } = await import('@/lib/redact') + const redacted = redactRequestPreview({ + headers: preview.headers, + body: preview.body + }) + + return { + providerId: effectiveProviderId, + modelId: effectiveModelId, + endpoint: preview.endpoint, + headers: redacted.headers, + body: redacted.body, + mayNotMatch: true // Always mark as potentially inconsistent since we're reconstructing + } + } catch (error) { + if (error instanceof Error && error.message.includes('not implemented')) { + return { + notImplemented: true, + providerId: effectiveProviderId, + modelId: effectiveModelId + } + } + throw error + } + } catch (error) { + console.error('[ThreadPresenter] getMessageRequestPreview failed:', error) + throw error + } + } } diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue index 7f66f68f9..77ffa37bf 100644 --- a/src/renderer/src/components/message/MessageItemAssistant.vue +++ b/src/renderer/src/components/message/MessageItemAssistant.vue @@ -77,6 +77,7 @@ @prev="handleAction('prev')" @next="handleAction('next')" @fork="handleAction('fork')" + @trace="handleAction('trace')" /> @@ -150,6 +151,7 @@ const emit = defineEmits<{ modelInfo: { model_name: string; model_provider: string } ] variantChanged: [messageId: string] + trace: [messageId: string] }>() // 获取当前会话ID @@ -269,6 +271,7 @@ type HandleActionType = | 'copyImage' | 'copyImageFromTop' | 'fork' + | 'trace' const handleAction = (action: HandleActionType) => { if (action === 'retry') { @@ -340,6 +343,8 @@ const handleAction = (action: HandleActionType) => { }) } else if (action === 'fork') { showForkDialog() + } else if (action === 'trace') { + emit('trace', currentMessage.value.id) } } diff --git a/src/renderer/src/components/message/MessageList.vue b/src/renderer/src/components/message/MessageList.vue index 62562e6ed..d1dc22c86 100644 --- a/src/renderer/src/components/message/MessageList.vue +++ b/src/renderer/src/components/message/MessageList.vue @@ -23,6 +23,7 @@ :is-capturing-image="capture.isCapturing.value" @copy-image="handleCopyImage" @variant-changed="scrollToMessage" + @trace="handleTrace" /> + @@ -72,6 +74,7 @@ import MessageItemUser from './MessageItemUser.vue' import MessageActionButtons from './MessageActionButtons.vue' import ReferencePreview from './ReferencePreview.vue' import MessageMinimap from './MessageMinimap.vue' +import TraceDialog from '../trace/TraceDialog.vue' // === Composables === import { useResizeObserver } from '@vueuse/core' @@ -124,6 +127,7 @@ const retry = useMessageRetry(toRef(props, 'messages')) const messageList = ref() const visible = ref(false) const shouldAutoFollow = ref(true) +const traceMessageId = ref(null) const scheduleScrollToBottom = (force = false) => { nextTick(() => { @@ -177,6 +181,10 @@ const showCancelButton = computed(() => { return chatStore.generatingThreadIds.has(chatStore.getActiveThreadId() ?? '') }) +const handleTrace = (messageId: string) => { + traceMessageId.value = messageId +} + // === Lifecycle Hooks === onMounted(() => { // Initialize scroll and visibility diff --git a/src/renderer/src/components/message/MessageToolbar.vue b/src/renderer/src/components/message/MessageToolbar.vue index b4c030021..3414a08b8 100644 --- a/src/renderer/src/components/message/MessageToolbar.vue +++ b/src/renderer/src/components/message/MessageToolbar.vue @@ -155,6 +155,19 @@ {{ t('thread.toolbar.retry') }} + + + + + {{ t('thread.toolbar.trace') }} + -
-
{{ formattedJson }}
+
+
+ +
+
{{ formattedJson }}
+
@@ -75,7 +92,7 @@ diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index 7f378e8bf..68191cf20 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -723,6 +723,7 @@ export interface ILlmProviderPresenter { temperature?: number, maxTokens?: number ): Promise + getProviderInstance(providerId: string): unknown } export type CONVERSATION_SETTINGS = { @@ -862,6 +863,9 @@ export interface IThreadPresenter { conversationId: string, format: 'markdown' | 'html' | 'txt' ): Promise<{ filename: string; content: string }> + + // Dev tools + getMessageRequestPreview(messageId: string): Promise } export type MESSAGE_STATUS = 'sent' | 'pending' | 'error' From 8101b0084d856900eb235fa4810a6f08fb477439 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 10 Nov 2025 10:25:22 +0800 Subject: [PATCH 3/5] feat: add i18n for trace dialog --- .../settings/components/CommonSettings.vue | 12 ++++++++++++ .../src/components/message/MessageToolbar.vue | 6 ++++-- src/renderer/src/i18n/en-US/settings.json | 1 + src/renderer/src/i18n/fa-IR/settings.json | 3 ++- src/renderer/src/i18n/fa-IR/thread.json | 3 ++- src/renderer/src/i18n/fa-IR/traceDialog.json | 17 +++++++++++++++++ src/renderer/src/i18n/fr-FR/settings.json | 3 ++- src/renderer/src/i18n/fr-FR/thread.json | 3 ++- src/renderer/src/i18n/fr-FR/traceDialog.json | 17 +++++++++++++++++ src/renderer/src/i18n/ja-JP/settings.json | 3 ++- src/renderer/src/i18n/ja-JP/thread.json | 3 ++- src/renderer/src/i18n/ja-JP/traceDialog.json | 17 +++++++++++++++++ src/renderer/src/i18n/ko-KR/settings.json | 3 ++- src/renderer/src/i18n/ko-KR/thread.json | 3 ++- src/renderer/src/i18n/ko-KR/traceDialog.json | 17 +++++++++++++++++ src/renderer/src/i18n/pt-BR/settings.json | 3 ++- src/renderer/src/i18n/pt-BR/thread.json | 3 ++- src/renderer/src/i18n/pt-BR/traceDialog.json | 17 +++++++++++++++++ src/renderer/src/i18n/ru-RU/settings.json | 3 ++- src/renderer/src/i18n/ru-RU/thread.json | 3 ++- src/renderer/src/i18n/ru-RU/traceDialog.json | 17 +++++++++++++++++ src/renderer/src/i18n/zh-CN/settings.json | 1 + src/renderer/src/i18n/zh-CN/thread.json | 2 +- src/renderer/src/i18n/zh-CN/traceDialog.json | 4 ++-- src/renderer/src/i18n/zh-HK/settings.json | 3 ++- src/renderer/src/i18n/zh-HK/thread.json | 3 ++- src/renderer/src/i18n/zh-HK/traceDialog.json | 17 +++++++++++++++++ src/renderer/src/i18n/zh-TW/settings.json | 3 ++- src/renderer/src/i18n/zh-TW/thread.json | 3 ++- src/renderer/src/i18n/zh-TW/traceDialog.json | 17 +++++++++++++++++ src/renderer/src/stores/settings.ts | 17 +++++++++++++++++ 31 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/i18n/fa-IR/traceDialog.json create mode 100644 src/renderer/src/i18n/fr-FR/traceDialog.json create mode 100644 src/renderer/src/i18n/ja-JP/traceDialog.json create mode 100644 src/renderer/src/i18n/ko-KR/traceDialog.json create mode 100644 src/renderer/src/i18n/pt-BR/traceDialog.json create mode 100644 src/renderer/src/i18n/ru-RU/traceDialog.json create mode 100644 src/renderer/src/i18n/zh-HK/traceDialog.json create mode 100644 src/renderer/src/i18n/zh-TW/traceDialog.json diff --git a/src/renderer/settings/components/CommonSettings.vue b/src/renderer/settings/components/CommonSettings.vue index a1f615647..3d5ad7558 100644 --- a/src/renderer/settings/components/CommonSettings.vue +++ b/src/renderer/settings/components/CommonSettings.vue @@ -26,6 +26,13 @@ :model-value="copyWithCotEnabled" @update:model-value="handleCopyWithCotChange" /> + @@ -51,6 +58,7 @@ const soundStore = useSoundStore() const searchPreviewEnabled = computed(() => settingsStore.searchPreviewEnabled) const soundEnabled = computed(() => soundStore.soundEnabled) const copyWithCotEnabled = computed(() => settingsStore.copyWithCotEnabled) +const traceDebugEnabled = computed(() => settingsStore.traceDebugEnabled) const handleSearchPreviewChange = (value: boolean) => { settingsStore.setSearchPreviewEnabled(value) @@ -63,4 +71,8 @@ const handleSoundChange = (value: boolean) => { const handleCopyWithCotChange = (value: boolean) => { settingsStore.setCopyWithCotEnabled(value) } + +const handleTraceDebugChange = (value: boolean) => { + settingsStore.setTraceDebugEnabled(value) +} diff --git a/src/renderer/src/components/message/MessageToolbar.vue b/src/renderer/src/components/message/MessageToolbar.vue index 3414a08b8..658581147 100644 --- a/src/renderer/src/components/message/MessageToolbar.vue +++ b/src/renderer/src/components/message/MessageToolbar.vue @@ -155,7 +155,7 @@
{{ t('thread.toolbar.retry') }}
- +