diff --git a/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md b/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md index b20e3a7..24c58da 100644 --- a/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md +++ b/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md @@ -12,47 +12,36 @@ ## Scope -- Read-only assessment of Makelore imported model capabilities, local profiles, model UI and Pi runtime capability resolution. -- Product source, test suites, main checkout and other task records remain unchanged. -- Maintain only this task record. +- Implement authoritative managed-model v2 import and persistence, provider input metadata, durable native reasoning choices, Main validation/frozen request context, Pi hook and child-worker propagation, runtime/image UI and focused unit/Electron coverage. ## Intent And Constraints -- User requested independent assessment across Works Square, one-api and Makelore; no subagents were created. -- Concurrent Task Gate: Passed. Bundled check/start/status verified this task ID, feature mode, branch, exact base and owned isolated worktree. The original checkout had unrelated uncommitted task documents. -- Project Context Loaded: read entry/memory index, active task, positioning/current-state, decision index, system/module/data-flow documents, domain/success criteria, relevant evidence/reflection/commitment/stale indexes; also AGENTS.md, README and ADR-006 Pi hard cutover. -- Goal and focus: Makelore provides Code/Canvas/Robot with Main-owned providers and Pi 0.84.2. -- Planning Gate: Passed for assessment only. Proposals do not enact architecture changes or promote canonical memory. -- Peer scopes were read via registry task-record paths only. Numerous historical owners and placeholder scopes remain; these are unknown coordination state, not authority to adopt work. Assessment is confined to a fixed committed baseline and task-owned records, so there is no shared source writer or conflicting implementation decision. -- Relevant established boundary: Works already owns optional per-model reasoning metadata; client local profiles supply fallback and provider wire adaptations. Replacing this authority is a proposal requiring agreement, not a presumed accepted ADR. -- Stale/unknown context: client positioning contains placeholders, so current README/AGENTS/ADR-006 bound the assessment; historical OpenCode/gateway notes are not runtime truth. Installed/deployed state, live provider/account coverage, and one-api internals are unverified. -- Diagnosis scope: no production failure or provider response was supplied. Used real source functions to reproduce concrete contract gaps; no speculative runtime root cause, temporary instrumentation, or remediation loop is claimed. +- The user approved the detailed design and requested implementation. Concurrent Task Gate and Planning Gate passed in this exact owned feature worktree before edits; start/status matched task/branch/base and peer records were read. Numerous historical placeholder peers remain unknown coordination state, with no unresolved concrete dependency blocking these isolated changes. +- Applied maintain-project-docs from C:/Users/7brot/.codex/skills/maintain-project-docs. Relevant entry, task, positioning/decisions/architecture/domain/evidence and commitments were loaded. No subagents, main-checkout changes, canonical-memory promotion, deployment or paid inference calls. +- Related task IDs: Works 20260912-model-capabilities-a7e194c2; one-api 20260912-model-capabilities-b6392e1d; Makelore 20260912-model-capabilities-c48271f9. ## Outcome -- Confirmed server reasoning override exists, but low/high/max normalization drops other efforts and modalities. Model labels depend on local profiles, runtime image support combines other local sources, and live effort controls use Main/Pi snapshots. No product change. Cross-project one-api inspection remains blocked pending user exception. -- Detailed evidence: D:\Datas\PythonProjects\.codex-worktrees\works-square-server\20260912-model-capabilities-a7e194c2\.project-docs\50-evidence\topics\20260912-model-capabilities-a7e194c2__model-capability-assessment.md -- Recommended provider/channel capability catalog -> Works business-filtered projection -> one resolved client UI/runtime capability. This remains a recommendation. -- one-api gate exception request remains pending; no inference from elapsed time. +- Managed niancode-user-models consumes only server v2 facts for image/reasoning capabilities. Missing/malformed capabilities stay unknown instead of borrowing local profiles; BYOK retains existing behavior. V2 public aliases and arbitrary native effort strings are preserved. +- ProductModelRef durably stores default/disabled/enabled+native effort separately from the Pi carrier enum. Model switches reset managed choice to default; refresh-invalid choices and unsupported image submissions are rejected by Main before prompt acceptance. +- Main writes a prepared managed request into the existing worker context, including child workers. The materialized Pi before_provider_request hook removes SDK-injected thinking controls and applies the frozen fields on each request/tool continuation. Default omits controls instead of accidentally disabling thinking. +- Renderer menus show model default, switch options and original effort strings (including xhigh/new values). Main/provider descriptor and image attachment UI agree; the project model cards display authoritative capability summaries. Existing Web Search and provider refresh coordination are retained. +- The legacy snapshot reducer rejected empty availableThinkingLevels, discovered by the new runtime test; managed snapshots now carry the separate capability field without emitting an invalid empty legacy enum list. ## Verification -- Bundled check_project_docs/start/status passed for this owned worktree. -- Actual shared TypeScript modules transpiled in memory and executed: low/medium/xhigh reduced to low, modality removed, gpt-4.1-mini label = text; intentional preservation assertion exited 1. -- Official Alibaba/Anthropic/OpenAI/Google docs were checked for metadata availability; no paid model/network API probe. -- No full application build or broad tests: no product behavior changed. Documentation drift and final Git boundary check are the completion checks. +- Task Documentation Gate: source changes reviewed and bundled task-aware documentation drift check passed. The result and this Ready for Integration record are committed together; registry completion follows the commit. + +- 70 tests passed in 11 focused files covering managed normalization/options and actual Pi HTTP payload, provider projection/import, runtime persistence/invalid selection rejection, session registry, project config, extension host/bundle, provider refresh and composer behavior. The updated provider import test also passed separately after adding v2 persistence coverage. +- Actual Electron E2E passed: v2 exposes native xhigh, submits {mode: enabled, effort: xhigh}, offers model-default/disabled, and disables image attachment for a text-only model. +- pnpm run typecheck and pnpm run build:vite passed. Additional full Main tsc reported 61 diagnostics; an in-memory TypeScript baseline comparison against recorded base found the same 61 and zero additions. The temporary comparison script was removed. +- HTTP serialization uses a local server and actual Pi 0.84.2 provider plus the materialized extension, covering default/disabled/enabled/low/medium/xhigh/max/future-native with Qwen and DeepSeek controls. No paid inference request was sent. +- Git working and committed changes since the recorded base were inspected. Documentation drift and final committed ownership completion recorded below. ## Follow-ups -- Finish one-api read-only source tracing only after user permission or a functioning task gate. -- Agree capability ownership and contract, then implement coordinated server/client/gateway changes with end-to-end fixture coverage and provider serialization tests. -- Actual deployment, refresh and account-specific provider metadata coverage require later verification. +- Integrate alongside one-api and Works. Deployment acceptance must verify the real account catalog/group and provider HTTP responses. Existing 61 Main type errors are outside this change. Budget DTO is preserved with no first-delivery budget editor. ## Promotion Candidates -- Target: canonical model/provider architecture and data-flow docs. -- Proposal: centralize platform-managed effective capability in a provider-aware one-api catalog and project it through Works Square to Main/Pi and UI; distinguish unknown, unsupported, switch-only, effort and budget. -- Evidence: source functions and dated official references in the assessment. -- Future impact: model onboarding, UI labels, attachments, reasoning selection and wire serialization. -- Semantic conflict: existing canonical rules currently make Works server static metadata authoritative with client local fallback; no canonical edit is made. -- Human confirmation required: yes, before accepting the architecture and implementing the cross-repository behavior. +- Promote v2 authority for managed modalities/reasoning, durable native product choice and Main-owned Pi payload preparation to canonical architecture/ADR/current-state during integration. Evidence: new actual HTTP test, runtime regression, Electron E2E and server/gateway linked tasks. User approved the design and implementation; BYOK and existing Web Search remain separate, with v1 reserved for older client support. diff --git a/README.md b/README.md index 91ca899..d5b2555 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ **一念成光,万物可创。** +平台托管模型的图片输入与思考选项由 Works Square 的 `model_capabilities_v2` 下发。未知能力保持未知;会话保存“模型默认 / 关闭 / 开启及原生强度”,Pi 请求前按冻结的选择写入供应商参数。配置刷新后失效的选择会在发送前提示重新选择;BYOK 模型沿用自身配置。 + Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 diff --git a/electron/api/routes/coding-conversations.ts b/electron/api/routes/coding-conversations.ts index 8666664..edf9dbd 100644 --- a/electron/api/routes/coding-conversations.ts +++ b/electron/api/routes/coding-conversations.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { parseManagedReasoningChoice } from '../../../shared/managed-model-capabilities'; import type { ConversationInteractionResponse } from '../../coding-runtime/contracts'; import { CodingConversationServiceError } from '../../coding-runtime/conversation-service'; import { normalizeProductModelRef } from '../../coding-projects/project-config'; @@ -182,12 +183,15 @@ export async function handleCodingConversationRoutes( return true; } if (action === 'thinking' && req.method === 'POST') { - const body = await parseJsonBody<{ thinkingLevel?: unknown }>(req); + const body = await parseJsonBody<{ thinkingLevel?: unknown; reasoningChoice?: unknown }>(req); + const reasoningChoice = body.reasoningChoice === undefined ? undefined : parseManagedReasoningChoice(body.reasoningChoice); + if (reasoningChoice === null) invalidRequest('Reasoning choice is invalid'); if (!THINKING_LEVELS.has(String(body.thinkingLevel))) invalidRequest('Thinking level is invalid'); sendJson(res, 200, { model: await service.setThinking( conversationId, body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max', + reasoningChoice ?? undefined, ), }); return true; diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index e5d5050..0a3fbb6 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -136,7 +136,10 @@ function normalizeWorksSquareAiGatewayBaseUrl(baseUrl: string, credentialMode: s return normalized; } +import { normalizeManagedModelCatalog } from '../../../shared/managed-model-capabilities'; + type ImportedUserModelConfig = { + modelCapabilitiesV2?: import('../../../shared/managed-model-capabilities').ManagedModelCatalog; label: string; baseUrl: string; apiKey: string; @@ -176,7 +179,10 @@ export function normalizeImportedUserModelConfig(payload: unknown): ImportedUser const apiKeyExpiresIn = typeof apiKeyExpiresInRaw === 'number' && Number.isFinite(apiKeyExpiresInRaw) ? Math.max(0, Math.floor(apiKeyExpiresInRaw)) : null; - const models = normalizeImportedModels(record.models); + const v2 = normalizeManagedModelCatalog(record.model_capabilities_v2); + const models = v2 && Array.isArray(record.models) + ? [...new Set(record.models.filter((id): id is string => typeof id === 'string' && !!id.trim()).map(id => id.trim()))] + : normalizeImportedModels(record.models); if (models.length === 0) { throw new Error('Works Square model config response has no models'); } @@ -185,6 +191,7 @@ export function normalizeImportedUserModelConfig(payload: unknown): ImportedUser ? record.label.trim() : NIANCODE_USER_MODEL_ACCOUNT_LABEL; const modelCapabilities = normalizeImportedModelCapabilities(record.model_capabilities, models); + const modelCapabilitiesV2 = normalizeManagedModelCatalog(record.model_capabilities_v2, models); return { label, @@ -194,6 +201,7 @@ export function normalizeImportedUserModelConfig(payload: unknown): ImportedUser apiKeyExpiresIn, models, ...(modelCapabilities ? { modelCapabilities } : {}), + ...(modelCapabilitiesV2 ? { modelCapabilitiesV2 } : {}), }; } @@ -217,9 +225,11 @@ function importedUserModelMetadata( const metadata = { ...(existing?.metadata ?? {}) }; delete metadata.worksSquareCredentialExpiresAt; delete metadata.worksSquareModelCapabilities; + delete metadata.worksSquareModelCapabilitiesV2; return { ...metadata, customModels: modelConfig.models, + ...(modelConfig.modelCapabilitiesV2 ? { worksSquareModelCapabilitiesV2: modelConfig.modelCapabilitiesV2 } : {}), ...(modelConfig.modelCapabilities ? { worksSquareModelCapabilities: modelConfig.modelCapabilities } : {}), @@ -232,12 +242,14 @@ function importedUserModelMetadata( delete metadata.worksSquareCredentialExpiresAt; delete metadata.worksSquareOneApiBaseUrl; delete metadata.worksSquareModelCapabilities; + delete metadata.worksSquareModelCapabilitiesV2; const credentialExpiresAt = modelConfig.apiKeyExpiresIn === null ? undefined : new Date(nowMs + modelConfig.apiKeyExpiresIn * 1000).toISOString(); return { ...metadata, customModels: modelConfig.models, + ...(modelConfig.modelCapabilitiesV2 ? { worksSquareModelCapabilitiesV2: modelConfig.modelCapabilitiesV2 } : {}), ...(modelConfig.modelCapabilities ? { worksSquareModelCapabilities: modelConfig.modelCapabilities } : {}), @@ -309,6 +321,7 @@ function providerAccountRuntimeShape(account: ProviderAccount): unknown { metadata: { customModels: account.metadata?.customModels, worksSquareModelCapabilities: account.metadata?.worksSquareModelCapabilities, + worksSquareModelCapabilitiesV2: account.metadata?.worksSquareModelCapabilitiesV2, worksSquareCredentialMode: account.metadata?.worksSquareCredentialMode, worksSquareOneApiBaseUrl: account.metadata?.worksSquareOneApiBaseUrl, }, diff --git a/electron/coding-projects/project-config.ts b/electron/coding-projects/project-config.ts index 95aeafe..ef6352a 100644 --- a/electron/coding-projects/project-config.ts +++ b/electron/coding-projects/project-config.ts @@ -109,6 +109,8 @@ function normalizeResponsibility(value: unknown): ProjectAgentResponsibility { }; } +import { parseManagedReasoningChoice } from '../../shared/managed-model-capabilities'; + export function normalizeProductModelRef(value: unknown): ProductModelRef { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Product model must be an object'); @@ -123,6 +125,9 @@ export function normalizeProductModelRef(value: unknown): ProductModelRef { accountId, modelId, thinkingLevel: record.thinkingLevel as ConversationThinkingLevel, + ...(record.reasoningChoice !== undefined ? { + reasoningChoice: parseManagedReasoningChoice(record.reasoningChoice) ?? (() => { throw new Error('Invalid reasoning choice'); })(), + } : {}), }; } diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 8103cf2..432845b 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -476,11 +476,11 @@ export class CodingConversationService { } catch (error) { runtimeError(error); } } - async setThinking(conversationId: string, thinkingLevel: ProductModelRef['thinkingLevel']): Promise { + async setThinking(conversationId: string, thinkingLevel: ProductModelRef['thinkingLevel'], reasoningChoice?: ProductModelRef['reasoningChoice']): Promise { await this.ensurePrepared(conversationId); const { project } = await this.projects.findActiveConversation(conversationId); try { - const state = await this.runtime.setThinking({ conversationId, thinkingLevel }); + const state = await this.runtime.setThinking({ conversationId, thinkingLevel, reasoningChoice }); await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state)); return state; } catch (error) { runtimeError(error); } diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index 9072a07..7754162 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -30,6 +30,7 @@ const CORE_PRODUCT_TOOL_NAMES = new Set([ ]); interface WorkerRegistrationRecord { + managedModelRequest?: import('../../../shared/managed-model-capabilities').ManagedModelRequest; token: string; conversationId: string; generation: number; @@ -59,6 +60,7 @@ export interface PiExtensionWorkerRegistration { } export interface RegisterPiExtensionWorkerInput { + managedModelRequest?: import('../../../shared/managed-model-capabilities').ManagedModelRequest; conversationId: string; generation: number; projectId: string; @@ -246,6 +248,7 @@ export class PiManagedExtensionHost { const token = randomBytes(32).toString('base64url'); const contextFile = path.join(input.extensionsDir, `worker-${randomUUID()}.json`); const record: WorkerRegistrationRecord = { + managedModelRequest: input.managedModelRequest, token, conversationId: input.conversationId, generation: input.generation, @@ -292,7 +295,7 @@ export class PiManagedExtensionHost { }; } - async bindRun(conversationId: string, generation: number, runId: string): Promise { + async bindRun(conversationId: string, generation: number, runId: string, managedModelRequest?: import('../../../shared/managed-model-capabilities').ManagedModelRequest): Promise { const record = this.findWorker(conversationId, generation); if (!record) throw new Error('Pi extension worker registration is unavailable'); if (this.productTools && record.projectPath) { @@ -301,6 +304,7 @@ export class PiManagedExtensionHost { this.releaseWorkerResources(record); this.runBindings.set(conversationId, runId); record.runId = runId; + if (managedModelRequest) record.managedModelRequest = structuredClone(managedModelRequest); await this.writeContext(record); } @@ -664,6 +668,7 @@ export class PiManagedExtensionHost { tools: record.tools, projectWriteLeaseToolNames: record.projectWriteLeaseToolNames, ...(record.runId ? { runId: record.runId } : {}), + ...(record.managedModelRequest ? { managedModelRequest: record.managedModelRequest } : {}), }); } diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts index 9330c42..e1944b9 100644 --- a/electron/coding-runtime/pi/extensions/makelore-runtime.ts +++ b/electron/coding-runtime/pi/extensions/makelore-runtime.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { atomicWriteText } from '../../../coding-projects/atomic-json'; -export const MAKELORE_PI_EXTENSION_VERSION = 6; +export const MAKELORE_PI_EXTENSION_VERSION = 7; export const MAKELORE_PI_EXTENSION_FILENAME = `makelore-runtime-v${MAKELORE_PI_EXTENSION_VERSION}.mjs`; const BUNDLE_SOURCE = String.raw` @@ -434,6 +434,20 @@ export function createMakeloreRuntime(runtimeDefaults = {}) { await registerDynamicProductTools(); + pi.on('before_provider_request', async (event) => { + const context = await readWorkerContext(); + const managed = context.managedModelRequest; + if (!managed || !context.runId) return; + if (!event.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)) return; + if (event.payload.model !== managed.modelId) return; + const payload = { ...event.payload }; + delete payload.thinking; + delete payload.enable_thinking; + delete payload.reasoning_effort; + delete payload.thinking_budget; + return { ...payload, ...managed.reasoningFields }; + }); + pi.on('tool_call', async (event, ctx) => { if (!MUTATION_TOOLS.has(event.toolName) && !dynamicLeaseTools.has(event.toolName)) return; const input = event.input || event.arguments || event.args || {}; diff --git a/electron/coding-runtime/pi/provider-config.ts b/electron/coding-runtime/pi/provider-config.ts index a3ac93f..54b3940 100644 --- a/electron/coding-runtime/pi/provider-config.ts +++ b/electron/coding-runtime/pi/provider-config.ts @@ -35,8 +35,10 @@ export const PI_PROVIDER_APIS = [ ] as const; export type PiProviderApi = (typeof PI_PROVIDER_APIS)[number]; +import { unknownManagedModelCapability } from '../../../shared/managed-model-capabilities'; export interface PiProviderModelDescriptor { + managedCapability?: import('../../../shared/managed-model-capabilities').ManagedModelCapability; id: string; name: string; input: Array<'text' | 'image'>; @@ -119,6 +121,7 @@ export async function buildPiProviderCatalogFromProviderService( } export interface PiProviderSelection { + managedCapability?: import('../../../shared/managed-model-capabilities').ManagedModelCapability; accountId: string; runtimeProviderId: string; modelId: string; @@ -258,7 +261,7 @@ function normalizeModelId(rawModelId: string | undefined, account: ProviderAccou const unqualified = modelId.startsWith(runtimePrefix) ? modelId.slice(runtimePrefix.length) : modelId; - return account.id === NIANCODE_USER_MODEL_ACCOUNT_ID + return account.id === NIANCODE_USER_MODEL_ACCOUNT_ID && !account.metadata?.worksSquareModelCapabilitiesV2 ? normalizeImportedUserModelId(unqualified) : unqualified; } @@ -306,41 +309,52 @@ function modelDescriptor( const backend = backendModels.get(modelId); const profile = getImportedModelProfile(modelId); const serverCapability = account.metadata?.worksSquareModelCapabilities?.[modelId]; + const managed = account.id === 'niancode-user-models' + ? account.metadata?.worksSquareModelCapabilitiesV2?.models[modelId] ?? unknownManagedModelCapability() + : undefined; const backendInput = Array.isArray(backend?.input) ? backend.input.filter((input): input is 'text' | 'image' => input === 'text' || input === 'image') : []; - const supportsImage = Boolean( + const supportsImage = managed ? managed.inputModalities?.includes('image') === true : Boolean( summary?.supportsVision || profile?.modalities.input.includes('image') || backendInput.includes('image'), ); - const contextWindow = finitePositiveInteger(summary?.contextWindow) + const contextWindow = finitePositiveInteger(managed?.limits?.contextWindow) + ?? finitePositiveInteger(summary?.contextWindow) ?? finitePositiveInteger(profile?.limit?.context) ?? finitePositiveInteger(backend?.contextWindow); - const maxOutputTokens = finitePositiveInteger(profile?.limit?.output) + const maxOutputTokens = finitePositiveInteger(managed?.limits?.maxOutputTokens) + ?? finitePositiveInteger(profile?.limit?.output) ?? finitePositiveInteger(backend?.maxTokens); return { id: modelId, name: summary?.name || (typeof backend?.name === 'string' && backend.name.trim()) || modelId, input: supportsImage ? ['text', 'image'] : ['text'], - reasoning: serverCapability + ...(managed ? { managedCapability: managed } : {}), + reasoning: managed ? managed.reasoning.supported === true : serverCapability ? serverCapability.reasoningEfforts.length > 0 : summary?.supportsReasoning === true || profile?.pi?.reasoning === true || backend?.reasoning === true, ...(contextWindow ? { contextWindow } : {}), ...(maxOutputTokens ? { maxOutputTokens } : {}), - ...(compat || profile?.pi?.compat || enforcedCompat || serverCapability + ...(compat || profile?.pi?.compat || enforcedCompat || serverCapability || managed ? { compat: { ...compat, ...profile?.pi?.compat, ...enforcedCompat, ...(serverCapability ? { supportsReasoningEffort: true } : {}), + ...(managed ? { + supportsReasoningEffort: false, + thinkingFormat: managed.reasoning.controlFormat ?? undefined, + requiresReasoningContentOnAssistantMessages: managed.reasoning.controlFormat !== null, + } : {}), }, } : {}), - ...(serverCapability + ...(managed ? {} : serverCapability ? { thinkingLevelMap: thinkingLevelMapForImportedModelCapability(serverCapability) } : profile?.pi?.thinkingLevelMap ? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } } @@ -498,6 +512,7 @@ export function selectPiProviderModel( modelId: model.id, thinkingLevel: modelRef.thinkingLevel, input: [...model.input], + ...(model.managedCapability ? { managedCapability: model.managedCapability } : {}), ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), ...(model.maxOutputTokens ? { maxOutputTokens: model.maxOutputTokens } : {}), }; diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index 57073f5..0e0900e 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -98,6 +98,7 @@ import { } from './extension-ui-projector'; type RuntimeIdKind = 'run' | 'queue'; +import { buildManagedModelRequest, managedPiThinkingLevel, validateManagedReasoningChoice } from '../../../shared/managed-model-capabilities'; export interface PiConversationRuntimeOptions { pool: PiWorkerPool; @@ -306,6 +307,9 @@ export function createPiManagedWorkerOpener( let extension; try { extension = await options.extensionHost.registerWorker({ + ...(selection.managedCapability ? { managedModelRequest: buildManagedModelRequest( + model.modelId, model.reasoningChoice ?? { mode: 'default' }, selection.managedCapability, + ) } : {}), conversationId: input.conversation.conversationId, generation: input.generation, projectId: input.conversation.projectId, @@ -796,6 +800,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { } this.snapshot(input.conversationId); + const managedRequest = await this.prepareManagedRequest(input.conversationId, input.attachments.length > 0); const images = await this.resolveImages(input.attachments); const runId = this.id('run'); this.acquireRunBackgroundLease(input.conversationId, runId); @@ -823,7 +828,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { let ticket; try { if (this.extensionHost && generation) { - await this.extensionHost.bindRun(input.conversationId, generation, runId); + await this.extensionHost.bindRun(input.conversationId, generation, runId, managedRequest); } ticket = this.pool.startTopLevel({ conversationId: input.conversationId, @@ -902,10 +907,12 @@ export class PiConversationRuntime implements CodingConversationRuntime { async validateModel(model: ProductModelRef): Promise { const selection = await this.resolveModel(model); + if (selection.managedCapability) validateManagedReasoningChoice(model.reasoningChoice ?? { mode: 'default' }, selection.managedCapability); return { accountId: selection.accountId, modelId: selection.modelId, thinkingLevel: model.thinkingLevel, + ...(selection.managedCapability ? { reasoningChoice: model.reasoningChoice ?? { mode: 'default' as const } } : {}), }; } @@ -924,6 +931,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { accountId: selection.accountId, modelId: selection.modelId, thinkingLevel, + ...(selection.managedCapability ? { reasoningChoice: { mode: 'default' as const } } : {}), }, modelResolution: 'resolved', }; @@ -981,6 +989,16 @@ export class PiConversationRuntime implements CodingConversationRuntime { true, ); } + const selection = current.model.accountId === 'niancode-user-models' ? await this.resolveModel(current.model) : undefined; + if (selection?.managedCapability) { + const choice = input.reasoningChoice ?? (input.thinkingLevel === 'off' + ? { mode: 'disabled' as const } : { mode: 'enabled' as const, effort: input.thinkingLevel }); + validateManagedReasoningChoice(choice, selection.managedCapability); + await this.pool.request(input.conversationId, { type: 'set_thinking_level', level: managedPiThinkingLevel(choice) }); + return this.persistEffectiveThinking(input.conversationId, { + model: { ...current.model, thinkingLevel: 'off', reasoningChoice: choice }, modelResolution: 'resolved', + }, {}, {}, true, true); + } const capabilities = await this.pool.request<{ levels?: unknown }>( input.conversationId, { type: 'get_available_thinking_levels' }, @@ -1025,6 +1043,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { async compact(conversationId: string): Promise { await this.waitForProjection(conversationId); this.assertNoUncertainMutation(conversationId); + const managedRequest = await this.prepareManagedRequest(conversationId, false); const runId = this.id('run'); this.acquireRunBackgroundLease(conversationId, runId); const generation = this.pool.getState(conversationId)?.generation; @@ -1032,7 +1051,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { let ticket; try { if (this.extensionHost && generation) { - await this.extensionHost.bindRun(conversationId, generation, runId); + await this.extensionHost.bindRun(conversationId, generation, runId, managedRequest); } ticket = this.pool.startTopLevel({ conversationId, @@ -1317,6 +1336,7 @@ export class PiConversationRuntime implements CodingConversationRuntime { if (snapshot.run.runId) { this.acquireRunBackgroundLease(input.conversationId, snapshot.run.runId); } + await this.prepareManagedRequest(input.conversationId, input.attachments.length > 0); const images = await this.resolveImages(input.attachments); const queuePosition = snapshot.queue.items.length + 1; const queueId = this.id('queue'); @@ -1809,6 +1829,17 @@ export class PiConversationRuntime implements CodingConversationRuntime { this.states.set(conversationId, createConversationReducerState(projected)); } + private async prepareManagedRequest(conversationId: string, hasImages: boolean) { + const model = this.snapshot(conversationId).conversation.model.model; + if (!model || model.accountId !== 'niancode-user-models') return undefined; + const selection = await this.resolveModel(model); + if (!selection.managedCapability) return undefined; + if (hasImages && !selection.input.includes('image')) { + throw new CodingRuntimeContractError('CODING_MODEL_UNAVAILABLE', '该模型尚未确认支持图片输入,请选择支持图片的模型', true); + } + return buildManagedModelRequest(model.modelId, model.reasoningChoice ?? { mode: 'default' }, selection.managedCapability); + } + private async persistEffectiveThinking( conversationId: string, requested: ConversationModelState, @@ -1818,6 +1849,19 @@ export class PiConversationRuntime implements CodingConversationRuntime { forcePersist = false, ): Promise { if (!requested.model) return clone(requested); + const selection = requested.model.accountId === 'niancode-user-models' ? await this.resolveModel(requested.model) : undefined; + if (selection?.managedCapability) { + const durable: ConversationModelState = { + model: { ...requested.model, thinkingLevel: 'off', + reasoningChoice: requested.model.reasoningChoice ?? { mode: 'default' } }, + modelResolution: 'resolved', + }; + const persisted = await this.registry.setModel(conversationId, durable); + this.pool.updateConversationModel(conversationId, persisted); + const result = { ...persisted, managedCapability: selection.managedCapability }; + if (replaceSnapshot) this.replaceModel(conversationId, result); + return clone(result); + } const effective = effectiveThinkingLevel(stateValue) ?? requested.model.thinkingLevel; const available = availableThinkingLevels(capabilitiesValue); if (!available.includes(effective)) available.push(effective); diff --git a/electron/coding-runtime/pi/session-registry.ts b/electron/coding-runtime/pi/session-registry.ts index 49eca6a..082165e 100644 --- a/electron/coding-runtime/pi/session-registry.ts +++ b/electron/coding-runtime/pi/session-registry.ts @@ -52,7 +52,8 @@ function sameModelState(left: ConversationModelState, right: ConversationModelSt ? left.model === right.model : left.model.accountId === right.model.accountId && left.model.modelId === right.model.modelId - && left.model.thinkingLevel === right.model.thinkingLevel); + && left.model.thinkingLevel === right.model.thinkingLevel + && JSON.stringify(left.model.reasoningChoice) === JSON.stringify(right.model.reasoningChoice)); } export class PiSessionRegistry { diff --git a/electron/coding-runtime/pi/subagent-child.ts b/electron/coding-runtime/pi/subagent-child.ts index fb1a631..f343861 100644 --- a/electron/coding-runtime/pi/subagent-child.ts +++ b/electron/coding-runtime/pi/subagent-child.ts @@ -1,4 +1,5 @@ import type { ModelSummary, ProviderAccount } from '../../shared/providers/types'; +import { buildManagedModelRequest } from '../../../shared/managed-model-capabilities'; import { readCodingProjectConfigV2 } from '../../coding-projects/project-config'; import type { CodingProjectStore } from '../../coding-projects/project-store'; import type { PublicUsage } from '../contracts'; @@ -224,6 +225,9 @@ export function createPiManagedSubagentChildOpener( : {}), }); const extension = await options.extensionHost.registerWorker({ + ...(selection.managedCapability ? { managedModelRequest: buildManagedModelRequest( + agent.model.modelId, agent.model.reasoningChoice ?? { mode: 'default' }, selection.managedCapability, + ) } : {}), conversationId: input.conversationId, generation: input.workerGeneration, projectId: input.projectId, diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index dec19e2..814f460 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -30,6 +30,7 @@ import { import type { ProviderWithKeyInfo } from '../../shared/providers/types'; import { logger } from '../../utils/logger'; import { normalizeImportedModelCapabilities } from '../../../shared/user-model-config'; +import { normalizeManagedModelCatalog } from '../../../shared/managed-model-capabilities'; function maskApiKey(apiKey: string | null): string | null { if (!apiKey) return null; @@ -76,6 +77,8 @@ function normalizeSyncedMetadata(metadata: Record): ProviderAcc if (worksSquareModelCapabilities) { result.worksSquareModelCapabilities = worksSquareModelCapabilities; } + const v2 = normalizeManagedModelCatalog(metadata.worksSquareModelCapabilitiesV2); + if (v2) result.worksSquareModelCapabilitiesV2 = v2; return result; } diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts index 6994fbc..dbfd48b 100644 --- a/electron/shared/providers/types.ts +++ b/electron/shared/providers/types.ts @@ -136,6 +136,7 @@ export interface ProviderAccount { resourceUrl?: string; customModels?: string[]; worksSquareModelCapabilities?: ImportedModelCapabilities; + worksSquareModelCapabilitiesV2?: import('../../../shared/managed-model-capabilities').ManagedModelCatalog; worksSquareCredentialMode?: string; worksSquareCredentialExpiresAt?: string; worksSquareOneApiBaseUrl?: string; diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index 699d0cd..1e52995 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -1,4 +1,5 @@ import type { CapabilityResultV1 } from './data-service'; +import type { ManagedReasoningChoice, ManagedModelCapability } from './managed-model-capabilities'; import type { ModelToolDetailsV1 } from './model-tools'; import type { DevicePackageToolDetailsV1 } from './device-packages'; @@ -12,12 +13,14 @@ export interface ProductModelRef { accountId: string; modelId: string; thinkingLevel: ConversationThinkingLevel; + reasoningChoice?: ManagedReasoningChoice; } export interface ConversationModelState { model: ProductModelRef | null; modelResolution: 'resolved' | 'required'; availableThinkingLevels?: ConversationThinkingLevel[]; + managedCapability?: ManagedModelCapability; } export interface PublicUsage { @@ -400,6 +403,7 @@ export interface SetConversationModelInput { export interface SetThinkingLevelInput { conversationId: string; thinkingLevel: ConversationThinkingLevel; + reasoningChoice?: ManagedReasoningChoice; } export interface ForkConversationInput { diff --git a/shared/coding-conversation-reducer.ts b/shared/coding-conversation-reducer.ts index d1f1a70..b3da7c9 100644 --- a/shared/coding-conversation-reducer.ts +++ b/shared/coding-conversation-reducer.ts @@ -114,6 +114,8 @@ function isPublicError(value: unknown): value is CodingRuntimePublicError { && typeof record.recoverable === 'boolean'; } +import { parseManagedReasoningChoice } from './managed-model-capabilities'; + function isModelState(value: unknown): boolean { const record = asRecord(value); if (!record || !['resolved', 'required'].includes(String(record.modelResolution))) return false; @@ -125,6 +127,7 @@ function isModelState(value: unknown): boolean { && isNonEmptyString(model.accountId) && isNonEmptyString(model.modelId) && THINKING_LEVELS.has(String(model.thinkingLevel)) + && (model.reasoningChoice === undefined || parseManagedReasoningChoice(model.reasoningChoice) !== null) && (record.availableThinkingLevels === undefined || (Array.isArray(record.availableThinkingLevels) && record.availableThinkingLevels.length > 0 diff --git a/shared/managed-model-capabilities.ts b/shared/managed-model-capabilities.ts new file mode 100644 index 0000000..b58997a --- /dev/null +++ b/shared/managed-model-capabilities.ts @@ -0,0 +1,193 @@ +import type { ImportedModelWebSearchCapability } from './imported-model-profile'; +import { normalizeImportedModelWebSearchCapability } from './user-model-config'; + +function object(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} +function field(value: Record, snake: string, camel: string): unknown { + return Object.hasOwn(value, snake) ? value[snake] : value[camel]; +} +const bool = (v: unknown): boolean | null => typeof v === 'boolean' ? v : null; +const str = (v: unknown): string | null => typeof v === 'string' && v.trim() ? v : null; +const num = (v: unknown): number | null => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : null; +const strings = (v: unknown): string[] | null => Array.isArray(v) && v.every(x => typeof x === 'string' && x.trim()) + ? [...new Set(v as string[])] : null; + +export function normalizeManagedModelCatalog(value: unknown, ids?: readonly string[]): ManagedModelCatalog | undefined { + const raw = object(value); + if (field(raw, 'schema_version', 'schemaVersion') !== 2) return undefined; + const models: ManagedModelCatalog['models'] = {}; + for (const [id, item] of Object.entries(object(raw.models))) { + if (ids && !ids.includes(id)) continue; + const p = object(item), r = object(p.reasoning), b = object(r.budget), l = object(p.limits); + const format = field(r, 'control_format', 'controlFormat'); + const status = field(p, 'resolution_status', 'resolutionStatus'); + const webSearch = normalizeImportedModelWebSearchCapability(field(p, 'web_search', 'webSearch')); + models[id] = { + inputModalities: strings(field(p, 'input_modalities', 'inputModalities')), + outputModalities: strings(field(p, 'output_modalities', 'outputModalities')), + reasoning: { + supported: bool(r.supported), canDisable: bool(field(r, 'can_disable', 'canDisable')), + defaultEnabled: bool(field(r, 'default_enabled', 'defaultEnabled')), + effortValues: strings(field(r, 'effort_values', 'effortValues')), + defaultEffort: str(field(r, 'default_effort', 'defaultEffort')), + controlFormat: format === 'qwen' || format === 'deepseek' ? format : null, + budget: r.budget ? { + supported: bool(b.supported), minTokens: num(field(b, 'min_tokens', 'minTokens')), + maxTokens: num(field(b, 'max_tokens', 'maxTokens')), defaultTokens: num(field(b, 'default_tokens', 'defaultTokens')), + exclusiveWithEffort: bool(field(b, 'exclusive_with_effort', 'exclusiveWithEffort')), + } : null, + }, + limits: p.limits ? { + contextWindow: num(field(l, 'context_window', 'contextWindow')), + maxInputTokens: num(field(l, 'max_input_tokens', 'maxInputTokens')), + maxOutputTokens: num(field(l, 'max_output_tokens', 'maxOutputTokens')), + } : null, + resolutionStatus: status === 'ready' || status === 'partial' || status === 'conflict' ? status : 'unknown', + issues: Array.isArray(p.issues) ? p.issues.flatMap(item => { + const i = object(item); return typeof i.field === 'string' && typeof i.code === 'string' ? [{ field: i.field, code: i.code }] : []; + }) : [], + updatedAt: str(field(p, 'updated_at', 'updatedAt')), + ...(webSearch ? { webSearch } : {}), + }; + } + const status = field(raw, 'refresh_status', 'refreshStatus'); + return { schemaVersion: 2, models, fetchedAt: str(field(raw, 'fetched_at', 'fetchedAt')), + refreshStatus: status === 'fresh' || status === 'stale' ? status : 'unavailable' }; +} + +export function parseManagedReasoningChoice(value: unknown): ManagedReasoningChoice | null { + const v = object(value); + if (v.mode === 'default' || v.mode === 'disabled') return { mode: v.mode }; + if (v.mode === 'enabled' && (v.effort === undefined || typeof v.effort === 'string' && v.effort.trim())) { + return { mode: 'enabled', ...(typeof v.effort === 'string' ? { effort: v.effort } : {}) }; + } + return null; +} + +export type ManagedReasoningChoice = + | { mode: 'default' } + | { mode: 'disabled' } + | { mode: 'enabled'; effort?: string }; + +export interface ManagedModelCapability { + inputModalities: string[] | null; + outputModalities: string[] | null; + reasoning: { + supported: boolean | null; + canDisable: boolean | null; + defaultEnabled: boolean | null; + effortValues: string[] | null; + defaultEffort: string | null; + controlFormat: 'qwen' | 'deepseek' | null; + budget: { + supported: boolean | null; + minTokens: number | null; + maxTokens: number | null; + defaultTokens: number | null; + exclusiveWithEffort: boolean | null; + } | null; + }; + limits: { + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; + } | null; + resolutionStatus: 'ready' | 'partial' | 'unknown' | 'conflict'; + issues: { field: string; code: string }[]; + updatedAt: string | null; + webSearch?: ImportedModelWebSearchCapability; +} + +export interface ManagedModelCatalog { + schemaVersion: 2; + models: Record; + fetchedAt: string | null; + refreshStatus: 'fresh' | 'stale' | 'unavailable'; +} + +export interface ManagedModelRequest { + modelId: string; + choice: ManagedReasoningChoice; + reasoningFields: Record; +} + +export function unknownManagedModelCapability(): ManagedModelCapability { + return { + inputModalities: null, + outputModalities: null, + reasoning: { + supported: null, canDisable: null, defaultEnabled: null, + effortValues: null, defaultEffort: null, controlFormat: null, budget: null, + }, + limits: null, + resolutionStatus: 'unknown', + issues: [], + updatedAt: null, + }; +} + +export function managedReasoningOptions(capability: ManagedModelCapability): { + value: string; label: string; choice: ManagedReasoningChoice; +}[] { + const options: ReturnType = [ + { value: 'default', label: '模型默认', choice: { mode: 'default' } }, + ]; + const r = capability.reasoning; + if (r.supported !== true || !r.controlFormat) return options; + if (r.canDisable === true) { + options.push({ value: 'disabled', label: '关闭思考', choice: { mode: 'disabled' } }); + options.push({ value: 'enabled', label: '开启(默认强度)', choice: { mode: 'enabled' } }); + } + for (const effort of (r.canDisable === true || r.defaultEnabled === true) ? r.effortValues ?? [] : []) { + options.push({ value: 'effort:' + effort, label: effort, choice: { mode: 'enabled', effort } }); + } + return options; +} + +export function managedReasoningChoiceKey(choice: ManagedReasoningChoice): string { + return choice.mode === 'enabled' && choice.effort !== undefined + ? 'effort:' + choice.effort : choice.mode; +} + +export function validateManagedReasoningChoice( + choice: ManagedReasoningChoice, + capability: ManagedModelCapability, +): void { + const key = managedReasoningChoiceKey(choice); + if (!managedReasoningOptions(capability).some((option) => option.value === key)) { + throw new Error('该模型的思考选项已不可用,请刷新模型配置后重新选择'); + } +} + +export function buildManagedModelRequest( + modelId: string, + choice: ManagedReasoningChoice, + capability: ManagedModelCapability, +): ManagedModelRequest { + validateManagedReasoningChoice(choice, capability); + const fields: Record = {}; + const r = capability.reasoning; + if (choice.mode !== 'default') { + if (r.canDisable === true) { + if (r.controlFormat === 'qwen') fields.enable_thinking = choice.mode === 'enabled'; + else fields.thinking = { type: choice.mode === 'enabled' ? 'enabled' : 'disabled' }; + } + if (choice.mode === 'enabled' && choice.effort !== undefined) { + fields.reasoning_effort = choice.effort; + } + } + return { modelId, choice: { ...choice }, reasoningFields: fields }; +} + +// Pi's fixed enum is an execution detail. The request hook applies the validated +// native choice; this carrier must never be persisted as the product selection. +export function managedPiThinkingLevel(choice: ManagedReasoningChoice): + 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' { + if (choice.mode !== 'enabled') return 'off'; + switch (choice.effort) { + case 'minimal': case 'low': case 'medium': case 'high': case 'xhigh': case 'max': + return choice.effort; + default: return 'low'; + } +} diff --git a/shared/user-model-config.ts b/shared/user-model-config.ts index 2d7efd0..0a5ae52 100644 --- a/shared/user-model-config.ts +++ b/shared/user-model-config.ts @@ -51,7 +51,7 @@ function normalizeImportedModelCapability(value: unknown): ImportedModelCapabili }; } -function normalizeImportedModelWebSearchCapability( +export function normalizeImportedModelWebSearchCapability( value: unknown, ): ImportedModelWebSearchCapability | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; diff --git a/src/lib/coding-conversations.ts b/src/lib/coding-conversations.ts index 108d685..b2b3b1f 100644 --- a/src/lib/coding-conversations.ts +++ b/src/lib/coding-conversations.ts @@ -81,10 +81,11 @@ export async function setCodingConversationModel( export async function setCodingConversationThinking( conversationId: string, thinkingLevel: ConversationThinkingLevel, + reasoningChoice?: import('../../shared/managed-model-capabilities').ManagedReasoningChoice, ): Promise { const response = await hostApiFetch<{ model: ConversationModelState }>( `/api/coding/conversations/${encodeURIComponent(conversationId)}/thinking`, - { method: 'POST', body: JSON.stringify({ thinkingLevel }) }, + { method: 'POST', body: JSON.stringify({ thinkingLevel, reasoningChoice }) }, ); return response.model; } diff --git a/src/lib/coding-model-options.ts b/src/lib/coding-model-options.ts index 630cf42..9f1d849 100644 --- a/src/lib/coding-model-options.ts +++ b/src/lib/coding-model-options.ts @@ -1,6 +1,7 @@ import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers'; import type { ConversationThinkingLevel, ProductModelRef } from '@/types/coding-conversation'; import { getImportedModelProfile } from '../../shared/imported-model-profile'; +import { unknownManagedModelCapability, type ManagedModelCapability } from '../../shared/managed-model-capabilities'; export interface CodingModelOption { key: string; @@ -8,6 +9,7 @@ export interface CodingModelOption { modelId: string; label: string; availableThinkingLevels: ConversationThinkingLevel[] | null; + managedCapability?: ManagedModelCapability; } const STANDARD_THINKING_LEVELS: readonly ConversationThinkingLevel[] = [ @@ -23,6 +25,7 @@ function availableThinkingLevels( account: ProviderAccount, modelId: string, ): ConversationThinkingLevel[] | null { + if (account.id === 'niancode-user-models') return []; const serverCapability = account.metadata?.worksSquareModelCapabilities?.[modelId]; if (serverCapability) { return [ @@ -85,6 +88,8 @@ export function buildCodingModelOptions( modelId, label: `${account.label}${vendor && vendor !== account.label ? ` · ${vendor}` : ''} / ${modelId}`, availableThinkingLevels: availableThinkingLevels(account, modelId), + ...(account.id === 'niancode-user-models' ? { managedCapability: + account.metadata?.worksSquareModelCapabilitiesV2?.models[modelId] ?? unknownManagedModelCapability() } : {}), }); } } diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 1b9be96..308b4e8 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -128,6 +128,7 @@ export interface ProviderAccount { resourceUrl?: string; customModels?: string[]; worksSquareModelCapabilities?: ImportedModelCapabilities; + worksSquareModelCapabilitiesV2?: import('../../shared/managed-model-capabilities').ManagedModelCatalog; worksSquareCredentialMode?: string; worksSquareCredentialExpiresAt?: string; }; diff --git a/src/pages/Chat/CodingComposer.tsx b/src/pages/Chat/CodingComposer.tsx index 1ea73a9..d84fa95 100644 --- a/src/pages/Chat/CodingComposer.tsx +++ b/src/pages/Chat/CodingComposer.tsx @@ -1,5 +1,6 @@ import { LoaderCircle, Mic, Plus, RotateCcw, Send, Square, X } from 'lucide-react'; import { useEffect, useRef } from 'react'; +import { useProviderStore } from '@/stores/providers'; import { Button } from '@/components/ui/button'; import { Select } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; @@ -69,6 +70,12 @@ export function CodingComposer({ onRemoveAttachment, onRefreshRuntime, }: CodingComposerProps) { + const accounts = useProviderStore(state => state.accounts); + const selectedModel = snapshot?.conversation.model.model ?? conversation?.model; + const managed = selectedModel?.accountId === 'niancode-user-models'; + const capability = selectedModel ? accounts.find(account => account.id === selectedModel.accountId) + ?.metadata?.worksSquareModelCapabilitiesV2?.models[selectedModel.modelId] : undefined; + const acceptsImages = !managed || capability?.inputModalities?.includes('image') === true; const fileInputRef = useRef(null); const latestValueRef = useRef(value); useEffect(() => { @@ -149,7 +156,7 @@ export function CodingComposer({ type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple - disabled={!editable || submitting} + disabled={!editable || submitting || !acceptsImages} className="sr-only" data-testid="coding-file-attachment-input" onChange={(event) => { @@ -193,7 +200,7 @@ export function CodingComposer({ if (canSend) onSubmit(); }} onPaste={(event) => { - if (submitting) return; + if (submitting || !acceptsImages) return; const files = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith('image/')); if (files.length > 0) onAddFiles(files); }} @@ -204,8 +211,9 @@ export function CodingComposer({ type="button" variant="ghost" className="h-8 w-8 shrink-0 rounded-full p-0" - disabled={!editable || submitting} + disabled={!editable || submitting || !acceptsImages} aria-label="添加图片" + title={acceptsImages ? '添加图片' : '该模型尚未确认支持图片输入'} onClick={() => fileInputRef.current?.click()} >