diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e6d77142..75cb8acec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,7 +83,7 @@ jobs: - name: Create Draft Release uses: softprops/action-gh-release@v1 with: - tag_name: workflow-${{ github.event.inputs.workflow_id }} + tag_name: v${{ steps.get_version.outputs.version }} name: DeepChat V${{ steps.get_version.outputs.version }} draft: true prerelease: ${{ github.event.inputs.prerelease }} 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/package.json b/package.json index ca4000b81..5b0376f1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DeepChat", - "version": "0.4.4", + "version": "0.4.5", "description": "DeepChat,一个简单易用的AI客户端", "main": "./out/main/index.js", "author": "ThinkInAIXYZ", diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 9e1e8bebc..2a3cd8f37 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -3845,6 +3845,38 @@ "output": 0 } }, + { + "id": "minimaxai/minimax-m2", + "name": "MiniMax-M2", + "display_name": "MiniMax-M2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 16384 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "knowledge": "2024-07", + "release_date": "2025-10-27", + "last_updated": "2025-10-31", + "cost": { + "input": 0, + "output": 0 + } + }, { "id": "google/gemma-3-27b-it", "name": "Gemma-3-27B-IT", @@ -23038,6 +23070,547 @@ } ] }, + "iflowcn": { + "id": "iflowcn", + "name": "iFlow", + "display_name": "iFlow", + "api": "https://apis.iflow.cn/v1", + "doc": "https://platform.iflow.cn/en/docs", + "models": [ + { + "id": "qwen3-coder", + "name": "Qwen3-Coder-480B-A35B", + "display_name": "Qwen3-Coder-480B-A35B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-07-01", + "last_updated": "2025-07-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "deepseek-v3", + "name": "DeepSeek-V3-671B", + "display_name": "DeepSeek-V3-671B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 32000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2024-10", + "release_date": "2024-12-26", + "last_updated": "2024-12-26", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "kimi-k2", + "name": "Kimi-K2", + "display_name": "Kimi-K2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "knowledge": "2024-10", + "release_date": "2024-12-01", + "last_updated": "2024-12-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "deepseek-r1", + "name": "DeepSeek-R1", + "display_name": "DeepSeek-R1", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 32000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "knowledge": "2024-12", + "release_date": "2025-01-20", + "last_updated": "2025-01-20", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "deepseek-v3.1", + "name": "DeepSeek-V3.1-Terminus", + "display_name": "DeepSeek-V3.1-Terminus", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "knowledge": "2024-12", + "release_date": "2025-01-01", + "last_updated": "2025-01-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "qwen3-235b", + "name": "Qwen3-235B-A22B", + "display_name": "Qwen3-235B-A22B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 32000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "knowledge": "2024-10", + "release_date": "2024-12-01", + "last_updated": "2024-12-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "kimi-k2-0905", + "name": "Kimi-K2-Instruct-0905", + "display_name": "Kimi-K2-Instruct-0905", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "knowledge": "2024-12", + "release_date": "2025-09-05", + "last_updated": "2025-09-05", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "qwen3-235b-a22b-thinking-2507", + "name": "Qwen3-235B-A22B-Thinking", + "display_name": "Qwen3-235B-A22B-Thinking", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-07-01", + "last_updated": "2025-07-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "qwen3-vl-plus", + "name": "Qwen3-VL-Plus", + "display_name": "Qwen3-VL-Plus", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 32000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "knowledge": "2024-12", + "release_date": "2025-01-01", + "last_updated": "2025-01-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "glm-4.6", + "name": "GLM-4.6", + "display_name": "GLM-4.6", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "knowledge": "2024-10", + "release_date": "2024-12-01", + "last_updated": "2024-12-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "tstars2.0", + "name": "TStars-2.0", + "display_name": "TStars-2.0", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "knowledge": "2024-01", + "release_date": "2024-01-01", + "last_updated": "2025-01-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "qwen3-235b-a22b-instruct", + "name": "Qwen3-235B-A22B-Instruct", + "display_name": "Qwen3-235B-A22B-Instruct", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-07-01", + "last_updated": "2025-07-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "qwen3-max", + "name": "Qwen3-Max", + "display_name": "Qwen3-Max", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 32000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "knowledge": "2024-12", + "release_date": "2025-01-01", + "last_updated": "2025-01-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "deepseek-v3.2", + "name": "DeepSeek-V3.2-Exp", + "display_name": "DeepSeek-V3.2-Exp", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2024-12", + "release_date": "2025-01-01", + "last_updated": "2025-01-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "qwen3-max-preview", + "name": "Qwen3-Max-Preview", + "display_name": "Qwen3-Max-Preview", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 32000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "knowledge": "2024-12", + "release_date": "2025-01-01", + "last_updated": "2025-01-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "qwen3-coder-plus", + "name": "Qwen3-Coder-Plus", + "display_name": "Qwen3-Coder-Plus", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-07-01", + "last_updated": "2025-07-01", + "cost": { + "input": 0, + "output": 0 + } + }, + { + "id": "qwen3-32b", + "name": "Qwen3-32B", + "display_name": "Qwen3-32B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 32000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2024-10", + "release_date": "2024-12-01", + "last_updated": "2024-12-01", + "cost": { + "input": 0, + "output": 0 + } + } + ] + }, "synthetic": { "id": "synthetic", "name": "Synthetic", @@ -25248,6 +25821,15 @@ "supported": false } }, + { + "id": "cc-glm-4.6", + "name": "cc-glm-4.6", + "display_name": "cc-glm-4.6", + "tool_call": false, + "reasoning": { + "supported": false + } + }, { "id": "chatglm_lite", "name": "chatglm_lite", @@ -30582,6 +31164,27 @@ "supported": false } }, + { + "id": "veo-3.1-generate-preview", + "name": "veo-3.1-generate-preview", + "display_name": "veo-3.1-generate-preview", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text", + "image", + "video" + ] + }, + "tool_call": false, + "reasoning": { + "supported": false + } + }, { "id": "veo3", "name": "veo3", @@ -30810,6 +31413,38 @@ "output": 1.68 } }, + { + "id": "accounts/fireworks/models/minimax-m2", + "name": "MiniMax-M2", + "display_name": "MiniMax-M2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 16384 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "knowledge": "2024-11", + "release_date": "2025-10-27", + "last_updated": "2025-10-27", + "cost": { + "input": 0.3, + "output": 1.2 + } + }, { "id": "accounts/fireworks/models/deepseek-v3-0324", "name": "Deepseek V3 03-24", @@ -33099,6 +33734,38 @@ "output": 1.2 } }, + { + "id": "zai-glm-4.6", + "name": "Z.AI GLM-4.6", + "display_name": "Z.AI GLM-4.6", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 40960 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "release_date": "2025-11-05", + "last_updated": "2025-11-05", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + } + }, { "id": "qwen-3-coder-480b", "name": "Qwen 3 Coder 480B", @@ -64602,15 +65269,6 @@ "supported": false } }, - { - "id": "openrouter/andromeda-alpha", - "name": "Andromeda Alpha", - "display_name": "Andromeda Alpha", - "tool_call": false, - "reasoning": { - "supported": false - } - }, { "id": "anthropic/claude-3-haiku", "name": "Anthropic: Claude 3 Haiku", @@ -65619,6 +66277,15 @@ "supported": false } }, + { + "id": "minimax/minimax-m2", + "name": "MiniMax: MiniMax M2", + "display_name": "MiniMax: MiniMax M2", + "tool_call": false, + "reasoning": { + "supported": false + } + }, { "id": "minimax/minimax-01", "name": "MiniMax: MiniMax-01", @@ -65898,6 +66565,15 @@ "supported": false } }, + { + "id": "mistralai/voxtral-small-24b-2507", + "name": "Mistral: Voxtral Small 24B 2507", + "display_name": "Mistral: Voxtral Small 24B 2507", + "tool_call": false, + "reasoning": { + "supported": false + } + }, { "id": "moonshotai/kimi-dev-72b", "name": "MoonshotAI: Kimi Dev 72B", @@ -66069,6 +66745,15 @@ "supported": false } }, + { + "id": "nvidia/nemotron-nano-12b-v2-vl", + "name": "NVIDIA: Nemotron Nano 12B 2 VL", + "display_name": "NVIDIA: Nemotron Nano 12B 2 VL", + "tool_call": false, + "reasoning": { + "supported": false + } + }, { "id": "nvidia/nemotron-nano-9b-v2", "name": "NVIDIA: Nemotron Nano 9B V2", @@ -66393,6 +67078,15 @@ "supported": false } }, + { + "id": "openai/gpt-oss-safeguard-20b", + "name": "OpenAI: gpt-oss-safeguard-20b", + "display_name": "OpenAI: gpt-oss-safeguard-20b", + "tool_call": false, + "reasoning": { + "supported": false + } + }, { "id": "openai/o1", "name": "OpenAI: o1", @@ -66492,6 +67186,15 @@ "supported": false } }, + { + "id": "openai/text-embedding-3-large", + "name": "OpenAI: Text Embedding 3 Large", + "display_name": "OpenAI: Text Embedding 3 Large", + "tool_call": false, + "reasoning": { + "supported": false + } + }, { "id": "opengvlab/internvl3-78b", "name": "OpenGVLab: InternVL3 78B", @@ -66528,6 +67231,15 @@ "supported": false } }, + { + "id": "perplexity/sonar-pro-search", + "name": "Perplexity: Sonar Pro Search", + "display_name": "Perplexity: Sonar Pro Search", + "tool_call": false, + "reasoning": { + "supported": false + } + }, { "id": "perplexity/sonar-reasoning", "name": "Perplexity: Sonar Reasoning", @@ -67554,6 +68266,28 @@ "supported": false } }, + { + "id": "amazon/nova-premier-v1", + "name": "Amazon: Nova Premier 1.0", + "display_name": "Amazon: Nova Premier 1.0", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 32000 + }, + "tool_call": false, + "reasoning": { + "supported": false + } + }, { "id": "amazon/nova-pro-v1", "name": "Amazon: Nova Pro 1.0", @@ -68221,47 +68955,6 @@ "supported": false } }, - { - "id": "cognitivecomputations/dolphin3.0-mistral-24b", - "name": "Dolphin3.0 Mistral 24B", - "display_name": "Dolphin3.0 Mistral 24B", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 32768, - "output": 32768 - }, - "tool_call": false, - "reasoning": { - "supported": false - } - }, - { - "id": "cognitivecomputations/dolphin3.0-mistral-24b:free", - "name": "Dolphin3.0 Mistral 24B (free)", - "display_name": "Dolphin3.0 Mistral 24B (free)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 32768 - }, - "tool_call": false, - "reasoning": { - "supported": false - } - }, { "id": "cohere/command-a", "name": "Cohere: Command A", @@ -69208,29 +69901,7 @@ ] }, "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - } - }, - { - "id": "google/gemma-2-9b-it:free", - "name": "Google: Gemma 2 9B (free)", - "display_name": "Google: Gemma 2 9B (free)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 + "context": 8192 }, "tool_call": false, "reasoning": { @@ -70517,29 +71188,7 @@ ] }, "limit": { - "context": 131072, - "output": 131072 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": false - } - }, - { - "id": "mistralai/devstral-small-2505:free", - "name": "Mistral: Devstral Small 2505 (free)", - "display_name": "Mistral: Devstral Small 2505 (free)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 32768 + "context": 128000 }, "temperature": true, "tool_call": true, @@ -71201,31 +71850,31 @@ } }, { - "id": "moonshotai/kimi-dev-72b", - "name": "MoonshotAI: Kimi Dev 72B", - "display_name": "MoonshotAI: Kimi Dev 72B", + "id": "mistralai/voxtral-small-24b-2507", + "name": "Mistral: Voxtral Small 24B 2507", + "display_name": "Mistral: Voxtral Small 24B 2507", "modalities": { "input": [ - "text" + "text", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 32000 }, - "tool_call": false, + "temperature": true, + "tool_call": true, "reasoning": { - "supported": true, - "default": true + "supported": false } }, { - "id": "moonshotai/kimi-dev-72b:free", - "name": "MoonshotAI: Kimi Dev 72B (free)", - "display_name": "MoonshotAI: Kimi Dev 72B (free)", + "id": "moonshotai/kimi-dev-72b", + "name": "MoonshotAI: Kimi Dev 72B", + "display_name": "MoonshotAI: Kimi Dev 72B", "modalities": { "input": [ "text" @@ -71235,7 +71884,8 @@ ] }, "limit": { - "context": 131072 + "context": 131072, + "output": 131072 }, "tool_call": false, "reasoning": { @@ -71407,47 +72057,6 @@ "supported": false } }, - { - "id": "nousresearch/deephermes-3-llama-3-8b-preview", - "name": "Nous: DeepHermes 3 Llama 3 8B Preview", - "display_name": "Nous: DeepHermes 3 Llama 3 8B Preview", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 131072 - }, - "tool_call": true, - "reasoning": { - "supported": false - } - }, - { - "id": "nousresearch/deephermes-3-llama-3-8b-preview:free", - "name": "Nous: DeepHermes 3 Llama 3 8B Preview (free)", - "display_name": "Nous: DeepHermes 3 Llama 3 8B Preview (free)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072 - }, - "tool_call": false, - "reasoning": { - "supported": false - } - }, { "id": "nousresearch/deephermes-3-mistral-24b-preview", "name": "Nous: DeepHermes 3 Mistral 24B Preview", @@ -72823,6 +73432,27 @@ }, "attachment": true }, + { + "id": "openai/text-embedding-3-large", + "name": "OpenAI: Text Embedding 3 Large", + "display_name": "OpenAI: Text Embedding 3 Large", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text", + "embeddings" + ] + }, + "limit": { + "context": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + } + }, { "id": "opengvlab/internvl3-78b", "name": "OpenGVLab: InternVL3 78B", @@ -72926,6 +73556,29 @@ "supported": false } }, + { + "id": "perplexity/sonar-pro-search", + "name": "Perplexity: Sonar Pro Search", + "display_name": "Perplexity: Sonar Pro Search", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 8000 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + } + }, { "id": "perplexity/sonar-reasoning", "name": "Perplexity: Sonar Reasoning", @@ -73514,7 +74167,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 131072 }, "tool_call": true, "reasoning": { @@ -73608,28 +74261,6 @@ "default": true } }, - { - "id": "qwen/qwen3-8b:free", - "name": "Qwen: Qwen3 8B (free)", - "display_name": "Qwen: Qwen3 8B (free)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 40960, - "output": 40960 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - } - }, { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder 480B A35B", @@ -73774,6 +74405,7 @@ "context": 256000, "output": 32768 }, + "temperature": true, "tool_call": true, "reasoning": { "supported": false @@ -74382,28 +75014,6 @@ "default": true } }, - { - "id": "thudm/glm-z1-32b", - "name": "THUDM: GLM Z1 32B", - "display_name": "THUDM: GLM Z1 32B", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 32768, - "output": 32768 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - } - }, { "id": "tngtech/deepseek-r1t-chimera", "name": "TNG: DeepSeek R1T Chimera", @@ -75068,6 +75678,23 @@ "supported": false } }, + { + "id": "doubao-1.5-pro-32k-character-250715", + "name": "doubao-1.5-pro-32k-character-250715", + "display_name": "doubao-1.5-pro-32k-character-250715", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "tool_call": true, + "reasoning": { + "supported": false + } + }, { "id": "baidu/ernie-4.5-300b-a47b-paddle", "name": "ERNIE 4.5 300B A47B", diff --git a/src/main/events.ts b/src/main/events.ts index f58649c1e..2b99d3289 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -23,6 +23,7 @@ export const CONFIG_EVENTS = { CONTENT_PROTECTION_CHANGED: 'config:content-protection-changed', SOUND_ENABLED_CHANGED: 'config:sound-enabled-changed', // 新增:声音开关变更事件 COPY_WITH_COT_CHANGED: 'config:copy-with-cot-enabled-changed', + TRACE_DEBUG_CHANGED: 'config:trace-debug-changed', // Trace 调试功能开关变更事件 PROXY_RESOLVED: 'config:proxy-resolved', LANGUAGE_CHANGED: 'config:language-changed', // 新增:语言变更事件 // 模型配置相关事件 diff --git a/src/main/lib/redact.ts b/src/main/lib/redact.ts new file mode 100644 index 000000000..37c7618de --- /dev/null +++ b/src/main/lib/redact.ts @@ -0,0 +1,138 @@ +/** + * 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 + * Note: We use exact match to avoid filtering legitimate keys like 'max_tokens' + */ +const SENSITIVE_BODY_KEYS = ['api_key', 'apiKey', 'apikey', 'secret', 'password', 'token'] + +/** + * Body keys that should never be redacted (even if they contain sensitive keywords) + */ +const ALLOWED_BODY_KEYS = [ + 'max_tokens', + 'max_completion_tokens', + 'max_output_tokens', + 'temperature', + 'stream', + 'model', + 'messages', + 'tools' +] + +/** + * 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)) { + // Skip redaction for allowed keys (like max_tokens, max_completion_tokens, etc.) + if (ALLOWED_BODY_KEYS.includes(key)) { + if (typeof value === 'object' && value !== null) { + redacted[key] = redactBody(value) + } else { + redacted[key] = value + } + continue + } + + // Check if key matches sensitive patterns (exact match or ends with sensitive keyword) + const keyLower = key.toLowerCase() + const shouldRedact = SENSITIVE_BODY_KEYS.some((sensitiveKey) => { + const sensitiveKeyLower = sensitiveKey.toLowerCase() + // Exact match + if (keyLower === sensitiveKeyLower) { + return true + } + // Key ends with sensitive keyword (e.g., 'api_token', 'access_token') + // But exclude keys that contain allowed patterns (e.g., 'max_tokens') + if (keyLower.endsWith(`_${sensitiveKeyLower}`) || keyLower.endsWith(sensitiveKeyLower)) { + // Double check: make sure it's not a false positive + return !ALLOWED_BODY_KEYS.some((allowed) => keyLower.includes(allowed.toLowerCase())) + } + return false + }) + + 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/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 2657ee824..950b3ad15 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -1061,6 +1061,11 @@ export class ConfigPresenter implements IConfigPresenter { eventBus.sendToRenderer(CONFIG_EVENTS.COPY_WITH_COT_CHANGED, SendTarget.ALL_WINDOWS, enabled) } + setTraceDebugEnabled(enabled: boolean): void { + this.setSetting('traceDebugEnabled', enabled) + eventBus.sendToRenderer(CONFIG_EVENTS.TRACE_DEBUG_CHANGED, SendTarget.ALL_WINDOWS, enabled) + } + // Get floating button switch status getFloatingButtonEnabled(): boolean { const value = this.getSetting('floatingButtonEnabled') ?? false 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 fc36a9ddd..e56be49ad 100644 --- a/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts @@ -14,12 +14,10 @@ import { createStreamEvent } from '@shared/types/core/llm-events' import { BaseLLMProvider, SUMMARY_TITLES_PROMPT } from '../baseProvider' import OpenAI, { AzureOpenAI } from 'openai' import { - ChatCompletionAssistantMessageParam, ChatCompletionContentPart, ChatCompletionContentPartText, ChatCompletionMessage, - ChatCompletionMessageParam, - ChatCompletionToolMessageParam + ChatCompletionMessageParam } from 'openai/resources' import { presenter } from '@/presenter' import { eventBus, SendTarget } from '@/eventbus' @@ -252,17 +250,33 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { /** * User messages: Upper layer will insert image_url based on whether vision exists * Assistant messages: Need to judge and convert images to correct context, as models can be switched - * @param messages - * @returns + * Tool calls and tool responses: + * - If supportsFunctionCall=true: Use standard OpenAI format (tool_calls + role:tool) + * - If supportsFunctionCall=false: Convert to mock user messages with function_call_record format + * @param messages - Chat messages array + * @param supportsFunctionCall - Whether the model supports native function calling + * @returns Formatted messages for OpenAI API */ - protected formatMessages(messages: ChatMessage[]): ChatCompletionMessageParam[] { - return messages.map((msg) => { + protected formatMessages( + messages: ChatMessage[], + supportsFunctionCall: boolean = false + ): ChatCompletionMessageParam[] { + const result: ChatCompletionMessageParam[] = [] + // Track pending tool calls for non-FC models (to pair with tool responses) + const pendingToolCalls: Map< + string, + { name: string; arguments: string; assistantContent?: string } + > = new Map() + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + // Handle basic message structure const baseMessage: Partial = { role: msg.role as 'system' | 'user' | 'assistant' | 'tool' } - // Handle content conversion to string + // Handle content conversion to string for non-user messages if (msg.content !== undefined && msg.role !== 'user') { if (typeof msg.content === 'string') { baseMessage.content = msg.content @@ -280,23 +294,120 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { baseMessage.content = textParts.join('\n') } } + + // Handle user messages (keep multimodal content structure) if (msg.role === 'user') { if (typeof msg.content === 'string') { baseMessage.content = msg.content } else if (Array.isArray(msg.content)) { baseMessage.content = msg.content as ChatCompletionContentPart[] } + result.push(baseMessage as ChatCompletionMessageParam) + continue } - if (msg.role === 'assistant' && msg.tool_calls) { - ;(baseMessage as ChatCompletionAssistantMessageParam).tool_calls = msg.tool_calls + // Handle assistant messages with tool_calls + if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) { + if (supportsFunctionCall) { + // Standard OpenAI format - preserve tool_calls structure + result.push({ + role: 'assistant', + content: baseMessage.content || null, + tool_calls: msg.tool_calls + } as ChatCompletionMessageParam) + } else { + // Mock format: Store tool calls and assistant content, wait for tool responses + // First add the assistant message if it has content + if (baseMessage.content) { + result.push({ + role: 'assistant', + content: baseMessage.content + } as ChatCompletionMessageParam) + } + + // Store tool calls for pairing with responses + for (const toolCall of msg.tool_calls) { + const toolCallId = toolCall.id || `tool-${Date.now()}-${Math.random()}` + pendingToolCalls.set(toolCallId, { + name: toolCall.function?.name || 'unknown', + arguments: + typeof toolCall.function?.arguments === 'string' + ? toolCall.function.arguments + : JSON.stringify(toolCall.function?.arguments || {}), + assistantContent: baseMessage.content as string | undefined + }) + } + } + continue } + + // Handle tool messages if (msg.role === 'tool') { - ;(baseMessage as ChatCompletionToolMessageParam).tool_call_id = msg.tool_call_id || '' + if (supportsFunctionCall) { + // Standard OpenAI format - preserve role:tool with tool_call_id + result.push({ + role: 'tool', + content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content), + tool_call_id: msg.tool_call_id || '' + } as ChatCompletionMessageParam) + } else { + // Mock format: Create user message with function_call_record + const toolCallId = msg.tool_call_id || '' + const pendingCall = pendingToolCalls.get(toolCallId) + + if (pendingCall) { + // Parse arguments to JSON if it's a string + let argsObj + try { + argsObj = + typeof pendingCall.arguments === 'string' + ? JSON.parse(pendingCall.arguments) + : pendingCall.arguments + } catch { + argsObj = {} + } + + // Format as function_call_record in user message + const mockRecord = { + function_call_record: { + name: pendingCall.name, + arguments: argsObj, + response: + typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + } + } + + result.push({ + role: 'user', + content: `${JSON.stringify(mockRecord)}` + } as ChatCompletionMessageParam) + + pendingToolCalls.delete(toolCallId) + } else { + // Fallback: tool response without matching call, still format as user message + const mockRecord = { + function_call_record: { + name: 'unknown', + arguments: {}, + response: + typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + } + } + + result.push({ + role: 'user', + content: `${JSON.stringify(mockRecord)}` + } as ChatCompletionMessageParam) + } + } + continue } - return baseMessage as ChatCompletionMessageParam - }) + // Handle other messages (system, assistant without tool_calls) + result.push(baseMessage as ChatCompletionMessageParam) + } + + return result } // OpenAI completion method @@ -313,8 +424,13 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { if (!modelId) { throw new Error('Model ID is required') } + + // Check if model supports function calling + const modelConfig = this.configPresenter.getModelConfig(modelId, this.provider.id) + const supportsFunctionCall = modelConfig?.functionCall || false + const requestParams: OpenAI.Chat.ChatCompletionCreateParams = { - messages: this.formatMessages(messages), + messages: this.formatMessages(messages, supportsFunctionCall), model: modelId, stream: false, temperature: temperature, @@ -608,7 +724,9 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { // 为 OpenAI 聊天补全准备消息和工具 const tools = mcpTools || [] const supportsFunctionCall = modelConfig?.functionCall || false // 判断是否支持原生函数调用 - let processedMessages = [...this.formatMessages(messages)] as ChatCompletionMessageParam[] + let processedMessages = [ + ...this.formatMessages(messages, supportsFunctionCall) + ] as ChatCompletionMessageParam[] // 如果不支持原生函数调用但存在工具,则准备非原生函数调用提示 if (tools.length > 0 && !supportsFunctionCall) { @@ -672,6 +790,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { // 如果存在 API 工具且支持函数调用,则添加到请求参数中 if (apiTools && apiTools.length > 0 && supportsFunctionCall) requestParams.tools = apiTools + // console.log('[handleChatCompletion] requestParams', JSON.stringify(requestParams)) // 发起 OpenAI 聊天补全请求 const stream = await this.openai.chat.completions.create(requestParams) @@ -1505,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..c7dc1efd6 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -15,6 +15,7 @@ import { ChatMessage, LLMAgentEventData } from '@shared/presenter' +import { ModelType } from '@shared/model' import { presenter } from '@/presenter' import { MessageManager } from './messageManager' import { eventBus, SendTarget } from '@/eventbus' @@ -575,12 +576,42 @@ export class ThreadPresenter implements IThreadPresenter { } } - if (image_data) { + if (image_data?.data) { + const rawData = image_data.data ?? '' + let normalizedData = rawData + let normalizedMimeType = image_data.mimeType?.trim() ?? '' + + // Handle URLs (imgcache://, http://, https://) + if ( + rawData.startsWith('imgcache://') || + rawData.startsWith('http://') || + rawData.startsWith('https://') + ) { + normalizedMimeType = 'deepchat/image-url' + } + // Handle data URIs: extract base64 content and mime type + else if (rawData.startsWith('data:image/')) { + const match = rawData.match(/^data:([^;]+);base64,(.*)$/) + if (match?.[1] && match?.[2]) { + normalizedMimeType = match[1] + normalizedData = match[2] + } + } + // Fallback to image/png if no mime type is provided + else if (!normalizedMimeType) { + normalizedMimeType = 'image/png' + } + + const normalizedImageData = { + data: normalizedData, + mimeType: normalizedMimeType + } const imageBlock: AssistantMessageBlock = { type: 'image', status: 'success', timestamp: currentTime, - content: image_data + content: 'image', + image_data: normalizedImageData } state.message.content.push(imageBlock) } @@ -1737,6 +1768,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 +3700,165 @@ 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 + + // Parse metadata to get model_provider and model_id + let messageMetadata: MESSAGE_METADATA | null = null + try { + messageMetadata = JSON.parse(message.metadata) as MESSAGE_METADATA + } catch (e) { + console.warn('Failed to parse message metadata:', e) + } + + const effectiveProviderId = messageMetadata?.provider || defaultProviderId + const effectiveModelId = messageMetadata?.model || defaultModelId + + // Get user message (parent of assistant message) + const userMessageSqlite = await this.sqlitePresenter.getMessage(message.parent_id || '') + if (!userMessageSqlite) { + throw new Error('User message not found') + } + + // Convert SQLITE_MESSAGE to Message type + const userMessage = this.messageManager['convertToMessage'](userMessageSqlite) + + // 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 + + // Extract user content from userMessage + let userContent = '' + if (typeof userMessage.content === 'string') { + userContent = userMessage.content + } else if ( + userMessage.content && + typeof userMessage.content === 'object' && + 'text' in userMessage.content + ) { + userContent = userMessage.content.text || '' + } + + const { finalContent } = await preparePromptContent({ + conversation, + userContent, + contextMessages, + searchResults: null, + urlResults: [], + userMessage, + vision: visionEnabled, + imageFiles: [], + supportsFunctionCall, + modelType: 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`) + } + + // Type assertion for provider instance + const providerInstance = provider as { + getRequestPreview: ( + messages: ChatMessage[], + modelId: string, + modelConfig: unknown, + temperature: number, + maxTokens: number, + mcpTools: MCPToolDefinition[] + ) => Promise<{ + endpoint: string + headers: Record + body: unknown + }> + } + + try { + const preview = await providerInstance.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/main/presenter/threadPresenter/promptBuilder.ts b/src/main/presenter/threadPresenter/promptBuilder.ts index 39d1661a5..39feef46b 100644 --- a/src/main/presenter/threadPresenter/promptBuilder.ts +++ b/src/main/presenter/threadPresenter/promptBuilder.ts @@ -19,6 +19,7 @@ import type { MCPToolDefinition } from '../../../shared/presenter' import { ContentEnricher } from './contentEnricher' import { buildUserMessageContext, getNormalizedUserMessageText } from './messageContent' import { generateSearchPrompt } from './searchManager' +import { nanoid } from 'nanoid' export type PendingToolCall = { id: string @@ -466,31 +467,47 @@ function addContextMessages( const content = msg.content as AssistantMessageBlock[] const messageContent: ChatMessageContent[] = [] const toolCalls: ChatMessage['tool_calls'] = [] + const toolResponses: { id: string; response: string }[] = [] content.forEach((block) => { if (block.type === 'tool_call' && block.tool_call) { + let toolCallId = block.tool_call.id || nanoid(8) toolCalls.push({ - id: block.tool_call.id, + id: toolCallId, type: 'function', function: { name: block.tool_call.name, arguments: block.tool_call.params || '' } }) + // Store tool response separately to create role:tool messages if (block.tool_call.response) { - messageContent.push({ type: 'text', text: block.tool_call.response }) + toolResponses.push({ + id: toolCallId, + response: block.tool_call.response + }) } } else if (block.type === 'content' && block.content) { messageContent.push({ type: 'text', text: block.content }) } }) + // Add assistant message with tool_calls (without responses in content) if (toolCalls.length > 0) { resultMessages.push({ role: 'assistant', content: messageContent.length > 0 ? messageContent : undefined, tool_calls: toolCalls }) + + // Add separate role:tool messages for each tool response + toolResponses.forEach((toolResp) => { + resultMessages.push({ + role: 'tool', + content: toolResp.response, + tool_call_id: toolResp.id + }) + }) } else if (messageContent.length > 0) { resultMessages.push({ role: 'assistant', 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/settings/components/ProviderApiConfig.vue b/src/renderer/settings/components/ProviderApiConfig.vue index c8f8d7913..35f199023 100644 --- a/src/renderer/settings/components/ProviderApiConfig.vue +++ b/src/renderer/settings/components/ProviderApiConfig.vue @@ -97,7 +97,7 @@ }}