Merge pull request #577 from devchat-ai/local-svc-client

Devchat local service client
This commit is contained in:
boob.yang 2024-07-18 10:10:23 +08:00 committed by GitHub
commit 19b7d14be9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1290 additions and 1263 deletions

View File

@ -130,6 +130,11 @@
"title": "Install slash commands",
"category": "DevChat"
},
{
"command": "DevChat.StartLocalService",
"title": "Start local service",
"category": "DevChat"
},
{
"command": "DevChat.Chat",
"title": "Chat with DevChat",
@ -253,6 +258,10 @@
{
"command": "DevChat.InstallCommands",
"when": "false"
},
{
"command": "DevChat.StartLocalService",
"when": "false"
}
],
"explorer/context": [

View File

@ -1,340 +0,0 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { ChatContext } from './contextManager';
import { logger } from '../util/logger';
import { handleCodeSelected } from './contextCodeSelected';
import DevChat, { ChatOptions } from '../toolwrapper/devchat';
import { UiUtilWrapper } from '../util/uiUtil';
async function getCurrentSelectText(activeEditor: vscode.TextEditor): Promise<string> {
if (!activeEditor) {
return "";
}
const document = activeEditor.document;
const selection = activeEditor.selection;
const selectedText = document.getText(selection);
return selectedText;
}
// get full text in activeEditor
async function getFullText(activeEditor: vscode.TextEditor): Promise<string> {
if (!activeEditor) {
return "";
}
const document = activeEditor.document;
return document.getText();
}
async function getUndefinedSymbols(content: string): Promise<string[] | undefined> {
// run devchat prompt command
const devChat = new DevChat();
const chatOptions: ChatOptions = {};
const onData = (partialResponse) => { };
const newContent = `
As a software developer skilled in code analysis, your goal is to examine and understand a provided code snippet.
The code may include various symbols, encompassing both variables and functions.
However, the snippet doesn't include the definitions of some of these symbols.
Now your specific task is to identify and list all such symbols whose definitions are missing but are essential for comprehending the entire code.
This will help in fully grasping the behavior and purpose of the code. Note that the code snippet in question could range from a few lines to a singular symbol.
Response is json string, don't include any other output except the json object. The json object should be an array of strings, each string is a symbol name:
\`\`\`json
["f1", "f2"]
\`\`\`
During this process, you cannot invoke the GPT function. The code snippet is as follows: \n\`\`\`` + content + '```'; ;
const chatResponse = await devChat.chat(newContent, chatOptions, onData, false);
if (chatResponse && chatResponse.response) {
logger.channel()?.info(chatResponse.response);
}
// parse data in chatResponse.response
// data format as:
// ```json {data} ```
// or [data]
// or plain text
// so, parse data between ```json and ``` or directly parse the array, or return an empty array
if (!chatResponse || !chatResponse.response) {
return [];
}
let responseText = chatResponse.response.trim();
let symbols: string[];
const indexBlock = responseText.indexOf('```');
if (indexBlock !== -1) {
const indexJsonEnd = responseText.indexOf('```', indexBlock+3);
if (indexJsonEnd !== -1) {
responseText = responseText.substring(indexBlock, indexJsonEnd + 3);
}
}
if (responseText.startsWith("```") && responseText.endsWith("```")) {
const index = responseText.indexOf('[');
responseText = responseText.substring(index, responseText.length - 3);
try {
symbols = JSON.parse(responseText);
} catch (error) {
return undefined;
}
} else {
try {
symbols = JSON.parse(responseText);
} catch (error) {
return undefined;
}
}
logger.channel()?.info(`getUndefinedSymbols: ${chatResponse.response}`);
return symbols;
}
function matchSymbolInline(line: string, symbol: string): number[] {
const escapedSymbol = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Create a RegExp with the escaped symbol and word boundaries
const regex = new RegExp(`\\b${escapedSymbol}\\b`, 'gu');
// Find the match in the selected text
const matches = [...line.matchAll(regex)];
// If the symbol is found
if (matches.length === 0) {
return [];
}
return matches.map((match) => match.index!);
}
function getMatchedSymbolPositions(selectText: string, symbol: string): object[] {
const lines = selectText.split('\n');
const positions: object[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const matchedPositions = matchSymbolInline(line, symbol);
for (const pos of matchedPositions) {
positions.push({
line: i,
character: pos
});
}
}
return positions;
}
function isLocationInstance(loc: vscode.Location | vscode.LocationLink): boolean {
try {
return (loc as vscode.Location).uri !== undefined;
} catch (error) {
return false;
}
}
async function handleCodeSelectedNoFile(fileSelected: string, codeSelected: string, startLine: number) {
const workspaceDir = UiUtilWrapper.workspaceFoldersFirstPath();
const relativePath = path.relative(workspaceDir!, fileSelected);
const data = {
path: relativePath,
startLine: startLine,
content: codeSelected
};
return data;
}
async function getSymbolDefine(symbolList: string[], activeEditor: vscode.TextEditor, toFile: boolean = true): Promise<string[]> {
const document = activeEditor!.document;
let selection = activeEditor!.selection;
let selectedText = document!.getText(selection);
if (selectedText === "" && !toFile) {
selectedText = await getFullText(activeEditor);
selection = new vscode.Selection(0, 0, 0, 0);
}
let contextList: any[] = [];
if (toFile) {
contextList = [await handleCodeSelectedNoFile(document.uri.fsPath, selectedText, selection.start.line)];
}
let hasVisitedSymbols: Set<string> = new Set();
let hasPushedSymbols: Set<string> = new Set();
// visit each symbol in symbolList, and get it's define
for (const symbol of symbolList) {
logger.channel()?.info(`handle symble: ${symbol} ...`);
// get symbol position in selectedText
// if selectedText is "abc2+abc", symbol is "abc", then symbolPosition is 5 not 0
// because abc2 is not a symbol
const positions: any[] = getMatchedSymbolPositions(selectedText, symbol);
for (const pos of positions) {
const symbolPosition = pos.character;
// if symbol is like a.b.c, then split it to a, b, c
const symbolSplit = symbol.split(".");
let curPosition = 0;
for (const symbolSplitItem of symbolSplit) {
const symbolPositionNew = symbol.indexOf(symbolSplitItem, curPosition) + symbolPosition;
curPosition = symbolPositionNew - symbolPosition + symbolSplitItem.length;
const newPos = new vscode.Position(pos.line + selection.start.line, (pos.line > 0 ? 0 : selection.start.character) + symbolPositionNew);
logger.channel()?.info(`handle sub symble: ${symbolSplitItem} at ${newPos.line}:${newPos.character}`);
try{
// call vscode.executeDefinitionProvider
const refLocations = await vscode.commands.executeCommand<vscode.Location[] | vscode.LocationLink[]>(
'vscode.executeDefinitionProvider',
document.uri,
newPos
);
if (!refLocations) {
logger.channel()?.info(`no def location for ${symbolSplitItem} at ${newPos.line}:${newPos.character}`);
}
// visit each refLocation, and get it's define
for (const refLocation of refLocations) {
let targetUri: vscode.Uri | undefined = undefined;
let targetRange: vscode.Range | undefined = undefined;
if (isLocationInstance(refLocation)) {
const locLocation = refLocation as vscode.Location;
targetUri = locLocation.uri;
targetRange = locLocation.range;
} else {
const locLocationLink = refLocation as vscode.LocationLink;
targetUri = locLocationLink.targetUri;
targetRange = locLocationLink.targetRange;
}
if (!targetUri ||!targetRange) {
continue;
}
logger.channel()?.info(`def location: ${targetUri.fsPath} ${targetRange.start.line}:${targetRange.start.character}-${targetRange.end.line}:${targetRange.end.character}`);
const refLocationString = targetUri.fsPath + "-" + targetRange.start.line + ":" + targetRange.start.character + "-" + targetRange.end.line + ":" + targetRange.end.character;
if (hasVisitedSymbols.has(refLocationString)) {
continue;
}
hasVisitedSymbols.add(refLocationString);
// get defines in refLocation file
const symbolsT: vscode.DocumentSymbol[] = await vscode.commands.executeCommand<vscode.DocumentSymbol[]>(
'vscode.executeDocumentSymbolProvider',
targetUri
);
let targetSymbol: any = undefined;
const visitFun = (symbol: vscode.DocumentSymbol) => {
if (targetRange!.start.isAfterOrEqual(symbol.range.start) && targetRange!.end.isBeforeOrEqual(symbol.range.end)) {
targetSymbol = symbol;
}
if (targetRange!.start.isAfter(symbol.range.end) || targetRange!.end.isBefore(symbol.range.start)) {
return;
}
for (const child of symbol.children) {
visitFun(child);
}
};
for (const symbol of symbolsT) {
visitFun(symbol);
}
if (targetSymbol !== undefined) {
logger.channel()?.info(`symbol define information: ${targetSymbol.name} at ${targetSymbol.location.uri.fsPath} ${targetSymbol.location.range.start.line}:${targetSymbol.location.range.start.character}-${targetSymbol.location.range.end.line}:${targetSymbol.location.range.end.character}`);
const defLocationString = targetSymbol.location.uri.fsPath + "-" + targetSymbol.location.range.start.line + ":" + targetSymbol.location.range.start.character + "-" + targetSymbol.location.range.end.line + ":" + targetSymbol.location.range.end.character;
if (hasPushedSymbols.has(defLocationString)) {
continue;
}
hasPushedSymbols.add(defLocationString);
const documentNew = await vscode.workspace.openTextDocument(targetUri);
if (targetSymbol.kind === vscode.SymbolKind.Variable) {
const renageNew = new vscode.Range(targetSymbol.range.start.line, 0, targetSymbol.range.end.line, 10000);
if (toFile) {
contextList.push(await handleCodeSelected(targetUri.fsPath, documentNew.getText(renageNew), targetSymbol.range.start.line));
} else {
contextList.push(await handleCodeSelectedNoFile(targetUri.fsPath, documentNew.getText(renageNew), targetSymbol.range.start.line));
}
} else {
if (toFile) {
contextList.push(await handleCodeSelected(targetUri.fsPath, documentNew.getText(targetSymbol.range), targetSymbol.range.start.line));
} else {
contextList.push(await handleCodeSelectedNoFile(targetUri.fsPath, documentNew.getText(targetSymbol.range), targetSymbol.range.start.line));
}
}
}
}
} catch (error) {
logger.channel()?.error(`getSymbolDefine error: ${error}`);
}
}
}
}
return contextList;
}
export async function getSymbolDefines() {
const activeEditor = vscode.window.activeTextEditor;
if (!activeEditor) {
logger.channel()?.error('No code selected!');
logger.channel()?.show();
return [];
}
let selectedText = await getCurrentSelectText(activeEditor);
if (selectedText === "") {
selectedText = await getFullText(activeEditor);
}
if (selectedText === "") {
logger.channel()?.error(`No code selected! Current selected editor is: ${activeEditor.document.uri.fsPath}}`);
logger.channel()?.show();
return [];
}
const symbolList = await getUndefinedSymbols(selectedText);
if (symbolList === undefined) {
logger.channel()?.error('Failed to get symbol list!');
logger.channel()?.show();
return [];
}
const contextList = await getSymbolDefine(symbolList, activeEditor, false);
return contextList;
}
export const refDefsContext: ChatContext = {
name: 'symbol definitions',
description: 'find related definitions of classes, functions, etc. in selected code',
handler: async () => {
const activeEditor = vscode.window.activeTextEditor;
if (!activeEditor) {
logger.channel()?.error('No code selected!');
logger.channel()?.show();
return [];
}
const selectedText = await getCurrentSelectText(activeEditor);
if (selectedText === "") {
logger.channel()?.error(`No code selected! Current selected editor is: ${activeEditor.document.uri.fsPath}}`);
logger.channel()?.show();
return [];
}
const symbolList = await getUndefinedSymbols(selectedText);
if (symbolList === undefined) {
logger.channel()?.error('Failed to get symbol list!');
logger.channel()?.show();
return [];
}
const contextList = await getSymbolDefine(symbolList, activeEditor);
return contextList;
},
};

View File

@ -8,11 +8,13 @@ import { FilePairManager } from "../util/diffFilePairs";
import { UiUtilWrapper } from "../util/uiUtil";
import { sendCommandListByDevChatRun } from '../handler/workflowCommandHandler';
import DevChat from "../toolwrapper/devchat";
import { DevChatClient } from "../toolwrapper/devchatClient";
import { chatWithDevChat } from '../handler/chatHandler';
import { focusDevChatInput } from '../handler/focusHandler';
import { DevChatConfig } from '../util/config';
import { MessageHandler } from "../handler/messageHandler";
import { startLocalService } from '../util/localService';
import { logger } from "../util/logger";
const readdir = util.promisify(fs.readdir);
const mkdir = util.promisify(fs.mkdir);
@ -196,7 +198,7 @@ export function registerInstallCommandsCommand(
"workflowsCommands"
); // Adjust this path as needed
const devchat = new DevChat();
const dcClient = new DevChatClient();
if (!fs.existsSync(sysDirPath)) {
await copyDirectory(pluginDirPath, sysDirPath);
@ -204,15 +206,15 @@ export function registerInstallCommandsCommand(
// Check if ~/.chat/scripts directory exists
if (!fs.existsSync(sysDirPath)) {
// Directory does not exist, wait for updateSysCommand to finish
await devchat.updateSysCommand();
// Directory does not exist, wait for updateWorkflows to finish
await dcClient.updateWorkflows();
sendCommandListByDevChatRun();
} else {
// Directory exists, execute sendCommandListByDevChatRun immediately
await sendCommandListByDevChatRun();
// Then asynchronously execute updateSysCommand
await devchat.updateSysCommand();
// Then asynchronously execute updateWorkflows
await dcClient.updateWorkflows();
await sendCommandListByDevChatRun();
}
}
@ -222,6 +224,28 @@ export function registerInstallCommandsCommand(
}
export function registerStartLocalServiceCommand(
context: vscode.ExtensionContext
) {
let disposable = vscode.commands.registerCommand(
"DevChat.StartLocalService",
async () => {
try {
const workspaceDir = UiUtilWrapper.workspaceFoldersFirstPath() ?? '';
logger.channel()?.debug(`extensionPath: ${context.extensionPath}`);
logger.channel()?.debug(`workspacePath: ${workspaceDir}`);
const port = await startLocalService(context.extensionPath, workspaceDir);
logger.channel()?.debug(`Local service started on port ${port}`);
} catch (error) {
logger.channel()?.error('Failed to start local service:', error);
}
}
);
context.subscriptions.push(disposable);
}
export function registerDevChatChatCommand(context: vscode.ExtensionContext) {
let disposable = vscode.commands.registerCommand(
"DevChat.Chat",

View File

@ -16,6 +16,7 @@ import {
registerFixCommand,
registerExplainCommand,
registerQuickFixCommand,
registerStartLocalServiceCommand
} from './contributes/commands';
import { regLanguageContext } from './contributes/context';
import { regDevChatView } from './contributes/views';
@ -33,6 +34,7 @@ import { DevChatConfig } from './util/config';
import { InlineCompletionProvider, registerCodeCompleteCallbackCommand } from "./contributes/codecomplete/codecomplete";
import { indexDir } from "./contributes/codecomplete/astIndex";
import registerQuickFixProvider from "./contributes/quickFixProvider";
import { stopLocalService } from './util/localService';
async function migrateConfig() {
@ -152,6 +154,7 @@ async function activate(context: vscode.ExtensionContext) {
registerStatusBarItemClickCommand(context);
registerInstallCommandsCommand(context);
registerStartLocalServiceCommand(context);
createStatusBarItem(context);
@ -178,6 +181,13 @@ async function activate(context: vscode.ExtensionContext) {
async function deactivate() {
// stop devchat
await stopDevChatBase({});
try {
await stopLocalService();
logger.channel()?.info('Local service stopped successfully');
} catch (error) {
logger.channel()?.error('Error stopping local service:', error);
}
}
exports.activate = activate;
exports.deactivate = deactivate;

View File

@ -3,7 +3,7 @@ import { insertCodeBlockToFile } from './codeBlockHandler';
import { replaceCodeBlockToFile } from './codeBlockHandler';
import { doCommit } from './commitHandler';
import { getHistoryMessages } from './historyMessagesHandler';
import { getWorkflowCommandList } from './workflowCommandHandler';
import { handleRegCommandList } from './workflowCommandHandler';
import { sendMessage, stopDevChat, regeneration, deleteChatMessage, userInput } from './sendMessage';
import { applyCodeWithDiff } from './diffHandler';
import { addConext } from './contextHandler';
@ -36,7 +36,7 @@ messageHandler.registerHandler('doCommit', doCommit);
messageHandler.registerHandler('historyMessages', getHistoryMessages);
// Register the command list
// Response: { command: 'regCommandList', result: <command list> }
messageHandler.registerHandler('regCommandList', getWorkflowCommandList);
messageHandler.registerHandler('regCommandList', handleRegCommandList);
// Send a message, send the message entered by the user to AI
// Response:
// { command: 'receiveMessagePartial', text: <response message text>, user: <user>, date: <date> }

View File

@ -1,4 +1,17 @@
import DevChat, { LogEntry, LogOptions } from '../toolwrapper/devchat';
import { DevChatClient, ShortLog } from '../toolwrapper/devchatClient';
export interface LogEntry {
hash: string;
parent: string | null;
user: string;
date: string;
request: string;
response: string;
context: Array<{
content: string;
role: string;
}>;
}
export interface LoadHistoryMessages {
command: string;
@ -41,18 +54,28 @@ OPENAI_API_KEY is missing from your environment or settings. Kindly input your O
} as LogEntry;
}
export async function loadTopicHistoryLogs(topicId: string | undefined): Promise<Array<LogEntry> | undefined> {
async function loadTopicHistoryLogs(topicId: string | undefined): Promise<Array<LogEntry> | undefined> {
if (!topicId) {
return undefined;
}
const devChat = new DevChat();
const logOptions: LogOptions = {
skip: 0,
maxCount: 10000,
topic: topicId
};
const logEntries = await devChat.log(logOptions);
const dcClient = new DevChatClient();
const shortLogs: ShortLog[] = await dcClient.getTopicLogs(topicId, 10000, 0);
const logEntries: Array<LogEntry> = [];
for (const shortLog of shortLogs) {
const logE: LogEntry = {
hash: shortLog.hash,
parent: shortLog.parent,
user: shortLog.user,
date: shortLog.date,
request: shortLog.request,
response: shortLog.responses[0],
context: shortLog.context,
};
logEntries.push(logE);
}
return logEntries;
}

View File

@ -1,6 +1,8 @@
import DevChat, { ChatOptions, ChatResponse } from '../toolwrapper/devchat';
import { logger } from '../util/logger';
import { assertValue } from '../util/check';
import { DevChatClient, ChatRequest, ChatResponse, buildRoleContextsFromFiles, LogData } from '../toolwrapper/devchatClient';
import { DevChatCLI } from '../toolwrapper/devchatCLI';
import { ApiKeyManager } from '../util/apiKey';
/**
@ -99,6 +101,15 @@ export function parseMessage(message: string): { context: string[]; instruction:
return { context: contextPaths, instruction: instructionPaths, reference: referencePaths, text };
}
// TODO: to be removed later
interface ChatOptions {
parent?: string;
reference?: string[];
header?: string[];
context?: string[];
}
/**
* Parses a message and sets the chat options based on the parsed message.
*
@ -130,7 +141,9 @@ export function processChatResponse(chatResponse: ChatResponse) : string {
}
const devChat = new DevChat();
// const devChat = new DevChat();
const dcClient = new DevChatClient();
const dcCLI = new DevChatCLI();
/**
* Sends a message to the DevChat and handles the response.
@ -148,24 +161,89 @@ export async function sendMessageBase(message: any, handlePartialData: (data: {
const [parsedMessage, chatOptions] = await parseMessageAndSetOptions(message);
logger.channel()?.trace(`parent hash: ${chatOptions.parent}`);
// call devchat chat
const chatResponse = await devChat.chat(
parsedMessage.text,
chatOptions,
(partialResponse: ChatResponse) => {
const partialDataText = partialResponse.response.replace(/```\ncommitmsg/g, "```commitmsg");
handlePartialData({ command: 'receiveMessagePartial', text: partialDataText!, user: partialResponse.user, date: partialResponse.date });
});
// send chat message to devchat service
const llmModelData = await ApiKeyManager.llmModel();
assertValue(!llmModelData || !llmModelData.model, 'You must select a LLM model to use for conversations');
const chatReq: ChatRequest = {
content: parsedMessage.text,
model_name: llmModelData.model,
api_key: llmModelData.api_key,
api_base: llmModelData.api_base,
parent: chatOptions.parent,
context: chatOptions.context,
};
let chatResponse = await dcClient.message(
chatReq,
(partialRes: ChatResponse) => {
const text = partialRes.response;
handlePartialData({
command: "receiveMessagePartial",
text: text!,
user: partialRes.user,
date: partialRes.date,
});
}
);
let workflowRes: ChatResponse | undefined = undefined;
if (chatResponse.finish_reason === "should_run_workflow") {
// invoke workflow via cli
workflowRes = await dcCLI.runWorkflow(
parsedMessage.text,
chatOptions,
(partialResponse: ChatResponse) => {
const partialDataText = partialResponse.response;
handlePartialData({
command: "receiveMessagePartial",
text: partialDataText!,
user: partialResponse.user,
date: partialResponse.date,
});
}
);
}
const finalResponse = workflowRes || chatResponse;
// insert log
const roleContexts = await buildRoleContextsFromFiles(chatOptions.context);
const messages = [
{
role: "user",
content: chatReq.content,
},
{
role: "assistant",
content: finalResponse.response,
},
...roleContexts
];
const logData: LogData = {
model: llmModelData.model,
messages: messages,
parent: chatOptions.parent,
timestamp: Math.floor(Date.now()/1000),
// TODO: 1 or real value?
request_tokens: 1,
response_tokens: 1,
};
const logRes = await dcClient.insertLog(logData);
if (logRes.hash) {
finalResponse["prompt-hash"] = logRes.hash;
}
assertValue(UserStopHandler.isUserInteractionStopped(), "User Stopped");
return {
command: 'receiveMessage',
text: processChatResponse(chatResponse),
hash: chatResponse['prompt-hash'],
user: chatResponse.user,
date: chatResponse.date,
isError: chatResponse.isError
text: processChatResponse(finalResponse),
hash: finalResponse['prompt-hash'],
user: finalResponse.user,
date: finalResponse.date,
isError: finalResponse.isError
};
} catch (error: any) {
logger.channel()?.error(`Error occurred while sending response: ${error.message}`);
@ -184,7 +262,8 @@ export async function sendMessageBase(message: any, handlePartialData: (data: {
export async function stopDevChatBase(message: any): Promise<void> {
logger.channel()?.info(`Stopping devchat`);
UserStopHandler.stopUserInteraction();
devChat.stop();
dcClient.stopAllRequest();
dcCLI.stop();
}
/**
@ -200,9 +279,10 @@ export async function deleteChatMessageBase(message:{'hash': string}): Promise<b
try {
assertValue(!message.hash, 'Message hash is required');
// delete the message by devchat
const bSuccess = await devChat.delete(message.hash);
assertValue(!bSuccess, "Failed to delete message from devchat");
// delete the message by devchatClient
const res = await dcClient.deleteLog(message.hash);
assertValue(!res.success, "Failed to delete message from devchat client");
return true;
} catch (error: any) {
logger.channel()?.error(`Error: ${error.message}`);
@ -219,5 +299,6 @@ export async function deleteChatMessageBase(message:{'hash': string}): Promise<b
* @returns {Promise<void>} - A Promise that resolves when the message has been sent.
*/
export async function sendTextToDevChat(text: string): Promise<void> {
return devChat.input(text);
dcCLI.input(text);
return;
}

View File

@ -1,67 +1,62 @@
import * as vscode from 'vscode';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
import { regInMessage, regOutMessage } from '../util/reg_messages';
import { MessageHandler } from './messageHandler';
import DevChat, { TopicEntry } from '../toolwrapper/devchat';
import { UiUtilWrapper } from '../util/uiUtil';
import * as vscode from "vscode";
import { regInMessage, regOutMessage } from "../util/reg_messages";
import { MessageHandler } from "./messageHandler";
import { DevChatClient } from "../toolwrapper/devchatClient";
import { LogEntry } from "./historyMessagesBase";
const dcClient = new DevChatClient();
function isDeleteTopic(topicId: string) {
let workspaceDir = UiUtilWrapper.workspaceFoldersFirstPath();
if (!workspaceDir) {
workspaceDir = os.homedir();
}
const deletedTopicsPath = path.join(workspaceDir!, '.chat', '.deletedTopics');
if (!fs.existsSync(deletedTopicsPath)) {
return false;
}
const deletedTopics = fs.readFileSync(deletedTopicsPath, 'utf-8').split('\n');
// check whether topicId in deletedTopics
return deletedTopics.includes(topicId);
export interface TopicEntry {
// eslint-disable-next-line @typescript-eslint/naming-convention
root_prompt: LogEntry;
// eslint-disable-next-line @typescript-eslint/naming-convention
latest_time: number;
hidden: boolean;
title: string | null;
}
// 注册获取当前topic列表的命令
regInMessage({ command: 'getTopics' });
regOutMessage({ command: 'getTopics', topics: [] });
export async function getTopics(message: any, panel: vscode.WebviewPanel | vscode.WebviewView): Promise<void> {
const devChat = new DevChat();
const topicEntriesAll: TopicEntry[] = await devChat.topics();
regInMessage({ command: "getTopics" });
regOutMessage({ command: "getTopics", topics: [] });
export async function getTopics(
message: any,
panel: vscode.WebviewPanel | vscode.WebviewView
): Promise<void> {
const topics = await dcClient.getTopics(100, 0);
const entries: TopicEntry[] = [];
let topicEntries: TopicEntry[] = [];
for (const topicEntry of topicEntriesAll) {
if (isDeleteTopic(topicEntry.root_prompt.hash)) {
continue;
}
// append topicEntry to topicEntries
topicEntries.push(topicEntry);
for (const topic of topics) {
const rootLog: LogEntry = {
hash: topic.root_prompt_hash,
parent: topic.root_prompt_parent,
user: topic.root_prompt_user,
date: topic.root_prompt_date,
request: topic.root_prompt_request,
response: topic.root_prompt_response,
context: [],
};
const e: TopicEntry = {
root_prompt: rootLog,
latest_time: topic.latest_time,
hidden: topic.hidden,
title: topic.title,
};
entries.push(e);
}
MessageHandler.sendMessage(panel, { command: 'getTopics', topicEntries });
MessageHandler.sendMessage(panel, {
command: "getTopics",
topicEntries: entries,
});
}
// 注册删除topic的命令
regInMessage({ command: 'deleteTopic', topicId: '' });
export async function deleteTopic(message: any, panel: vscode.WebviewPanel | vscode.WebviewView): Promise<void> {
regInMessage({ command: "deleteTopic", topicId: "" });
export async function deleteTopic(
message: any,
panel: vscode.WebviewPanel | vscode.WebviewView
): Promise<void> {
const topicId = message.topicId;
let workspaceDir = UiUtilWrapper.workspaceFoldersFirstPath();
if (!workspaceDir) {
workspaceDir = os.homedir();
}
const deletedTopicsPath = path.join(workspaceDir!, '.chat', '.deletedTopics');
// read ${WORKSPACE_ROOT}/.chat/.deletedTopics as String[]
// add topicId to String[]
// write String[] to ${WORKSPACE_ROOT}/.chat/.deletedTopics
let deletedTopics: string[] = [];
if (fs.existsSync(deletedTopicsPath)) {
deletedTopics = fs.readFileSync(deletedTopicsPath, 'utf-8').split('\n');
}
deletedTopics.push(topicId);
fs.writeFileSync(deletedTopicsPath, deletedTopics.join('\n'));
await dcClient.deleteTopic(topicId);
}

View File

@ -1,24 +1,60 @@
import * as vscode from 'vscode';
import { MessageHandler } from './messageHandler';
import { regInMessage, regOutMessage } from '../util/reg_messages';
import { ApiKeyManager } from '../util/apiKey';
import DevChat from '../toolwrapper/devchat';
import * as vscode from "vscode";
import { MessageHandler } from "./messageHandler";
import { regInMessage, regOutMessage } from "../util/reg_messages";
import { DevChatClient } from "../toolwrapper/devchatClient";
import { logger } from "../util/logger";
let existPannel: vscode.WebviewPanel | vscode.WebviewView | undefined =
undefined;
let existPannel: vscode.WebviewPanel|vscode.WebviewView|undefined = undefined;
regInMessage({ command: "regCommandList" });
regOutMessage({
command: "regCommandList",
result: [{ name: "", pattern: "", description: "" }],
});
export async function handleRegCommandList(
message: any,
panel: vscode.WebviewPanel | vscode.WebviewView
): Promise<void> {
existPannel = panel;
if (process.env.DC_LOCALSERVICE_PORT) {
await getWorkflowCommandList(message, existPannel!);
}
}
regInMessage({command: 'regCommandList'});
regOutMessage({command: 'regCommandList', result: [{name: '', pattern: '', description: ''}]});
export async function getWorkflowCommandList(message: any, panel: vscode.WebviewPanel|vscode.WebviewView): Promise<void> {
existPannel = panel;
export async function getWorkflowCommandList(
message: any,
panel: vscode.WebviewPanel | vscode.WebviewView
): Promise<void> {
const dcClient = new DevChatClient();
const commandList = await new DevChat().commands();
MessageHandler.sendMessage(panel, { command: 'regCommandList', result: commandList });
return;
// All workflows registered in DevChat
const workflows = await dcClient.getWorkflowList();
// Get recommends from config
const workflowsConfig = await dcClient.getWorkflowConfig();
const recommends = workflowsConfig.recommend?.workflows || [];
// Filter active workflows and add recommend info
const commandList = workflows
.filter((workflow) => workflow.active)
.map((workflow: any) => ({
...workflow,
recommend: recommends.indexOf(workflow.name),
}));
if (commandList.length > 0) {
MessageHandler.sendMessage(panel, {
command: "regCommandList",
result: commandList,
});
}
return;
}
export async function sendCommandListByDevChatRun() {
if (existPannel) {
await getWorkflowCommandList({}, existPannel!);
}
if (existPannel) {
await getWorkflowCommandList({}, existPannel!);
}
}

View File

@ -1,6 +1,4 @@
import * as vscode from "vscode";
import { applyCodeWithDiff, applyEditCodeWithDiff } from "../../handler/diffHandler";
import { getSymbolDefines } from "../../context/contextRefDefs";
export namespace UnofficialEndpoints {
@ -13,16 +11,6 @@ export namespace UnofficialEndpoints {
return true;
}
export async function getSymbolDefinesInSelectedCode() {
// find needed symbol defines in current editor document
// return value is a list of symbol defines
// each define has three fileds:
// path: file path contain that symbol define
// startLine: start line in that file
// content: source code for symbol define
return getSymbolDefines();
}
export async function runCode(code: string) {
// run code
// delcare use vscode

View File

@ -81,10 +81,6 @@ const functionRegistry: any = {
/**
* Unofficial endpoints
*/
"/get_symbol_defines_in_selected_code": {
keys: [],
handler: UnofficialEndpoints.getSymbolDefinesInSelectedCode,
},
"/run_code": {
keys: ["code"],
handler: UnofficialEndpoints.runCode,

View File

@ -51,6 +51,7 @@ export function createStatusBarItem(context: vscode.ExtensionContext): vscode.St
// install devchat workflow commands
if (!hasInstallCommands) {
hasInstallCommands = true;
await vscode.commands.executeCommand('DevChat.StartLocalService');
await vscode.commands.executeCommand('DevChat.InstallCommands');
// vscode.commands.executeCommand('DevChat.InstallCommandPython');
}

View File

@ -1,545 +0,0 @@
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 * as toml from '@iarna/toml';
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 LogOptions {
skip?: number;
maxCount?: number;
topic?: string;
}
export interface LogEntry {
hash: string;
parent: string;
user: string;
date: string;
request: string;
response: string;
context: Array<{
content: string;
role: string;
}>;
}
export interface CommandEntry {
name: string;
description: string;
path: string;
// default value is -1, which means not recommended
recommend: number;
}
export interface TopicEntry {
// eslint-disable-next-line @typescript-eslint/naming-convention
root_prompt: LogEntry;
// eslint-disable-next-line @typescript-eslint/naming-convention
latest_time: number;
hidden: boolean;
title: string | null;
}
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;
}
class DevChat {
private commandRun: CommandRun;
constructor() {
this.commandRun = new CommandRun();
}
private async loadContextsFromFiles(contexts: string[] | undefined): Promise<string[]> {
if (!contexts) {
return [];
}
const loadedContexts: string[] = [];
for (const context of contexts) {
const contextContent = await getFileContent(context);
if (!contextContent) {
continue;
}
loadedContexts.push(contextContent);
}
return loadedContexts;
}
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 buildLogArgs(options: LogOptions): string[] {
let args = ["-m", "devchat", "log"];
if (options.skip) {
args.push('--skip', `${options.skip}`);
}
if (options.maxCount) {
args.push('--max-count', `${options.maxCount}`);
} else {
const maxLogCount = DevChatConfig.getInstance().get('max_log_count');
args.push('--max-count', `${maxLogCount}`);
}
if (options.topic) {
args.push('--topic', `${options.topic}`);
}
return args;
}
private parseOutData(stdout: string, isPartial: boolean): ChatResponse {
const responseLines = stdout.trim().split("\n");
if (responseLines.length < 2) {
return this.createChatResponse("", "", "", "", !isPartial);
}
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,
};
}
private async runCommand(args: string[]): Promise<{code: number | null, stdout: string, stderr: string}> {
// build env variables for command
const envs = {
...process.env,
// eslint-disable-next-line @typescript-eslint/naming-convention
"PYTHONUTF8":1,
// eslint-disable-next-line @typescript-eslint/naming-convention
"PYTHONPATH": UiUtilWrapper.extensionPath() + "/tools/site-packages",
// eslint-disable-next-line @typescript-eslint/naming-convention
"DEVCHAT_PROXY": DevChatConfig.getInstance().get('DEVCHAT_PROXY') || "",
"MAMBA_BIN_PATH": getMicromambaUrl(),
};
const pythonApp = DevChatConfig.getInstance().get('python_for_chat') || "python3";
// run command
const { exitCode: code, stdout, stderr } = await this.commandRun.spawnAsync(
pythonApp,
args,
{
maxBuffer: 10 * 1024 * 1024, // Set maxBuffer to 10 MB
cwd: UiUtilWrapper.workspaceFoldersFirstPath(),
env: envs
},
undefined, undefined, undefined, undefined
);
return {code, stdout, stderr};
}
public input(data: string) {
this.commandRun?.write(data + "\n");
}
public stop() {
this.commandRun.stop();
}
async chat(content: string, options: ChatOptions = {}, onData: (data: ChatResponse) => void, saveToLog: boolean = true): Promise<ChatResponse> {
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);
let promptHash = "";
if (saveToLog) {
const logs = await this.logInsert(options.context, content, responseData.response, options.parent);
assertValue(!logs || !logs.length, "Failed to insert devchat log");
promptHash = logs[0]["hash"];
}
// return result
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
"prompt-hash": promptHash,
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,
};
}
}
async logInsert(contexts: string[] | undefined, request: string, response: string, parent: string | undefined): Promise<LogEntry[]> {
try {
// build log data
const llmModelData = await ApiKeyManager.llmModel();
const contextContentList = await this.loadContextsFromFiles(contexts);
const contextWithRoleList = contextContentList.map(content => {
return {
"role": "system",
"content": `<context>${content}</context>`
};
});
let logData = {
"model": llmModelData?.model || "gpt-3.5-turbo",
"messages": [
{
"role": "user",
"content": request
},
{
"role": "assistant",
"content": response
},
...contextWithRoleList
],
"timestamp": Math.floor(Date.now()/1000),
// eslint-disable-next-line @typescript-eslint/naming-convention
"request_tokens": 1,
// eslint-disable-next-line @typescript-eslint/naming-convention
"response_tokens": 1,
...parent? {"parent": parent} : {}
};
const insertValue = JSON.stringify(logData);
let insertValueOrFile = insertValue;
if (insertValue.length > 4 * 1024) {
const tempDir = os.tmpdir();
const tempFile = path.join(tempDir, 'devchat_log_insert.json');
await fs.promises.writeFile(tempFile, insertValue);
insertValueOrFile = tempFile;
}
// build args for log insert
const args = ["-m", "devchat", "log", "--insert", insertValueOrFile];
const {code, stdout, stderr} = await this.runCommand(args);
assertValue(code !== 0, stderr || `Command exited with ${code}`);
assertValue(stdout.indexOf('Failed to insert log') >= 0, stdout);
if (stderr.trim() !== "") {
logger.channel()?.warn(`${stderr}`);
}
const logs = JSON.parse(stdout.trim()).reverse();
for (const log of logs) {
log.response = log.responses[0];
delete log.responses;
}
return logs;
} catch (error: any) {
logger.channel()?.error(`Failed to insert log: ${error.message}`);
logger.channel()?.show();
return [];
}
}
async delete(hash: string): Promise<boolean> {
try {
// build args for log delete
const args = ["-m", "devchat", "log", "--delete", hash];
const {code, stdout, stderr} = await this.runCommand(args);
assertValue(code !== 0, stderr || `Command exited with ${code}`);
assertValue(stdout.indexOf('Failed to delete prompt') >= 0, stdout);
if (stderr.trim() !== "") {
logger.channel()?.warn(`${stderr}`);
}
return true;
} catch (error: any) {
logger.channel()?.error(`Failed to delete log: ${error.message}`);
logger.channel()?.show();
return false;
}
}
async log(options: LogOptions = {}): Promise<LogEntry[]> {
try {
const args = this.buildLogArgs(options);
const {code, stdout, stderr} = await this.runCommand(args);
assertValue(code !== 0, stderr || `Command exited with ${code}`);
if (stderr.trim() !== "") {
logger.channel()?.warn(`${stderr}`);
}
const logs = JSON.parse(stdout.trim()).reverse();
for (const log of logs) {
log.response = log.responses[0];
delete log.responses;
}
return logs;
} catch (error: any) {
logger.channel()?.error(`Failed to get logs: ${error.message}`);
logger.channel()?.show();
return [];
}
}
async loadRecommendCommands(): Promise<string[]> {
try {
const args = ["-m", "devchat", "workflow", "config", "--json"];
const {code, stdout, stderr} = await this.runCommand(args);
assertValue(code !== 0, stderr || `Command exited with ${code}`);
if (stderr.trim() !== "") {
logger.channel()?.warn(`${stderr}`);
}
let workflowConfig;
try {
workflowConfig = JSON.parse(stdout.trim());
} catch (error) {
logger.channel()?.error('Failed to parse commands JSON:', error);
return [];
}
return workflowConfig.recommend?.workflows || [];
} catch (error: any) {
logger.channel()?.error(`Error: ${error.message}`);
logger.channel()?.show();
return [];
}
}
async commands(): Promise<any[]> {
try {
const args = ["-m", "devchat", "workflow", "list", "--json"];
const {code, stdout, stderr} = await this.runCommand(args);
assertValue(code !== 0, stderr || `Command exited with ${code}`);
if (stderr.trim() !== "") {
logger.channel()?.warn(`${stderr}`);
}
let commands;
try {
commands = JSON.parse(stdout.trim());
} catch (error) {
logger.channel()?.error('Failed to parse commands JSON:', error);
return [];
}
// 确保每个CommandEntry对象的recommend字段默认为-1
const recommendCommands = await this.loadRecommendCommands();
commands = commands.map((cmd: any) => ({
...cmd,
recommend: recommendCommands.indexOf(cmd.name),
}));
return commands;
} catch (error: any) {
logger.channel()?.error(`Error: ${error.message}`);
logger.channel()?.show();
return [];
}
}
async updateSysCommand(): Promise<string> {
try {
const args = ["-m", "devchat", "workflow", "update"];
const {code, stdout, stderr} = await this.runCommand(args);
assertValue(code !== 0, stderr || `Command exited with ${code}`);
if (stderr.trim() !== "") {
logger.channel()?.warn(`${stderr}`);
}
logger.channel()?.trace(`${stdout}`);
return stdout;
} catch (error: any) {
logger.channel()?.error(`Error: ${error.message}`);
logger.channel()?.show();
return "";
}
}
async topics(): Promise<TopicEntry[]> {
try {
const args = ["-m", "devchat", "topic", "-l"];
const {code, stdout, stderr} = await this.runCommand(args);
assertValue(code !== 0, stderr || `Command exited with ${code}`);
if (stderr.trim() !== "") {
logger.channel()?.warn(`${stderr}`);
}
const topics = JSON.parse(stdout.trim()).reverse();
for (const topic of topics) {
if (topic.root_prompt.responses) {
topic.root_prompt.response = topic.root_prompt.responses[0];
delete topic.root_prompt.responses;
}
}
return topics;
} catch (error: any) {
logger.channel()?.error(`Error: ${error.message}`);
logger.channel()?.show();
return [];
}
}
}
export default DevChat;

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,475 @@
import axios, { AxiosResponse, CancelTokenSource } from "axios";
import * as path from "path";
import * as fs from "fs";
import * as os from "os";
import { logger } from "../util/logger";
import { getFileContent } from "../util/commonUtil";
import { UiUtilWrapper } from "../util/uiUtil";
class DCLocalServicePortNotSetError extends Error {
constructor() {
super("DC_LOCALSERVICE_PORT is not set");
this.name = "DCLocalServicePortNotSetError";
}
}
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;
}
function catchAndReturn(defaultReturn: any) {
return function (
target: Object,
propertyKey: string,
descriptor: TypedPropertyDescriptor<any>
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
try {
return await originalMethod.apply(this, args);
} catch (error) {
if (error instanceof DCLocalServicePortNotSetError) {
logger.channel()?.warn(`DC_LOCALSERVICE_PORT is not set in [${propertyKey}]`);
return defaultReturn;
}
logger.channel()?.error(`Error in [${propertyKey}]: ${error}`);
return defaultReturn;
}
};
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;
}
export interface LogDeleteRes {
success?: boolean;
}
export interface ShortLog {
hash: string;
parent: string | null;
user: string;
date: string;
request: string;
responses: string[];
context: Array<{
content: string;
role: 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 | undefined;
private _cancelMessageToken: CancelTokenSource | null = null;
static readonly logRawDataSizeLimit = 4 * 1024;
constructor() {
}
async _get(path: string, config?: any): Promise<AxiosResponse> {
if (!this.baseURL) {
if (!process.env.DC_LOCALSERVICE_PORT) {
logger.channel()?.info("No local service port found.");
throw new DCLocalServicePortNotSetError();
}
const port: number = parseInt(process.env.DC_LOCALSERVICE_PORT || '8008', 10);
this.baseURL = `http://localhost:${port}`;
}
try {
logger.channel()?.debug(`GET request to ${this.baseURL}${path}`);
const response = await axios.get(`${this.baseURL}${path}`, config);
return response;
} catch (error) {
console.error(error);
throw error;
}
}
async _post(path: string, data: any = undefined): Promise<AxiosResponse> {
if (!this.baseURL) {
if (!process.env.DC_LOCALSERVICE_PORT) {
logger.channel()?.info("No local service port found.");
throw new DCLocalServicePortNotSetError();
}
const port: number = parseInt(process.env.DC_LOCALSERVICE_PORT || '8008', 10);
this.baseURL = `http://localhost:${port}`;
}
try {
logger.channel()?.debug(`POST request to ${this.baseURL}${path}`);
const response = await axios.post(`${this.baseURL}${path}`, data);
return response;
} catch (error) {
console.error(error);
throw error;
}
}
@timeThis
@catchAndReturn([])
async getWorkflowList(): Promise<any[]> {
const response = await this._get("/workflows/list");
logger
.channel()
?.trace(
`getWorkflowList response data: \n${JSON.stringify(
response.data
)}`
);
return response.data;
}
@timeThis
@catchAndReturn({})
async getWorkflowConfig(): Promise<any> {
const response = await this._get("/workflows/config");
logger
.channel()
?.trace(
`getWorkflowConfig response data: \n${JSON.stringify(
response.data
)}`
);
return response.data;
}
@timeThis
@catchAndReturn(undefined)
async updateWorkflows(): Promise<void> {
const response = await this._post("/workflows/update");
logger
.channel()
?.trace(
`updateWorkflows response data: \n${JSON.stringify(
response.data
)}`
);
}
@timeThis
async message(
message: ChatRequest,
onData: (data: ChatResponse) => void
): Promise<ChatResponse> {
if (!this.baseURL) {
if (!process.env.DC_LOCALSERVICE_PORT) {
logger.channel()?.info("No local service port found.");
}
const port: number = parseInt(process.env.DC_LOCALSERVICE_PORT || '8008', 10);
this.baseURL = `http://localhost:${port}`;
}
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": "", // prompt-hash is not in chatting response, it is created in the later insertLog()
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"];
if (chatRes.finish_reason === "should_run_workflow") {
chatRes.extra = chunkData["extra"];
logger.channel()?.debug("should run workflow via cli.");
return;
}
chatRes.isError = chunkData["isError"];
chatRes.response += chunkData["content"];
onData(chatRes);
});
response.data.on("end", () => {
resolve(chatRes); // Resolve the promise with chatRes when the stream ends
});
response.data.on("error", (error) => {
logger.channel()?.error("Streaming error:", error);
chatRes.isError = true;
chatRes.response += `\n${error}`;
resolve(chatRes); // handle error by resolving the promise
});
} catch (error) {
const errorRes: ChatResponse = {
"prompt-hash": "",
user: "",
date: "",
response: `${error}`,
finish_reason: "",
isError: true,
};
resolve(errorRes); // handle error by resolving the promise using an errorRes
}
});
}
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
@catchAndReturn({ hash: "" })
async insertLog(logData: LogData): Promise<LogInsertRes> {
let body = {
workspace: UiUtilWrapper.workspaceFoldersFirstPath(),
};
const jsondata = JSON.stringify(logData);
let filepath = "";
if (jsondata.length <= DevChatClient.logRawDataSizeLimit) {
// Use json data directly
body["jsondata"] = jsondata;
} else {
// Write json data to a temp file
const tempDir = os.tmpdir();
const tempFile = path.join(tempDir, "devchat_log_insert.json");
await fs.promises.writeFile(tempFile, jsondata);
filepath = tempFile;
body["filepath"] = filepath;
}
const response = await this._post("/logs/insert", body);
logger
.channel()
?.trace(
`insertLog response data: ${JSON.stringify(
response.data
)}, ${typeof response.data}}`
);
// Clean up temp file
if (filepath) {
try {
await fs.promises.unlink(filepath);
} catch (error) {
logger
.channel()
?.error(`Failed to delete temp file ${filepath}: ${error}`);
}
}
const res: LogInsertRes = {
hash: response.data["hash"],
};
return res;
}
@timeThis
@catchAndReturn({ success: false })
async deleteLog(logHash: string): Promise<LogDeleteRes> {
const data = {
workspace: UiUtilWrapper.workspaceFoldersFirstPath(),
hash: logHash,
};
const response = await this._post("/logs/delete", data);
logger
.channel()
?.trace(
`deleteLog response data: ${JSON.stringify(
response.data
)}, ${typeof response.data}}`
);
const res: LogDeleteRes = {
success: response.data["success"],
};
return res;
}
@timeThis
@catchAndReturn([])
async getTopicLogs(
topicRootHash: string,
limit: number,
offset: number
): Promise<ShortLog[]> {
const data = {
limit: limit,
offset: offset,
workspace: UiUtilWrapper.workspaceFoldersFirstPath(),
};
const response = await this._get(`/topics/${topicRootHash}/logs`, {
params: data,
});
const logs: ShortLog[] = response.data;
logs.reverse();
logger
.channel()
?.trace(`getTopicLogs response data: ${JSON.stringify(logs)}`);
return logs;
}
@timeThis
@catchAndReturn([])
async getTopics(limit: number, offset: number): Promise<any[]> {
const data = {
limit: limit,
offset: offset,
workspace: UiUtilWrapper.workspaceFoldersFirstPath(),
};
const response = await this._get(`/topics`, {
params: data,
});
const topics: any[] = response.data;
topics.reverse();
logger
.channel()
?.trace(`getTopics response data: ${JSON.stringify(topics)}`);
return topics;
}
@timeThis
@catchAndReturn(undefined)
async deleteTopic(topicRootHash: string): Promise<void> {
const data = {
topic_hash: topicRootHash,
workspace: UiUtilWrapper.workspaceFoldersFirstPath(),
};
const response = await this._post("/topics/delete", data);
logger
.channel()
?.trace(
`deleteTopic response data: ${JSON.stringify(response.data)}`
);
return;
}
stopAllRequest(): void {
this.cancelMessage();
// add other requests here if needed
}
}

View File

@ -1,7 +1,5 @@
// src/apiKey.ts
import DevChat from '@/toolwrapper/devchat';
import { UiUtilWrapper } from './uiUtil';
import { DevChatConfig } from './config';
import { logger } from './logger';

View File

@ -0,0 +1,26 @@
import net from 'net';
export async function findAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer().listen();
server.on('listening', () => {
const address = server.address();
if (typeof address !== 'object' || !address?.port) {
server.close();
reject(new Error('Failed to get port from server'));
return;
}
server.close(() => resolve(address.port));
});
server.on('error', (err) => {
const errWithCode = err as NodeJS.ErrnoException;
if (errWithCode.code === 'EADDRINUSE') {
reject(new Error('Port already in use'));
} else {
reject(err);
}
});
});
}

105
src/util/localService.ts Normal file
View File

@ -0,0 +1,105 @@
import { spawn, ChildProcess } from 'child_process';
import { findAvailablePort } from './findServicePort';
import * as http from 'http';
import { logger } from './logger';
import { DevChatConfig } from './config';
let serviceProcess: ChildProcess | null = null;
export async function startLocalService(extensionPath: string, workspacePath: string): Promise<number> {
if (serviceProcess) {
throw new Error('Local service is already running');
}
try {
// 1. 获取可用端口号
const port = await findAvailablePort();
// 2. 设置环境变量 DC_SVC_PORT
process.env.DC_SVC_PORT = port.toString();
// 3. 设置 DC_SVC_WORKSPACE 环境变量
process.env.DC_SVC_WORKSPACE = workspacePath;
// 新增:设置 PYTHONPATH 环境变量
process.env.PYTHONPATH = `${extensionPath}/tools/site-packages`;
// 4. 启动进程 python main.py
const mainPyPath = extensionPath + "/tools/site-packages/devchat/_service/main.py";
const pythonApp =
DevChatConfig.getInstance().get("python_for_chat") || "python3";
serviceProcess = spawn(pythonApp, [mainPyPath], {
env: { ...process.env },
stdio: 'inherit',
windowsHide: true, // hide the console window on Windows
});
serviceProcess.on('error', (err) => {
logger.channel()?.error('Failed to start local service:', err);
serviceProcess = null;
});
serviceProcess.on('exit', (code) => {
logger.channel()?.info(`Local service exited with code ${code}`);
serviceProcess = null;
});
// 5. 等待服务启动并验证
await waitForServiceToStart(port);
// 6. 服务启动成功后,记录启动的端口号到环境变量
process.env.DC_LOCALSERVICE_PORT = port.toString();
logger.channel()?.info(`Local service port recorded: ${port}`);
return port;
} catch (error) {
logger.channel()?.error('Error starting local service:', error);
throw error;
}
}
async function waitForServiceToStart(port: number): Promise<void> {
const maxRetries = 30;
const retryInterval = 1000; // 1 second
for (let i = 0; i < maxRetries; i++) {
try {
const response = await new Promise<string>((resolve, reject) => {
http.get(`http://localhost:${port}/ping`, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => resolve(data));
}).on('error', reject);
});
if (response === '{"message":"pong"}') {
logger.channel()?.info('Local service started successfully');
return;
}
} catch (error) {
// Ignore errors and continue retrying
}
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
throw new Error('Failed to start local service: timeout');
}
export async function stopLocalService(): Promise<void> {
return new Promise((resolve) => {
if (!serviceProcess) {
logger.channel()?.warn('No local service is running');
resolve();
return;
}
serviceProcess.on('exit', () => {
serviceProcess = null;
logger.channel()?.info('Local service stopped');
resolve();
});
serviceProcess.kill();
});
}

View File

@ -1,181 +0,0 @@
import { expect } from 'chai';
// import { describe, it } from 'mocha';
import sinon from 'sinon';
import * as path from 'path';
import { parseMessage, parseMessageAndSetOptions, processChatResponse, sendMessageBase, stopDevChatBase } from '../../src/handler/sendMessageBase';
import { ChatResponse } from '../../src/toolwrapper/devchat';
import { UiUtilWrapper } from '../../src/util/uiUtil';
import * as dotenv from 'dotenv';
const envPath = path.join(__dirname, '../../', '.env');
dotenv.config({ path: envPath });
describe('sendMessageBase', () => {
let workspaceFoldersFirstPathStub: sinon.SinonStub;
let getConfigurationStub: sinon.SinonStub;
beforeEach(() => {
workspaceFoldersFirstPathStub = sinon.stub(UiUtilWrapper, 'workspaceFoldersFirstPath');
getConfigurationStub = sinon.stub(UiUtilWrapper, 'getConfiguration');
});
afterEach(() => {
workspaceFoldersFirstPathStub.restore();
getConfigurationStub.restore();
});
describe('parseMessage', () => {
it('should parse message correctly', () => {
const message = '[context|path/to/context] [instruction|path/to/instruction] [reference|path/to/reference] Hello, world!';
const result = parseMessage(message);
expect(result.context).to.deep.equal(['path/to/context']);
expect(result.instruction).to.deep.equal(['path/to/instruction']);
expect(result.reference).to.deep.equal(['path/to/reference']);
expect(result.text).to.equal('Hello, world!');
});
});
describe('parseMessageAndSetOptions', () => {
it('should parse message and set options correctly', async () => {
const message = {
text: '[context|path/to/context] [instruction|path/to/instruction] [reference|path/to/reference] Hello, world!'
};
const [result, chatOptions] = await parseMessageAndSetOptions(message);
expect(result.context).to.deep.equal(['path/to/context']);
expect(result.instruction).to.deep.equal(['path/to/instruction']);
expect(result.reference).to.deep.equal(['path/to/reference']);
expect(result.text).to.equal('Hello, world!');
expect(chatOptions.context).to.deep.equal(['path/to/context']);
expect(chatOptions.header).to.deep.equal([]);
expect(chatOptions.reference).to.deep.equal(['path/to/reference']);
});
});
describe('processChatResponse', () => {
it('should handle response text correctly when isError is false', async () => {
const partialDataText = 'Partial data';
const chatResponse: ChatResponse = {
"finish_reason": "",
response: 'Hello, user!',
isError: false,
user: 'user',
date: '2022-01-01T00:00:00.000Z',
'prompt-hash': 'responsehash'
};
const result = await processChatResponse(chatResponse);
expect(result).to.equal('Hello, user!');
});
it('should handle response text correctly when isError is true', async () => {
const partialDataText = 'Partial data';
const chatResponse: ChatResponse = {
"finish_reason": "",
response: 'Error occurred!',
isError: true,
user: 'user',
date: '2022-01-01T00:00:00.000Z',
'prompt-hash': 'responsehash'
};
const result = await processChatResponse(chatResponse);
expect(result).to.equal('Error occurred!');
});
});
describe('sendMessageBase', () => {
it('should send message correct with DevChat access key', async function() {
const message = {
text: 'Hello, world!'
};
const handlePartialData = (data: { command: string, text: string, user: string, date: string }) => {
// Handle partial data
};
workspaceFoldersFirstPathStub.returns('./');
getConfigurationStub.withArgs('DevChat', 'Access_Key_DevChat').returns(process.env.TEST_DEVCHAT_KEY);
getConfigurationStub.withArgs('DevChat', 'OpenAI.temperature').returns(0);
getConfigurationStub.withArgs('DevChat', 'OpenAI.stream').returns('true');
const result = await sendMessageBase(message, handlePartialData);
expect(result).to.be.an('object');
expect(result!.command).to.equal('receiveMessage');
expect(result!.text).to.be.a('string');
expect(result!.hash).to.be.a('string');
expect(result!.user).to.be.a('string');
expect(result!.date).to.be.a('string');
// TODO fix
// Need to mock more config setting
// expect(result!.isError).to.be.false;
});
it('should send message error with invalid api key', async function() {
const message = {
text: 'Hello, world!'
};
const handlePartialData = (data: { command: string, text: string, user: string, date: string }) => {
// Handle partial data
};
workspaceFoldersFirstPathStub.returns('./');
getConfigurationStub.withArgs('DevChat', 'Access_Key_DevChat').returns('sk-KvH7ZCtHmFDCBTqH0jUv');
getConfigurationStub.withArgs('DevChat', 'OpenAI.temperature').returns('0');
getConfigurationStub.withArgs('DevChat', 'OpenAI.stream').returns('true');
const result = await sendMessageBase(message, handlePartialData);
expect(result).to.be.an('object');
expect(result!.command).to.equal('receiveMessage');
expect(result!.text).to.be.a('string');
expect(result!.hash).to.be.a('string');
expect(result!.user).to.be.a('string');
expect(result!.date).to.be.a('string');
expect(result!.isError).to.be.true;
});
});
describe('stopDevChatBase', () => {
it('should stop sendMessageBase correctly', async () => {
const message = {
text: 'Hello, world!'
};
const handlePartialData = (data: { command: string, text: string, user: string, date: string }) => {
// Handle partial data
};
workspaceFoldersFirstPathStub.returns('./');
getConfigurationStub.withArgs('DevChat', 'Access_Key_DevChat').returns(process.env.TEST_DEVCHAT_KEY);
getConfigurationStub.withArgs('DevChat', 'OpenAI.temperature').returns(0);
getConfigurationStub.withArgs('DevChat', 'OpenAI.stream').returns('true');
// Start sendMessageBase in a separate Promise
const sendMessagePromise = sendMessageBase(message, handlePartialData);
// Wait for a short period to ensure sendMessageBase has started
await new Promise((resolve) => setTimeout(resolve, 100));
// Call stopDevChatBase
const stopMessage = {
text: 'stop'
};
await stopDevChatBase(stopMessage);
// Check if sendMessageBase has been stopped and returns an error
// TODO fix
// Need to mock more config setting
// try {
// const result = await sendMessagePromise;
// expect(result).to.undefined;
// } catch (error) {
// expect(error).to.be.an('error');
// }
});
});
});

View File

@ -1,70 +0,0 @@
import { expect } from 'chai';
// import { describe, it } from 'mocha';
import sinon from 'sinon';
import DevChat, { ChatOptions } from '../../src/toolwrapper/devchat';
import { CommandRun } from '../../src/util/commonUtil';
import { UiUtilWrapper } from '../../src/util/uiUtil';
import { ApiKeyManager } from '../../src/util/apiKey';
describe('DevChat', () => {
let devChat: DevChat;
let spawnAsyncStub: sinon.SinonStub;
let workspaceFoldersFirstPathStub: sinon.SinonStub;
let apiKeyManagerStub: sinon.SinonStub;
beforeEach(() => {
devChat = new DevChat();
spawnAsyncStub = sinon.stub(CommandRun.prototype, 'spawnAsync');
workspaceFoldersFirstPathStub = sinon.stub(UiUtilWrapper, 'workspaceFoldersFirstPath');
apiKeyManagerStub = sinon.stub(ApiKeyManager, 'llmModel');
});
afterEach(() => {
spawnAsyncStub.restore();
workspaceFoldersFirstPathStub.restore();
apiKeyManagerStub.restore();
});
describe('chat', () => {
it('should return a ChatResponse object with isError false when the chat is successful', async () => {
const content = 'Test chat content';
const options: ChatOptions = {
// Provide mock values for the options
parent: 'parent_value',
reference: ['ref1', 'ref2'],
header: ['header1', 'header2'],
context: ['context1', 'context2'],
};
const mockResponse = {
exitCode: 0,
stdout: 'User: Test user\nDate: 2022-01-01\nTest chat response\nprompt-hash: 12345',
stderr: '',
};
const mockWorkspacePath = './';
const llmModelResponse = {
"model": "gpt-3.5-turbo",
"api_key": "DC.1234567890"
}
spawnAsyncStub.resolves(mockResponse);
workspaceFoldersFirstPathStub.returns(mockWorkspacePath);
apiKeyManagerStub.resolves(llmModelResponse);
const response = await devChat.chat(content, options, (data)=>{}, false);
expect(response).to.have.property('prompt-hash', '');
expect(response).to.have.property('user', '');
expect(response).to.have.property('date', '');
expect(response).to.have.property('response', 'Test chat response');
expect(response).to.have.property('isError', false);
expect(spawnAsyncStub.calledOnce).to.be.true;
expect(workspaceFoldersFirstPathStub.calledOnce).to.be.true;
});
// Add more test cases for the chat method here
});
// ... other test cases
});

View File

@ -0,0 +1,29 @@
// test/util/findServicePort.test.ts
import { expect } from 'chai';
import net from 'net';
import { findAvailablePort } from '../../src/util/findServicePort';
describe('findAvailablePort', () => {
it('should return an available port when successful', async () => {
// Arrange
const expectedPort = await findAvailablePort();
// Act
const server = net.createServer();
const isAvailable = await new Promise<boolean>((resolve) => {
server.listen(expectedPort, () => {
server.close();
resolve(true);
});
server.on('error', () => {
resolve(false);
});
});
// Assert
expect(isAvailable).to.be.true;
expect(expectedPort).to.be.a('number');
expect(expectedPort).to.be.greaterThan(0);
});
});

View File

@ -0,0 +1,72 @@
// test/util/localService.test.ts
import { expect } from 'chai';
import { startLocalService, stopLocalService } from '../../src/util/localService';
import * as http from 'http';
describe('localService', () => {
let port: number;
describe('startLocalService', () => {
it('should start the local service successfully', async () => {
const extensionPath = '.';
const workspacePath = '.';
port = await startLocalService(extensionPath, workspacePath);
expect(port).to.be.a('number');
expect(process.env.DC_SVC_PORT).to.equal(port.toString());
expect(process.env.DC_SVC_WORKSPACE).to.equal(workspacePath);
expect(process.env.DC_LOCALSERVICE_PORT).to.equal(port.toString());
// Verify that the service is running by sending a ping request
const response = await sendPingRequest(port);
expect(response).to.equal('{"message":"pong"}');
});
});
describe('stopLocalService', () => {
it('should stop the local service', async () => {
await stopLocalService();
// Wait a bit to ensure the service has fully stopped
await new Promise(resolve => setTimeout(resolve, 1000));
// Verify that the service is no longer running
try {
await sendPingRequest(port);
throw new Error('Service is still running');
} catch (error) {
console.log('Error type:', typeof error);
console.log('Error:', error);
if (error instanceof Error) {
expect(error.message).to.include('connect ECONNREFUSED');
} else if (typeof error === 'object' && error !== null) {
// Check if the error object has a 'code' property
if ('code' in error) {
expect(error.code).to.equal('ECONNREFUSED');
} else if ('errors' in error && Array.isArray(error.errors)) {
// Check if it's an AggregateError-like object
const hasConnectionRefused = error.errors.some((e: any) => e.code === 'ECONNREFUSED');
expect(hasConnectionRefused).to.be.true;
} else {
throw new Error(`Unexpected error structure: ${JSON.stringify(error)}`);
}
} else {
throw new Error(`Unexpected error type: ${typeof error}`);
}
}
});
});
});
function sendPingRequest(port: number): Promise<string> {
return new Promise((resolve, reject) => {
http.get(`http://localhost:${port}/ping`, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => resolve(data));
}).on('error', reject);
});
}

2
tools

@ -1 +1 @@
Subproject commit 0557f55a8d5dadabcf225a2b7a24db9db120f895
Subproject commit c53f73df6322df029075593792bd5ec111bfd9df

View File

@ -18,7 +18,8 @@
"@/*": [
"./src/*"
]
}
},
"experimentalDecorators": true
},
"exclude": [
"test","gui","tools"