gchcg-cli
Version:
1 lines • 11.7 kB
JavaScript
const Command=require("./command"),{execSync:execSync}=require("child_process"),{OpenAI:OpenAI}=require("openai"),log=require("./log"),inquirer=require("inquirer").default,SimpleGit=require("simple-git"),Big=require("big.js"),path=require("path"),fs=require("fs"),{readFile:readFile,writeFile:writeFile,spinnerStart:spinnerStart,sleep:sleep}=require("./utils"),CONFIG_NAME="commit.json",BASE_URL="https://openrouter.ai/api/v1",TOKEN="",MODEL="openai/gpt-4o-mini",PRICING_PROMPT="0.00000015",PRICING_COMPLETION="0.0000006",CONTEXT_LENGTH=128e3,MAX_COMPLETION_LENGTH=16384;function isValidPositiveNumber(e){return!(!/^(0\.\d+|[1-9]\d*(\.\d+)?)$/.test(e)||!isFinite(e))&&(!/^0\d*$/.test(e)&&!e.endsWith("."))}class CommitCommand extends Command{async init(){this.baseUrl=this._cmd.baseUrl,this.setToken=this._cmd.setToken,this.setModel=this._cmd.setModel,this.files=this._cmd.files,this.context=this._cmd.context,this.skip=this._cmd.skip,this.config={},this.configPath=path.resolve(process.env.CLI_HOME_PATH,CONFIG_NAME)}async exec(){const e=(new Date).getTime();try{if(this.prepare(),this.baseUrl)return await this.setBaseUrlAction(),void log.verbose("本次执行指令耗时:"+((new Date).getTime()-e)/1e3+"秒");if(this.setToken)return await this.setTokenAction(),void log.verbose("本次执行指令耗时:"+((new Date).getTime()-e)/1e3+"秒");if(this.setModel)return await this.setModelAction(),void log.verbose("本次执行指令耗时:"+((new Date).getTime()-e)/1e3+"秒");if(!this.config.baseUrl)throw new Error("baseUrl 未设置");if(!this.config.token){const e=/^https?:\/\/localhost/;if(log.info(`BaseUrl:${this.config.baseUrl}`),!e.test(this.config.baseUrl))throw new Error("token 必须设置, 请执行:cg commit --setToken");log.warn("token 为空,请执行:cg commit --setToken")}if(!this.config.model)throw log.info(`BaseUrl:${this.config.baseUrl}`),new Error("model 未设置, 请执行:cg commit --setModel");log.verbose(`当前使用模型:${this.config.model}`);const t=require("child_process").execSync("git config user.name").toString().trim();if(!t)throw new Error('git用户名不存在,请先执行git config --global user.name "Your Name"');if(!this.skip){const e=spinnerStart("正在 git add ...");await sleep();try{execSync(`git add ${Array.isArray(this.files)&&this.files.length?this.files.join(" "):"."}`),e.stop(!0),log.success("git add 成功")}catch(t){e.stop(!0)}}const i=this.getDiff();await this.commitAction(t,i),await this.pushAction(t)}catch(e){log.error(e.message)}log.verbose("本次执行指令耗时:"+((new Date).getTime()-e)/1e3+"秒")}async setBaseUrlAction(){const e=this.configPath;log.info(`默认 baseUrl:${BASE_URL}`),this.config.baseUrl&&this.config.baseUrl!==BASE_URL&&log.info(`当前 baseUrl:${this.config.baseUrl}`);const t={type:"text",name:"baseUrl",message:"请输入新的 baseUrl:",default:"",validate:e=>!(!e.startsWith("http")&&!e.startsWith("https"))||"baseUrl 格式错误,请输入正确的 url"};let i="";for(;!i;){i=(await inquirer.prompt(t)).baseUrl}this.config.baseUrl=i,writeFile(e,JSON.stringify(this.config,null,2)),log.success(`baseUrl 设置成功:${i}`)}async setTokenAction(){const e=this.configPath;log.warn("token 非必填");const t={type:"text",name:"token",message:"请输入 Token:",default:""};let i=(await inquirer.prompt(t)).token,s=spinnerStart("检查 token ...");await sleep();try{const t=new OpenAI({apiKey:i,baseURL:this.config.baseUrl});await t.models.list(),s.stop(!0),this.config.token=i,writeFile(e,JSON.stringify(this.config,null,2)),log.success("token 设置成功"+(""===this.config.token?",token 为空。":":"+this.config.token))}catch(e){throw s.stop(!0),log.error("设置 token 失败!"),new Error(e?.message)}}addOneZero(e){return e<10?"0"+e:e}addTwoZero(e){return e<10?"00"+e:e<100?"0"+e:e}formatModelIndex(e,t){return t<100?this.addOneZero(e+1):this.addTwoZero(e+1)}async setModelAction(){const e=this.configPath;log.verbose(`当前使用模型:${this.config.model}`);const t=new OpenAI({apiKey:this.config.token,baseURL:this.config.baseUrl}),i=await t.models.list(),s=i.data.map((e=>e.id)).sort(((e,t)=>e.slice(0,1).localeCompare(t.slice(0,1)))),o=s.map((e=>({name:`${e}`,value:e})));if(log.verbose(`${i.data.length}个模型,↑↓ 键浏览,enter 选择`),i.data.length>10){const e=new Set(s.map((e=>e.slice(0,1).toLowerCase())));log.info(`输入首字母快速翻页:${Array.from(e).join("、")}`)}const n={type:"list",name:"model",message:"请选择模型:",default:s.includes(this.config.model)?this.config.model:s.includes(MODEL)?MODEL:s[0]||"",choices:o},r=await inquirer.prompt(n),a=i.data.find((e=>e.id===r.model)),c=a.id;let l=spinnerStart("检测模型是否可用...");await sleep();try{const e=await t.chat.completions.create({model:c,messages:[{role:"system",content:"请简短输出答案"},{role:"user",content:"1+1=?"}]});l.stop(!0),this.config.model=c;let i=a.top_provider?.max_completion_tokens,s=a.pricing?.prompt,o=a.pricing?.completion;if("number"!=typeof i)for(;!i;){log.verbose("示例(1M=1百万,1k=1000):\n单次输入最大64k(64*1000),填写64000");i=(await inquirer.prompt({type:"text",name:"maxTokens",message:"模型支持最大输入tokens:",default:"16384",validate:e=>!!/^[1-9]\d*$/.test(e)||"请输入正确的正整数,不允许特殊字符,不允许0开头"})).maxTokens}if("string"!=typeof s)for(;!s;){log.verbose("示例(1M=1百万,1k=1000):\n$0.5/百万token,可写为 $0.5/M,计算方式 $0.5/1000000,填写 0.0000005");s=(await inquirer.prompt({type:"text",name:"pricing_prompt",message:"模型输入 1 token 费用:",default:"0.00000015",validate:e=>!!isValidPositiveNumber(e)||"请输入大于0的正确数字"})).pricing_prompt}if("string"!=typeof o)for(log.verbose("示例(1M=1百万,1k=1000):\n$1/百万token,可写为 $1/M,计算方式 $1/1000000,填写 0.000001");!o;){o=(await inquirer.prompt({type:"text",name:"pricing_completion",message:"模型输出 1 token 费用:",default:"0.00000015",validate:e=>!!isValidPositiveNumber(e)||"请输入大于0的正确数字"})).pricing_completion}this.config.max_completion_tokens=+i,this.config.pricing_prompt=s,this.config.pricing_completion=o;const n=new Big(this.config.pricing_prompt).times(e.usage?.prompt_tokens||0),r=new Big(this.config.pricing_completion).times(e.usage?.completion_tokens||0),g=n.plus(r);log.success(`模型设置成功。\n本次模型检测费用统计:\n输入 ${e.usage?.prompt_tokens||0} tokens,$${this.config.pricing_prompt}/token\n输出 ${e.usage?.completion_tokens||0} tokens,$${this.config.pricing_completion}/token\n总计 ${e.usage?.total_tokens||0} tokens\n费用:$${n.toString()}+$${r.toString()}=$${g.toString()}\n`)}catch(e){l.stop(!0),log.error(`${c}模型不可用`),log.warn(e)}if(writeFile(e,JSON.stringify(this.config,null,2)),!this.config.model)throw new Error(`${c}模型不可用`)}getDiff(){try{let e=execSync("git diff --staged").toString();return e.length>this.config.max_completion_tokens&&(log.warn(`更新内容超过模型支持的最大 ${this.config.max_completion_tokens} tokens,简要统计信息。`),e=execSync("git diff --staged --stat").toString()),e}catch(e){return log.warn("未知异常:",e?.message),execSync("git diff --staged --stat").toString()}}formatMessage(e,t){const i=/\r\n |\r|\n/g;let s=e.trim();if(this.config.model.includes("deepseek")&&s.includes("```")){const e=s.split("```").filter((e=>""!==e));s="```"+e.slice(0,1)+"```"}if(s.startsWith("```")&&s.endsWith("```")&&(s=s.split(i).slice(1).join("\n"),s=s.slice(0,-4)),(s.startsWith("---")||s.endsWith("---"))&&(s=s.split(i).filter((e=>!e.startsWith("---"))).join("\n")),s.includes("```")){let e=100;s=s.split(i).filter(((t,i)=>(t.startsWith("```")&&(e=i),i<e))).join("\n")}if(s.includes("---")){let e=100;s=s.split(i).filter(((t,i)=>(t.startsWith("---")&&(e=i),i<e))).join("\n")}const o=s.split("\n");let n=0;return o.forEach(((e,i)=>{i&&!n&&(t.test(e)?(o[i]=e.replace(t,""),n=i):e.startsWith("/^((feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)")&&(o[i]="",n=i))})),n&&(s=o.slice(0,n).join("\n")),s}async commitAction(e,t){const i=SimpleGit(this.projectInfo.dir),s=await i.status();if(!(s.not_added.length>0||s.created.length>0||s.deleted.length>0||s.modified.length>0||s.renamed.length>0))return log.warn("没有需要 commit 的文件变更"),Promise.resolve();const o=spinnerStart("正在生成 commit message...");await sleep();try{const s=["feat","fix","docs","style","refactor","perf","test","build","ci","chore","revert"];let n="";this.config.model.includes("deepseek")&&(n="禁止生成如:该提交信息遵循以下规范...、该提交信息遵循以下格式...、等字样");const r=new RegExp(`^((${s.join("|")})\\(${e}\\)):[\\s\\S]*$`),a=[{role:"system",content:`您是一个中文提交消息生成器,通过diff字符串创建一个提交消息,而不添加不必要的信息!以下是https://karma-runner.github.io/6.4/dev/git-commit-msg.html指南中一个好的中文提交消息的格式:\n \n ---\n <type>(${e}): <subject>\n <BLANK LINE>\n <body>\n ---\n \n 允许的< type >值有${s.join("、")}。生成格式正则校验:${r}。这里有一个很好的中文提交消息的例子:\n \n ---sh\n fix(${e}): 确保范围标题更加符合RFC 2616\n \n 添加一个新的依赖项,使用 range-parser ( Express dependency)计算范围。它在野外更经得起考验。\n ---${n?`\n \n 按照这个指示 "${n}"!`:""}`},{role:"user",content:t}],c=new OpenAI({apiKey:this.config.token,baseURL:this.config.baseUrl}),l=await c.chat.completions.create({model:this.config.model,messages:a});o.stop(!0);const g=this.formatMessage(l.choices[0].message.content,r);if(!r.test(g))throw log.error(`AI 生成的内容:\n${l.choices[0].message.content}`),new Error("生成格式不正确,请重试");log.success(`AI 生成的内容:\n${g}`);const m=new Big(this.config.pricing_prompt).times(l.usage?.prompt_tokens||0),p=new Big(this.config.pricing_completion).times(l.usage?.completion_tokens||0),h=m.plus(p);log.success(`模型统计:输入${l.usage?.prompt_tokens||0} tokens、输出 ${l.usage?.completion_tokens||0} tokens、费用 $${h.toString()}`);const f=spinnerStart("正在提交 commit message ...");await sleep();try{await i.commit(g),f.stop(!0),log.success("commit 提交成功")}catch(e){throw f.stop(!0),new Error(e?.message)}}catch(e){throw o.stop(!0),this.skip||execSync("git reset"),new Error(`commit message 失败:\n${e.message}`)}}async pushAction(){const e=SimpleGit(this.projectInfo.dir),t=await e.status();if(t.behind)return log.warn("远程分支领先本地分支:"+t.behind+"次。请拉取代码:cg pull"),Promise.resolve();if(!t.ahead)return log.warn("本地分支已经是最新分支,无需 push"),Promise.resolve();let i=spinnerStart(`正在 push 到 ${t.current} 分支...`);await sleep();try{await e.push("origin",t.current),i.stop(!0),log.success("代码推送成功")}catch(e){throw i.stop(!0),new Error(`代码推送失败: \n${e.message}`)}}prepare(){const e=process.cwd(),t=path.resolve(e,".git");if(!(this.baseUrl||this.setToken||this.setModel||fs.existsSync(t)))throw new Error("未找到 .git 文件夹,初始化 git 后重试");const i=this.configPath;try{const e=JSON.parse(readFile(i));Object.keys(e).length>0?this.config=e:this.config={}}catch(e){this.config={}}this.config.baseUrl||(this.config.baseUrl=BASE_URL),this.config.token||(this.config.token=""),this.config.model||(this.config.model=MODEL),writeFile(i,JSON.stringify(this.config,null,2)),this.projectInfo={dir:e}}}function init(e){return new CommitCommand(e)}module.exports=init,module.exports.CommitCommand=CommitCommand;