Files
nanobot/webui/src/lib/ansi.ts
T
Xubin RenandGitHub 9c81280300 feat(transcription): add shared voice input support (#4232)
* feat(webui): add voice transcription input

* feat(webui): render ANSI output in code blocks

* refactor(webui): isolate voice recorder logic

* refactor(transcription): keep websocket ingress thin

* refactor(transcription): resolve channel audio settings on demand

* style(webui): neutralize voice waveform color

* feat(webui): add voice input tooltip

* feat(webui): add voice input keyboard shortcut

* fix(webui): distinguish voice shortcut platforms

* fix(webui): place voice button after model selector

* refactor(webui): share voice hold recording helpers

* fix(desktop): allow microphone voice input

* fix(webui): stabilize token usage month labels

* feat(webui): show voice input on settings overview

* fix(webui): label voice capability as recognition

* fix(webui): align capability overview status

* refactor(webui): isolate transcription socket handling

* fix(webui): soften silent voice waveform

* refactor(audio): clarify transcription service location

* docs(transcription): clarify audio and provider boundaries

* fix(exec): reduce session output polling flake
2026-06-09 01:08:49 +08:00

211 lines
5.6 KiB
TypeScript

export type AnsiSegment = {
text: string;
style?: AnsiStyle;
};
export type AnsiStyle = {
backgroundColor?: string;
color?: string;
fontStyle?: "italic";
fontWeight?: number;
opacity?: number;
textDecorationLine?: "underline";
};
type AnsiState = {
backgroundColor?: string;
bold: boolean;
color?: string;
dim: boolean;
inverse: boolean;
italic: boolean;
underline: boolean;
};
const ESC = String.fromCharCode(27);
const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, "g");
const ANSI_COLORS = [
"#000000",
"#cd3131",
"#0dbc79",
"#e5e510",
"#2472c8",
"#bc3fbc",
"#11a8cd",
"#e5e5e5",
];
const ANSI_BRIGHT_COLORS = [
"#666666",
"#f14c4c",
"#23d18b",
"#f5f543",
"#3b8eea",
"#d670d6",
"#29b8db",
"#ffffff",
];
const RGB_STEPS = [0, 95, 135, 175, 215, 255];
export function hasAnsi(value: string): boolean {
ANSI_PATTERN.lastIndex = 0;
return ANSI_PATTERN.test(value);
}
export function stripAnsi(value: string): string {
ANSI_PATTERN.lastIndex = 0;
return value.replace(ANSI_PATTERN, "");
}
function initialState(): AnsiState {
return {
bold: false,
dim: false,
inverse: false,
italic: false,
underline: false,
};
}
function colorFrom256(value: number): string | undefined {
if (value < 0 || value > 255) return undefined;
if (value < 8) return ANSI_COLORS[value];
if (value < 16) return ANSI_BRIGHT_COLORS[value - 8];
if (value < 232) {
const offset = value - 16;
const red = RGB_STEPS[Math.floor(offset / 36)];
const green = RGB_STEPS[Math.floor((offset % 36) / 6)];
const blue = RGB_STEPS[offset % 6];
return `rgb(${red}, ${green}, ${blue})`;
}
const gray = 8 + ((value - 232) * 10);
return `rgb(${gray}, ${gray}, ${gray})`;
}
function colorFromRgb(red: number, green: number, blue: number): string | undefined {
if ([red, green, blue].some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
return undefined;
}
return `rgb(${red}, ${green}, ${blue})`;
}
function normalizedSgrParams(sequence: string): number[] | null {
if (!sequence.endsWith("m")) return null;
const body = sequence.slice(2, -1).trim();
if (!body) return [0];
return body.split(/[;:]/).map((part) => {
const value = Number.parseInt(part || "0", 10);
return Number.isFinite(value) ? value : 0;
});
}
function applyExtendedColor(
state: AnsiState,
params: number[],
index: number,
key: "color" | "backgroundColor",
): number {
const mode = params[index + 1];
if (mode === 5) {
const color = colorFrom256(params[index + 2]);
if (color) state[key] = color;
return index + 2;
}
if (mode === 2) {
const color = colorFromRgb(params[index + 2], params[index + 3], params[index + 4]);
if (color) state[key] = color;
return index + 4;
}
return index;
}
function applySgrParams(state: AnsiState, params: number[]): void {
for (let index = 0; index < params.length; index += 1) {
const code = params[index];
if (code === 0) {
Object.assign(state, initialState());
} else if (code === 1) {
state.bold = true;
state.dim = false;
} else if (code === 2) {
state.dim = true;
state.bold = false;
} else if (code === 3) {
state.italic = true;
} else if (code === 4) {
state.underline = true;
} else if (code === 7) {
state.inverse = true;
} else if (code === 22) {
state.bold = false;
state.dim = false;
} else if (code === 23) {
state.italic = false;
} else if (code === 24) {
state.underline = false;
} else if (code === 27) {
state.inverse = false;
} else if (code === 39) {
delete state.color;
} else if (code === 49) {
delete state.backgroundColor;
} else if (code >= 30 && code <= 37) {
state.color = ANSI_COLORS[code - 30];
} else if (code >= 40 && code <= 47) {
state.backgroundColor = ANSI_COLORS[code - 40];
} else if (code >= 90 && code <= 97) {
state.color = ANSI_BRIGHT_COLORS[code - 90];
} else if (code >= 100 && code <= 107) {
state.backgroundColor = ANSI_BRIGHT_COLORS[code - 100];
} else if (code === 38) {
index = applyExtendedColor(state, params, index, "color");
} else if (code === 48) {
index = applyExtendedColor(state, params, index, "backgroundColor");
}
}
}
function styleFromState(state: AnsiState): AnsiStyle | undefined {
const foreground = state.inverse ? state.backgroundColor : state.color;
const background = state.inverse ? state.color : state.backgroundColor;
const style: AnsiStyle = {};
if (foreground) style.color = foreground;
if (background) style.backgroundColor = background;
if (state.bold) style.fontWeight = 700;
if (state.dim) style.opacity = 0.72;
if (state.italic) style.fontStyle = "italic";
if (state.underline) style.textDecorationLine = "underline";
return Object.keys(style).length ? style : undefined;
}
export function parseAnsiSegments(value: string): AnsiSegment[] {
const segments: AnsiSegment[] = [];
const state = initialState();
let cursor = 0;
ANSI_PATTERN.lastIndex = 0;
for (const match of value.matchAll(ANSI_PATTERN)) {
const index = match.index ?? 0;
if (index > cursor) {
segments.push({
text: value.slice(cursor, index),
style: styleFromState(state),
});
}
const params = normalizedSgrParams(match[0]);
if (params) applySgrParams(state, params);
cursor = index + match[0].length;
}
if (cursor < value.length) {
segments.push({
text: value.slice(cursor),
style: styleFromState(state),
});
}
return segments.filter((segment) => segment.text.length > 0);
}