Init DevChatClient and DevChatCLI

This commit is contained in:
kagami 2024-07-08 18:35:34 +08:00
parent 1110171074
commit d1f712ff90
2 changed files with 550 additions and 0 deletions

View File

@ -0,0 +1,294 @@
import * as dotenv from "dotenv";
import * as path from "path";
import * as fs from "fs";
import * as os from "os";
import { logger } from "../util/logger";
import { CommandRun } from "../util/commonUtil";
import { UiUtilWrapper } from "../util/uiUtil";
import { ApiKeyManager } from "../util/apiKey";
import { assertValue } from "../util/check";
import { getFileContent } from "../util/commonUtil";
import { DevChatConfig } from "../util/config";
import { getMicromambaUrl } from "../util/python_installer/conda_url";
const readFileAsync = fs.promises.readFile;
const envPath = path.join(__dirname, "..", ".env");
dotenv.config({ path: envPath });
export interface ChatOptions {
parent?: string;
reference?: string[];
header?: string[];
context?: string[];
}
export interface ChatResponse {
// eslint-disable-next-line @typescript-eslint/naming-convention
"prompt-hash": string;
user: string;
date: string;
response: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
finish_reason: string;
isError: boolean;
}
export class DevChatCLI {
private commandRun: CommandRun;
constructor() {
this.commandRun = new CommandRun();
}
private async buildArgs(options: ChatOptions): Promise<string[]> {
let args = ["-m", "devchat", "route"];
if (options.reference) {
for (const reference of options.reference) {
args.push("-r", reference);
}
}
if (options.header) {
for (const header of options.header) {
args.push("-i", header);
}
}
if (options.context) {
for (const context of options.context) {
args.push("-c", context);
}
}
if (options.parent) {
args.push("-p", options.parent);
}
const llmModelData = await ApiKeyManager.llmModel();
assertValue(
!llmModelData || !llmModelData.model,
"You must select a LLM model to use for conversations"
);
args.push("-m", llmModelData.model);
const functionCalling = DevChatConfig.getInstance().get(
"enable_function_calling"
);
if (functionCalling) {
args.push("-a");
}
return args;
}
private parseOutData(stdout: string, isPartial: boolean): ChatResponse {
const responseLines = stdout.trim().split("\n");
if (responseLines.length < 2) {
return this.createChatResponse("", "", "", "", !isPartial);
}
// logger.channel()?.info(`\n-responseLines: ${responseLines}`);
const [userLine, remainingLines1] = this.extractLine(
responseLines,
"User: "
);
const user = this.parseLine(userLine, /User: (.+)/);
const [dateLine, remainingLines2] = this.extractLine(
remainingLines1,
"Date: "
);
const date = this.parseLine(dateLine, /Date: (.+)/);
const [promptHashLine, remainingLines3] = this.extractLine(
remainingLines2,
"prompt"
);
const [finishReasonLine, remainingLines4] = this.extractLine(
remainingLines3,
"finish_reason:"
);
if (!promptHashLine) {
return this.createChatResponse(
"",
user,
date,
remainingLines4.join("\n"),
!isPartial
);
}
const finishReason = finishReasonLine.split(" ")[1];
const promptHash = promptHashLine.split(" ")[1];
const response = remainingLines4.join("\n");
return this.createChatResponse(
promptHash,
user,
date,
response,
false,
finishReason
);
}
private extractLine(
lines: string[],
startWith: string
): [string, string[]] {
const index = lines.findIndex((line) => line.startsWith(startWith));
const extractedLine = index !== -1 ? lines.splice(index, 1)[0] : "";
return [extractedLine, lines];
}
private parseLine(line: string, regex: RegExp): string {
return line.match(regex)?.[1] ?? "";
}
private createChatResponse(
promptHash: string,
user: string,
date: string,
response: string,
isError: boolean,
finishReason = ""
): ChatResponse {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
"prompt-hash": promptHash,
user,
date,
response,
// eslint-disable-next-line @typescript-eslint/naming-convention
finish_reason: finishReason,
isError,
};
}
public input(data: string) {
this.commandRun?.write(data + "\n");
}
public stop() {
this.commandRun.stop();
}
async runWorkflow(
content: string,
options: ChatOptions = {},
onData: (data: ChatResponse) => void,
): Promise<ChatResponse> {
// TODO: Use another cli command to run workflow instead of `devchat route`
try {
// build args for devchat prompt command
const args = await this.buildArgs(options);
args.push("--");
args.push(content);
// build env variables for prompt command
const llmModelData = await ApiKeyManager.llmModel();
assertValue(!llmModelData, "No valid llm model selected");
const envs = {
...process.env,
// eslint-disable-next-line @typescript-eslint/naming-convention
PYTHONUTF8: 1,
// eslint-disable-next-line @typescript-eslint/naming-convention
command_python:
DevChatConfig.getInstance().get("python_for_commands") ||
"",
// eslint-disable-next-line @typescript-eslint/naming-convention
PYTHONPATH:
UiUtilWrapper.extensionPath() + "/tools/site-packages",
// eslint-disable-next-line @typescript-eslint/naming-convention
OPENAI_API_KEY: llmModelData.api_key.trim(),
DEVCHAT_UNIT_TESTS_USE_USER_MODEL: 1,
// eslint-disable-next-line @typescript-eslint/naming-convention
...(llmModelData.api_base
? {
OPENAI_API_BASE: llmModelData.api_base,
OPENAI_BASE_URL: llmModelData.api_base,
}
: {}),
DEVCHAT_PROXY:
DevChatConfig.getInstance().get("DEVCHAT_PROXY") || "",
MAMBA_BIN_PATH: getMicromambaUrl(),
};
// build process options
const spawnAsyncOptions = {
maxBuffer: 10 * 1024 * 1024, // Set maxBuffer to 10 MB
cwd: UiUtilWrapper.workspaceFoldersFirstPath(),
env: envs,
};
logger
.channel()
?.info(
`api_key: ${llmModelData.api_key.replace(
/^(.{4})(.*)(.{4})$/,
(_, first, middle, last) =>
first + middle.replace(/./g, "*") + last
)}`
);
logger.channel()?.info(`api_base: ${llmModelData.api_base}`);
// run command
// handle stdout as steam mode
let receviedStdout = "";
const onStdoutPartial = (stdout: string) => {
receviedStdout += stdout;
const data = this.parseOutData(receviedStdout, true);
onData(data);
};
// run command
const pythonApp =
DevChatConfig.getInstance().get("python_for_chat") || "python3";
logger
.channel()
?.info(`Running devchat:${pythonApp} ${args.join(" ")}`);
const {
exitCode: code,
stdout,
stderr,
} = await this.commandRun.spawnAsync(
pythonApp,
args,
spawnAsyncOptions,
onStdoutPartial,
undefined,
undefined,
undefined
);
// handle result
assertValue(code !== 0, stderr || "Command exited with error code");
const responseData = this.parseOutData(stdout, false);
// return result
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
"prompt-hash": "",
user: "",
date: "",
response: responseData.response,
// eslint-disable-next-line @typescript-eslint/naming-convention
finish_reason: "",
isError: false,
};
} catch (error: any) {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
"prompt-hash": "",
user: "",
date: "",
response: `Error: ${error.message}`,
// eslint-disable-next-line @typescript-eslint/naming-convention
finish_reason: "error",
isError: true,
};
}
}
}

View File

@ -0,0 +1,256 @@
import axios, { AxiosResponse, CancelTokenSource } from "axios";
import { logger } from "../util/logger";
import { getFileContent } from "../util/commonUtil";
import {
devchatSocket,
startSocketConn,
closeSocketConn,
} from "./socketClient";
import { UiUtilWrapper } from "../util/uiUtil";
import { workspace } from "vscode";
import { deleteTopic } from "@/handler/topicHandler";
function timeThis(
target: Object,
propertyKey: string,
descriptor: TypedPropertyDescriptor<any>
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const start = process.hrtime.bigint();
const result = await originalMethod.apply(this, args);
const end = process.hrtime.bigint();
const nanoseconds = end - start;
const seconds = Number(nanoseconds) / 1e9;
const className = target.constructor.name;
logger
.channel()
?.debug(`Exec time [${className}.${propertyKey}]: ${seconds} s`);
return result;
};
return descriptor;
}
export interface ChatRequest {
content: string;
model_name: string;
api_key: string;
api_base: string;
parent?: string;
context?: string[];
}
export interface ChatResponse {
// eslint-disable-next-line @typescript-eslint/naming-convention
"prompt-hash": string;
user: string;
date: string;
response: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
finish_reason: string;
isError: boolean;
extra?: object;
}
export interface LogData {
model: string;
messages: object[];
parent?: string;
timestamp: number;
request_tokens: number;
response_tokens: number;
}
export interface LogInsertRes {
hash?: string;
error?: string;
}
export async function buildRoleContextsFromFiles(
files: string[] | undefined
): Promise<object[]> {
const contexts: object[] = [];
if (!files) {
return contexts;
}
for (const file of files) {
const content = await getFileContent(file);
if (!content) {
continue;
}
contexts.push({
role: "system",
content: `<context>${content}</context>`,
});
}
return contexts;
}
export class DevChatClient {
private baseURL: string;
private _cancelMessageToken: CancelTokenSource | null = null;
constructor() {
// TODO: tmp dev
this.baseURL = "http://localhost:22222";
}
async _get(path: string): Promise<AxiosResponse> {
try {
logger.channel()?.debug(`GET request to ${this.baseURL}${path}`);
const response = await axios.get(`${this.baseURL}${path}`);
return response;
} catch (error) {
console.error(error);
throw error;
}
}
async _post(path: string, data: any = undefined): Promise<AxiosResponse> {
try {
const response = await axios.post(`${this.baseURL}${path}`, data);
return response;
} catch (error) {
console.error(error);
throw error;
}
}
@timeThis
async message(
message: ChatRequest,
onData: (data: ChatResponse) => void
): Promise<ChatResponse> {
this._cancelMessageToken = axios.CancelToken.source();
const workspace = UiUtilWrapper.workspaceFoldersFirstPath();
// const workspace = undefined;
const data = {
...message,
workspace: workspace,
};
return new Promise<ChatResponse>(async (resolve, reject) => {
try {
const response = await axios.post(
`${this.baseURL}/message/msg`,
data,
{
responseType: "stream",
cancelToken: this._cancelMessageToken!.token,
}
);
const chatRes: ChatResponse = {
"prompt-hash": "", // TODO: prompt-hash is not in chatting response
user: "",
date: "",
response: "",
finish_reason: "",
isError: false,
};
response.data.on("data", (chunk) => {
const chunkData = JSON.parse(chunk.toString());
if (chatRes.user === "") {
chatRes.user = chunkData["user"];
}
if (chatRes.date === "") {
chatRes.date = chunkData["date"];
}
chatRes.finish_reason = chunkData["finish_reason"];
// TODO: tmp string literal 临时字面量
if (chatRes.finish_reason === "should_run_workflow") {
chatRes.extra = chunkData["extra"];
logger
.channel()
?.debug(
"res on data: should_run_workflow. do nothing now."
);
logger
.channel()
?.debug(
`chatRes.extra: ${JSON.stringify(
chatRes.extra
)}`
);
return;
}
chatRes.isError = chunkData["isError"];
chatRes.response += chunkData["content"];
logger.channel()?.debug(`${chunkData["content"]}`);
onData(chatRes);
});
response.data.on("end", () => {
logger.channel()?.debug("\nStreaming ended");
resolve(chatRes); // Resolve the promise with chatRes when the stream ends
});
response.data.on("error", (error) => {
logger.channel()?.error("Streaming error:", error);
// TODO: handle error
reject(error); // Reject the promise on error
});
} catch (error) {
// TODO: handle error
reject(error); // Reject the promise if the request fails
}
});
}
cancelMessage(): void {
if (this._cancelMessageToken) {
this._cancelMessageToken.cancel(
"Message request cancelled by user"
);
this._cancelMessageToken = null;
}
}
/**
* Insert a message log.
*
* @param logData - The log data to be inserted.
* @returns A tuple of inserted hash and error message.
*/
@timeThis
async insertLog(logData: LogData): Promise<LogInsertRes> {
// TODO: 处理当jsondata太大时写入临时文件
const data = {
workspace: UiUtilWrapper.workspaceFoldersFirstPath(),
// workspace: undefined,
jsondata: JSON.stringify(logData),
};
const response = await this._post("/logs/insert", data);
logger
.channel()
?.debug(
`insertLog response data: ${JSON.stringify(
response.data
)}, ${typeof response.data}}`
);
// TODO: handle error
const res: LogInsertRes = {
hash: response.data["hash"],
error: response.data["error"],
};
return res;
}
stopAllRequest(): void {
this.cancelMessage();
// add other requests here if needed
}
}