Przeglądaj źródła

feat(codex): 聊天页模型选择器改用后端模型管理,会话中锁定

选择器数据源从 Codex 内置 model/list 换成后端「模型管理」(modelApi.listModels 过滤 llm)。选中即应用:store.applySessionModel 组装 ApplyProviderInput 调 codexApplyProvider 重启运行时,成功后回写 runtime 状态并按会话记 modelId;失败由页面提示,显示值由会话模型反查驱动天然回退(弃用 v-model 双向绑定)。会话有消息即锁定选择器(title 提示「会话进行中不可切换模型」),空会话可换。选项 value 用后端记录 id 防同 modelId 重名;删除「跟随 Codex 默认模型」空值项与 codexListModels/CodexModelOption 死代码。

验证:pnpm type:check 89 条基线/codex 包 0 报错;pnpm build exit=0。主进程未动。
cc 2 tygodni temu
rodzic
commit
d67f027059

+ 19 - 0
.workbuddy/memory/2026-09-24.md

@@ -341,3 +341,22 @@ D(主进程 watcher:只覆盖活会话,要养常驻进程)、E(按回
 
 验证:tsc 0 报错、114 单测全过、smoke 桥接 turn 通、build-electron exit=0(chat-bridge 已进包)、
 前端 type:check 89 基线 0 新增 + build exit=0。
+
+## 聊天页模型选择器:数据源换后端模型管理 + 会话中锁定
+
+需求:选择器用「模型管理」里配置的模型;会话过程中不能切换模型。已拍板:选中即应用、有消息即锁。
+
+- store:`models` 由 codexListModels(内置目录)改为 `modelApi.listModels()`(过滤 llm)+
+  `listProviders()`(查 providerType);新 action `applySessionModel(recordId)` —— 组装
+  ApplyProviderInput 调 codexApplyProvider(重启运行时),成功后回写 `this.runtime = result.status`
+  + `setSessionModel(activeThreadId, record.modelId)`;失败抛错由页面提示。
+- 选择器 value 用**后端记录 id**(防同 modelId 不同端点重名);会话 model 仍存 modelId
+  (turnRun/导出直接用,语义不变)。页面弃用 v-model 改 `:model-value + @update`——
+  应用是 async 可失败,显示值由 selectedRecordId 反查驱动,失败天然回退。
+- composer:props.models 改轻量 `{value,label}[]`(组件不感知 AiModel);新增
+  `modelLocked`(有消息即锁,title 提示)/`modelLoading`;删「跟随 Codex 默认模型」空值项。
+- 清理:codexApi.codexListModels / CodexModelOption 已删(前端零引用);
+  主进程 codexCtl.listModels handler 保留未动。
+- 边界:未登录后端 → models=[] → 「未配置可用模型」(同现状静默降级)。
+
+验证:pnpm type:check 89 基线 / codex 包 0 报错;pnpm build exit=0。主进程未动。

+ 0 - 14
ai-electron/frontend/src/codex/api/codexApi.ts

@@ -138,20 +138,6 @@ export function codexRestart(): Promise<CodexStatus> {
   return codexInvoke<CodexStatus>('restart');
 }
 
-export interface CodexModelOption {
-  id: string;
-  model: string;
-  displayName: string;
-  description: string;
-  isDefault: boolean;
-  defaultEffort: string;
-  efforts: string[];
-}
-
-export function codexListModels(): Promise<CodexModelOption[]> {
-  return codexInvoke<CodexModelOption[]>('listModels');
-}
-
 // ─── 模型 provider ─────────────────────────────────────────────────────────
 
 /** 与主进程 ApplyProviderInput 对应;providerType 取自 agent_model_provider.type */

+ 12 - 14
ai-electron/frontend/src/codex/components/codexComposer.vue

@@ -24,9 +24,11 @@
             v-model:value="selectedModel"
             class="codex-composer__model"
             size="small"
-            :disabled="disabled"
-            :options="modelOptions"
+            :disabled="disabled || modelLocked"
+            :loading="modelLoading"
+            :options="models"
             :placeholder="models.length ? '选择模型' : '未配置可用模型'"
+            :title="modelLocked ? '会话进行中不可切换模型' : ''"
           />
 
           <span class="codex-composer__hint">
@@ -59,20 +61,24 @@
   import { computed, ref, watch } from 'vue';
   import { Button, Select, Textarea } from 'ant-design-vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
-  import type { CodexModelOption } from '../api/codexApi';
   import CodexRunOptionsPanel from './codexRunOptionsPanel.vue';
 
   const props = withDefaults(
     defineProps<{
-      models: CodexModelOption[];
-      /** 当前会话选中的模型 */
+      /** 轻量选项(value/label 由父级组装,组件不感知后端模型记录) */
+      models: Array<{ value: string; label: string }>;
+      /** 当前会话选中的模型(后端记录 id) */
       modelValue?: string | null;
       disabled?: boolean;
+      /** 会话已有消息:锁定模型选择,不可切换 */
+      modelLocked?: boolean;
+      /** 模型应用中(选中即应用会重启运行时) */
+      modelLoading?: boolean;
       streaming?: boolean;
       /** 当前会话工作目录,仅用于提示 */
       cwd?: string;
     }>(),
-    { modelValue: null, disabled: false, streaming: false, cwd: '' },
+    { modelValue: null, disabled: false, modelLocked: false, modelLoading: false, streaming: false, cwd: '' },
   );
 
   const emit = defineEmits<{
@@ -96,14 +102,6 @@
       : '描述你要做的分析,例如:统计各表记录数并找出异常值',
   );
 
-  const modelOptions = computed(() => [
-    { value: '', label: '跟随 Codex 默认模型' },
-    ...props.models.map((item) => ({
-      value: item.model || item.id,
-      label: item.displayName || item.model || item.id,
-    })),
-  ]);
-
   const selectedModel = computed<string>({
     get: () => props.modelValue ?? '',
     set: (value) => emit('update:modelValue', value || null),

+ 37 - 6
ai-electron/frontend/src/codex/store/codexChatStore.ts

@@ -8,6 +8,8 @@
  */
 import { defineStore } from 'pinia';
 import { useUserStore } from '@/core/store/modules/case';
+import * as modelApi from '@/ai/api/modelApi';
+import type { AiModel, ModelProvider } from '@/ai/api/types';
 import * as codexApi from '../api/codexApi';
 import * as codexArtifactApi from '../api/codexArtifactApi';
 import type {
@@ -189,7 +191,10 @@ export const useCodexChatStore = defineStore('codex-chat', {
     runtime: null as codexApi.CodexStatus | null,
     /** 运行时探测/启动失败原因 */
     runtimeError: '',
-    models: [] as codexApi.CodexModelOption[],
+    /** 对话模型列表(来自后端「模型管理」,过滤 llm;未登录后端时为空) */
+    models: [] as AiModel[],
+    /** 模型厂商列表(applySessionModel 查 providerType 用) */
+    providers: [] as ModelProvider[],
     /** 会话列表(localStorage 自持) */
     sessions: [] as CodexSessionMeta[],
     activeThreadId: '',
@@ -370,12 +375,14 @@ export const useCodexChatStore = defineStore('codex-chat', {
       }
     },
 
+    /** 模型列表来自后端「模型管理」(HTTP+登录态),与 Codex 运行时无关;失败静默降级为空 */
     async loadModels() {
-      try {
-        this.models = (await codexApi.codexListModels()) || [];
-      } catch {
-        this.models = [];
-      }
+      const [models, providers] = await Promise.all([
+        modelApi.listModels().catch(() => [] as AiModel[]),
+        modelApi.listProviders().catch(() => [] as ModelProvider[]),
+      ]);
+      this.models = (models || []).filter((item) => !item.type || item.type === 'llm');
+      this.providers = providers || [];
     },
 
     async loadDefaultCwd() {
@@ -475,6 +482,30 @@ export const useCodexChatStore = defineStore('codex-chat', {
       this.persistSessions();
     },
 
+    /**
+     * 选中即应用:把后端「模型管理」里的一条记录应用到 Codex 运行时
+     * (provider 是全局单例,会重启子进程),成功后记到当前会话。
+     * 失败原样抛错——页面负责提示,选择器显示值由会话模型反查驱动、天然回退。
+     */
+    async applySessionModel(recordId: string) {
+      const record = this.models.find((item) => item.id === recordId);
+      if (!record) throw new Error('模型记录不存在或已被删除,请刷新模型列表');
+      const providerType = this.providers.find((item) => item.name === record.provider)?.type ?? null;
+      const result = await codexApi.codexApplyProvider({
+        modelId: record.modelId || '',
+        name: record.name || null,
+        baseUrl: record.baseUrl || null,
+        apiKey: record.apiKey || null,
+        headersJson: record.headersJson || null,
+        config: record.config || null,
+        providerType,
+        modelRecordId: record.id,
+      });
+      // 应用会重启运行时,状态回写保持 ready 判定新鲜
+      this.runtime = result.status;
+      if (this.activeThreadId) this.setSessionModel(this.activeThreadId, record.modelId || null);
+    },
+
     async startRuntime() {
       try {
         this.runtime = await codexApi.codexStart();

+ 36 - 9
ai-electron/frontend/src/codex/views/codexAnalysis/codexAnalysis.vue

@@ -66,11 +66,14 @@
 
           <CodexComposer
             ref="composerRef"
-            v-model:model-value="modelId"
-            :models="store.models"
+            :model-value="selectedRecordId ?? null"
+            :models="modelOptions"
+            :model-locked="modelLocked"
+            :model-loading="modelApplying"
             :disabled="composerDisabled"
             :streaming="store.streaming"
             :cwd="store.activeCwd"
+            @update:model-value="onModelChange"
             @send="onSend"
             @stop="onStop"
             @templates="templatesOpen = true"
@@ -143,13 +146,37 @@
     '把上面两个结果整理成一份带结论的报告',
   ];
 
-  /** 模型按会话记忆:切换后从下一个回合生效(turnRun 会带上 model) */
-  const modelId = computed<string | null>({
-    get: () => store.activeSession?.model ?? null,
-    set: (value) => {
-      if (store.activeThreadId) store.setSessionModel(store.activeThreadId, value);
-    },
-  });
+  /**
+   * 模型选择器(数据源 = 后端「模型管理」):
+   * 选项 value 用后端记录 id(防同 modelId 不同端点重名);会话存的是 modelId,反查记录得选中值。
+   */
+  const modelOptions = computed(() =>
+    store.models.map((item) => ({
+      value: item.id,
+      label: `${item.name || item.modelId || item.id}(${item.modelId || '—'})`,
+    })),
+  );
+  const selectedRecordId = computed(() =>
+    store.models.find((item) => item.modelId && item.modelId === store.activeSession?.model)?.id,
+  );
+  /** 有消息即锁:会话进行中不可切换模型(空会话可换,换完再提问) */
+  const modelLocked = computed(() => !store.isSessionEmpty(store.activeThreadId));
+
+  const modelApplying = ref(false);
+
+  /** 选中即应用(重启运行时);失败只需提示——显示值由 selectedRecordId 驱动,天然回退 */
+  async function onModelChange(recordId: string | null) {
+    if (!recordId || modelApplying.value) return;
+    modelApplying.value = true;
+    try {
+      await store.applySessionModel(recordId);
+      showMessage('已应用模型', 'success');
+    } catch (error: any) {
+      showMessage(error?.message || '应用模型失败', 'error');
+    } finally {
+      modelApplying.value = false;
+    }
+  }
 
   // 多会话并行:输入区只看「运行时是否就绪」,当前会话在跑时按钮会变成「停止」
   const composerDisabled = computed(() => !store.bridgeAvailable || !store.ready);