Prechádzať zdrojové kódy

refactor(codex): 端点只按 Chat 协议判定,模型默认选中并在首次发送时应用

配置里的模型都是 /chat/completions 端点,Responses 只属于 Codex 自己的模型,
所以把四态协议判定砍成 chat | unsupported | unreachable。

探测改为只 POST {base}/chat/completions、请求体只带 model 不带 messages:端点在校验
阶段就回 400,实测 15–27ms。原来的探测要求生成完成,实测非流式 1 个 token 要 11 秒、
16 个 token 超过 30 秒、stream 20 秒不吐首字节——探测必然超时,只会把可用端点误判成故障。
一并撤掉先加的 GET /models 探活:这类服务是单槽推理,一次生成能把整个 HTTP 服务占住
(实测探活请求也被堵 21 秒)。代价记录在案:模型名写错不再被探测拦住,要等真正提问时暴露。
删掉 /responses 探测、Ollama 版本消歧链、responsesStatus/ollamaVersion/ollamaNeedsUpgrade、
恒真的「桥接」标记,以及为查 providerType 而多拉的厂商列表。不可达的报错带上具体 URL 与耗时。

模型选择改为默认选中后端 defaultModel,但只在首次发送时应用:应用会重启子进程并探测端点,
进页面就做等于白等。store 新增 sessionModelRecordId / modelApplied / modelGuardMessage,
send() 前确保已应用;选择器改动只记到会话并提示下次提问生效;模型记录被删时解锁选择器,
否则既发不出也换不了;没选到模型时提示而不是静默失败。

验证:tsc --noEmit 0 报错;vitest 109 passed(新断言含探测请求体不得带 messages、
不可达提示必须含 URL);vue-tsc 93 条存量、src/codex 与 aiPlugin 命中 0;
用应用自身 probeEndpoint 打真实端点 27ms 判 chat。未跑 build / itest / 客户端 UI。
cc 2 týždňov pred
rodič
commit
451545bbb7

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

@@ -394,3 +394,42 @@ stdout 随之关闭,`jsonRpcPeer` 把挂着的 `initialize` reject 成那句
 另用真实 codex.exe + 临时 codexHome 跑过三条端到端(错误信息含 exit 1 与原文、
 applyProvider 失败后 provider 回滚且下次 start 到 ready、Ollama 记录生成的参数不含保留 id 且进程起得来),
 临时用例已删。未跑 build / itest。
+
+## 端点探测收敛为 Chat 单协议 + 模型默认选中与延迟应用
+
+拍板:**后端「模型管理」里的记录一律是 `/chat/completions` 端点**,不会有 `/responses`;
+Responses 只属于 Codex 自己的模型。据此把四态判定砍成 `chat | unsupported | unreachable`。
+
+实测数据(`10.66.66.66:8080`,llama.cpp 挂 35B GGUF,推翻了我先前两次判断):
+
+| 请求 | 结果 |
+| --- | --- |
+| `GET /v1/models`、`/health` | 14–29ms |
+| `POST /v1/responses` 非流式 1 token | 11.0 秒 |
+| 同上 16 token(原探测参数) | >30 秒 |
+| 同上 `stream:true` | 20 秒不吐首字节 |
+| `POST /v1/chat/completions` `max_tokens:1` | 13.99 秒 |
+| `POST /v1/chat/completions` 只带 `{model}` | **400,15–27ms** |
+| `GET /v1/models`(被一次进行中的生成堵住时) | 21 秒无响应 |
+
+结论:**单槽推理服务会把整个 HTTP 服务占住**,所以探测既不能要求生成完成、也不能用短超时,
+`GET /models` 探活同样会被堵(先加的 2 秒探活因此撤掉)。
+
+- `probeEndpoint`:只 `POST {base}/chat/completions`,body 故意只带 `model`、不带 `messages`
+  → 端点在校验阶段回 400,实测 15–27ms;非 404/405 即判 `chat`。超时留 15 秒作排队余量。
+  代价说清楚:**模型名写错不再被探测拦住**,要等真正提问时才暴露。
+  报错文案带具体 URL 与耗时(`端点不可达:http://… 在 15.0 秒内没有响应…`)。
+- 删除:`/responses` 探测、`/models` 探活、Ollama 版本消歧整条链
+  (`probeOllamaVersion`/`parseVersion`/`versionLt`/最低版本常量)、
+  `responsesStatus`/`ollamaVersion`/`ollamaNeedsUpgrade` 字段、恒真的「桥接」chip 与其 CSS、
+  `providerType` 全链路(含 store 里为它而加的 `providers` 与 `listProviders()` 请求)。
+- `applyProvider` 成功即桥接,去掉 `bridged` 分支与「直通时 stop 桥接」的分支。
+- 模型选择:**默认选中后端 `defaultModel` 那条,但只在首次发送时应用**
+  (应用=重启子进程+探测,进页面就做会白等)。store 新增 `sessionModelRecordId`
+  (会话记过就反查、新会话退默认模型、记录被删返回空)、`modelApplied`、`modelGuardMessage`;
+  `send()` 开头 `ensureModelApplied()`;选择器改动只写进会话并提示「下次提问时生效」;
+  `modelLocked` 加「记录被删则解锁」,否则既发不出也换不了;未选模型点发送给提示不发。
+
+验证:tsc 0 报错;vitest 109 passed(新断言:探测请求体不得带 `messages`、不可达提示必须含 URL);
+vue-tsc 93 条存量、src/codex 与 aiPlugin 命中 0;用应用自身 `probeEndpoint` 打真实端点 27ms 判 `chat`。
+itest / build / 客户端 UI 未跑(渲染进程要 Electron 的 codex IPC 桥,浏览器里进不去)。

+ 0 - 2
ai-electron/electron/controller/codexCtl.ts

@@ -125,7 +125,6 @@ class CodexCtl {
     baseUrl?: string;
     apiKey?: string | null;
     modelId?: string | null;
-    providerType?: string | null;
   }): Promise<Rpc<unknown>> {
     return guard('probeProvider', async () => {
       const baseUrl = requireString(params?.baseUrl, 'baseUrl');
@@ -133,7 +132,6 @@ class CodexCtl {
         baseUrl,
         modelId: params?.modelId ?? null,
         apiKey: params?.apiKey ?? null,
-        providerType: params?.providerType ?? null,
       });
       return { ...result, hint: baseUrlHint(baseUrl) };
     });

+ 3 - 4
ai-electron/electron/service/codex/chatBridge.itest.ts

@@ -9,7 +9,7 @@ import { CodexRuntime } from './codexRuntime';
 import { applyProvider, readAppliedProvider } from './providerService';
 
 /**
- * 桥接集成冒烟:mock chat-only 上游(/responses 404,/chat/completions 返回 SSE 固定文本)
+ * 桥接集成冒烟:mock Chat 上游(/chat/completions 返回 SSE 固定文本,其余路由 404)
  * → 内置桥接 → 真实 codex app-server 跑完整 turn。
  * 同时验证「Codex 无状态调用」假设:第二轮 turn 的 messages 里必须携带第一轮内容。
  * 默认 npm test 不跑(.itest.ts),用 npm run smoke:codex 单独跑。
@@ -49,7 +49,7 @@ beforeAll(async () => {
       });
       return;
     }
-    // /responses 一律 404 → 探测走 chat 分支
+    // 除 Chat 外的路由一律 404(探活的 /models 也算活着,不影响判定)
     req.resume();
     res.writeHead(404, { 'content-type': 'application/json' });
     res.end('{}');
@@ -66,7 +66,7 @@ afterAll(async () => {
   await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
 });
 
-describe('内置桥接:chat-only 端点接入', () => {
+describe('内置桥接:Chat 端点接入', () => {
   it('桥接应用成功,真实 Codex 经桥接跑通两轮 turn(验证无状态全量历史)', async () => {
     const codexHome = await ensureCodexHome();
     const runtime = new CodexRuntime({ codexHome });
@@ -75,7 +75,6 @@ describe('内置桥接:chat-only 端点接入', () => {
         modelId: 'bridge-smoke-model',
         name: '桥接冒烟',
         baseUrl: `http://127.0.0.1:${upstreamPort}/v1`,
-        providerType: 'VLLM',
       });
       expect(applied.bridged).toBe(true);
       // 落盘存上游真实地址;桥接的本地 URL 不落盘

+ 14 - 12
ai-electron/electron/service/codex/codexCtl.itest.ts

@@ -32,7 +32,8 @@ let tmpRoot: string;
 beforeAll(async () => {
   server = createServer((req, res) => {
     req.resume();
-    res.writeHead(req.url?.endsWith('/responses') ? 404 : 200, { 'content-type': 'application/json' });
+    // 一律 404:端点活着但没有任何可用路由(含 /chat/completions)
+    res.writeHead(404, { 'content-type': 'application/json' });
     res.end('{}');
   });
   await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
@@ -142,23 +143,24 @@ describe('CodexCtl Skill', () => {
 });
 
 describe('CodexCtl 模型 provider', () => {
-  it('探测到未实现 Responses 的端点时给出明确原因', async () => {
+  it('端点没有 Chat 路由时给出明确原因', async () => {
     expectFailure(
-      await ctl.applyProvider({ modelId: 'x', baseUrl: notFoundUrl, apiKey: 'sk-nope', providerType: 'OPENAI' }),
-      /未实现 Responses API/,
+      await ctl.applyProvider({ modelId: 'x', baseUrl: notFoundUrl, apiKey: 'sk-nope' }),
+      /没有 \/chat\/completions/,
     );
   }, 120_000);
 
-  it('probeProvider 返回兼容性与 base_url 提醒', async () => {
-    const result = (await ctl.probeProvider({ baseUrl: notFoundUrl })) as Rpc<{
-      compatible: boolean;
-      status: number | null;
+  it('probeProvider 返回判定与 base_url 提醒', async () => {
+    const result = await ctl.probeProvider({ baseUrl: notFoundUrl });
+    const data = unwrap(result) as {
+      protocol: string;
+      chatStatus: number | null;
       detail: string;
       hint: string | null;
-    }>;
-    const data = unwrap(result);
-    expect(data.compatible).toBe(false);
-    expect(data.status).toBe(404);
+    };
+    expect(data.protocol).toBe('unsupported');
+    expect(data.chatStatus).toBe(404);
+    expect(data.hint).toBeNull();
   }, 120_000);
 
   it('缺 modelId 时报错', async () => {

+ 12 - 20
ai-electron/electron/service/codex/codexRuntime.itest.ts

@@ -18,19 +18,17 @@ const API_KEY = 'sk-smoke-secret-value-1234567890';
 const SKILL_MD = ['---', 'name: smoke-skill', 'description: 冒烟测试用的技能', '---', '', '# smoke', ''].join('\n');
 
 let server: Server;
-let responsesStatus = 401;
-let chatStatus = 401;
+let endpointStatus = 401;
 let baseUrl: string;
 let codexHome: string;
 let runtime: CodexRuntime;
 let tmpRoot: string;
 
 beforeAll(async () => {
-  // 假端点:401 表示「路由存在但需要鉴权」,足以让 provider 探测判为兼容
+  // 假端点:401 表示「路由存在但需要鉴权」,足以让 Chat 探测判为可用
   server = createServer((req, res) => {
     req.resume();
-    const status = req.url?.endsWith('/chat/completions') ? chatStatus : responsesStatus;
-    res.writeHead(status, { 'content-type': 'application/json' });
+    res.writeHead(endpointStatus, { 'content-type': 'application/json' });
     res.end(JSON.stringify({ error: { message: 'smoke endpoint' } }));
   });
   await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
@@ -190,14 +188,12 @@ describe('Skill 安装能被 Codex 识别', () => {
 
 describe('切换 provider 会重启子进程', () => {
   it('applyProvider 后 config 更新且磁盘上没有密钥', async () => {
-    responsesStatus = 401;
-    chatStatus = 401;
+    endpointStatus = 401;
     const applied = await applyProvider(runtime, {
       modelId: 'smoke-model-2',
       name: '冒烟端点 2',
       baseUrl,
       apiKey: API_KEY,
-      providerType: 'OPENAI',
       modelRecordId: 42,
     });
     expect(applied).toMatchObject({ model: 'smoke-model-2', providerId: 'zsjz', modelRecordId: '42' });
@@ -213,14 +209,12 @@ describe('切换 provider 会重启子进程', () => {
     expect(JSON.stringify(persisted)).not.toContain(API_KEY);
   });
 
-  it('chat-only 端点经内置桥接应用成功,落盘存上游真实地址', async () => {
-    responsesStatus = 404;
-    chatStatus = 200;
+  it('应用一律经内置桥接,落盘存上游真实地址', async () => {
+    endpointStatus = 200;
     const applied = await applyProvider(runtime, {
       modelId: 'smoke-chat-model',
       baseUrl,
       apiKey: API_KEY,
-      providerType: 'VLLM',
     });
     expect(applied.bridged).toBe(true);
     // 落盘存上游真实地址,不是桥接的本地 URL
@@ -233,20 +227,18 @@ describe('切换 provider 会重启子进程', () => {
     expect(entry?.wire_api).toBe('responses');
   });
 
-  it('双协议都不存在的端点拒绝应用', async () => {
-    responsesStatus = 404;
-    chatStatus = 404;
-    await expect(
-      applyProvider(runtime, { modelId: 'x', baseUrl, apiKey: API_KEY, providerType: 'OPENAI' }),
-    ).rejects.toThrow(/无法对接/);
+  it('没有 Chat 路由的端点拒绝应用', async () => {
+    endpointStatus = 404;
+    await expect(applyProvider(runtime, { modelId: 'x', baseUrl, apiKey: API_KEY })).rejects.toThrow(
+      /没有 \/chat\/completions/,
+    );
   });
 
   it('clearProvider 回到无 provider 状态', async () => {
     await clearProvider(runtime);
     expect(await readAppliedProvider()).toBeNull();
     expect(runtime.provider).toBeNull();
-    responsesStatus = 401;
-    chatStatus = 401;
+    endpointStatus = 401;
   });
 });
 

+ 24 - 64
ai-electron/electron/service/codex/providerService.test.ts

@@ -11,9 +11,7 @@ import type { ApplyProviderInput } from './types';
 
 let server: Server;
 let port = 0;
-let responsesStatus = 404;
 let chatStatus = 404;
-let ollamaVersion: string | null = null;
 let lastRequest: { url: string | null; auth: string | null; body: string | null } = { url: null, auth: null, body: null };
 
 beforeAll(async () => {
@@ -27,15 +25,7 @@ beforeAll(async () => {
         body: Buffer.concat(chunks).toString('utf8') || null,
       };
       res.setHeader('content-type', 'application/json');
-      if (req.url === '/api/version') {
-        if (ollamaVersion === null) {
-          res.writeHead(404).end('{}');
-        } else {
-          res.writeHead(200).end(JSON.stringify({ version: ollamaVersion }));
-        }
-        return;
-      }
-      const status = req.url?.endsWith('/chat/completions') ? chatStatus : responsesStatus;
+      const status = req.url?.endsWith('/chat/completions') ? chatStatus : 404;
       res.writeHead(status).end(JSON.stringify({ status }));
     });
   });
@@ -53,7 +43,6 @@ const openaiLike: ApplyProviderInput = {
   name: '通义千问',
   baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
   apiKey: 'sk-test-key',
-  providerType: 'DASHSCOPE',
   headersJson: JSON.stringify({ 'X-DashScope-WorkSpace': 'ws-1' }),
   config: JSON.stringify({ request_max_retries: 3, temperature: 0.2 }),
 };
@@ -88,21 +77,16 @@ describe('toProviderSpec', () => {
     expect(spec.extra).toEqual({ request_max_retries: 3 });
   });
 
-  it('OLLAMA 同样走自定义 provider:内置 id 是 Codex 保留字,地址原样使用', () => {
-    const spec = toProviderSpec({
-      modelId: 'qwen',
-      providerType: 'OLLAMA',
-      baseUrl: 'http://10.66.66.66:8080/v1/',
-    });
+  it('地址原样使用,尾部斜杠规整;没配 key 就不写 env_key', () => {
+    const spec = toProviderSpec({ modelId: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1/' });
     expect(spec).toMatchObject({ id: 'zsjz', model: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1' });
-    // 本地服务没有 key,就不该写 env_key
     expect(spec.apiKey ?? null).toBeNull();
   });
 
-  it('缺 modelId 或缺 baseUrl 直接报错,不退回 localhost 内置 provider', () => {
+  it('缺 modelId 或缺 baseUrl 直接报错,不做任何地址兜底', () => {
     expect(() => toProviderSpec({ modelId: '  ' })).toThrow(/modelId/);
-    expect(() => toProviderSpec({ modelId: 'gpt', providerType: 'OPENAI' })).toThrow(/base_url/);
-    expect(() => toProviderSpec({ modelId: 'qwen3:8b', providerType: 'OLLAMA' })).toThrow(/base_url/);
+    expect(() => toProviderSpec({ modelId: 'gpt' })).toThrow(/base_url/);
+    expect(() => toProviderSpec({ modelId: 'gpt', baseUrl: '   ' })).toThrow(/base_url/);
   });
 
   it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => {
@@ -122,66 +106,42 @@ describe('toProviderSpec', () => {
 describe('probeEndpoint', () => {
   const base = () => `http://127.0.0.1:${port}/v1`;
 
-  it('用真实模型名探 /responses,带上 api key', async () => {
-    responsesStatus = 401;
+  it('只探 Chat 路由存在性,请求体不带 messages(不能触发推理)', async () => {
+    chatStatus = 400;
     const result = await probeEndpoint({
       baseUrl: `${base()}/`,
       modelId: 'qwen3-max',
       apiKey: 'sk-test-key',
     });
-    expect(result.protocol).toBe('responses');
-    expect(lastRequest.url).toBe('/v1/responses');
+    expect(result.protocol).toBe('chat');
+    expect(lastRequest.url).toBe('/v1/chat/completions');
     expect(lastRequest.auth).toBe('Bearer sk-test-key');
-    expect(JSON.parse(lastRequest.body ?? '{}').model).toBe('qwen3-max');
+    const body = JSON.parse(lastRequest.body ?? '{}') as Record<string, unknown>;
+    expect(body.model).toBe('qwen3-max');
+    // 一旦带上 messages,单槽本地服务就会真的开始生成,实测一次要 14 秒
+    expect(body.messages).toBeUndefined();
   });
 
-  it('401/400/2xx 判为 responses 直通', async () => {
-    for (const status of [401, 400, 200]) {
-      responsesStatus = status;
+  it('2xx 与 4xx 只要有路由就判 chat 可用', async () => {
+    for (const status of [200, 400, 401]) {
+      chatStatus = status;
       const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
-      expect(result).toMatchObject({ protocol: 'responses', responsesStatus: status });
+      expect(result).toMatchObject({ protocol: 'chat', chatStatus: status });
     }
   });
 
-  it('双协议 404 判 unsupported', async () => {
-    responsesStatus = 404;
+  it('没有 Chat 路由判 unsupported', async () => {
     chatStatus = 404;
-    const result = await probeEndpoint({ baseUrl: base(), modelId: 'x', providerType: 'OPENAI' });
+    const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
     expect(result.protocol).toBe('unsupported');
-    expect(result.detail).toMatch(/无法对接/);
-  });
-
-  it('responses 404 + chat 存在判 chat-only', async () => {
-    responsesStatus = 404;
-    chatStatus = 200;
-    const result = await probeEndpoint({ baseUrl: base(), modelId: 'x', providerType: 'VLLM' });
-    expect(result).toMatchObject({ protocol: 'chat-only', responsesStatus: 404, chatStatus: 200 });
-    expect(result.detail).toMatch(/chat\/completions/);
-  });
-
-  it('Ollama ≥ 0.13.3:/responses 404 只是模型未拉取,判 responses', async () => {
-    responsesStatus = 404;
-    chatStatus = 404;
-    ollamaVersion = '0.13.3';
-    const result = await probeEndpoint({ baseUrl: base(), modelId: 'qwen3:8b', providerType: 'OLLAMA' });
-    expect(result).toMatchObject({ protocol: 'responses', ollamaVersion: '0.13.3', ollamaNeedsUpgrade: false });
-    expect(result.detail).toMatch(/ollama pull/);
-  });
-
-  it('Ollama < 0.13.3:判 chat-only 并标记需要升级', async () => {
-    responsesStatus = 404;
-    chatStatus = 200;
-    ollamaVersion = '0.12.9';
-    const result = await probeEndpoint({ baseUrl: base(), modelId: 'qwen3:8b', providerType: 'OLLAMA' });
-    expect(result).toMatchObject({ protocol: 'chat-only', ollamaVersion: '0.12.9', ollamaNeedsUpgrade: true });
-    expect(result.detail).toMatch(/低于 0\.13\.3/);
-    ollamaVersion = null;
+    expect(result.detail).toMatch(/没有 \/chat\/completions/);
   });
 
-  it('端点不可达判 unreachable', async () => {
+  it('端点不可达时报错带上是哪个地址', async () => {
     const result = await probeEndpoint({ baseUrl: 'http://127.0.0.1:1/v1', modelId: 'x' });
     expect(result.protocol).toBe('unreachable');
-    expect(result.responsesStatus).toBeNull();
+    expect(result.chatStatus).toBeNull();
     expect(result.detail).toMatch(/不可达/);
+    expect(result.detail).toContain('http://127.0.0.1:1/v1/chat/completions');
   });
 });

+ 70 - 124
ai-electron/electron/service/codex/providerService.ts

@@ -15,19 +15,16 @@ export const PROVIDER_ID = 'zsjz';
 
 export type AppliedProvider = AppliedProviderInfo;
 
-/** 端点协议判定:responses 直通;chat-only 走内置桥接;unsupported 双协议都没有;unreachable 网络不可达 */
-export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable';
+/**
+ * 端点判定。后端「模型管理」里的记录一律是 Chat 协议(没有 /responses),
+ * 所以这里只关心「能不能用 Chat」:chat → 走内置桥接;其余两种拒绝。
+ */
+export type EndpointProtocol = 'chat' | 'unsupported' | 'unreachable';
 
 export interface EndpointProbeResult {
   protocol: EndpointProtocol;
-  /** /responses 的 HTTP 状态;未探或网络失败为 null */
-  responsesStatus: number | null;
-  /** /chat/completions 的 HTTP 状态;未探为 null */
+  /** /chat/completions 的 HTTP 状态;未探或网络失败为 null */
   chatStatus: number | null;
-  /** /api/version 探到的 Ollama 版本;非 Ollama 或未探为 null */
-  ollamaVersion: string | null;
-  /** Ollama 低于 0.13.3(没有非状态化 /v1/responses):提示升级,但允许走桥接 */
-  ollamaNeedsUpgrade: boolean;
   detail: string;
 }
 
@@ -42,7 +39,7 @@ function providerFile(): string {
   return join(getCodexDataDir(), 'provider.json');
 }
 
-/** Codex 要求 base_url 是 API 根(形如 https://api.openai.com/v1),它自己再拼 /responses */
+/** Codex 要求 base_url 是 API 根(形如 https://api.openai.com/v1),它自己再拼路由 */
 export function normalizeBaseUrl(raw: string | null | undefined): string | null {
   const value = (raw ?? '').trim().replace(/\/+$/u, '');
   return value || null;
@@ -53,7 +50,7 @@ export function baseUrlHint(baseUrl: string | null): string | null {
   if (!baseUrl) return '未配置 base_url';
   if (!/^https?:\/\//u.test(baseUrl)) return 'base_url 必须以 http:// 或 https:// 开头';
   if (!/\/v\d+$/u.test(baseUrl)) {
-    return 'base_url 通常应写到版本段(如 .../v1),否则 Codex 拼出的 /responses 可能 404';
+    return 'base_url 通常应写到版本段(如 .../v1),否则拼出的 /chat/completions 可能 404';
   }
   return null;
 }
@@ -120,143 +117,101 @@ export function describeDroppedKeys(input: ApplyProviderInput): string[] {
   return Object.keys(config ?? {}).filter((key) => !EXTRA_ALLOWLIST.includes(key));
 }
 
-/** Ollama 自 0.13.3 起提供非状态化 /v1/responses(流式 / 工具调用 / reasoning summaries) */
-const OLLAMA_RESPONSES_MIN_VERSION: readonly number[] = [0, 13, 3];
+/**
+ * 探测只问「路由在不在」,绝不让端点真去生成:本地服务多是单槽推理,
+ * 一次生成能占住整个 HTTP 服务几十秒(实测 max_tokens:1 也要 14 秒,期间连 /models 都不应答)。
+ * 15 秒是留给排队的余量。
+ */
+const PROBE_TIMEOUT_MS = 15_000;
 
 interface ProbeAttempt {
   status: number | null;
   error: string | null;
+  ms: number;
+  timedOut: boolean;
 }
 
-async function postProbe(url: string, body: Record<string, unknown>, apiKey?: string | null): Promise<ProbeAttempt> {
+function describeFetchError(error: unknown): { message: string; timedOut: boolean } {
+  const err = error instanceof Error ? error : new Error(String(error));
+  return {
+    message: err.message,
+    timedOut: err.name === 'TimeoutError' || err.name === 'AbortError' || /timeout/iu.test(err.message),
+  };
+}
+
+async function probeFetch(url: string, init: RequestInit, timeoutMs: number): Promise<ProbeAttempt> {
+  const startedAt = Date.now();
   try {
-    const response = await fetch(url, {
+    const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
+    return { status: response.status, error: null, ms: Date.now() - startedAt, timedOut: false };
+  } catch (error) {
+    const { message, timedOut } = describeFetchError(error);
+    return { status: null, error: message, ms: Date.now() - startedAt, timedOut };
+  }
+}
+
+async function postProbe(url: string, body: Record<string, unknown>, apiKey?: string | null): Promise<ProbeAttempt> {
+  return probeFetch(
+    url,
+    {
       method: 'POST',
       headers: {
         'content-type': 'application/json',
         ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
       },
       body: JSON.stringify(body),
-      signal: AbortSignal.timeout(8_000),
-    });
-    return { status: response.status, error: null };
-  } catch (error) {
-    return { status: null, error: error instanceof Error ? error.message : String(error) };
-  }
-}
-
-/** Ollama 的 /api/version 挂在 API 根之外(去掉 /v1 尾段);非 Ollama 端点返回 null */
-async function probeOllamaVersion(baseUrl: string): Promise<string | null> {
-  const origin = baseUrl.replace(/\/v\d+$/u, '');
-  try {
-    const response = await fetch(`${origin}/api/version`, { signal: AbortSignal.timeout(5_000) });
-    if (response.status !== 200) return null;
-    const parsed = (await response.json()) as { version?: unknown };
-    return typeof parsed.version === 'string' && parsed.version ? parsed.version : null;
-  } catch {
-    return null;
-  }
-}
-
-function parseVersion(raw: string): number[] | null {
-  const match = raw.trim().match(/^(\d+)\.(\d+)(?:\.(\d+))?/u);
-  if (!match) return null;
-  return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
+    },
+    PROBE_TIMEOUT_MS,
+  );
 }
 
-function versionLt(a: number[], b: readonly number[]): boolean {
-  for (let index = 0; index < 3; index += 1) {
-    if (a[index] !== b[index]) return a[index] < b[index];
+/**
+ * 不可达的提示必须带上是哪个地址、等了多久——只说「端点不可达」时,
+ * 用户既不知道配错在哪,也无从判断是没起服务还是被防火墙慢慢吞掉。
+ */
+function unreachableDetail(url: string, attempt: ProbeAttempt): string {
+  if (attempt.timedOut) {
+    return `端点不可达:${url} 在 ${(attempt.ms / 1000).toFixed(1)} 秒内没有响应,请确认服务已启动、地址与端口正确`;
   }
-  return false;
+  return `端点不可达:${url} —— ${attempt.error ?? '网络错误'}`;
 }
 
 function probeResult(partial: Partial<EndpointProbeResult> & { protocol: EndpointProtocol; detail: string }): EndpointProbeResult {
-  return {
-    responsesStatus: null,
-    chatStatus: null,
-    ollamaVersion: null,
-    ollamaNeedsUpgrade: false,
-    ...partial,
-  };
+  return { chatStatus: null, ...partial };
 }
 
 /**
- * 探测端点协议能力(四态判定)。
+ * 判定端点能不能用 Chat 协议——后端「模型管理」里的记录都是 Chat 端点,
+ * 有的就交给内置桥接。
  *
- * Codex 0.155+ 只会发 Responses API:responses → 直通;chat-only → 内置桥接转换;
- * 其余两种拒绝。判定顺序:
- *  1. POST /responses(用**真实模型名**——Ollama 对未拉取的模型返回 404,不能直接判端点不支持);
- *  2. 404/405 且是 Ollama → 查 /api/version 消歧:≥0.13.3 说明 404 只是模型没拉(判 responses),
- *     低于 0.13.3 直接判 chat-only(/chat/completions 必有);
- *  3. 否则再 POST /chat/completions:路由存在判 chat-only,也不存在判 unsupported。
+ * 只看 /chat/completions 这条路由在不在:请求体故意不带 messages,
+ * 端点会在校验阶段就回 400(实测 15ms),不会真的开始生成。
+ * 代价是模型名写错要到真正提问时才暴露。
  */
 export async function probeEndpoint(params: {
   baseUrl: string;
   modelId?: string | null;
   apiKey?: string | null;
-  providerType?: string | null;
 }): Promise<EndpointProbeResult> {
   const baseUrl = params.baseUrl.replace(/\/+$/u, '');
+  const chatUrl = `${baseUrl}/chat/completions`;
   const model = params.modelId?.trim() || 'probe';
-  const isOllama = `${params.providerType ?? ''}`.trim().toUpperCase() === 'OLLAMA';
 
-  const responses = await postProbe(
-    `${baseUrl}/responses`,
-    { model, input: 'ping', max_output_tokens: 16, stream: false },
-    params.apiKey,
-  );
-  if (responses.status === null) {
-    return probeResult({ protocol: 'unreachable', detail: `端点不可达:${responses.error ?? '网络错误'}` });
-  }
-  if (responses.status !== 404 && responses.status !== 405) {
-    return probeResult({
-      protocol: 'responses',
-      responsesStatus: responses.status,
-      detail: `端点已实现 Responses API(HTTP ${responses.status})`,
-    });
-  }
-
-  if (isOllama) {
-    const version = await probeOllamaVersion(baseUrl);
-    if (version) {
-      const parsed = parseVersion(version);
-      if (parsed && !versionLt(parsed, OLLAMA_RESPONSES_MIN_VERSION)) {
-        return probeResult({
-          protocol: 'responses',
-          responsesStatus: responses.status,
-          ollamaVersion: version,
-          detail: `Ollama ${version} 支持 Responses API;/responses 返回 404 通常只是模型「${model}」未拉取,请先 ollama pull ${model}`,
-        });
-      }
-      return probeResult({
-        protocol: 'chat-only',
-        responsesStatus: responses.status,
-        ollamaVersion: version,
-        ollamaNeedsUpgrade: true,
-        detail: `Ollama ${version} 低于 0.13.3,只提供 /chat/completions(建议升级 Ollama 以直连)`,
-      });
-    }
+  const chat = await postProbe(chatUrl, { model }, params.apiKey);
+  if (chat.status === null) {
+    return probeResult({ protocol: 'unreachable', detail: unreachableDetail(chatUrl, chat) });
   }
-
-  const chat = await postProbe(
-    `${baseUrl}/chat/completions`,
-    { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false },
-    params.apiKey,
-  );
-  if (chat.status !== null && chat.status !== 404 && chat.status !== 405) {
+  if (chat.status !== 404 && chat.status !== 405) {
     return probeResult({
-      protocol: 'chat-only',
-      responsesStatus: responses.status,
+      protocol: 'chat',
       chatStatus: chat.status,
-      detail: `端点只提供 /chat/completions(/responses 返回 HTTP ${responses.status})`,
+      detail: `端点支持 Chat 协议(HTTP ${chat.status}),应用时经内置桥接转换为 Responses`,
     });
   }
   return probeResult({
     protocol: 'unsupported',
-    responsesStatus: responses.status,
     chatStatus: chat.status,
-    detail: `端点 Responses 与 Chat Completions 均不存在(HTTP ${responses.status}/${chat.status ?? '网络错误'}),无法对接`,
+    detail: `端点没有 /chat/completions 路由(HTTP ${chat.status}),本客户端只对接 Chat 协议端点`,
   });
 }
 
@@ -280,35 +235,26 @@ export async function applyProvider(
   input: ApplyProviderInput & { modelRecordId?: string | number | null },
 ): Promise<AppliedProvider> {
   const spec = toProviderSpec(input);
-  /** 桥接时落盘存上游真实地址(展示用);桥接 URL 每次应用临时分配,不落盘 */
+  /** 落盘存上游真实地址(展示用);桥接 URL 每次应用临时分配,不落盘 */
   const displayBaseUrl = spec.baseUrl;
-  let bridged = false;
 
   // 一定要先探端点:应用成功后才发现连不上,比这里直接报错更难排查
   const probe = await probeEndpoint({
     baseUrl: spec.baseUrl,
     modelId: spec.model,
     apiKey: spec.apiKey,
-    providerType: input.providerType,
   });
-  if (probe.protocol !== 'responses' && probe.protocol !== 'chat-only') {
-    throw new Error(probe.detail);
-  }
-  if (probe.protocol === 'chat-only') {
-    // chat-only 端点:启动内置桥接,Codex 改连本地代理的 /v1/responses
-    const bridge = await chatBridge.start({ upstreamBaseUrl: spec.baseUrl, headers: spec.httpHeaders ?? null });
-    spec.baseUrl = bridge.url;
-    bridged = true;
-  }
+  if (probe.protocol !== 'chat') throw new Error(probe.detail);
 
-  // 直通:确保上一轮的桥接已停
-  if (!bridged) await chatBridge.stop();
+  // Codex 只会发 Responses,所以 Chat 端点一律经内置桥接:Codex 改连本地代理的 /v1/responses
+  const bridge = await chatBridge.start({ upstreamBaseUrl: spec.baseUrl, headers: spec.httpHeaders ?? null });
+  spec.baseUrl = bridge.url;
 
   try {
     await runtime.applyProvider(spec);
   } catch (error) {
     // 应用失败回滚桥接,避免留下一个指着旧上游的孤儿代理
-    if (bridged) await chatBridge.stop();
+    await chatBridge.stop();
     throw error;
   }
 
@@ -321,7 +267,7 @@ export async function applyProvider(
     providerId: spec.id,
     name: spec.name ?? null,
     appliedAt: new Date().toISOString(),
-    ...(bridged ? { bridged: true } : {}),
+    bridged: true,
   };
   await writeAppliedProvider(applied);
   return applied;

+ 5 - 7
ai-electron/electron/service/codex/types.ts

@@ -38,9 +38,8 @@ export interface BackendModelRecord {
 }
 
 /**
- * 应用到 Codex 运行时的入参。厂商协议类型在 agent_model_provider.type 上(OLLAMA/OPENAI/…),
- * 不在 model 记录里,所以由渲染进程把 /models 与 /providers 关联好后显式传进来
- * (只用于端点探测的 Ollama 消歧,provider 一律走自定义)。
+ * 应用到 Codex 运行时的入参,字段直接取自后端「模型管理」的 ModelVO。
+ * 后端记录一律是 Chat 协议端点,不需要厂商协议类型:判定与转换由探测 + 内置桥接负责。
  */
 export interface ApplyProviderInput {
   modelId: string;
@@ -49,8 +48,6 @@ export interface ApplyProviderInput {
   apiKey?: string | null;
   headersJson?: string | Record<string, string> | null;
   config?: string | Record<string, unknown> | null;
-  /** agent_model_provider.type */
-  providerType?: string | null;
 }
 
 export interface CodexPingResult {
@@ -86,8 +83,9 @@ export interface AppliedProviderInfo {
   name: string | null;
   appliedAt: string;
   /**
-   * true 表示经内置桥接转换(chat-only 端点)。此时 baseUrl 存的是上游真实地址,
-   * 桥接的本地 URL 每次应用临时分配、不落盘,重启后由页面重新「应用」重建。
+   * 恒为 true:后端记录都是 Chat 端点,一律经内置桥接接入。
+   * 此时 baseUrl 存的是上游真实地址,桥接的本地 URL 每次应用临时分配、不落盘,
+   * 重启后由页面重新「应用」重建。
    */
   bridged?: boolean;
 }

+ 12 - 38
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -159,8 +159,8 @@
       <a-tab-pane key="runtime" tab="Codex 运行时">
         <div class="ai-plugin__hint">
           本客户端<b>不做任何 Codex / OpenAI 账号登录</b>:模型来自后端「模型管理」, 选中后由主进程写入本机 Codex
-          配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 起只发 Responses 协议;仅提供
-          <b>/chat/completions</b> 的端点会由内置桥接自动转换,本地 Ollama 建议 0.13.3+ 以直连。
+          配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 只会发 Responses 协议,
+          而这里配置的模型都是 <b>/chat/completions</b> 端点,所以一律经内置桥接转换后再交给 Codex。
         </div>
 
         <div class="ai-plugin__panel">
@@ -258,9 +258,6 @@
             已应用:<b>{{ status.applied.name || status.applied.model }}</b>
             <span class="ai-plugin__mono">{{ status.applied.model }}</span>
             <span class="ai-plugin__mono">{{ status.applied.baseUrl }}</span>
-            <span v-if="status.applied.bridged" class="ai-plugin__bridge-chip" title="端点只提供 /chat/completions,由内置桥接转换为 Responses 协议">
-              桥接
-            </span>
             <span class="ai-plugin__sub">{{ status.applied.appliedAt }}</span>
           </div>
           <div class="ai-plugin__note">
@@ -406,7 +403,7 @@
   import { useMessage } from '@/core/hooks/web/useMessage';
   import * as codexApi from '../../api/codexApi';
   import * as modelApi from '../../api/modelApi';
-  import type { AiModel, ModelProvider } from '../../api/types';
+  import type { AiModel } from '../../api/types';
 
   const AAlert = Alert;
   const AButton = Button;
@@ -438,7 +435,6 @@
   const diagnostics = ref<codexApi.CodexDiagnostic[]>([]);
 
   const models = ref<AiModel[]>([]);
-  const providers = ref<ModelProvider[]>([]);
   const modelsLoading = ref(false);
   const selectedModelId = ref<string | undefined>(undefined);
   const probing = ref(false);
@@ -519,20 +515,16 @@
 
   const selectedModel = computed(() => models.value.find((item) => item.id === selectedModelId.value) || null);
 
-  /** 探测结果三态:responses 直通(绿)/ chat-only 桥接(黄)/ 其余(红) */
-  const probeAlertType = computed<'success' | 'warning' | 'error'>(() => {
-    if (probeResult.value?.protocol === 'responses') return 'success';
-    if (probeResult.value?.protocol === 'chat-only') return 'warning';
-    return 'error';
-  });
+  /** 探测结果:chat 可用(绿)/ 没有 Chat 路由或连不上(红) */
+  const probeAlertType = computed<'success' | 'error'>(() =>
+    probeResult.value?.protocol === 'chat' ? 'success' : 'error',
+  );
   const probeAlertTitle = computed(() => {
     switch (probeResult.value?.protocol) {
-      case 'responses':
-        return '端点兼容(Responses 协议直通)';
-      case 'chat-only':
-        return '仅支持 Chat 协议(应用时将启用内置桥接)';
+      case 'chat':
+        return '端点可用(Chat 协议,应用时经内置桥接转换)';
       case 'unsupported':
-        return '端点无法对接(双协议均不存在)';
+        return '端点没有 Chat 协议路由,无法对接';
       default:
         return '端点不可达';
     }
@@ -599,12 +591,8 @@
   async function loadModels() {
     modelsLoading.value = true;
     try {
-      const [list, providerList] = await Promise.all([
-        modelApi.listModels(),
-        modelApi.listProviders().catch(() => [] as ModelProvider[]),
-      ]);
+      const list = await modelApi.listModels();
       models.value = list || [];
-      providers.value = providerList || [];
       const applied = status.value?.applied;
       selectedModelId.value = applied?.modelRecordId || models.value.find((m) => m.defaultModel)?.id || undefined;
     } catch (e: any) {
@@ -614,9 +602,8 @@
     }
   }
 
-  /** 把后端模型记录翻译成主进程要的入参;厂商协议类型来自 agent_model_provider.type */
+  /** 把后端模型记录翻译成主进程要的入参 */
   function buildApplyInput(model: AiModel): codexApi.ApplyProviderInput {
-    const providerType = providers.value.find((item) => item.name === model.provider)?.type ?? null;
     return {
       modelId: model.modelId || '',
       name: model.name || null,
@@ -624,7 +611,6 @@
       apiKey: model.apiKey || null,
       headersJson: model.headersJson || null,
       config: model.config || null,
-      providerType,
       modelRecordId: model.id,
     };
   }
@@ -640,7 +626,6 @@
         baseUrl: input.baseUrl || '',
         apiKey: input.apiKey,
         modelId: input.modelId,
-        providerType: input.providerType,
       });
     } catch (e: any) {
       showMessage(e?.message || '探测失败', 'error');
@@ -1239,17 +1224,6 @@
       color: var(--ai-text-2);
     }
 
-    /* chat-only 端点经内置桥接接入的标记 */
-    &__bridge-chip {
-      padding: 1px 8px;
-      font-size: 11px;
-      color: var(--ai-primary);
-      background: var(--ai-primary-soft);
-      border: 1px solid var(--ai-primary);
-      border-radius: 999px;
-      cursor: help;
-    }
-
     &__note {
       margin-top: var(--ai-sp-2);
       font-size: 12px;

+ 4 - 9
ai-electron/frontend/src/codex/api/codexApi.ts

@@ -98,7 +98,7 @@ export interface AppliedProviderInfo {
   providerId: string | null;
   name: string | null;
   appliedAt: string;
-  /** true 表示经内置桥接转换(chat-only 端点);此时 baseUrl 是上游真实地址 */
+  /** 恒为 true:模型都是 Chat 端点,经内置桥接接入;此时 baseUrl 是上游真实地址 */
   bridged?: boolean;
 }
 
@@ -139,7 +139,7 @@ export function codexRestart(): Promise<CodexStatus> {
 
 // ─── 模型 provider ─────────────────────────────────────────────────────────
 
-/** 与主进程 ApplyProviderInput 对应;providerType 取自 agent_model_provider.type */
+/** 与主进程 ApplyProviderInput 对应(后端「模型管理」的一条记录) */
 export interface ApplyProviderInput {
   modelId: string;
   name?: string | null;
@@ -147,19 +147,15 @@ export interface ApplyProviderInput {
   apiKey?: string | null;
   headersJson?: string | Record<string, string> | null;
   config?: string | Record<string, unknown> | null;
-  providerType?: string | null;
   modelRecordId?: string | number | null;
 }
 
-/** 端点协议判定:responses 直通;chat-only 走内置桥接;unsupported 双协议都没有;unreachable 不可达 */
-export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable';
+/** 端点判定:chat → 应用时走内置桥接;unsupported 没有 Chat 路由;unreachable 网络不可达 */
+export type EndpointProtocol = 'chat' | 'unsupported' | 'unreachable';
 
 export interface ProviderProbeResult {
   protocol: EndpointProtocol;
-  responsesStatus: number | null;
   chatStatus: number | null;
-  ollamaVersion: string | null;
-  ollamaNeedsUpgrade: boolean;
   detail: string;
   hint: string | null;
 }
@@ -175,7 +171,6 @@ export function codexProbeProvider(input: {
   baseUrl: string;
   apiKey?: string | null;
   modelId?: string | null;
-  providerType?: string | null;
 }): Promise<ProviderProbeResult> {
   return codexInvoke<ProviderProbeResult>('probeProvider', input);
 }

+ 43 - 14
ai-electron/frontend/src/codex/store/codexChatStore.ts

@@ -9,7 +9,7 @@
 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 type { AiModel } from '@/ai/api/types';
 import * as codexApi from '../api/codexApi';
 import * as codexArtifactApi from '../api/codexArtifactApi';
 import type {
@@ -193,8 +193,6 @@ export const useCodexChatStore = defineStore('codex-chat', {
     runtimeError: '',
     /** 对话模型列表(来自后端「模型管理」,过滤 llm;未登录后端时为空) */
     models: [] as AiModel[],
-    /** 模型厂商列表(applySessionModel 查 providerType 用) */
-    providers: [] as ModelProvider[],
     /** 会话列表(localStorage 自持) */
     sessions: [] as CodexSessionMeta[],
     activeThreadId: '',
@@ -243,6 +241,26 @@ export const useCodexChatStore = defineStore('codex-chat', {
       if (!threadId) return [];
       return buildCodexMessages(state.events[threadId] ?? [], state.prompts[threadId] ?? []);
     },
+    /**
+     * 当前会话该用「模型管理」里的哪条记录:会话记过 model 就反查,新会话退到后端默认模型。
+     * 会话记过但记录已被删除 → 返回空,页面据此提示重选(并放开被锁住的选择器)。
+     */
+    sessionModelRecordId(): string {
+      const model = this.activeSession?.model;
+      if (model) return this.models.find((item) => item.modelId === model)?.id ?? '';
+      return this.models.find((item) => item.defaultModel)?.id ?? '';
+    },
+    /** 会话选中的模型是否已经应用到运行时(provider 是全局单例,按后端记录 id 比对) */
+    modelApplied(): boolean {
+      const recordId = this.sessionModelRecordId;
+      return !!recordId && this.runtime?.applied?.modelRecordId === recordId;
+    },
+    /** 没有可用模型时的提示文案,发送前的两道校验共用 */
+    modelGuardMessage(state): string {
+      return state.models.length
+        ? '请先在下方选择本次会话使用的模型'
+        : '后端「模型管理」里还没有可用模型,请先添加';
+    },
     /** 当前是否有回合在跑 */
     streaming(): boolean {
       const list = this.messages;
@@ -377,12 +395,12 @@ export const useCodexChatStore = defineStore('codex-chat', {
 
     /** 模型列表来自后端「模型管理」(HTTP+登录态),与 Codex 运行时无关;失败静默降级为空 */
     async loadModels() {
-      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 || [];
+      try {
+        const models = await modelApi.listModels();
+        this.models = (models || []).filter((item) => !item.type || item.type === 'llm');
+      } catch {
+        this.models = [];
+      }
     },
 
     async loadDefaultCwd() {
@@ -483,14 +501,13 @@ export const useCodexChatStore = defineStore('codex-chat', {
     },
 
     /**
-     * 选中即应用:把后端「模型管理」里的一条记录应用到 Codex 运行时
-     * (provider 是全局单例,会重启子进程),成功后记到当前会话。
-     * 失败原样抛错——页面负责提示,选择器显示值由会话模型反查驱动、天然回退。
+     * 把后端「模型管理」里的一条记录应用到 Codex 运行时。
+     * provider 是全局单例,应用会重启子进程并探一次端点,所以只在需要时做(见 ensureModelApplied)。
+     * 失败原样抛错——页面负责提示,选择器显示值由 sessionModelRecordId 驱动、天然回退。
      */
     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,
@@ -498,7 +515,6 @@ export const useCodexChatStore = defineStore('codex-chat', {
         apiKey: record.apiKey || null,
         headersJson: record.headersJson || null,
         config: record.config || null,
-        providerType,
         modelRecordId: record.id,
       });
       // 应用会重启运行时,状态回写保持 ready 判定新鲜
@@ -506,6 +522,17 @@ export const useCodexChatStore = defineStore('codex-chat', {
       if (this.activeThreadId) this.setSessionModel(this.activeThreadId, record.modelId || null);
     },
 
+    /**
+     * 首次发送(或换了模型再发)时确保会话选中的模型已应用到运行时。
+     * 应用要重启子进程 + 探端点,进页面就做会白白等好几秒,所以挪到这里。
+     */
+    async ensureModelApplied(): Promise<void> {
+      const recordId = this.sessionModelRecordId;
+      if (!recordId) throw new Error(this.modelGuardMessage);
+      if (this.modelApplied) return;
+      await this.applySessionModel(recordId);
+    },
+
     async startRuntime() {
       try {
         this.runtime = await codexApi.codexStart();
@@ -697,6 +724,8 @@ export const useCodexChatStore = defineStore('codex-chat', {
       const threadId = session.threadId;
       // 同一会话不允许重复提交;不同会话可以并行跑(各自独立的事件流与回合)
       if (this.runningThreads.includes(threadId)) return;
+      // 先确保模型已应用,再落提问:应用失败(端点不通等)不该留下一条问不出去的消息
+      await this.ensureModelApplied();
 
       const now = new Date().toISOString();
       const parsed: CodexUserMessage = {

+ 22 - 26
ai-electron/frontend/src/codex/views/codexAnalysis/codexAnalysis.vue

@@ -156,26 +156,22 @@
       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));
+  /** 选中值由 store 统一给:会话记过的模型 → 反查记录;新会话 → 后端默认模型 */
+  const selectedRecordId = computed(() => store.sessionModelRecordId || null);
+  /**
+   * 有消息即锁:会话进行中不可切换模型(空会话可换)。
+   * 记录被删除时不锁——否则选不了模型也发不出消息,会话就废在那里了。
+   */
+  const modelLocked = computed(() => !!selectedRecordId.value && !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;
-    }
+  /** 选择只记到会话;真正应用(重启运行时 + 探端点)留到首次发送 */
+  function onModelChange(recordId: string | null) {
+    const record = store.models.find((item) => item.id === recordId);
+    if (!record || !store.activeThreadId) return;
+    store.setSessionModel(store.activeThreadId, record.modelId || null);
+    showMessage('下次提问时生效', 'success');
   }
 
   // 多会话并行:输入区只看「运行时是否就绪」,当前会话在跑时按钮会变成「停止」
@@ -191,7 +187,7 @@
     if (status.state === 'error') return status.error || '运行时启动失败,请查看诊断日志。';
     if (status.state === 'starting') return '运行时正在启动,稍候即可提问。';
     if (!status.applied) {
-      return '运行时尚未应用模型。请先到「插件」页选择后端模型管理里的模型并应用,再回到本页。';
+      return '尚未应用模型:在下方选择模型即可,首次提问时会自动应用(默认取「模型管理」里的默认模型)。';
     }
     return '点击启动 Codex 运行时后即可开始提问。';
   });
@@ -228,21 +224,21 @@
     }
   }
 
-  /** 未应用 provider 时 Codex 只会打它自己的默认端点,报错还看不懂,所以没选模型就不发 */
-  const modelMissing = computed(() => !store.activeSession?.model);
-
+  /** 未选模型(也没有后端默认模型)就不发:未应用 provider 时 Codex 只会打它自己的默认端点 */
   async function onSend(text: string) {
-    if (modelMissing.value) {
-      showMessage(
-        store.models.length ? '请先在下方选择本次会话使用的模型' : '后端「模型管理」里还没有可用模型,请先添加',
-        'warning',
-      );
+    if (!store.sessionModelRecordId) {
+      showMessage(store.modelGuardMessage, 'warning');
       return;
     }
+    // 首次提问要先应用模型(重启运行时 + 探端点),等待期间给出可见反馈
+    const applying = !store.modelApplied;
+    modelApplying.value = applying;
     try {
       await store.send(text);
     } catch (error: any) {
       showMessage(error?.message || '发送失败', 'error');
+    } finally {
+      if (applying) modelApplying.value = false;
     }
   }
 

+ 3 - 3
ai-electron/scripts/codex-ipc-probe.html

@@ -148,9 +148,9 @@
           return { success: true, data: { checked: targets.filter((f) => fs.existsSync(f)) } };
         });
         await check(
-          'applyProvider 远端 Ollama 不可达时报错(回归)',
-          () => call('applyProvider', { modelId: 'qwen', baseUrl: 'http://10.66.66.66:8080', providerType: 'OLLAMA' }),
-          expectFailure(/不可达|未实现/),
+          'applyProvider 端点不可达时报错(回归)',
+          () => call('applyProvider', { modelId: 'qwen', baseUrl: 'http://127.0.0.1:9/v1' }),
+          expectFailure(/不可达|没有 \/chat\/completions/),
         );
         await check('clearProvider', () => call('clearProvider'), expectData((d) => d.providerId === null && d.applied === null));
         fake.close();