116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
import type { CompletionRequest, AbortSignalOption } from "./Agent";
|
|
import type { AgentConfig } from "./AgentConfig";
|
|
import { rootLogger } from "./logger";
|
|
import { splitLines } from "./utils";
|
|
|
|
function clamp(min: number, max: number, value: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
export class CompletionDebounce {
|
|
private readonly logger = rootLogger.child({ component: "CompletionDebounce" });
|
|
private lastCalledTimeStamp = 0;
|
|
|
|
private baseInterval = 200; // ms
|
|
private calledIntervalHistory: number[] = [];
|
|
|
|
private options = {
|
|
baseIntervalSlideWindowAvg: {
|
|
minSize: 20,
|
|
maxSize: 100,
|
|
min: 100,
|
|
max: 400,
|
|
},
|
|
adaptiveRate: {
|
|
min: 1.5,
|
|
max: 3.0,
|
|
},
|
|
contextScoreWeights: {
|
|
triggerCharacter: 0.5,
|
|
noSuffixInCurrentLine: 0.4,
|
|
noSuffix: 0.1,
|
|
},
|
|
requestDelay: {
|
|
min: 100, // ms
|
|
max: 1000,
|
|
},
|
|
};
|
|
|
|
async debounce(
|
|
context: {
|
|
request: CompletionRequest;
|
|
config: AgentConfig["completion"]["debounce"];
|
|
responseTime: number;
|
|
},
|
|
options?: AbortSignalOption,
|
|
): Promise<void> {
|
|
const { request, config, responseTime } = context;
|
|
if (request.manually) {
|
|
return this.sleep(0, options);
|
|
}
|
|
if (config.mode === "fixed") {
|
|
return this.sleep(config.interval, options);
|
|
}
|
|
const now = Date.now();
|
|
this.updateBaseInterval(now - this.lastCalledTimeStamp);
|
|
this.lastCalledTimeStamp = now;
|
|
const contextScore = this.calcContextScore(request);
|
|
const adaptiveRate =
|
|
this.options.adaptiveRate.max - (this.options.adaptiveRate.max - this.options.adaptiveRate.min) * contextScore;
|
|
const expectedLatency = adaptiveRate * this.baseInterval;
|
|
const delay = clamp(this.options.requestDelay.min, this.options.requestDelay.max, expectedLatency - responseTime);
|
|
return this.sleep(delay, options);
|
|
}
|
|
|
|
private async sleep(delay: number, options?: AbortSignalOption): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(resolve, Math.min(delay, 0x7fffffff));
|
|
if (options?.signal) {
|
|
if (options.signal.aborted) {
|
|
clearTimeout(timer);
|
|
reject(options.signal.reason);
|
|
} else {
|
|
options.signal.addEventListener("abort", () => {
|
|
clearTimeout(timer);
|
|
reject(options.signal.reason);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private updateBaseInterval(interval: number) {
|
|
if (interval > this.options.baseIntervalSlideWindowAvg.max) {
|
|
return;
|
|
}
|
|
this.calledIntervalHistory.push(interval);
|
|
if (this.calledIntervalHistory.length > this.options.baseIntervalSlideWindowAvg.maxSize) {
|
|
this.calledIntervalHistory.shift();
|
|
}
|
|
if (this.calledIntervalHistory.length > this.options.baseIntervalSlideWindowAvg.minSize) {
|
|
const avg = this.calledIntervalHistory.reduce((a, b) => a + b, 0) / this.calledIntervalHistory.length;
|
|
this.baseInterval = clamp(
|
|
this.options.baseIntervalSlideWindowAvg.min,
|
|
this.options.baseIntervalSlideWindowAvg.max,
|
|
avg,
|
|
);
|
|
}
|
|
}
|
|
|
|
// return score in [0, 1], 1 means the context has a high chance to accept the completion
|
|
private calcContextScore(request: CompletionRequest): number {
|
|
let score = 0;
|
|
const weights = this.options.contextScoreWeights;
|
|
const triggerCharacter = request.text[request.position - 1] ?? "";
|
|
score += triggerCharacter.match(/^\W*$/) ? weights.triggerCharacter : 0;
|
|
|
|
const suffix = request.text.slice(request.position) ?? "";
|
|
const currentLineInSuffix = splitLines(suffix)[0] ?? "";
|
|
score += currentLineInSuffix.match(/^\W*$/) ? weights.noSuffixInCurrentLine : 0;
|
|
score += suffix.match(/^\W*$/) ? weights.noSuffix : 0;
|
|
|
|
score = clamp(0, 1, score);
|
|
return score;
|
|
}
|
|
}
|