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);
};