From bec42627f5016e7f3b3fcf8c4014545d63fb1da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:59:54 +0800 Subject: [PATCH] fix: guide coding model selection before sending --- ...20261014-code-model-onboarding-01e8cbed.md | 70 ++++++ README.md | 2 + .../coding-runtime/conversation-service.ts | 67 +++++- src/pages/Chat/CodingChatPanel.tsx | 96 +++++++- src/pages/Chat/CodingComposer.tsx | 57 ++++- .../Chat/CodingComposerRuntimeControls.tsx | 137 +++++++---- src/stores/coding-conversations.ts | 5 +- src/stores/providers.ts | 7 +- tests/e2e/pi-coding-first-chat.spec.ts | 166 +++++++++++-- tests/unit/coding-chat-panel.test.tsx | 226 +++++++++++++++++- tests/unit/coding-core-routes.test.ts | 215 ++++++++++++++++- tests/unit/coding-feature-ui.test.tsx | 53 ++++ 12 files changed, 1000 insertions(+), 101 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-code-model-onboarding-01e8cbed.md diff --git a/.project-docs/30-worklog/tasks/20261014-code-model-onboarding-01e8cbed.md b/.project-docs/30-worklog/tasks/20261014-code-model-onboarding-01e8cbed.md new file mode 100644 index 00000000..b88b5363 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-code-model-onboarding-01e8cbed.md @@ -0,0 +1,70 @@ +# Task: Guide model selection before new coding conversation submission + +## Identity + +- Task ID: 20261014-code-model-onboarding-01e8cbed +- Mode: Feature +- Branch: codex/20261014-code-model-onboarding-01e8cbed-code-model-onboarding +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-code-model-onboarding-01e8cbed +- Base commit: f34e651774beb6a2a213b5711f2ffb887d22d4b7 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Show model selection as normal preparation before Code conversation submission, including saved new conversations and provisional drafts. Provide direct model choices, preserve drafts/attachments, prevent prompt submission until configured, and retain valid defaults. + +## Intent And Constraints + +- User approved the proposed interaction. Keep the Makelore light visual system, existing provider-qualified model identity and Main prompt/auth validation. Do not auto-send, rewrite project defaults/prompts/Skills, restore conversation settings, change teacher behavior or move/delete user history. +- This task implements/tests source only; no running-app update, real model invocation, merge/push or other-worktree cleanup requested. + +## Project Context Loaded + +Task context: +- Official feature start/status verified task id01e8cbed, absolute worktree/branch, owner and latest fetched origin/main base f34e651774beb6a2a213b5711f2ffb887d22d4b7. Primary unrelated dirty documents untouched. +- All77 peers/231 required sections read/hash checked; no concrete semantic conflict or missing record. Preserve deletion-cleared selection, drafts, hidden metadata and model-independent preview recovery. + +Read: +- Read-before-planning, memory-index, own record and AGENTS. Canonical positioning/current-state/decisions/architecture/domain/evidence/reflection/commitments/stale context reused from prior completed same-base tasks; canonical diff remains empty. Relevant ADR006 model resolution and ADR teacher scope rechecked. +- Main conversation service/history/Pi preparation, provider/model option stores, sidebar selection/toast path, panel/composer/runtime controls and existing unit/native Electron fixtures. +- Design skill and design-system/React UX searches: apply accessible focus/labels/neutral preparation; generic dark/marketing/font recommendations conflict with existing light product and are not applied. + +Relevant understanding: +- Opening a missing-model conversation currently prepares Pi, rejects and surfaces both sidebar toast and composer retry. Provisional draft has no selector; current canSend does not validate model. +- Main can read persisted history without a model session. Preparing/accepting a real prompt still requires a valid model. Provider initial empty array is not confirmed empty catalog; loading/error must be distinguished. +- Files: conversation-service, CodingChatPanel/CodingComposer/runtime controls, provider loading state, focused tests and README. No new model/Provider backend contract or teacher changes. + +Gate result: Passed. + +## Plan + +1. Return read-only conversation history/metadata for unresolved model selection without opening Pi, retaining genuine errors and prompt validation. +2. Add neutral preparation guidance with one-level model picker; preserve per-project provisional model choice without creating/sending a conversation until the user sends. Block submit until model saves/refreshes; keep valid defaults and draft/attachments. +3. Verify loading/empty/failed catalogs, keyboard selection, error recovery, no auto-send, conversation/project isolation and existing default path through focused units and native Electron screenshots; run typecheck/lint/build and independent review. + +## Outcome + +- Main getSnapshot reads durable history/metadata normally for unresolved models, without preparing Pi. Only known model-selection preparation failures project a required model; persisted old choice remains unchanged. Active/uncertain workers retain runtime authority. Late history re-reads metadata and revalidates changed models, preserving real auth/start/history failures instead of returning stale required state. Prompt/model/provider validation remains unchanged. +- Added neutral '选一个模型,开始聊聊' guidance and one-level model menu, separate catalog loading/failed/confirmed-empty states with appropriate refresh/config actions. Provider initialized state distinguishes initial unknown from confirmed empty. A valid Main-resolved default remains usable even if not listed by the narrower picker catalog. +- Both saved conversations and provisional drafts can select models. Provisional selection is local and scoped by account/project/agent; it neither creates a conversation nor sends. First manual send creates/persists its selected model before prompt; the created conversation and model mutation are guarded against repeated sends during save/refresh. Existing project default and hidden configuration are preserved. +- Input/attachment drafts remain available through selection. First-send text is frozen at the click; newer edits remain as a new draft, including edits while create/save/refresh wait. Successful submit removes only sent attachments. Model save + authoritative refresh clears only the captured draft's model-choice error, without hiding unrelated errors or uncertain work. Same-scope successful keyboard selection restores textarea focus; switching scopes does not steal focus. +- Retained entry error codes for precise model-guidance projection, updated README and meaningful unit/native regressions. Independent review's signature, stale-error, draft-intent, catalog-validity and late-history findings were fixed and rechecked; final review approved with no remaining blocker. + +## Verification + +- Pinned pnpm10.33.4 used with existing ignored dependency link. Standard typecheck, scoped ESLint, production Renderer/Main/Preload/utility build and diff check passed. Logs /tmp/makelore-code-model-onboarding-types-01e8cbed.log, /tmp/makelore-code-model-onboarding-lint-01e8cbed.log, /tmp/makelore-code-model-onboarding-build-01e8cbed.log. +- 242 related units passed: Main core routes44 (includes deferred-history auth/start race), panel/runtime UI61 (includes A-send/B-draft, catalog-external valid default, save/refresh pending, no auto-send and focus), store/workspace/facade/voice/model-options/work-preview112, provider regressions25. Logs /tmp/code-model-onboarding-units-reviewed-01e8cbed.log, /tmp/makelore-code-model-onboarding-regression-01e8cbed.log, /tmp/makelore-code-model-onboarding-providers-01e8cbed.log; backend agent ran44 after reproducing and fixing the two late-history failures. +- Isolated native Electron3/3 passed: removed saved model replacement with keyboard focus, saved missing model with deferred save/refresh and retained text/images, provisional choice without API/create followed by explicit create→model→prompt. No captured prompt on model selection. Root visually inspected direct picker and completed-selection screenshots. Log /tmp/code-model-onboarding-electron-01e8cbed/run.log; screenshots under results/pi-coding-first-chat-missi-0bb00-nishes-without-auto-sending/ in that directory. +- Native test generated Main entry restored byte-for-byte, SHA ad83191846f6f9e58fd1f0237a04a90e8e358344f74b89cad5a19b0cbdecdbc9; test app processes exited. Test fixtures use isolated data/fake provider host, not the real trial/account/paid model. +- Additional strict Main module-closure check reports one existing TS2322 at unchanged electron/coding-projects/project-service.ts:217 (optional projectId). Running the same strict check directly on that unchanged module reproduces the identical diagnostic; its entire coding-project/shared dependency sources have no base diff. No changed-file diagnostic; this extra check is not declared clean. Logs /tmp/makelore-code-model-onboarding-main-types-01e8cbed.log and /tmp/makelore-code-model-onboarding-unchanged-base-types-01e8cbed.log. +- No live app update, real model call, real cloud submission, merge/push, or user-project file changes. Passing local/native fixtures does not constitute installed/real-provider release acceptance. + +## Follow-ups + +- Source ready for integration and temporary-app update when requested. Earlier submission-directory repair0d6ac41 remains on its separate completed task branch; combine both during delivery as appropriate, preserving both README behavior paragraphs. Running trial still uses previous source. +- Existing additional strict Main project-service optional-id diagnostic is outside this UI task; keep it visible for future Main typecheck work. + +## Promotion Candidates + +- Target ADR006 and architecture data-flow/module-map: distinguish model-free conversation history/selection preparation from validated Pi execution. Evidence: Main44, UI61, related137 units, native3 and independent review. Future impact: missing model is ordinary preparation, history never causes prompt/model execution, and delayed reads cannot override newer runtime/model state. Conflicts: none with77 peer scopes; prompt/auth/project identity and teacher execution unchanged. Human confirmation: user explicitly authorized this interaction; canonical promotion requires serialized integration. diff --git a/README.md b/README.md index 02b1e42a..28520569 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 作品入口按项目记住已确认的本机页面和受限的打开方式。应用重启后先检查并复用原服务;服务停止时,可直接重新启动已有的 Vite 工程或普通静态网页,再显示实际可访问的页面,不要求当前对话选择模型,也不自动新建对话。旧项目可从已成功打开页面的工具回执恢复入口。空项目仍显示原有空状态;无法确定打开方式、缺少运行组件或入口改变时说明原因,由用户明确选择「让助手检查」后才调用编程助手。后台恢复只访问本机地址,保存的打开方式按项目真实目录校验,不保存或重放任意终端命令。 +编程新对话尚未选定模型时,会先显示“选一个模型,开始聊聊”,点击“选择模型”直接列出当前账号的模型;加载中、读取失败和暂无模型分别说明。输入和图片草稿保持可编辑,选择保存及刷新完成前不能发送,也不会自动发送。有效默认模型可直接使用;尚未创建对话的模型选择只保留为当前账号、项目和编程配置的临时选择,首次手动发送时才创建对话并保存。缺少模型时仍可读取已有历史,不因此启动 Pi 或显示重试报错;实际发送与模型权限仍由 Main 校验。 + 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 创建智能体时,如果本次新操作被服务明确拒绝,会显示失败原因、保留名称和头像,并允许修改后重新提交;不会留下“结果尚未确认”提醒。断网、响应异常或已有待确认操作的重试失败仍保留原创建请求,可重试确认结果,避免重复创建。当前创建表单一旦出现结果不确定,即使移除提醒后重试被拒绝,也继续保留原请求;移除提醒不代表取消创建。创建页面会及时刷新本机恢复记录。服务器的字段校验错误只转换为固定中文提示,不展示内部错误或提交原文。一句话介绍仍可留空;创建与保存需要配套的智能体云端服务支持。 diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 508180fb..99527ece 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -114,6 +114,11 @@ function runtimeError(error: unknown): never { throw new CodingConversationServiceError(503, 'CODING_RUNTIME_UNAVAILABLE', 'The local coding runtime is unavailable'); } +function isModelSelectionError(error: unknown): boolean { + return error instanceof CodingConversationServiceError + && (error.code === 'CODING_MIGRATION_MODEL_REQUIRED' || error.code === 'CODING_MODEL_UNAVAILABLE'); +} + async function persist(operation: () => Promise): Promise { try { return await operation(); @@ -415,13 +420,32 @@ export class CodingConversationService { } async getSnapshot(conversationId: string): Promise { + this.assertNotDeleting(conversationId); const { project, conversation } = await this.projects.findActiveConversation(conversationId); const { config } = await this.projects.getConfig(project.id); const agent = config.agents.find((item) => item.id === conversation.agentId); if (this.options.readHistory && (!agent?.enabled || agent.archivedAt)) { return await this.options.readHistory(project.id, conversation); } - await this.ensurePrepared(conversationId); + if (this.options.readHistory && (!conversation.model || conversation.modelResolution !== 'resolved')) { + if (this.hasWorker(conversationId)) { + try { return await this.runtime.getSnapshot(conversationId); } catch (error) { runtimeError(error); } + } + if (!this.prepareFlights.has(conversationId)) { + return await this.readModelSelectionHistory(project.id, conversation); + } + } + try { + await this.ensurePrepared(conversationId); + } catch (error) { + if (this.options.readHistory && isModelSelectionError(error)) { + if (this.hasWorker(conversationId)) { + try { return await this.runtime.getSnapshot(conversationId); } catch (runtimeFailure) { runtimeError(runtimeFailure); } + } + return await this.readModelSelectionHistory(project.id, conversation); + } + throw error; + } try { return await this.runtime.getSnapshot(conversationId); } catch (error) { @@ -429,6 +453,47 @@ export class CodingConversationService { } } + private hasWorker(conversationId: string): boolean { + return this.runtime.getDiagnostics().workers.some((worker) => worker.conversationId === conversationId); + } + + private async readModelSelectionHistory( + projectId: string, + conversation: CodingConversationV2, + ): Promise { + const snapshot = await this.options.readHistory!(projectId, conversation); + // A model selection or prompt may have started while durable history was read. + // Its real runtime state, including uncertain work, must remain authoritative. + const preparing = this.prepareFlights.get(conversation.id); + if (preparing) { + try { await preparing; } catch (error) { + if (!isModelSelectionError(error)) throw error; + } + } + this.assertNotDeleting(conversation.id); + if (this.hasWorker(conversation.id)) { + try { return await this.runtime.getSnapshot(conversation.id); } catch (error) { runtimeError(error); } + } + const { conversation: current } = await this.projects.findActiveConversation(conversation.id); + this.assertNotDeleting(conversation.id); + if (current.modelResolution !== conversation.modelResolution + || JSON.stringify(current.model) !== JSON.stringify(conversation.model)) { + // A concurrent choice may have persisted and failed preparation before this + // read completed. Recheck that model instead of masking its real failure. + return await this.getSnapshot(conversation.id); + } + if (this.hasWorker(conversation.id)) { + try { return await this.runtime.getSnapshot(conversation.id); } catch (error) { runtimeError(error); } + } + return { + ...snapshot, + conversation: { + ...snapshot.conversation, + model: { model: null, modelResolution: 'required' }, + }, + }; + } + /** Read existing facts for work restoration without opening a model session. */ async getStoredSnapshot(conversationId: string): Promise { const { project, conversation } = await this.projects.findActiveConversation(conversationId); diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 67458215..bfa8d258 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -21,6 +21,7 @@ import { import { abortCodingConversation, forkCodingConversation, + setCodingConversationModel, } from '@/lib/coding-conversations'; import { subscribeHostEvent } from '@/lib/host-events'; import { @@ -35,6 +36,7 @@ import { codingWorkspaceStore, useCodingWorkspaceStore } from '@/stores/coding-w import type { CodingDraftAttachment, PromptMode, + ProductModelRef, } from '@/types/coding-conversation'; import type { CodingConversationMetadata, @@ -59,6 +61,8 @@ import { codingRecovery, CONTINUE_CODING_PROMPT, type CodingRecoveryAction } fro import { codingRecoveryAccount, type CodingLoginRecovery } from '@/lib/coding-login-recovery'; import { openWorksBillingAccount } from '@/lib/works-billing'; +const needsModelSelection = (code?: string) => code === 'CODING_MIGRATION_MODEL_REQUIRED' || code === 'CODING_MODEL_UNAVAILABLE'; + export interface CodingChatPanelProps { navigationDraft?: string; onCreateProject?(): void; @@ -178,6 +182,8 @@ export function CodingChatPanel({ const markConversationUnread = useCodingConversationStore((state) => state.markUnread); const [provisionalDrafts, setProvisionalDrafts] = useState>({}); + const [provisionalModels, setProvisionalModels] = useState>({}); + const [modelActions, setModelActions] = useState>({}); const [submissionErrors, setSubmissionErrors] = useState>({}); const [modesByDraftKey, setModesByDraftKey] = useState>({}); const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState< @@ -201,6 +207,7 @@ export function CodingChatPanel({ setTeacherQuote(current => current?.reference === reference ? undefined : current); }, []); const appliedNavigationDraftRef = useRef(null); + const provisionalDraftsRef = useRef(provisionalDrafts); const selectedConversationContextRef = useRef(null); const attachmentsRef = useRef(attachmentsByDraftKey); const uploadedAttachmentsRef = useRef(new Map()); @@ -233,6 +240,7 @@ export function CodingChatPanel({ ), [attachmentsByDraftKey, draftKey]); attachmentsRef.current = attachmentsByDraftKey; + provisionalDraftsRef.current = provisionalDrafts; const selectProjectConversation = useCallback((projectId: string, conversationId: string) => { selectedConversationContextRef.current = `${projectId}:${conversationId}`; @@ -297,6 +305,22 @@ export function CodingChatPanel({ ? state.entriesByConversationId[targetConversationId]?.error ?? null : null )); + const entryErrorCode = useCodingConversationStore(state => { + const entry = targetConversationId ? state.entriesByConversationId[targetConversationId] : null; + return entry?.error ? entry.errorCode : undefined; + }); + const provisionalModelKey = provisionalDraftKey ? `${account}:${provisionalDraftKey}` : null; + const provisionalModelOverride = provisionalModelKey ? provisionalModels[provisionalModelKey] : undefined; + const provisionalModel = provisionalModelOverride ?? selectedAgent?.model ?? null; + const currentModelState = snapshot?.conversation.model ?? selectedConversation ?? selectedAgent; + const currentModel = targetConversationId ? currentModelState?.model : provisionalModel; + const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null; + const modelSelectionRequired = !currentModel + || ((Boolean(targetConversationId) || !provisionalModelOverride) && currentModelState?.modelResolution === 'required') + || needsModelSelection(runtimeError?.code) + || needsModelSelection(entryErrorCode) + || needsModelSelection(submissionError?.backendCode); + const modelActionBusy = draftKey ? modelActions[draftKey] ?? false : false; const blockingRequestCount = useCodingConversationStore((state) => requestCount( state, targetConversationId, @@ -534,6 +558,7 @@ export function CodingChatPanel({ }, []); const handleSubmit = useCallback(() => { + if (modelSelectionRequired || modelActionBusy) return; if (!targetConversationId && draftKey && activeProject && selectedAgent && !submissionFlightsRef.current.has(draftKey)) { const projectId = activeProject.id; const initialText = provisionalDraft; @@ -543,24 +568,39 @@ export function CodingChatPanel({ submissionFlightsRef.current.add(draftKey); void codingWorkspaceStore.getState().createConversation().then(async (conversation) => { createdId = conversation.id; + submissionFlightsRef.current.add(conversation.id); + setModelActions(current => ({ ...current, [conversation.id]: true })); primeConversation(createLocalConversationSnapshot(projectId, conversation)); - setConversationDraft(conversation.id, initialText); + const latestText = provisionalDraftsRef.current[draftKey] ?? initialText; + setConversationDraft(conversation.id, latestText); setProvisionalDrafts(current => { - if (current[draftKey] !== initialText) return current; + if (current[draftKey] !== latestText) return current; const next = { ...current }; delete next[draftKey]; return next; }); setAttachmentsByDraftKey(current => { - const next = { ...current, [conversation.id]: attachments }; + const next = { ...current, [conversation.id]: current[draftKey] ?? attachments }; delete next[draftKey]; return next; }); if (codingWorkspaceStore.getState().activeProjectId === projectId && codingConversationStore.getState().selectedConversationId === selectedAtSend) { void selectProjectConversation(projectId, conversation.id).catch(() => undefined); } - await submitPrompt({ conversationId: conversation.id, mode: 'prompt', + if (provisionalModelOverride) { + const modelState = await setCodingConversationModel(conversation.id, provisionalModelOverride); + upsertConversation(projectId, { ...conversation, ...modelState }); + await loadConversationSnapshot(conversation.id, 'silent'); + } + const latestDraft = selectCodingConversationDraft(conversation.id)(codingConversationStore.getState()); + await submitPrompt({ conversationId: conversation.id, mode: 'prompt', text: initialText, + preserveDraft: latestDraft.text !== initialText, prepareAttachments: attachments.length ? () => prepareAttachments(attachments) : undefined }); setAttachmentsByDraftKey(current => { - const next = { ...current }; delete next[conversation.id]; return next; + const sentIds = new Set(attachments.map(item => item.id)); + const remaining = (current[conversation.id] ?? []).filter(item => !sentIds.has(item.id)); + const next = { ...current }; + if (remaining.length) next[conversation.id] = remaining; + else delete next[conversation.id]; + return next; }); for (const attachment of attachments) { URL.revokeObjectURL(attachment.previewUrl); @@ -568,7 +608,14 @@ export function CodingChatPanel({ } }).catch((error) => { setSubmissionErrors(current => ({ ...current, [createdId ?? draftKey]: localSubmissionError(error) })); - }).finally(() => submissionFlightsRef.current.delete(draftKey)); + }).finally(() => { + submissionFlightsRef.current.delete(draftKey); + if (createdId) { + submissionFlightsRef.current.delete(createdId); + const completedId = createdId; + setModelActions(current => ({ ...current, [completedId]: false })); + } + }); return; } if (!targetConversationId || !draftKey || submissionFlightsRef.current.has(targetConversationId)) return; @@ -617,11 +664,16 @@ export function CodingChatPanel({ promptMode, targetConversationId, submitPrompt, + modelSelectionRequired, + modelActionBusy, + provisionalModelOverride, + upsertConversation, + loadConversationSnapshot, ]); const workerStatus = snapshot?.worker.status ?? 'stopped'; - const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null; - const preparationError = entryError ?? runtimeError?.message ?? null; + const preparationError = (needsModelSelection(entryErrorCode) ? null : entryError) + ?? (needsModelSelection(runtimeError?.code) ? null : runtimeError?.message) ?? null; useEffect(() => { if (!draftKey || uncertainRequestCount > 0 @@ -646,6 +698,8 @@ export function CodingChatPanel({ const editable = Boolean(activeProject && selectedAgent?.enabled && !selectedAgent.archivedAt); const canSend = Boolean( editable + && !modelSelectionRequired + && !modelActionBusy && (draft.trim() || localAttachments.length > 0) && modeMatchesRunState && submittingRequestCount === 0 @@ -940,13 +994,27 @@ export function CodingChatPanel({ runStatus={runStatus} mode={promptMode} queue={snapshot?.queue ?? { items: [] }} - error={submissionError?.message ?? preparationError} + error={(needsModelSelection(submissionError?.backendCode) ? null : submissionError?.message) ?? preparationError} recoverableError={!submissionError && Boolean(preparationError)} attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))} submitting={submittingRequestCount > 0} placeholder={selectedAgent ? '说说你的想法,接下来想做什么?' : '项目编程配置尚未就绪'} conversation={selectedConversation} snapshot={snapshot} + provisionalModel={provisionalModel} + modelSelectionRequired={modelSelectionRequired} + runtimeBusy={modelActionBusy} + onSelectProvisionalModel={async (model) => { + if (provisionalModelKey) setProvisionalModels(current => ({ ...current, [provisionalModelKey]: model })); + if (draftKey) setSubmissionErrors(current => { + if (!needsModelSelection(current[draftKey]?.backendCode)) return current; + const next = { ...current }; delete next[draftKey]; return next; + }); + }} + onRuntimeBusyChange={(busy) => { + if (draftKey) setModelActions(current => ({ ...current, [draftKey]: busy })); + }} + onOpenModelSettings={onOpenProjectSettings} voiceScopeKey={draftKey} onModeChange={(mode) => { if (draftKey) setModesByDraftKey((current) => ({ ...current, [draftKey]: mode })); @@ -985,7 +1053,15 @@ export function CodingChatPanel({ onAddFiles={handleAddFiles} onRemoveAttachment={handleRemoveAttachment} onRefreshRuntime={async () => { - if (targetConversationId) await loadConversationSnapshot(targetConversationId, 'silent'); + if (targetConversationId) { + const refreshed = await loadConversationSnapshot(targetConversationId, 'silent'); + if (draftKey && refreshed.conversation.model.model && refreshed.conversation.model.modelResolution === 'resolved') { + setSubmissionErrors(current => { + if (!needsModelSelection(current[draftKey]?.backendCode)) return current; + const next = { ...current }; delete next[draftKey]; return next; + }); + } + } }} />} diff --git a/src/pages/Chat/CodingComposer.tsx b/src/pages/Chat/CodingComposer.tsx index b9ed8023..412c12c9 100644 --- a/src/pages/Chat/CodingComposer.tsx +++ b/src/pages/Chat/CodingComposer.tsx @@ -11,6 +11,7 @@ import type { ConversationQueueState, ConversationRunStatus, PromptMode, + ProductModelRef, } from '@/types/coding-conversation'; import type { CodingConversationMetadata } from '@/types/coding-project'; import { CodingComposerRuntimeControls } from './CodingComposerRuntimeControls'; @@ -32,6 +33,12 @@ export interface CodingComposerProps { placeholder: string; conversation?: CodingConversationMetadata | null; snapshot?: ConversationSnapshot | null; + provisionalModel?: ProductModelRef | null; + modelSelectionRequired?: boolean; + runtimeBusy?: boolean; + onSelectProvisionalModel?(model: ProductModelRef): Promise; + onRuntimeBusyChange?(busy: boolean): void; + onOpenModelSettings?(): void; voiceScopeKey?: string | null; onChange(value: string): void; onModeChange(mode: PromptMode): void; @@ -59,6 +66,12 @@ export function CodingComposer({ placeholder, conversation, snapshot, + provisionalModel, + modelSelectionRequired = false, + runtimeBusy = false, + onSelectProvisionalModel, + onRuntimeBusyChange, + onOpenModelSettings, voiceScopeKey, onChange, onModeChange, @@ -70,12 +83,22 @@ export function CodingComposer({ onRefreshRuntime, }: CodingComposerProps) { const accounts = useProviderStore(state => state.accounts); - const selectedModel = snapshot?.conversation.model.model ?? conversation?.model; + const selectedModel = snapshot ? snapshot.conversation.model.model : conversation?.model ?? provisionalModel; 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 textareaRef = useRef(null); + const selectionFocusScope = useRef(null); + const composerScope = voiceScopeKey ?? conversation?.id ?? 'draft'; + useEffect(() => { + if (selectionFocusScope.current !== composerScope) selectionFocusScope.current = null; + else if (!modelSelectionRequired) { + selectionFocusScope.current = null; + textareaRef.current?.focus(); + } + }, [composerScope, modelSelectionRequired]); const latestValueRef = useRef(value); useEffect(() => { latestValueRef.current = value; @@ -92,6 +115,23 @@ export function CodingComposer({ }); const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); const showAbort = Boolean(onAbort && running && !canSend && runStatus !== 'aborting'); + const runtimeControls = (onboarding = false) => ( + undefined)} + onSelectProvisionalModel={onSelectProvisionalModel} + onBusyChange={(busy) => { + if (busy && onboarding) selectionFocusScope.current = composerScope; + onRuntimeBusyChange?.(busy); + }} + onOpenModelSettings={onOpenModelSettings} + /> + ); return (
+ {modelSelectionRequired && ( +
+ 选一个模型,开始聊聊 + {runtimeControls(true)} +
+ )} {(preparing || recovering || submitting || error) && (
)}