Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
ea06916
1
Parent(s): dfae1c7
fix: harden extraction pipeline + update Codex Desktop v26.309.31024
Browse files- Fallback originator scanning: try/catch extractFromMainJs, scan all
.vite/build/*.js on failure instead of aborting
- Webview model discovery: scan webview/assets/*.js for additional
model IDs not found in main.js
- Fix bracket search in extractPrompts(): limit lastIndexOf("[") to
50-char window instead of unbounded backward search
- Prompt validation in savePrompt(): reject content <50 chars or
with >3 garbled lines to prevent corruption
- Fix corrupted title-generation.md (garbled lines 17-35)
- Update Codex Desktop to v26.309.31024 (build 962)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CHANGELOG.md +16 -0
- config/default.yaml +2 -3
- config/prompts/title-generation.md +11 -19
- scripts/extract-fingerprint.ts +91 -4
CHANGELOG.md
CHANGED
|
@@ -8,18 +8,34 @@
|
|
| 8 |
|
| 9 |
### Added
|
| 10 |
|
|
|
|
| 11 |
- 更新弹窗 + 自动重启:点击"有可用更新"弹出 Modal 显示 changelog,一键更新后服务器自动重启、前端自动刷新,零人工干预(git 模式 spawn 新进程、Docker/Electron 显示对应操作指引)
|
| 12 |
- Model-aware 多计划账号路由:不同 plan(free/plus/business)的账号自动路由到各自支持的模型,business 账号可继续使用 gpt-5.4 等高端模型 (#57)
|
| 13 |
- Structured Outputs 支持:`/v1/chat/completions` 支持 `response_format`(`json_object` / `json_schema`),Gemini 端点支持 `responseMimeType` + `responseSchema`,自动翻译为 Codex Responses API 的 `text.format`;`/v1/responses` 直通 `text` 字段
|
| 14 |
|
| 15 |
### Changed
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
- 模型目录大幅更新:后端移除 free 账号的 `gpt-5.4`、`gpt-5.3-codex` 全系列(plus 及以上仍可用),新旗舰模型为 `gpt-5.2-codex`(`codex` 别名指向此模型)
|
| 18 |
- 新增模型:`gpt-5.2`、`gpt-5.1-codex`、`gpt-5.1`、`gpt-5-codex`、`gpt-5`、`gpt-oss-120b`、`gpt-oss-20b`、`gpt-5-codex-mini`
|
| 19 |
- 模型目录从 23 个静态模型精简为 11 个(匹配后端实际返回)
|
| 20 |
|
| 21 |
### Fixed
|
| 22 |
|
|
|
|
|
|
|
|
|
|
| 23 |
- JSON Schema `additionalProperties` 递归注入:`injectAdditionalProperties()` 递归注入 `additionalProperties: false` 到 JSON Schema 所有 object 节点,覆盖 `properties`、`patternProperties`、`$defs`/`definitions`、`items`、`prefixItems`、组合器(`oneOf`/`anyOf`/`allOf`)、条件(`if`/`then`/`else`),含循环检测;三个端点(OpenAI/Gemini/Responses passthrough)统一调用 (#64)
|
| 24 |
- CONNECT tunnel header 解析:循环跳过中间 header block(CONNECT 200、100 Continue),修复代理模式下 tunnel 的 `HTTP/1.1 200` 被当作真实状态码导致上游 4xx 错误被掩盖为 502 的问题 (#64)
|
| 25 |
- 上游 HTTP 状态码透传:非流式 collect 路径从错误消息提取真实 HTTP 状态码,不再硬编码 502;提取 `toErrorStatus()` 辅助函数统一 4 处 StatusCode 转换 (#64)
|
|
|
|
| 8 |
|
| 9 |
### Added
|
| 10 |
|
| 11 |
+
- 双窗口配额显示:Dashboard 账号卡片同时展示主窗口(小时限制)和次窗口(周限制)的用量百分比、进度条和重置时间,后端 `secondary_window` 不再被忽略
|
| 12 |
- 更新弹窗 + 自动重启:点击"有可用更新"弹出 Modal 显示 changelog,一键更新后服务器自动重启、前端自动刷新,零人工干预(git 模式 spawn 新进程、Docker/Electron 显示对应操作指引)
|
| 13 |
- Model-aware 多计划账号路由:不同 plan(free/plus/business)的账号自动路由到各自支持的模型,business 账号可继续使用 gpt-5.4 等高端模型 (#57)
|
| 14 |
- Structured Outputs 支持:`/v1/chat/completions` 支持 `response_format`(`json_object` / `json_schema`),Gemini 端点支持 `responseMimeType` + `responseSchema`,自动翻译为 Codex Responses API 的 `text.format`;`/v1/responses` 直通 `text` 字段
|
| 15 |
|
| 16 |
### Changed
|
| 17 |
|
| 18 |
+
- 提取管道强化:`extract-fingerprint.ts` 新增 fallback 扫描(`.vite/build/*.js` 全文件回退)和 webview 模型发现(`webview/assets/*.js`),pattern 失败不再中断整个流程
|
| 19 |
+
- 模型/别名自动添加降级为 semi-auto:后端已通过 `isCodexCompatibleId()` 自动合并新模型,`apply-update.ts` 不再自动写入 `models.yaml`(避免 `mutateYaml` 破坏 YAML 格式)
|
| 20 |
+
- Codex Desktop 版本更新至 v26.309.31024 (build 962)
|
| 21 |
+
|
| 22 |
+
### Fixed (pipeline)
|
| 23 |
+
|
| 24 |
+
- Prompt 提取括号定位修复:`extractPrompts()` 的 `lastIndexOf("[")` 无限回溯导致匹配到无关 `[`,截取错误代码片段产出乱码;改为 50 字符窗口内搜索
|
| 25 |
+
- Prompt 覆写安全校验:`savePrompt()` 和 `applyAutoChanges()` 新增内容验证(最小长度 50 字符、乱码行数 ≤3),拒绝将损坏数据写入 `config/prompts/`
|
| 26 |
+
- `title-generation.md` 修复:还原因提取 bug 损坏的 title 生成 prompt(第 17-35 行乱码)
|
| 27 |
+
|
| 28 |
+
### Changed (previous)
|
| 29 |
+
|
| 30 |
- 模型目录大幅更新:后端移除 free 账号的 `gpt-5.4`、`gpt-5.3-codex` 全系列(plus 及以上仍可用),新旗舰模型为 `gpt-5.2-codex`(`codex` 别名指向此模型)
|
| 31 |
- 新增模型:`gpt-5.2`、`gpt-5.1-codex`、`gpt-5.1`、`gpt-5-codex`、`gpt-5`、`gpt-oss-120b`、`gpt-oss-20b`、`gpt-5-codex-mini`
|
| 32 |
- 模型目录从 23 个静态模型精简为 11 个(匹配后端实际返回)
|
| 33 |
|
| 34 |
### Fixed
|
| 35 |
|
| 36 |
+
- 429 真实冷却时间:从 429 错误响应体解析 `resets_in_seconds` / `resets_at`,账号按后端实际冷却期(如 free 计划 5.5 天)标记限速,不再使用硬编码 60s 默认值 (#65)
|
| 37 |
+
- 429 自动降级:收到 429 后自动尝试下一个可用账号,所有账号耗尽后才返回 429 给客户端 (#65)
|
| 38 |
+
- 调度优先级优化:`least_used` 策略新增 `window_reset_at` 二级排序,配额窗口更早重置的账号优先使用 (#65)
|
| 39 |
- JSON Schema `additionalProperties` 递归注入:`injectAdditionalProperties()` 递归注入 `additionalProperties: false` 到 JSON Schema 所有 object 节点,覆盖 `properties`、`patternProperties`、`$defs`/`definitions`、`items`、`prefixItems`、组合器(`oneOf`/`anyOf`/`allOf`)、条件(`if`/`then`/`else`),含循环检测;三个端点(OpenAI/Gemini/Responses passthrough)统一调用 (#64)
|
| 40 |
- CONNECT tunnel header 解析:循环跳过中间 header block(CONNECT 200、100 Continue),修复代理模式下 tunnel 的 `HTTP/1.1 200` 被当作真实状态码导致上游 4xx 错误被掩盖为 502 的问题 (#64)
|
| 41 |
- 上游 HTTP 状态码透传:非流式 collect 路径从错误消息提取真实 HTTP 状态码,不再硬编码 502;提取 `toErrorStatus()` 辅助函数统一 4 处 StatusCode 转换 (#64)
|
config/default.yaml
CHANGED
|
@@ -3,8 +3,8 @@ api:
|
|
| 3 |
timeout_seconds: 60
|
| 4 |
client:
|
| 5 |
originator: Codex Desktop
|
| 6 |
-
app_version: 26.
|
| 7 |
-
build_number: "
|
| 8 |
platform: darwin
|
| 9 |
arch: arm64
|
| 10 |
chromium_version: "144"
|
|
@@ -34,5 +34,4 @@ tls:
|
|
| 34 |
curl_binary: auto
|
| 35 |
impersonate_profile: chrome144
|
| 36 |
proxy_url: null
|
| 37 |
-
# 当代理不支持 HTTP/2 时启用此选项(如遇到 SETTINGS frame 错误)
|
| 38 |
force_http11: true
|
|
|
|
| 3 |
timeout_seconds: 60
|
| 4 |
client:
|
| 5 |
originator: Codex Desktop
|
| 6 |
+
app_version: 26.309.31024
|
| 7 |
+
build_number: "962"
|
| 8 |
platform: darwin
|
| 9 |
arch: arm64
|
| 10 |
chromium_version: "144"
|
|
|
|
| 34 |
curl_binary: auto
|
| 35 |
impersonate_profile: chrome144
|
| 36 |
proxy_url: null
|
|
|
|
| 37 |
force_http11: true
|
config/prompts/title-generation.md
CHANGED
|
@@ -14,22 +14,14 @@ Generate a single-line title that captures the question or core change requested
|
|
| 14 |
- Do not use punctuation at the end.
|
| 15 |
- Output the title as plain text with no surrounding quotes or backticks.
|
| 16 |
- Use precise, non-redundant language.
|
| 17 |
-
s locale (e.g., "Fix bug" -> "Corrige el error" in Spanish-ES), but leave code terms in English unless a widely adopted translation exists.
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
(de-DE) -> Login-Fehler 500 beheben','- User:
|
| 29 |
-
(fr-FR) -> Refactoriser composant sidebar','- User:
|
| 30 |
-
-> Troubleshoot login bug','- User:
|
| 31 |
-
-> Locate foo_bar',`- User:
|
| 32 |
-
-> Calculate 2+2`,
|
| 33 |
-
,
|
| 34 |
-
,
|
| 35 |
-
,
|
|
|
|
| 14 |
- Do not use punctuation at the end.
|
| 15 |
- Output the title as plain text with no surrounding quotes or backticks.
|
| 16 |
- Use precise, non-redundant language.
|
| 17 |
+
- Translate fixed phrases into the user's locale (e.g., "Fix bug" -> "Corrige el error" in Spanish-ES), but leave code terms in English unless a widely adopted translation exists.
|
| 18 |
+
- If the user provides a title explicitly, reuse it (translated if needed) and skip generation logic.
|
| 19 |
+
- Do NOT respond to the user, answer questions, or attempt to solve the problem; just write a title that can represent the user's query.
|
| 20 |
+
|
| 21 |
+
Examples:
|
| 22 |
+
- User: "Can we add dark-mode support to the settings page?" -> Add dark-mode support
|
| 23 |
+
- User: "Fehlerbehebung: Beim Anmelden erscheint 500." (de-DE) -> Login-Fehler 500 beheben
|
| 24 |
+
- User: "Refactoriser le composant sidebar pour réduire le code dupliqué." (fr-FR) -> Refactoriser composant sidebar
|
| 25 |
+
- User: "How do I fix our login bug?" -> Troubleshoot login bug
|
| 26 |
+
- User: "Where in the codebase is foo_bar created" -> Locate foo_bar
|
| 27 |
+
- User: "what is 2+2?" -> Calculate 2+2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/extract-fingerprint.ts
CHANGED
|
@@ -188,6 +188,17 @@ function extractFromMainJs(
|
|
| 188 |
};
|
| 189 |
}
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
/**
|
| 192 |
* Step B (continued): Extract system prompts from main.js
|
| 193 |
*/
|
|
@@ -227,8 +238,8 @@ function extractPrompts(content: string): {
|
|
| 227 |
// Find the enclosing array end: ].join(
|
| 228 |
const joinIdx = content.indexOf("].join(", titleStart);
|
| 229 |
if (joinIdx !== -1) {
|
| 230 |
-
//
|
| 231 |
-
const bracketStart =
|
| 232 |
if (bracketStart !== -1) {
|
| 233 |
const arrayContent = content.slice(bracketStart + 1, joinIdx);
|
| 234 |
// Parse string literals from the array
|
|
@@ -244,7 +255,7 @@ function extractPrompts(content: string): {
|
|
| 244 |
if (prStart !== -1) {
|
| 245 |
const joinIdx = content.indexOf("].join(", prStart);
|
| 246 |
if (joinIdx !== -1) {
|
| 247 |
-
const bracketStart =
|
| 248 |
if (bracketStart !== -1) {
|
| 249 |
const arrayContent = content.slice(bracketStart + 1, joinIdx);
|
| 250 |
prGeneration = parseStringArray(arrayContent);
|
|
@@ -318,6 +329,17 @@ function savePrompt(name: string, content: string | null): { hash: string | null
|
|
| 318 |
const sanitized = sanitizePrompt(content);
|
| 319 |
if (!sanitized) return { hash: null, path: null };
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
mkdirSync(PROMPTS_DIR, { recursive: true });
|
| 322 |
const filePath = join(PROMPTS_DIR, `${name}.md`);
|
| 323 |
writeFileSync(filePath, sanitized);
|
|
@@ -433,7 +455,47 @@ async function main() {
|
|
| 433 |
if (mainJs) {
|
| 434 |
console.log(`[extract] main.js loaded (${mainJs.split("\n").length} lines)`);
|
| 435 |
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
console.log(` API base URL: ${mainJsResults.apiBaseUrl}`);
|
| 438 |
console.log(` originator: ${mainJsResults.originator}`);
|
| 439 |
console.log(` models: ${mainJsResults.models.join(", ")}`);
|
|
@@ -448,6 +510,31 @@ async function main() {
|
|
| 448 |
console.log(` automation-response: ${promptResults.automationResponse ? "found" : "NOT FOUND"}`);
|
| 449 |
}
|
| 450 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
// Save extracted prompts
|
| 452 |
const dc = savePrompt("desktop-context", promptResults.desktopContext);
|
| 453 |
const tg = savePrompt("title-generation", promptResults.titleGeneration);
|
|
|
|
| 188 |
};
|
| 189 |
}
|
| 190 |
|
| 191 |
+
/**
|
| 192 |
+
* Find the nearest `[` bracket within maxDistance chars before the given position.
|
| 193 |
+
* Prevents unbounded `lastIndexOf("[")` from matching a wrong bracket thousands of chars away.
|
| 194 |
+
*/
|
| 195 |
+
function findNearbyBracket(content: string, position: number, maxDistance = 50): number {
|
| 196 |
+
const searchStart = Math.max(0, position - maxDistance);
|
| 197 |
+
const slice = content.slice(searchStart, position);
|
| 198 |
+
const idx = slice.lastIndexOf("[");
|
| 199 |
+
return idx !== -1 ? searchStart + idx : -1;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
/**
|
| 203 |
* Step B (continued): Extract system prompts from main.js
|
| 204 |
*/
|
|
|
|
| 238 |
// Find the enclosing array end: ].join(
|
| 239 |
const joinIdx = content.indexOf("].join(", titleStart);
|
| 240 |
if (joinIdx !== -1) {
|
| 241 |
+
// Find the opening [ within 50 chars before the marker (not unbounded lastIndexOf)
|
| 242 |
+
const bracketStart = findNearbyBracket(content, titleStart);
|
| 243 |
if (bracketStart !== -1) {
|
| 244 |
const arrayContent = content.slice(bracketStart + 1, joinIdx);
|
| 245 |
// Parse string literals from the array
|
|
|
|
| 255 |
if (prStart !== -1) {
|
| 256 |
const joinIdx = content.indexOf("].join(", prStart);
|
| 257 |
if (joinIdx !== -1) {
|
| 258 |
+
const bracketStart = findNearbyBracket(content, prStart);
|
| 259 |
if (bracketStart !== -1) {
|
| 260 |
const arrayContent = content.slice(bracketStart + 1, joinIdx);
|
| 261 |
prGeneration = parseStringArray(arrayContent);
|
|
|
|
| 329 |
const sanitized = sanitizePrompt(content);
|
| 330 |
if (!sanitized) return { hash: null, path: null };
|
| 331 |
|
| 332 |
+
// Validate: reject suspiciously short or garbled content
|
| 333 |
+
if (sanitized.length < 50) {
|
| 334 |
+
console.warn(`[extract] Prompt "${name}" too short (${sanitized.length} chars), skipping save`);
|
| 335 |
+
return { hash: null, path: null };
|
| 336 |
+
}
|
| 337 |
+
const garbageLines = sanitized.split("\n").filter((l) => /^[,`'"]\s*$/.test(l.trim()));
|
| 338 |
+
if (garbageLines.length > 3) {
|
| 339 |
+
console.warn(`[extract] Prompt "${name}" has ${garbageLines.length} garbled lines, skipping save`);
|
| 340 |
+
return { hash: null, path: null };
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
mkdirSync(PROMPTS_DIR, { recursive: true });
|
| 344 |
const filePath = join(PROMPTS_DIR, `${name}.md`);
|
| 345 |
writeFileSync(filePath, sanitized);
|
|
|
|
| 455 |
if (mainJs) {
|
| 456 |
console.log(`[extract] main.js loaded (${mainJs.split("\n").length} lines)`);
|
| 457 |
|
| 458 |
+
try {
|
| 459 |
+
mainJsResults = extractFromMainJs(mainJs, patterns.main_js);
|
| 460 |
+
} catch (err) {
|
| 461 |
+
console.warn(`[extract] Primary extraction failed: ${(err as Error).message}`);
|
| 462 |
+
console.log("[extract] Scanning all .vite/build/*.js for fallback...");
|
| 463 |
+
|
| 464 |
+
const buildDir = join(asarRoot, ".vite/build");
|
| 465 |
+
if (existsSync(buildDir)) {
|
| 466 |
+
const jsFiles = readdirSync(buildDir).filter((f) => f.endsWith(".js"));
|
| 467 |
+
for (const file of jsFiles) {
|
| 468 |
+
const content = readFileSync(join(buildDir, file), "utf-8");
|
| 469 |
+
const origPattern = patterns.main_js.originator;
|
| 470 |
+
if (origPattern?.pattern) {
|
| 471 |
+
const m = content.match(new RegExp(origPattern.pattern));
|
| 472 |
+
if (m) {
|
| 473 |
+
mainJsResults.originator = m[origPattern.group ?? 0] ?? m[0];
|
| 474 |
+
console.log(`[extract] Originator found in fallback file: ${file}`);
|
| 475 |
+
break;
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
// Re-extract non-critical fields from mainJs
|
| 482 |
+
const apiPattern = patterns.main_js.api_base_url;
|
| 483 |
+
if (apiPattern?.pattern) {
|
| 484 |
+
const m = mainJs.match(new RegExp(apiPattern.pattern));
|
| 485 |
+
if (m) mainJsResults.apiBaseUrl = m[0];
|
| 486 |
+
}
|
| 487 |
+
const modelPattern = patterns.main_js.models;
|
| 488 |
+
if (modelPattern?.pattern) {
|
| 489 |
+
const re = new RegExp(modelPattern.pattern, "g");
|
| 490 |
+
const groupIdx = modelPattern.group ?? 0;
|
| 491 |
+
const modelSet = new Set<string>();
|
| 492 |
+
for (const m of mainJs.matchAll(re)) {
|
| 493 |
+
modelSet.add(m[groupIdx] ?? m[0]);
|
| 494 |
+
}
|
| 495 |
+
mainJsResults.models = [...modelSet].sort();
|
| 496 |
+
}
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
console.log(` API base URL: ${mainJsResults.apiBaseUrl}`);
|
| 500 |
console.log(` originator: ${mainJsResults.originator}`);
|
| 501 |
console.log(` models: ${mainJsResults.models.join(", ")}`);
|
|
|
|
| 510 |
console.log(` automation-response: ${promptResults.automationResponse ? "found" : "NOT FOUND"}`);
|
| 511 |
}
|
| 512 |
|
| 513 |
+
// Scan webview assets for additional model IDs
|
| 514 |
+
const webviewAssetsDir = join(asarRoot, "webview/assets");
|
| 515 |
+
if (existsSync(webviewAssetsDir)) {
|
| 516 |
+
console.log("[extract] Scanning webview assets for additional models...");
|
| 517 |
+
const modelPattern = patterns.main_js.models;
|
| 518 |
+
if (modelPattern?.pattern) {
|
| 519 |
+
const webviewFiles = readdirSync(webviewAssetsDir).filter((f) => f.endsWith(".js"));
|
| 520 |
+
const webviewModels = new Set<string>();
|
| 521 |
+
for (const file of webviewFiles) {
|
| 522 |
+
const content = readFileSync(join(webviewAssetsDir, file), "utf-8");
|
| 523 |
+
const re = new RegExp(modelPattern.pattern, "g");
|
| 524 |
+
const groupIdx = modelPattern.group ?? 0;
|
| 525 |
+
for (const m of content.matchAll(re)) {
|
| 526 |
+
webviewModels.add(m[groupIdx] ?? m[0]);
|
| 527 |
+
}
|
| 528 |
+
}
|
| 529 |
+
const existingModels = new Set(mainJsResults.models);
|
| 530 |
+
const newFromWebview = [...webviewModels].filter((m) => !existingModels.has(m));
|
| 531 |
+
if (newFromWebview.length > 0) {
|
| 532 |
+
console.log(`[extract] Webview: ${newFromWebview.length} additional models: ${newFromWebview.join(", ")}`);
|
| 533 |
+
mainJsResults.models = [...mainJsResults.models, ...newFromWebview].sort();
|
| 534 |
+
}
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
// Save extracted prompts
|
| 539 |
const dc = savePrompt("desktop-context", promptResults.desktopContext);
|
| 540 |
const tg = savePrompt("title-generation", promptResults.titleGeneration);
|