File size: 8,911 Bytes
494c9e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | import * as d3 from 'd3';
import type { TextStats } from '../utils/textStatistics';
import { calculateTextStats } from '../utils/textStatistics';
import { countTokenCharacters } from '../utils/Util';
import type { FrontendAnalyzeResult } from '../api/GLTR_API';
import {
updateBasicMetrics,
updateTotalSurprisal,
updateModel,
updateApiUsageDisplay,
validateMetricsElements,
type ApiTokenUsage
} from '../utils/textMetricsUpdater';
import { tr } from '../lang/i18n-lite';
/**
* 扩展的 Input 事件接口
* 用于在 input 事件中传递额外的标志信息
*/
export interface ExtendedInputEvent extends Event {
isMatchingAnalysis?: boolean;
}
export type TextInputControllerOptions = {
textField: d3.Selection<any, unknown, any, any>;
textCountValue: d3.Selection<any, unknown, any, any>;
/** 首页等由 AppStateManager 控制显隐;未传则仅不持有引用 */
textMetrics?: d3.Selection<any, unknown, any, any>;
/** 首页等:bytes / chars / tokens / surprisal;Chat 页省略,改用 metricUsage */
metricBytes?: d3.Selection<any, unknown, any, any>;
metricChars?: d3.Selection<any, unknown, any, any>;
metricTokens?: d3.Selection<any, unknown, any, any>;
metricTotalSurprisal?: d3.Selection<any, unknown, any, any>;
/** Chat:仅展示 API 返回的 usage(由 chat 页集中调用 updateChatCompletionMetrics 时可不传) */
metricUsage?: d3.Selection<any, unknown, any, any>;
metricModel?: d3.Selection<any, unknown, any, any>;
clearBtn: d3.Selection<any, unknown, any, any>;
submitBtn: d3.Selection<any, unknown, any, any>;
saveBtn: d3.Selection<any, unknown, any, any>;
pasteBtn: d3.Selection<any, unknown, any, any>;
totalSurprisalFormat: (value: number | null) => string;
showAlertDialog: (title: string, message: string) => void;
};
export class TextInputController {
private options: TextInputControllerOptions;
constructor(options: TextInputControllerOptions) {
this.options = options;
this.initialize();
}
private initialize(): void {
// 初始化时检查一次按钮状态
this.updateButtonStates();
// Clear 按钮状态完全由 TextInputController 内部管理
// 使用原生 addEventListener 监听 input 事件,避免被 D3 的 .on() 覆盖
// 这样可以允许多个监听器共存
const textFieldNode = this.options.textField.node() as HTMLTextAreaElement | null;
if (textFieldNode) {
textFieldNode.addEventListener('input', () => {
this.updateButtonStates();
});
}
// Clear 按钮点击事件
this.options.clearBtn.on('click', () => {
this.handleClear();
});
// Paste 按钮点击事件
this.options.pasteBtn.on('click', async () => {
await this.handlePaste();
});
}
/**
* 更新按钮有效性和字符计数(私有方法,仅内部使用)
* 只负责更新 Clear 按钮状态和字符计数
* 注意:submitBtn 和 saveBtn 的状态由外部状态系统统一管理
*/
private updateButtonStates(): void {
const textValue = this.options.textField.property('value') || '';
const hasText = textValue.length > 0;
// Clear按钮:只在文本框有内容时有效
this.options.clearBtn.classed('inactive', !hasText);
// 注意:submitBtn 的状态现在由外部状态系统统一管理,不再在这里设置
if (!this.options.textCountValue.empty()) {
const charCount = countTokenCharacters(textValue);
this.options.textCountValue.text(charCount.toString());
}
}
/**
* 更新文本指标内容(包括模型显示,不控制显示/隐藏,显示/隐藏由 AppStateManager 统一管理)
* @param stats 统计数据,为 null 时不更新统计内容
* @param modelName 模型名称,始终显示以反映原始情况
* @param apiUsage 可选:后端 tokenizer 计数(如 completions 的 usage)
*/
public updateTextMetrics(
stats: TextStats | null,
modelName?: string | null | undefined,
apiUsage?: ApiTokenUsage | null
): void {
const {
metricBytes,
metricChars,
metricTokens,
metricTotalSurprisal,
metricUsage,
metricModel,
totalSurprisalFormat
} = this.options;
// Chat:仅 model + API usage
if (metricUsage && !metricUsage.empty()) {
if (
!metricModel ||
metricModel.empty() ||
!validateMetricsElements(metricUsage, metricModel)
) {
return;
}
updateApiUsageDisplay(metricUsage, apiUsage ?? null);
updateModel(metricModel, modelName);
return;
}
if (
!metricBytes ||
!metricChars ||
!metricTokens ||
!metricTotalSurprisal ||
!metricModel ||
metricModel.empty() ||
!validateMetricsElements(
metricBytes,
metricChars,
metricTokens,
metricTotalSurprisal,
metricModel
)
) {
return;
}
if (stats) {
updateBasicMetrics(metricBytes, metricChars, metricTokens, stats, apiUsage);
updateTotalSurprisal(metricTotalSurprisal, stats, totalSurprisalFormat);
}
updateModel(metricModel, modelName);
}
/**
* 处理清空文本
*/
private handleClear(): void {
const textValue = this.options.textField.property('value') || '';
if (textValue.length === 0) {
return;
}
this.options.textField.property('value', '');
// 触发 input 事件,让外部统一处理状态更新
this.options.textField.node()?.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* 处理粘贴
*/
private async handlePaste(): Promise<void> {
try {
const text = await navigator.clipboard.readText();
if (text) {
const currentValue = this.options.textField.property('value') || '';
// 在光标位置插入,如果没有光标或光标在末尾,则追加
const textarea = this.options.textField.node() as HTMLTextAreaElement;
if (textarea) {
const start = textarea.selectionStart || currentValue.length;
const end = textarea.selectionEnd || currentValue.length;
const newValue = currentValue.substring(0, start) + text + currentValue.substring(end);
this.options.textField.property('value', newValue);
// 设置光标位置到粘贴内容的末尾
textarea.setSelectionRange(start + text.length, start + text.length);
} else {
this.options.textField.property('value', currentValue + text);
}
// 触发 input 事件,让外部统一处理状态更新
this.options.textField.node()?.dispatchEvent(new Event('input', { bubbles: true }));
}
} catch (error) {
console.error('粘贴失败:', error);
// 如果clipboard API不可用,提示用户手动粘贴
this.options.showAlertDialog(tr('Info'), tr('Failed to read clipboard, please paste manually'));
}
}
/**
* 获取当前文本框的值
*/
public getTextValue(): string {
return this.options.textField.property('value') || '';
}
/**
* 设置文本框的值
* @param value 要设置的文本值
* @param isMatchingAnalysis 如果为true,表示这是匹配分析结果的文本填入(如加载demo),不会清除hasValidData
* 如果为false或未提供,表示这是单方面的文本修改(如用户输入、预填充),会清除hasValidData
*/
public setTextValue(value: string, isMatchingAnalysis: boolean = false): void {
this.options.textField.property('value', value);
// 触发 input 事件,添加标志以区分两种场景
const event = new Event('input', { bubbles: true }) as ExtendedInputEvent;
event.isMatchingAnalysis = isMatchingAnalysis;
this.options.textField.node()?.dispatchEvent(event);
}
}
/**
* 计算文本统计信息(便捷函数)
*/
export const calculateTextStatsForController = (
result: FrontendAnalyzeResult,
originalText: string
): TextStats => {
return calculateTextStats(result, originalText);
};
|