454 lines
20 KiB
JavaScript
454 lines
20 KiB
JavaScript
// AI 对话面板(底部 dock)
|
||
// ============================================================================
|
||
//
|
||
// 给一段自然语言修改要求 + 当前文件内容,发给主进程调 AI。
|
||
// 状态机:idle → submitting → (success | error | cancelled) → idle
|
||
// 不持有 diff 数据(diff 走 AiDiffPanel / AiController)。
|
||
//
|
||
// 职责边界:
|
||
// - 状态展示(status / error 通过 onNotify 转发到状态栏 #status-ai chip)
|
||
// - 收集用户输入
|
||
// - 防 IME(compositionstart/end 期间不响应 Enter)
|
||
// - 失败重试:失败后用户改完提示词直接按 Enter 重新提交,不用关面板
|
||
//
|
||
// 状态展示迁移([feedback-ai-tips-into-status-bar]):原本写在自己
|
||
// .ai-chat-status-row 的过程/错误文案,现在通过 onNotify(text, type) 转发
|
||
// 给外部(通常是 statusbar 上的 AI chip)。dock 本身只剩输入行,不再占底部一行。
|
||
// ============================================================================
|
||
|
||
import { AI_ERROR } from './ai-status.js';
|
||
|
||
function createRequestId() {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
return crypto.randomUUID();
|
||
}
|
||
return `ai-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||
}
|
||
|
||
/**
|
||
* 长提示词软警告阈值(按字符计,UTF-8 近似字节;CJK 是 3 字节/字符,这里保守
|
||
* 按 ASCII 1 字节算,实际 CJK 阈值会更大一些,方向是「宁可漏报也不骚扰」)。
|
||
* 32KB 是经验阈值:超过后多数模型服务开始明显变慢,部分服务开始截断。
|
||
*/
|
||
const LONG_PROMPT_BYTES = 32 * 1024;
|
||
|
||
/**
|
||
* 把 controller 的中性状态文案映射到 chip 的视觉类型。
|
||
* panel 这一侧不深推;不识别就回退 'success'(中性 muted)。
|
||
* - 空文本 → 'idle'(chip 回到"无动作"中性态,只显示 "AI" 标签)
|
||
* - '正在发送...' → running
|
||
* - '正在取消...' → running(用户操作中)
|
||
* - '已取消...' → cancelled
|
||
* 其它走 success。controller 在三处显式调 onNotify(_, 'diff'),不走这条路径。
|
||
*
|
||
* 注:空文本返回 'success' 会让 chip 的 success dot 一直亮着,掩盖「已回到
|
||
* 无动作态」的信号 —— 用户看到 chip 高亮会以为还有事在跑。所以空文本走 'idle'.
|
||
*
|
||
* @param {string} text
|
||
* @returns {'idle'|'running'|'cancelled'|'success'}
|
||
*/
|
||
function inferStatusType(text) {
|
||
if (!text) return 'idle';
|
||
if (text.indexOf('正在发送') === 0 || text.indexOf('正在取消') === 0) return 'running';
|
||
if (text.indexOf('已取消') === 0) return 'cancelled';
|
||
// 'info' 是 panel 主动调的(如长提示词提示),不参与被动推断
|
||
return 'success';
|
||
}
|
||
|
||
export class AiChatPanel {
|
||
/**
|
||
* @param {object} options
|
||
* @param {HTMLElement} options.element - 容器 div(已在 index.html 写好骨架)
|
||
* @param {(payload: {prompt:string, requestId:string}) => Promise<{ok:boolean, id?:string, content?:string, responseFormat?:string, error?:string, message?:string}>} options.onSubmit
|
||
* @param {(requestId:string) => void} options.onCancel
|
||
* @param {(text:string, type:'idle'|'running'|'cancelled'|'error'|'success'|'diff') => void} [options.onNotify]
|
||
* 把过程/错误文案转发到外部 UI(statusbar 上的 #status-ai chip)。不传则静默丢弃。
|
||
*/
|
||
constructor({ element, onSubmit, onCancel, onNotify }) {
|
||
this.element = element;
|
||
this.onSubmit = onSubmit;
|
||
this.onCancel = onCancel;
|
||
this.onNotify = typeof onNotify === 'function' ? onNotify : null;
|
||
this._open = false;
|
||
this._enabled = true;
|
||
this._submitting = false;
|
||
this._composing = false;
|
||
this._currentRequestId = null;
|
||
this._cancelled = null; // Set:被用户取消的 requestId 集合;懒创建于 _triggerCancel
|
||
this._listeners = [];
|
||
this._bind();
|
||
}
|
||
|
||
_bind() {
|
||
const input = this.element.querySelector('.ai-chat-input');
|
||
const submitBtn = this.element.querySelector('.ai-chat-submit');
|
||
const toggleBtn = this.element.querySelector('.ai-chat-toggle');
|
||
|
||
if (input) {
|
||
this._listeners.push([input, 'input', () => {
|
||
this._syncSubmitButton();
|
||
this._syncLongPromptHint();
|
||
}]);
|
||
this._listeners.push([input, 'compositionstart', () => { this._composing = true; }]);
|
||
this._listeners.push([input, 'compositionend', () => { this._composing = false; }]);
|
||
// 兜底:IME 异常不发 compositionend 时,_composing 会永远卡 true,
|
||
// 导致 Enter 永久失效。blur 时强制重置(用户切走时输入已结束)。
|
||
this._listeners.push([input, 'blur', () => { this._composing = false; }]);
|
||
this._listeners.push([input, 'keydown', (e) => {
|
||
// Enter = 提交(不 Shift);IME 输入中不发
|
||
if (e.key === 'Enter' && !e.shiftKey && !this._composing) {
|
||
e.preventDefault();
|
||
this._triggerSubmit();
|
||
}
|
||
}]);
|
||
}
|
||
// 没有 .ai-chat-form 包装时(index.html 当前结构),按钮点击就是触发的来源
|
||
if (submitBtn) {
|
||
this._listeners.push([submitBtn, 'click', (e) => {
|
||
// submitting 时:点击 = 取消(需要在 _triggerSubmit 之前 preventDefault)
|
||
if (this._submitting) {
|
||
e.preventDefault();
|
||
this._triggerCancel();
|
||
return;
|
||
}
|
||
// 否则:提交
|
||
e.preventDefault();
|
||
this._triggerSubmit();
|
||
}]);
|
||
}
|
||
if (toggleBtn) {
|
||
this._listeners.push([toggleBtn, 'click', () => this.close()]);
|
||
}
|
||
for (const [el, ev, fn] of this._listeners) {
|
||
el.addEventListener(ev, fn);
|
||
}
|
||
}
|
||
|
||
open() {
|
||
this._open = true;
|
||
this.element.hidden = false;
|
||
// C1 fix (audit):close→open 路径上若 _submitting 状态残留(IPCF 还没回包),
|
||
// input.disabled 仍为 true,输入框会被永久禁用。这里走 _inputShouldBeDisabled()
|
||
// 合并所有禁用条件,_submitting 自身的回包路径仍会通过 finally 再次同步状态。
|
||
const input = this.element.querySelector('.ai-chat-input');
|
||
if (input) input.disabled = this._inputShouldBeDisabled();
|
||
this._syncSubmitButton();
|
||
// 自动聚焦输入框(用户点按钮来开)
|
||
requestAnimationFrame(() => {
|
||
const el = this.element.querySelector('.ai-chat-input');
|
||
if (el && !el.disabled) el.focus();
|
||
});
|
||
}
|
||
|
||
close() {
|
||
this._open = false;
|
||
this.element.hidden = true;
|
||
// 关闭时若还有请求:撤销
|
||
if (this._submitting) this._triggerCancel();
|
||
// P3 fix:hung 请求兜底 —— _triggerCancel 只是把 cancel 发给主进程;如果 AI
|
||
// 服务永远不回包,_triggerSubmit 的 await 永远不会 resolve,finally 永远跑不到,
|
||
// _submitting 卡在 true。close 不强制复位,下次 open 时用户按提交会被
|
||
// `if (this._submitting) return` 挡住、误以为按钮坏了。
|
||
// audit fix (Phase O-L18):原 `if (this._submitting && this._canceling)` 是
|
||
// 死代码 —— _canceling 从未置 true(line 376-377 注释明确说不设),整个 if
|
||
// 分支永远不进。简化:仅看 _submitting;notify 不动(让残留的 in-flight
|
||
// 旧请求自己跑完)。输入框 / 输入按钮的 disabled 由下次 open() + setEnabled() 同步。
|
||
if (this._submitting) {
|
||
this._submitting = false;
|
||
this._currentRequestId = null;
|
||
this._syncSubmitButton();
|
||
}
|
||
// 关掉 dock 时清掉状态栏 chip —— 否则 chip 会留下上次的"已应用"或"AI 请求失败"
|
||
// 残留,让用户误以为是新请求的结果
|
||
this._notify('', 'idle');
|
||
}
|
||
|
||
isOpen() {
|
||
return this._open;
|
||
}
|
||
|
||
/**
|
||
* 当主进程给出新内容(成功路径),清空输入框。
|
||
* 失败 / 取消保留输入框,方便用户改完直接重试。
|
||
*/
|
||
resetInput() {
|
||
const input = this.element.querySelector('.ai-chat-input');
|
||
if (input) input.value = '';
|
||
this._syncSubmitButton();
|
||
}
|
||
|
||
/**
|
||
* 强制重置整个面板状态(status / error / submitting / 输入框 disabled)。
|
||
* controller 在 onFileChanged / closeAll 时调用,避免切文件后残留
|
||
* "正在提交…" 状态 / 错误行。
|
||
*
|
||
* Bug-3 fix (audit):同时清掉 _cancelled Set —— 切文件/重置时可能还有
|
||
* "用户取消但 await 还没回" 的 requestId 留在 Set 里。reset 不清的话:
|
||
* 1. 用户在新文件重新提交 → 触发 _triggerSubmit 的 cancelled.has(requestId)
|
||
* 检查时,可能误命中旧 requestId(UUID 冲突概率虽低,但存在);
|
||
* 2. Set 只增不减,长期使用会有轻微内存泄漏。
|
||
* _cancelled 是懒创建(_triggerCancel 第一次调用时),所以这里要 lazy-init。
|
||
*/
|
||
reset() {
|
||
this._submitting = false;
|
||
this._canceling = false;
|
||
this._currentRequestId = null;
|
||
if (this._cancelled) this._cancelled.clear();
|
||
const input = this.element.querySelector('.ai-chat-input');
|
||
if (input) {
|
||
input.disabled = !this._enabled;
|
||
input.value = '';
|
||
}
|
||
this._notify('', 'idle');
|
||
this._syncSubmitButton();
|
||
}
|
||
|
||
/**
|
||
* 没有打开文件 / 没有可修改内容时,禁用面板。
|
||
* @param {boolean} enabled
|
||
*/
|
||
setEnabled(enabled) {
|
||
this._enabled = !!enabled;
|
||
const input = this.element.querySelector('.ai-chat-input');
|
||
const submitBtn = this.element.querySelector('.ai-chat-submit');
|
||
if (input) {
|
||
input.disabled = this._inputShouldBeDisabled();
|
||
input.placeholder = this._enabled ? '描述你想如何修改当前文档...' : '请先打开一个 Markdown 文件';
|
||
}
|
||
if (submitBtn) submitBtn.disabled = this._inputShouldBeDisabled();
|
||
}
|
||
|
||
/**
|
||
* 单一来源:输入框 / 提交按钮的 disabled 取值。
|
||
* 把"未启用 + 提交中 + 空输入"三个条件集中在一处 —— open() / setEnabled() /
|
||
* _syncSubmitButton() / finally 路径都走这里,避免某条路径漏合并 _submitting 状态
|
||
* 而让用户在 AI 响应期间又能输入新内容(会让新输入框内容与正在响应的内容竞速)。
|
||
* @returns {boolean}
|
||
*/
|
||
_inputShouldBeDisabled() {
|
||
return !this._enabled || !!this._submitting;
|
||
}
|
||
|
||
/**
|
||
* 应用启动后调用:根据"是否有打开的文件"同步 disabled。
|
||
* @param {boolean} hasFile
|
||
*/
|
||
syncFileState(hasFile) {
|
||
this.setEnabled(hasFile);
|
||
}
|
||
|
||
_syncSubmitButton() {
|
||
const input = this.element.querySelector('.ai-chat-input');
|
||
const submitBtn = this.element.querySelector('.ai-chat-submit');
|
||
if (!submitBtn) return;
|
||
if (this._submitting) {
|
||
submitBtn.textContent = '取消';
|
||
submitBtn.dataset.mode = 'cancel';
|
||
// a11y #1(critical):按钮文字在「生成修改 / 取消」之间切换时,
|
||
// aria-label 必须同步 —— 屏幕阅读器只读 aria-label,不读 textContent,
|
||
// 否则用户听到的还是 "生成修改",与可见状态完全失配。
|
||
submitBtn.setAttribute('aria-label', '取消 AI 请求');
|
||
} else {
|
||
const empty = !input || input.value.trim().length === 0;
|
||
submitBtn.textContent = '生成修改';
|
||
submitBtn.dataset.mode = 'submit';
|
||
submitBtn.setAttribute('aria-label', '生成 AI 修改');
|
||
// 非 submitting 时只受「未启用 + 空输入」影响(_inputShouldBeDisabled 已隐含 !_submitting)
|
||
submitBtn.disabled = !this._enabled || empty;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* audit fix (Phase L3-AI 1):超长提示词的软警告。
|
||
*
|
||
* 用户在 AI dock 里粘贴一大段(比如复制整个章节当 prompt),主进程要把全文
|
||
* 拼进 IPC payload 一次性送给模型服务。提示词越长,IPC 序列化 / 网络上传 /
|
||
* 模型处理时间都线性涨,且超出模型上下文窗口会被服务端截断甚至 400。
|
||
*
|
||
* UX:仅在 input value 跨过 LONG_PROMPT_BYTES(≈32KB)阈值时,向 onNotify
|
||
* 推一条短提示到 statusbar 的 #status-ai chip;用户删回阈值下后立刻清回
|
||
* idle(不污染后续 submit/cancel 的 chip 文案)。
|
||
*
|
||
* 注意:不算 input.value 实际 trim 后的 prompt 字节——用户粘的是「即将提交的
|
||
* 文本」,input.value 是最接近的真相;trim 后的差异在 32KB 量级上无意义。
|
||
* 不要在这里 throw —— 监听器抛错会让 input 整体崩(绑定时未用 { signal },
|
||
* addEventListener 会把异常往上抛到 dispatch)。
|
||
*/
|
||
_syncLongPromptHint() {
|
||
const input = this.element.querySelector('.ai-chat-input');
|
||
if (!input) return;
|
||
// chip 已被 submit / cancel / error 占用时不抢戏;只在 idle 状态下显示提示
|
||
if (this._submitting) return;
|
||
const len = input.value.length;
|
||
if (len >= LONG_PROMPT_BYTES) {
|
||
const kb = Math.round(len / 1024);
|
||
this._notify(`提示词较长(约 ${kb} KB),AI 响应可能变慢`, 'info');
|
||
} else if (len === 0) {
|
||
// 空输入让 chip 回到 idle;用户已清空提示词就别再保留长提示
|
||
this._notify('', 'idle');
|
||
}
|
||
// 中间区间(>0 且 < 阈值)保持现状:不打扰用户
|
||
}
|
||
|
||
async _triggerSubmit() {
|
||
if (!this.onSubmit) return;
|
||
if (this._submitting) return;
|
||
if (!this._enabled) return;
|
||
const input = this.element.querySelector('.ai-chat-input');
|
||
const prompt = (input && input.value || '').trim();
|
||
if (!prompt) {
|
||
// M7 fix (audit):守卫失败的分支也会被 Enter 触发(例如上一次 success 后用户
|
||
// 直接回车提交空字符串)。早返回前把 chip 清回 idle,避免 chip 残留上一次的
|
||
// success / running 视觉误导用户以为"还有事在跑"或"已经提交过了"。
|
||
this._notify('', 'idle');
|
||
return;
|
||
}
|
||
|
||
const requestId = createRequestId();
|
||
this._currentRequestId = requestId;
|
||
this._submitting = true;
|
||
this._canceling = false;
|
||
this._notify('正在发送请求…', 'running');
|
||
this._syncSubmitButton();
|
||
if (input) input.disabled = true;
|
||
|
||
try {
|
||
const result = await this.onSubmit({ prompt, requestId });
|
||
// 已被新请求顶掉(用户点了"取消"或再次提交):静默
|
||
if (this._currentRequestId !== requestId) return;
|
||
// P1-3 fix (audit):用户主动点了「取消」→ 忽略该 requestId 的响应,
|
||
// 不 resetInput,不展示 AI 结果。
|
||
if (this._cancelled && this._cancelled.has(requestId)) {
|
||
this._cancelled.delete(requestId);
|
||
this._notify('已取消 AI 请求', 'cancelled');
|
||
return;
|
||
}
|
||
if (result && result.ok) {
|
||
// 成功状态文案由 controller 算(依赖是否有 diff)后调用 setStatus
|
||
this.resetInput();
|
||
// 输入框保持 disabled 由 setEnabled 决定
|
||
} else if (result && result.error === AI_ERROR.ERR_CANCELLED) {
|
||
this._notify('已取消 AI 请求', 'cancelled');
|
||
} else {
|
||
const msg = (result && result.message) ? result.message : 'AI 修改失败';
|
||
this._notify(msg, 'error');
|
||
}
|
||
} catch (e) {
|
||
if (this._currentRequestId !== requestId) return;
|
||
// L17 fix (audit):catch 分支的 e.message 可能来自 IPC 序列化失败 / 主进程
|
||
// 抛错 / controller 内部 bug,里面可能含路径 / stack / 内部常量。与其
|
||
// echo 给用户(泄露内部细节),不如走中性文案 + console 留痕供排查。
|
||
console.error('[ai-chat-panel] onSubmit threw:', e);
|
||
this._notify('AI 修改失败,请稍后重试', 'error');
|
||
} finally {
|
||
if (this._currentRequestId === requestId) {
|
||
this._submitting = false;
|
||
this._canceling = false;
|
||
this._currentRequestId = null;
|
||
const i = this.element.querySelector('.ai-chat-input');
|
||
if (i) i.disabled = !this._enabled;
|
||
this._syncSubmitButton();
|
||
}
|
||
}
|
||
}
|
||
|
||
_triggerCancel() {
|
||
if (!this._submitting) return;
|
||
if (!this._currentRequestId) return;
|
||
const oldRequestId = this._currentRequestId;
|
||
// P1-3 fix (audit):先标记该 requestId 为 cancelled,让 _triggerSubmit
|
||
// 后续 await 返回时直接丢弃(不 resetInput / 不应用结果)。
|
||
if (!this._cancelled) this._cancelled = new Set();
|
||
this._cancelled.add(oldRequestId);
|
||
// P2-1 fix (audit):onCancel 抛错不要立即写 error 行 —— 让 _triggerSubmit
|
||
// 的 result 分支统一根据 error code 决定显示「已取消」或「失败」。
|
||
try {
|
||
if (this.onCancel) this.onCancel(oldRequestId);
|
||
} catch (e) {
|
||
console.warn('[ai-chat-panel] onCancel threw:', e);
|
||
}
|
||
// audit fix (Phase L3-RACE 1):cancel 后立刻允许用户提交新 prompt,不必等
|
||
// 主进程响应真正 abort 后 _triggerSubmit 的 finally 才复位 _submitting。
|
||
// 旧 _triggerSubmit 的 finally 因 currentRequestId !== requestId 会跳过状态
|
||
// 重置(看下面那个 `if (this._currentRequestId === requestId)` 守卫),
|
||
// 所以提前清是安全的;用户立刻打新 prompt + Enter 不会被 `if (this._submitting) return` 拦住。
|
||
// 真正的取消通知由旧 _triggerSubmit 的 _cancelled.has 分支产生 '已取消 AI 请求'。
|
||
this._submitting = false;
|
||
this._currentRequestId = null;
|
||
this._notify('已取消 AI 请求', 'cancelled');
|
||
const input = this.element.querySelector('.ai-chat-input');
|
||
if (input) input.disabled = !this._enabled;
|
||
this._syncSubmitButton();
|
||
}
|
||
|
||
/**
|
||
* controller 调:强制取消(用于切文件等场景)—— 即使 chatPanel 自己不知道
|
||
* 这次提交是否还"在 submitting 状态",都把 IPC cancel 发出去。
|
||
* @param {string} requestId
|
||
*/
|
||
cancelRequest(requestId) {
|
||
if (typeof requestId === 'string' && requestId) {
|
||
// L6 fix (audit):先把 requestId 加进 _cancelled —— 即使 IPC aiCancel 失败
|
||
// (main 端找不到 pending、abort 没赶上),_triggerSubmit 的 await 返回时
|
||
// 仍会命中 _cancelled.has() 短路,显示「已取消」而不是误导性错误信息。
|
||
if (!this._cancelled) this._cancelled = new Set();
|
||
this._cancelled.add(requestId);
|
||
if (this.onCancel) {
|
||
try {
|
||
this.onCancel(requestId);
|
||
} catch (e) {
|
||
// audit fix (1.2):controller 在切文件 / 用户强退等关键路径调用本方法,
|
||
// 即便 IPC 失败也不应给用户弹红色错误行(瞬时态冲突、误导)。
|
||
// 只在 console 留痕即可。
|
||
console.warn('[ai-chat-panel] cancelRequest 抛错:', e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* controller 调:更新 chip 状态文案(兼容层 —— 旧 controller 路径会继续用)。
|
||
* 内部按文案前缀推断 chip 视觉类型(running / cancelled / success)。
|
||
* 若 controller 想强制类型(如 'diff'),请直接调 onNotify。
|
||
* @param {string} text
|
||
*/
|
||
setStatus(text) {
|
||
this._notify(text || '', inferStatusType(text));
|
||
}
|
||
|
||
/**
|
||
* controller 调:把错误文案写到 chip(danger 色)。
|
||
* @param {string} text
|
||
*/
|
||
setError(text) {
|
||
this._notify(text || '', 'error');
|
||
}
|
||
|
||
/**
|
||
* 内部统一出口:把 (text, type) 转发到 onNotify,未注入则静默。
|
||
* 见 [feedback-ai-tips-into-status-bar]:所有 AI 相关提示都收进底部状态栏 chip,
|
||
* 不再在 dock 底部单开一行。
|
||
* @param {string} text
|
||
* @param {'idle'|'running'|'cancelled'|'error'|'success'|'diff'} type
|
||
*/
|
||
_notify(text, type) {
|
||
if (this.onNotify) {
|
||
try { this.onNotify(text || '', type); } catch { /* 不让 UI 异常阻断 AI 流程 */ }
|
||
}
|
||
}
|
||
|
||
destroy() {
|
||
for (const [el, ev, fn] of this._listeners) {
|
||
el.removeEventListener(ev, fn);
|
||
}
|
||
this._listeners = [];
|
||
// P3-5 fix (audit):destroy 也要清内部状态,避免 bindMountPoint 重新
|
||
// 调用时旧实例残留 _composing / _submitting / _canceling / _currentRequestId
|
||
this._composing = false;
|
||
this._submitting = false;
|
||
this._canceling = false;
|
||
this._currentRequestId = null;
|
||
this._cancelled = null;
|
||
}
|
||
}
|