diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 8fb0b90fe..000000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -shamefully-hoist=true -shell-emulator=true diff --git a/docs/.vitepress/components.d.ts b/docs/.vitepress/components.d.ts index d9b313edd..15df2bf78 100644 --- a/docs/.vitepress/components.d.ts +++ b/docs/.vitepress/components.d.ts @@ -10,9 +10,7 @@ declare module 'vue' { AdsASide: typeof import('./theme/components/AdsASide.vue')['default'] Badge: typeof import('./theme/components/Badge.vue')['default'] CodeGroupItem: typeof import('./theme/components/CodeGroupItem.vue')['default'] - copy: typeof import('./theme/components/StepFlow copy.vue')['default'] HomePage: typeof import('./theme/components/HomePage.vue')['default'] - StepFlow: typeof import('./theme/components/StepFlow.vue')['default'] StepFlowItem: typeof import('./theme/components/StepFlowItem.vue')['default'] } } diff --git a/docs/recipes/openai.md b/docs/recipes/openai.md index f840a7945..4ca957965 100644 --- a/docs/recipes/openai.md +++ b/docs/recipes/openai.md @@ -68,7 +68,7 @@ czg --api-key=sk-xxxxx 1. Get DeepSeek [API Key](https://platform.deepseek.com/api_keys) 2. Run the command to configure ```sh - npx czg --api-key="sk-xxxxxx" --api-endpoint="https://api.deepseek.com" --api-model="deepseek-chat" + npx czg --api-key="sk-xxxxxx" --api-endpoint="https://api.deepseek.com" --api-model="deepseek-v4-flash" ``` ::: diff --git a/docs/zh/recipes/openai.md b/docs/zh/recipes/openai.md index b5d95257c..a42c2ecbe 100644 --- a/docs/zh/recipes/openai.md +++ b/docs/zh/recipes/openai.md @@ -79,7 +79,7 @@ czg --api-key=sk-xxxxx 1. 获取 DeepSeek [API Key](https://platform.deepseek.com/api_keys) 2. 运行命令进行配置 ```sh - npx czg --api-key="sk-xxxxxx" --api-endpoint="https://api.deepseek.com" --api-model="deepseek-chat" + npx czg --api-key="sk-xxxxxx" --api-endpoint="https://api.deepseek.com" --api-model="deepseek-v4-flash" ``` ::: diff --git a/package.json b/package.json index 5c53b0aed..909cd5ca2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "cz-git", "version": "1.12.0", "private": true, - "packageManager": "pnpm@9.11.0", + "packageManager": "pnpm@10.33.2", "description": "A better customizable and git support commitizen adapter", "author": "Zhengqbbb (https://github.com/Zhengqbbb)", "license": "MIT", @@ -69,7 +69,7 @@ "npm-run-all2": "^6.2.3", "ora": "^8.1.0", "pathe": "^1.1.2", - "pnpm": "^9.11.0", + "pnpm": "^10.33.2", "rimraf": "catalog:rimraf", "simple-git-hooks": "^2.11.1", "ts-json-schema-generator": "^2.3.0", @@ -78,28 +78,6 @@ "typescript": "^5.5.4", "vitest": "^2.0.5" }, - "pnpm": { - "overrides": { - "@commitlint/config-validator": "catalog:commitlint", - "chalk": "4.1.2", - "color-convert": "2.0.1", - "import-meta-resolve": "4.1.0", - "resolve-from": "5.0.0", - "supports-color": "8.1.1" - }, - "peerDependencyRules": { - "ignoreMissing": [ - "@algolia/client-search", - "@types/react", - "eslint-plugin-import", - "eslint-plugin-n", - "eslint-plugin-promise", - "react", - "react-dom", - "webpack" - ] - } - }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged", "commit-msg": "pnpm commitlint --edit $1", diff --git a/packages/cz-git/__tests__/stream.test.ts b/packages/cz-git/__tests__/stream.test.ts new file mode 100644 index 000000000..d26c1f22f --- /dev/null +++ b/packages/cz-git/__tests__/stream.test.ts @@ -0,0 +1,40 @@ +import { Readable } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { readChatCompletionStreamToSubjects } from '../src/shared/utils/stream' + +function asStream(body: string): NodeJS.ReadableStream { + return Readable.from([body]) +} + +describe('readChatCompletionStreamToSubjects', () => { + it('parses non-stream JSON when no SSE choice deltas appear', async () => { + const json = JSON.stringify({ + choices: [{ index: 0, message: { role: 'assistant', content: 'fix login redirect' } }], + }) + const subjects = await readChatCompletionStreamToSubjects(asStream(json), 1) + expect(subjects).toEqual(['fix login redirect']) + }) + + it('parses SSE data lines as before', async () => { + const sse = [ + 'data: {"choices":[{"index":0,"delta":{"content":"hello"}}]}', + '', + 'data: {"choices":[{"index":0,"delta":{"content":" world"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n') + const subjects = await readChatCompletionStreamToSubjects(asStream(sse), 1) + expect(subjects).toEqual(['hello world']) + }) + + it('throws when body has neither SSE choices nor non-stream completion', async () => { + await expect(readChatCompletionStreamToSubjects(asStream('not json'), 1)).rejects.toThrow( + /no streamed choice deltas/, + ) + }) + + it('does not return a single empty subject when stream is empty', async () => { + await expect(readChatCompletionStreamToSubjects(asStream(''), 1)).rejects.toThrow() + }) +}) diff --git a/packages/cz-git/src/generator/api.ts b/packages/cz-git/src/generator/api.ts index 62d20aa86..1f0d09e39 100644 --- a/packages/cz-git/src/generator/api.ts +++ b/packages/cz-git/src/generator/api.ts @@ -4,6 +4,13 @@ import { style } from '@cz-git/inquirer' import HttpsProxyAgent from 'https-proxy-agent' import { isNodeVersionInRange, log, transformSubjectCase } from '../shared' import type { CommitizenGitOptions } from '../shared' +import { bodyToNodeReadable, readChatCompletionStreamToSubjects } from '../shared/utils/stream' + +/** Enough headroom for reasoning + short subject (legacy default was 200). */ +const AI_MAX_COMPLETION_TOKENS = 4096 + +/** Streaming first byte and full generation can exceed the old 10s window. */ +const AI_FETCH_TIMEOUT_MS = 60 * 1000 export async function fetchOpenAIMessage(options: CommitizenGitOptions, prompt: string) { if (!options.openAIToken) { @@ -33,7 +40,7 @@ export async function fetchOpenAIMessage(options: CommitizenGitOptions, prompt: }, method: 'POST', body: JSON.stringify(aiContext.payload), - signal: isNodeVersionInRange(18) ? AbortSignal?.timeout(10 * 1000) : undefined, + signal: isNodeVersionInRange(18) ? AbortSignal?.timeout(AI_FETCH_TIMEOUT_MS) : undefined, }) if ( @@ -44,10 +51,11 @@ export async function fetchOpenAIMessage(options: CommitizenGitOptions, prompt: const errorJson: any = await response.json() throw new APIError(errorJson?.error?.message, response.status) } - const json: any = await response.json() - return json - .choices - .map((r: any) => parseAISubject(options, aiContext.parseFn(r))) + + const choiceCount = options.aiNumber || 1 + const readable = bodyToNodeReadable(response.body) + const rawSubjects = await readChatCompletionStreamToSubjects(readable, choiceCount) + return rawSubjects.map(s => parseAISubject(options, s)) } catch (err: any) { let errorMsg = 'Fetch OpenAI API message failure.' @@ -74,14 +82,13 @@ function useModelStrategy(options: CommitizenGitOptions, prompt: string) { payload: { model: options.aiModel, messages: [{ role: 'user', content: prompt }], - stream: false, + stream: true, top_p: 1, temperature: 0.7, - max_tokens: 200, + max_tokens: AI_MAX_COMPLETION_TOKENS, n: options.aiNumber || 1, }, url: `${options.apiEndpoint}/chat/completions`, - parseFn: (res: any) => res?.message?.content, } } diff --git a/packages/cz-git/src/shared/utils/index.ts b/packages/cz-git/src/shared/utils/index.ts index 72b3b2e36..33917f193 100644 --- a/packages/cz-git/src/shared/utils/index.ts +++ b/packages/cz-git/src/shared/utils/index.ts @@ -1,4 +1,5 @@ export * from './editor' -export * from './util' export * from './rule' +export * from './stream' +export * from './util' export * from './wrap' diff --git a/packages/cz-git/src/shared/utils/stream.ts b/packages/cz-git/src/shared/utils/stream.ts new file mode 100644 index 000000000..6fa18dee1 --- /dev/null +++ b/packages/cz-git/src/shared/utils/stream.ts @@ -0,0 +1,171 @@ +/** + * @description Parse OpenAI-compatible `chat/completions` streaming (SSE) bodies + * @author Zhengqbbb + * @license MIT + */ + +import { Buffer } from 'node:buffer' +import { Readable } from 'node:stream' +import type { ReadableStream as WebReadableStream } from 'node:stream/web' + +/** + * Normalize `fetch` response body to a Node.js readable stream. + */ +export function bodyToNodeReadable(body: unknown): NodeJS.ReadableStream { + if (body == null) + throw new Error('Response has no body') + if (typeof (body as WebReadableStream).getReader === 'function') + return Readable.fromWeb(body as WebReadableStream) + return body as NodeJS.ReadableStream +} + +/** + * Append only user-visible completion tokens from `delta.content`. + * Skips reasoning / `reasoning_content` (not present on `content` in typical deltas). + */ +export function appendVisibleDelta(acc: string, delta: { content?: unknown } | undefined): string { + if (!delta) + return acc + const c = delta.content + if (c == null) + return acc + if (typeof c === 'string') + return acc + c + if (Array.isArray(c)) { + let s = acc + for (const p of c) { + if (p && typeof p === 'object' && (p as { type?: string, text?: string }).type === 'text') { + const t = (p as { text?: string }).text + if (typeof t === 'string') + s += t + } + } + return s + } + return acc +} + +interface StreamChoiceChunk { index?: number, delta?: { content?: unknown } } + +interface NonStreamChoice { index?: number, message?: { content?: unknown } } + +async function readableToUtf8String(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stream as AsyncIterable) { + if (Buffer.isBuffer(chunk)) + chunks.push(chunk) + else if (typeof chunk === 'string') + chunks.push(Buffer.from(chunk)) + else + chunks.push(Buffer.from(String(chunk))) + } + return Buffer.concat(chunks as readonly Uint8Array[]).toString('utf8') +} + +/** + * Parse a non-streaming `chat/completions` JSON body when `stream: true` was ignored. + * @returns subjects slice, or `undefined` if the body is not a usable completion object. + */ +function trySubjectsFromNonStreamCompletionJson( + body: string, + choiceCount: number, +): string[] | undefined { + const t = body.trim() + if (!t.startsWith('{')) + return undefined + let json: unknown + try { + json = JSON.parse(t) + } + catch { + return undefined + } + if (!json || typeof json !== 'object') + return undefined + const o = json as { choices?: NonStreamChoice[], error?: { message?: string } } + if (o.error) + throw new Error(o.error.message || 'OpenAI API error') + if (!Array.isArray(o.choices)) + return undefined + + const buffers = Array.from({ length: choiceCount }, () => '') + let maxIndexSeen = -1 + for (const ch of o.choices) { + const idx = typeof ch.index === 'number' ? ch.index : 0 + if (idx >= 0 && idx < choiceCount) { + buffers[idx] = appendVisibleDelta('', { content: ch.message?.content }) + maxIndexSeen = Math.max(maxIndexSeen, idx) + } + } + if (maxIndexSeen < 0) + return undefined + return buffers.slice(0, maxIndexSeen + 1) +} + +function collectSubjectsFromSseLines( + body: string, + choiceCount: number, +): { buffers: string[], maxIndexSeen: number } { + const buffers = Array.from({ length: choiceCount }, () => '') + let maxIndexSeen = -1 + for (const line of body.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) + continue + const payload = trimmed.slice(5).trim() + if (payload === '[DONE]') + continue + try { + const json = JSON.parse(payload) as { + error?: { message?: string } + choices?: StreamChoiceChunk[] + } + if (json.error) + throw new Error(json.error.message || 'OpenAI stream error') + + for (const ch of json.choices ?? []) { + const idx = typeof ch.index === 'number' ? ch.index : 0 + if (idx >= 0 && idx < choiceCount) { + buffers[idx] = appendVisibleDelta(buffers[idx], ch.delta) + maxIndexSeen = Math.max(maxIndexSeen, idx) + } + } + } + catch (e) { + if (e instanceof SyntaxError) + continue + throw e + } + } + return { buffers, maxIndexSeen } +} + +/** + * Read an OpenAI-style `chat/completions` response body and return one finished string per choice. + * Primary path: SSE lines (`data: {...}`) with `choices[].delta`, bucketed by `choices[].index` + * up to `choiceCount` (requested `n`). Returned length is `maxSeenIndex + 1` (capped by `choiceCount`), + * mirroring non-stream `choices.length` when fewer parallel completions appear. + * Fallback: if no choice index ever appears (e.g. provider ignores `stream: true` and returns one JSON object), + * the full body is parsed as a non-streaming completion using `choices[].message.content`. + */ +export async function readChatCompletionStreamToSubjects( + input: NodeJS.ReadableStream, + choiceCount: number, +): Promise { + if (choiceCount < 1) + throw new Error('choiceCount must be at least 1') + + const body = await readableToUtf8String(input) + const { buffers, maxIndexSeen } = collectSubjectsFromSseLines(body, choiceCount) + + if (maxIndexSeen >= 0) + return buffers.slice(0, maxIndexSeen + 1) + + const fromJson = trySubjectsFromNonStreamCompletionJson(body, choiceCount) + if (fromJson !== undefined) + return fromJson + + throw new Error( + 'Chat completions response had no streamed choice deltas and is not a parseable non-streaming JSON body with choices (or choices were empty).', + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55c969ed5..7dcd409bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,8 +104,8 @@ importers: specifier: ^1.1.2 version: 1.1.2 pnpm: - specifier: ^9.11.0 - version: 9.11.0 + specifier: ^10.33.2 + version: 10.33.2 rimraf: specifier: catalog:rimraf version: 3.0.2 @@ -1838,46 +1838,55 @@ packages: resolution: {integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.21.2': resolution: {integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.21.2': resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.21.2': resolution: {integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-powerpc64le-gnu@4.21.2': resolution: {integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.21.2': resolution: {integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.21.2': resolution: {integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.21.2': resolution: {integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.21.2': resolution: {integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.21.2': resolution: {integrity: sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==} @@ -4227,8 +4236,8 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} - pnpm@9.11.0: - resolution: {integrity: sha512-CiA/+u1aP2MkLNBkyPtYkjZsED4ygHkxj3gGLyTqjJ1QvGpHqjVnyr79gk0XDnj6J0XtHxaxMuFkNhRrdojxmw==} + pnpm@10.33.2: + resolution: {integrity: sha512-qQ+vb+6rca1sblf5Tg/hoS9dzCLNdU20CulZPraj4LaxLjVAIYuzeuCDQEsfLObbKkEh6XmCm0r/lLmfSdoc+A==} engines: {node: '>=18.12'} hasBin: true @@ -9527,7 +9536,7 @@ snapshots: pluralize@8.0.0: {} - pnpm@9.11.0: {} + pnpm@10.33.2: {} postcss-load-config@6.0.1(jiti@1.21.6)(postcss@8.4.44)(tsx@4.19.0): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5b65c6075..b9fcc25e2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,25 @@ packages: - "docs" - "packages/**" - "!**/__tests__/**" +shellEmulator: true +shamefullyHoist: true +overrides: + '@commitlint/config-validator': "catalog:commitlint" + 'chalk': "4.1.2" + 'color-convert': "2.0.1" + 'import-meta-resolve': "4.1.0" + 'resolve-from': "5.0.0" + 'supports-color': "8.1.1" +peerDependencyRules: + ignoreMissing: + - "@algolia/client-search" + - "@types/react" + - "eslint-plugin-import" + - "eslint-plugin-n" + - "eslint-plugin-promise" + - "react" + - "react-dom" + - "webpack" catalog: # cosmiconfig > 8.2.0 will lead bundle size to 10MB +