Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/desktop/src/main/cindy-brain/__tests__/cindySlot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,25 @@ describe('目录空清单 = 能力暂不可用', () => {
});

describe('意识专属后端覆盖(解析表第②层)', () => {
it('新版 Provider-aware 覆盖保留精确来源到最终派发', async () => {
const { slot, generateImage } = makeSlot({
getMediaOverride: vi.fn(() => ({
modelId: 'gpt-image-2',
providerId: 'openai',
label: 'GPT Image 2',
})),
});

const result = await slot.handleModelRequest('art', REQ);

expect(result).toMatchObject({ ok: true, model: 'gpt-image-2', modelLabel: 'GPT Image 2' });
expect(generateImage).toHaveBeenCalledWith({
prompt: '一只猫',
model: 'gpt-image-2',
providerId: 'openai',
});
});

it('覆盖压过档位;调用显式点名仍压过覆盖;下架型号的覆盖静默落回', async () => {
const pinned = makeSlot({
getOverride: vi.fn(() => 'gemini-3-pro-image') as unknown as CindySlotDeps['getOverride'],
Expand Down
79 changes: 63 additions & 16 deletions apps/desktop/src/main/cindy-brain/cindySlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ export interface CindyImageCapabilities {
maxEditImages?: number;
}

export interface CindyMediaOverrideSelection {
modelId: string;
providerId: string;
label?: string;
}

export interface CindySlotDeps {
getGhost(id: string): InstalledGhost | null;
/** 当前账号作用域;跨 await 的媒体任务必须捕获并持续复核。 */
Expand All @@ -147,29 +153,31 @@ export interface CindySlotDeps {
generateImage(params: {
prompt: string;
model: string;
providerId?: string;
aspectRatio?: GhostImageAspectRatio;
}): Promise<{ buffer: Uint8Array; mimeType: string }>;
/** 主机统一图片通道·改图;源图以磁盘路径喂给网关(意识摸不到路径)。
* aspectRatio 语义同 generateImage:不传 = 跟随源图画幅(后端 auto)。 */
editImage(params: {
prompt: string;
model: string;
providerId?: string;
imagePaths: string[];
aspectRatio?: GhostImageAspectRatio;
}): Promise<{ buffer: Uint8Array; mimeType: string }>;
/**
* 该图像型号的 provider 实际能力。缺席/查无 = 只执行通用 1–4 图粗筛;
* provider 上限更低时,slot 在读源图与出网前给出型号级明确拒绝。
*/
imageCapabilities?(model: string): CindyImageCapabilities | null;
imageCapabilities?(model: string, providerId?: string): CindyImageCapabilities | null;
/**
* 主机统一视频通道·文生视频(art 视频 provider 层复用,submit→
* 轮询→下载一条龙在注入实现里完成);返回视频字节与 mime,外加实际
* 生效的画面参数回执(上游上报值优先,缺项回落提交值)。长任务:
* 分钟级才 resolve,在途名额在整个等待期占用。
*/
generateVideo(
params: { prompt: string; model: string } & CindyVideoParams,
params: { prompt: string; model: string; providerId?: string } & CindyVideoParams,
): Promise<{ buffer: Uint8Array; mimeType: string; videoParams?: GhostVideoResultParams }>;
/**
* 主机统一视频通道·参考图生视频(源图以磁盘路径注入)。`refMode` 决定这
Expand All @@ -181,6 +189,7 @@ export interface CindySlotDeps {
params: {
prompt: string;
model: string;
providerId?: string;
imagePaths: string[];
refMode: GhostVideoRefMode;
} & CindyVideoParams,
Expand All @@ -190,7 +199,7 @@ export interface CindySlotDeps {
* 该型号 → null)。可选依赖:不注入 = 跳过按型号校验,只做协议层粗筛
* (值仍会被 provider 层自己的校验拦下,只是话术不如这里友好)。
*/
videoCapabilities?(model: string): CindyVideoCapabilities | null;
videoCapabilities?(model: string, providerId?: string): CindyVideoCapabilities | null;
/**
* 指纹 → 磁盘路径,且仅当该媒体在此意识名下(出生或画廊,查账本);
* 不属于它 / 查无此账 / 文件缺失一律 null(不区分,不给探测空间)。
Expand All @@ -204,6 +213,14 @@ export interface CindySlotDeps {
* 白名单校验(型号可能已随主机演进下架)。
*/
getOverride(ghostId: string, capability: string): string | null;
/**
* Provider-aware 媒体覆盖。新版 Host 偏好按 providerId + modelId 保存;存在时
* 必须贯穿能力校验与最终派发,不能降回裸 modelId 后走 first-wins。
*/
getMediaOverride?(
ghostId: string,
capability: string,
): CindyMediaOverrideSelection | null;
/**
* 当前图像能力配置——真身是 providers.json 运行时目录(与会话模型列表
* 同一获取来源),每单现读跟随热更。models = 白名单与显示名;defaults =
Expand Down Expand Up @@ -830,20 +847,29 @@ export class GhostCindySlot {
const defaults = cfg.defaults;
const whitelist = new Set(cfg.models.map((m) => m.id));
let model = defaults.standard;
let providerId: string | undefined;
let providerModelLabel: string | undefined;
if (p.tier !== undefined) {
if (typeof p.tier !== 'string' || !(GHOST_MODEL_TIERS as readonly string[]).includes(p.tier)) {
return { ok: false, message: `未知档位(可用:${GHOST_MODEL_TIERS.join(' / ')})` };
}
model = defaults[p.tier as GhostModelTier];
}
const capability = `${info.category}.${info.action}`;
const override = this.deps.getOverride(ghostId, capability);
if (override !== null) {
if (whitelist.has(override)) {
model = override;
} else {
// 钉的型号已随白名单演进下架:落回上面的档位/默认,不让老配置卡死能力。
this.deps.log?.warn('ghost cindy override no longer whitelisted, ignored', { ghostId, override });
const mediaOverride = this.deps.getMediaOverride?.(ghostId, capability) ?? null;
if (mediaOverride) {
model = mediaOverride.modelId;
providerId = mediaOverride.providerId;
providerModelLabel = mediaOverride.label;
} else {
const override = this.deps.getOverride(ghostId, capability);
if (override !== null) {
if (whitelist.has(override)) {
model = override;
} else {
// 钉的型号已随白名单演进下架:落回上面的档位/默认,不让老配置卡死能力。
this.deps.log?.warn('ghost cindy override no longer whitelisted, ignored', { ghostId, override });
}
}
}
if (p.model !== undefined) {
Expand All @@ -855,6 +881,10 @@ export class GhostCindySlot {
message: `不支持的模型(不在主机白名单内)。当前可用:${cfg.models.length > 0 ? cfg.models.map((m) => m.id).join(' / ') : '(暂无可用型号)'}`,
};
}
if (p.model !== model) {
providerId = undefined;
providerModelLabel = undefined;
}
model = p.model;
}

Expand All @@ -864,7 +894,7 @@ export class GhostCindySlot {
// 明拒并列出该型号的可用值,不做最近似降级——静默改成别的档位会让意识
// 以为自己的参数生效了。
if (info.category === 'video' && presentVideoKeys.length > 0) {
const caps = this.deps.videoCapabilities?.(model) ?? null;
const caps = this.deps.videoCapabilities?.(model, providerId) ?? null;
if (caps) {
const unsupported = describeUnsupportedVideoParams(videoParams, caps);
if (unsupported) {
Expand Down Expand Up @@ -897,7 +927,7 @@ export class GhostCindySlot {
}

if (kind === 'edit_image') {
const perModelMax = this.deps.imageCapabilities?.(model)?.maxEditImages;
const perModelMax = this.deps.imageCapabilities?.(model, providerId)?.maxEditImages;
if (perModelMax !== undefined && hashes.length > perModelMax) {
const label = cfg.models.find((m) => m.id === model)?.label ?? model;
return {
Expand All @@ -912,7 +942,7 @@ export class GhostCindySlot {
// 张数上限都不一样。不支持即明拒,不降级成另一种用法——降级会出一条
// 用户没要的片子,还照样计费。
if (kind === 'edit_video') {
const caps = this.deps.videoCapabilities?.(model) ?? null;
const caps = this.deps.videoCapabilities?.(model, providerId) ?? null;
const perModelMax = caps?.maxImagesByRefMode?.[refMode];
if (caps?.maxImagesByRefMode !== undefined) {
const label = cfg.models.find((m) => m.id === model)?.label ?? model;
Expand Down Expand Up @@ -961,6 +991,7 @@ export class GhostCindySlot {
this.deps.log?.info(`ghost cindy-request ${kind} start`, {
ghostId,
model,
...(providerId ? { providerId } : {}),
callId,
...(p.mode === 'submit' ? { mode: 'submit' } : {}),
});
Expand Down Expand Up @@ -997,19 +1028,33 @@ export class GhostCindySlot {
generated = await this.deps.editImage({
prompt,
model,
...(providerId ? { providerId } : {}),
imagePaths,
...(aspectRatio !== undefined ? { aspectRatio } : {}),
});
} else if (kind === 'gen_image') {
generated = await this.deps.generateImage({
prompt,
model,
...(providerId ? { providerId } : {}),
...(aspectRatio !== undefined ? { aspectRatio } : {}),
});
} else if (kind === 'edit_video') {
generated = await this.deps.editVideo({ prompt, model, imagePaths, refMode, ...videoParams });
generated = await this.deps.editVideo({
prompt,
model,
...(providerId ? { providerId } : {}),
imagePaths,
refMode,
...videoParams,
});
} else {
generated = await this.deps.generateVideo({ prompt, model, ...videoParams });
generated = await this.deps.generateVideo({
prompt,
model,
...(providerId ? { providerId } : {}),
...videoParams,
});
}
assertOwnerScopeCurrent();

Expand All @@ -1027,13 +1072,15 @@ export class GhostCindySlot {
this.deps.log?.info(`ghost cindy-request ${kind} done`, {
ghostId,
model,
...(providerId ? { providerId } : {}),
callId,
hash: saved.hash,
bytes: generated.buffer.byteLength,
});
// 实际选型随结果回传(主机权威信息):意识交卷 note、会话里的 AI 与
// 用户由此看得见"这单是谁画的"。
const modelLabel = cfg.models.find((m) => m.id === model)?.label ?? model;
const modelLabel =
providerModelLabel ?? cfg.models.find((m) => m.id === model)?.label ?? model;
// 图片代办附带像素宽高(字节头解析,best-effort):意识供聊天卡片时
// 据此精确声明卡高,首帧零跳动;解析不出就缺省,意识回退估计值。
const dims =
Expand Down
96 changes: 65 additions & 31 deletions apps/desktop/src/main/cindy-brain/codexImageClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
* SSE stream because image-generation events may be newer than SDK typings.
*/

import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';

import type { ImageChannel, ImageChannelResult } from './imageChannelRegistry.js';
import { mediaRequestParamsForLog, mediaRequestUrlForLog } from '../cindy-media/mediaRequestLog.js';
import { sniffMediaMime } from '../cindy-media/sniffMediaMime.js';
import { createLogger } from '../logger.js';

Expand Down Expand Up @@ -143,6 +145,7 @@ export function createCodexImageChannel(opts: CreateCodexImageChannelOptions): I
prompt: string;
imagePaths?: string[];
aspectRatio?: '1:1' | '3:2' | '2:3';
signal?: AbortSignal;
}): Promise<ImageChannelResult> {
if (params.model !== `openai/${IMAGE_MODEL}`) {
throw new Error(`Codex 图像通道不支持模型:${params.model}`);
Expand All @@ -159,36 +162,66 @@ export function createCodexImageChannel(opts: CreateCodexImageChannelOptions): I
{ type: 'input_text', text: params.prompt },
...images,
];
const response = await doFetch(CODEX_RESPONSES_URL, {
const body = {
model: HOST_MODEL,
store: false,
stream: true,
instructions: 'Use the image_generation tool to fulfill this image request.',
input: [{ type: 'message', role: 'user', content }],
tools: [
{
type: 'image_generation',
model: IMAGE_MODEL,
...(params.aspectRatio ? { size: SIZE_BY_ASPECT[params.aspectRatio] } : {}),
quality: 'medium',
output_format: 'png',
background: 'opaque',
partial_images: 1,
},
],
};
const requestId = randomUUID();
const startedAt = Date.now();
const requestLog = {
requestId,
providerId: 'openai',
modelId: params.model,
method: 'POST',
headers: {
Authorization: `Bearer ${auth.accessToken}`,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'OpenAI-Beta': 'responses=experimental',
originator: 'codex_cli_rs',
'User-Agent': USER_AGENT,
...(auth.accountId ? { 'ChatGPT-Account-Id': auth.accountId } : {}),
},
body: JSON.stringify({
model: HOST_MODEL,
store: false,
stream: true,
instructions: 'Use the image_generation tool to fulfill this image request.',
input: [{ type: 'message', role: 'user', content }],
tools: [
{
type: 'image_generation',
model: IMAGE_MODEL,
...(params.aspectRatio ? { size: SIZE_BY_ASPECT[params.aspectRatio] } : {}),
quality: 'medium',
output_format: 'png',
background: 'opaque',
partial_images: 1,
},
],
}),
url: mediaRequestUrlForLog(CODEX_RESPONSES_URL),
};
log.info('media request dispatch', {
...requestLog,
params: mediaRequestParamsForLog(body),
});
Comment thread
MagicLizi marked this conversation as resolved.
let response: Response;
try {
response = await doFetch(CODEX_RESPONSES_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${auth.accessToken}`,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'OpenAI-Beta': 'responses=experimental',
originator: 'codex_cli_rs',
'User-Agent': USER_AGENT,
...(auth.accountId ? { 'ChatGPT-Account-Id': auth.accountId } : {}),
},
body: JSON.stringify(body),
signal: params.signal,
});
log.info('media request response', {
...requestLog,
status: response.status,
durationMs: Date.now() - startedAt,
});
} catch (error) {
log.warn('media request failed', {
...requestLog,
durationMs: Date.now() - startedAt,
error: mediaRequestParamsForLog(error instanceof Error ? error.message : String(error)),
});
throw error;
}
if (!response.ok) await httpError(response, auth.accessToken, opts.onAuthFailure);
const b64 = await collectImageB64(response);
if (!b64) throw new Error('Codex 返回中没有图片,请重试或改用 OpenAI Platform API key');
Expand All @@ -197,8 +230,9 @@ export function createCodexImageChannel(opts: CreateCodexImageChannelOptions): I

return {
ready: opts.hasOAuthLogin,
generateImage: ({ model, prompt, aspectRatio }) => generate({ model, prompt, aspectRatio }),
editImage: ({ model, prompt, imagePaths, aspectRatio }) =>
generate({ model, prompt, imagePaths, aspectRatio }),
generateImage: ({ model, prompt, aspectRatio, signal }) =>
generate({ model, prompt, aspectRatio, signal }),
editImage: ({ model, prompt, imagePaths, aspectRatio, signal }) =>
generate({ model, prompt, imagePaths, aspectRatio, signal }),
};
}
13 changes: 8 additions & 5 deletions apps/desktop/src/main/cindy-brain/forge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2242,20 +2242,23 @@ const result = await (await fetch('/media-models?type=image')).json();
// models:[{
// id,
// name,
// providerId,
// modalities:{ input:['text','image'], output:['image'] }
// }],
// defaultModelId:string|null
// defaultModelId:string|null,
// defaultProviderId:string|null
// }
\`\`\`

\`type\` 只接受 \`image\` / \`video\`。Host 按 Gateway \`mode\` 切大类,并结合插件在
\`cindy.image/video\` 声明的动作、Gateway \`modalities\`、Guide operation 与当前客户端
\`type\` 只接受 \`image\` / \`video\`。Host 按模型 \`mode\` 切大类,并结合插件在
\`cindy.image/video\` 声明的动作、模型 \`modalities\`、Guide operation 与当前客户端
协议支持度,只返回当前真正可执行的模型。单个模型的 Guide 缺失、损坏或版本过新只隔离
该模型,不拖垮整个目录。

响应仍只把 Gateway \`architecture\` 已归一化后的 \`modalities.input/output\` 原样交给插件
响应只把已归一化的 \`modalities.input/output\` 与来源 \`providerId\` 交给插件
不下发 Guide、endpoint、凭证或 Host 内部兼容判定。插件可把用户选择的模型 id 存进自己的
\`/kv\`,再通过工具结果或插件说明交给当前 Agent;付费请求前 Core 会再次校验。
\`/kv\`,但必须同时保存 \`providerId\`,并把这对精确选择交给当前 Agent;同一个模型 id
可由多个 Provider 提供,不能按 id 去重或自行改换来源。付费请求前 Core 会再次校验。

插件与 Agent 不需要新的媒体协议,继续使用现有工具调用链:

Expand Down
Loading