Files
auto_agent/src/web/js/edit-mode.js
T

877 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// edit-mode.js -- 文件管理模式(重构版)
// 保留: 文件树渲染、文档修改追踪、外部变更处理、快捷键、拖拽引用
// 移除: Monaco 编辑器、标签页、Agent 面板
import { $, escapeHtml, showToast, dedupePendingFiles, filterOutUnsupported, isUnsupportedFile } from './utils.js';
import { api } from './api.js';
import { state } from './state.js';
import {
readTextFile, readBinaryFile, writeTextFile, listDir, getFileUrl,
pickFolder, watchDir, onFileChanged, copyExternalFile, writeFromBuffer, deleteFile
} from './electron-api.js';
import {
renderPersistentToolCalls, renderToolCallRow, formatDuration, getToolCallsTotalDuration,
} from './toolcalls.js';
import { renderDocTree, setDocTreeDeps } from './doc-tree.js';
import { openFilePreview, closeFilePreview } from './file-preview.js';
// ===== DOM 引用 =====
const modeCronToggleBtn = $("modeCronToggleBtn");
const chatPanel = $("chatPanel");
const sessionListWrap = $("sessionListWrap");
const docTreeWrap = $("docTreeWrap");
const cronListWrap = $("cronListWrap");
const docTree = $("docTree");
const sidebarHint = document.querySelector(".sidebar-hint");
// ===== 外部文件变更 =====
let _externalChanges = {};
let _fsChangeTimer = null;
let _fsApplying = false;
// 预览相关
const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico']);
const ALL_TEXT_EXTS = new Set(['.txt', '.json', '.md', '.py', '.js', '.ts', '.css', '.xml', '.yaml', '.yml',
'.toml', '.csv', '.log', '.env', '.ini', '.cfg', '.sh', '.bat', '.ps1', '.sql', '.r', '.go', '.rs',
'.rb', '.php', '.swift', '.kt', '.scala', '.cpp', '.c', '.h', '.java', '.m', '.html', '.htm', '.svg']);
function isSupportedFile(name) {
const ext = (name || '').toLowerCase();
const dot = ext.lastIndexOf('.');
if (dot < 0) return false;
const e = ext.substring(dot);
return IMG_EXTS.has(e) || e === '.svg' || ALL_TEXT_EXTS.has(e) || e === '.pdf' || e === '.pptx'
|| e === '.xlsx' || e === '.xls' || e === '.docx' || e === '.doc' || e === '.ppt';
}
function isPreviewableImage(name) {
const ext = (name || '').toLowerCase();
const dot = ext.lastIndexOf('.');
return dot >= 0 && IMG_EXTS.has(ext.substring(dot));
}
function isPreviewableHtml(name) {
return (name || '').toLowerCase().endsWith('.html') || (name || '').toLowerCase().endsWith('.htm');
}
/** 二进制表格(XLSX/XLS):由后端 /api/table/text 提取为 TSV 后按列着色预览 */
function isPreviewableTable(name) {
const ext = (name || '').toLowerCase();
return ext.endsWith('.xlsx') || ext.endsWith('.xls');
}
const BINARY_EXTS = new Set([
'.exe','.dll','.so','.dylib','.bin','.dat','.zip','.rar','.7z','.tar','.gz','.bz2','.xz',
'.png','.jpg','.jpeg','.gif','.bmp','.ico','.webp','.tiff','.tif','.psd','.ai','.eps',
'.mp3','.wav','.flac','.ogg','.m4a','.aac','.wma','.aiff',
'.mp4','.avi','.mkv','.mov','.wmv','.flv','.webm','.m4v',
'.pdf','.doc','.docx','.xls','.xlsx','.ppt','.pptx',
'.ttf','.otf','.woff','.woff2','.eot',
'.db','.sqlite','.sqlite3','.mdb',
'.iso','.dmg','.img','.vmdk',
'.class','.jar','.war','.pyc','.pyo','.pyd',
'.o','.obj','.lib','.a','.wasm',
]);
function isBinaryFile(name) {
const lower = (name || '').toLowerCase();
const dot = lower.lastIndexOf('.');
return dot >= 0 && BINARY_EXTS.has(lower.substring(dot));
}
// ===== 模式切换 =====
let _modeSwitchCooldown = false;
/** 同步顶栏定时任务按钮的按下状态(cron 模式按下高亮,其余复位) */
function syncModeToggleBtn() {
if (!modeCronToggleBtn) return;
const isCron = state.mode === "cron";
modeCronToggleBtn.classList.toggle("is-active", isCron);
modeCronToggleBtn.setAttribute("aria-pressed", isCron ? "true" : "false");
modeCronToggleBtn.title = isCron ? "返回对话" : "定时任务";
}
export function switchToMode(mode) {
// 防连点:短时间内禁止重复切换
if (_modeSwitchCooldown) return;
_modeSwitchCooldown = true;
setTimeout(() => { _modeSwitchCooldown = false; }, 300);
// Edit 已迁入右栏:模式切换只影响左侧栏(会话列表/定时任务)与右栏显隐,
// 聊天区保持不变,Agent 运行中也可自由切换
state.mode = mode;
syncModeToggleBtn();
if (mode === "edit") {
state.editMode = true;
// 左栏始终显示会话列表;文件树在右栏
sessionListWrap?.classList.remove("mode-hidden");
cronListWrap?.classList.add("mode-hidden");
if (sidebarHint) sidebarHint.classList.remove("mode-hidden");
setRightPanel(true);
_onLeaveCron();
} else if (mode === "cron") {
state.editMode = false;
chatPanel?.classList.remove("mode-hidden");
sessionListWrap?.classList.add("mode-hidden");
if (sidebarHint) sidebarHint.classList.add("mode-hidden");
cronListWrap?.classList.remove("mode-hidden");
setRightPanel(false);
closeFilePreview();
_onEnterCron();
} else {
// chat 模式
state.editMode = false;
chatPanel?.classList.remove("mode-hidden");
sessionListWrap?.classList.remove("mode-hidden");
if (sidebarHint) sidebarHint.classList.remove("mode-hidden");
cronListWrap?.classList.add("mode-hidden");
setRightPanel(false);
closeFilePreview();
_onLeaveCron();
}
// 同步回合导航条显隐(cron 模式复用聊天容器,需隐藏导航条)
import('./turn-nav.js').then((m) => m.syncTurnNavVisibility()).catch(() => {});
}
// ===== git 状态(文件树着色:新增绿 / 修改橙 / 删除橙+删除线) =====
let _gitStatusTimer = null;
export function refreshGitStatus() {
if (!state._docRootPath) return;
if (_gitStatusTimer) clearTimeout(_gitStatusTimer);
_gitStatusTimer = setTimeout(async () => {
try {
const rootPath = state._docRootPath;
const data = await api(`/api/workspace/git-status?path=${encodeURIComponent(rootPath)}`);
const map = {};
for (const s of data.status || []) map[s.path] = s.state;
state.gitStatus = map;
renderDocTree();
} catch { /* git 不可用/非仓库时静默,保持无着色 */ }
}, 150);
}
// ===== 右栏(Edit 文件树)控制 =====
export function setRightPanel(open) {
const appRoot = document.getElementById("app");
if (!appRoot) return;
appRoot.classList.toggle("right-panel-open", !!open);
const rp = document.getElementById("rightPanel");
if (rp) rp.setAttribute("aria-hidden", open ? "false" : "true");
// 打开时刷新 git 状态着色
if (open) {
try {
window.dispatchEvent(new CustomEvent("git-status-changed"));
} catch { /* ignore */ }
}
}
// ===== Cron 模式进入/离开钩子(由 app.js 注入,避免循环依赖) =====
let _onEnterCronFn = null;
let _onLeaveCronFn = null;
export function setCronModeHooks({ onEnter, onLeave } = {}) {
_onEnterCronFn = onEnter || null;
_onLeaveCronFn = onLeave || null;
}
function _onEnterCron() {
try { _onEnterCronFn && _onEnterCronFn(); } catch { /* ignore */ }
}
function _onLeaveCron() {
try { _onLeaveCronFn && _onLeaveCronFn(); } catch { /* ignore */ }
}
// ===== 打开文件夹 =====
export async function autoOpenWorkspace() {
if (!window.electronAPI) return;
try {
const data = await api("/api/workspace");
const current = data.current || "";
const defaultPath = data.default || "";
const rootPath = current || defaultPath;
if (rootPath) {
await openFolder(rootPath);
}
} catch { /* ignore */ }
}
export async function openFolder(folderPath) {
try {
let rootPath = (typeof folderPath === "string" && folderPath) ? folderPath : null;
if (!rootPath) {
rootPath = await pickFolder();
}
if (!rootPath) return;
// 先校验目录存在并列出文件;文件夹被删除/不可访问时给出明确提示,避免把失效路径写入状态
let listRes;
try {
listRes = await listDir(rootPath, 10);
} catch (e) {
showToast("打开文件夹失败: " + (e.message || e));
return;
}
state._docRootPath = rootPath;
state._docRootName = rootPath.split(/[/\\]/).pop() || rootPath;
try {
const { addRecentFolder } = await import('../app.js');
await addRecentFolder(rootPath);
} catch {}
if (!state._docTreeFolders) state._docTreeFolders = {};
state._docTreeFolders[state._docRootName] = true;
const files = listRes.files || [];
const supportedFiles = files.filter((f) => !f.is_dir && isSupportedFile(f.name));
// 去重:按 path 去重
const seen = new Set();
const uniqueFiles = [];
for (const f of supportedFiles) {
if (seen.has(f.path)) continue;
seen.add(f.path);
// 规范化路径:去掉 rootPath 前缀,确保相对路径,统一用 /
let relPath = f.path.replace(/\\/g, "/");
const normalizedRoot = rootPath.replace(/\\/g, "/").replace(/\/$/, "");
if (relPath.startsWith(normalizedRoot + "/")) {
relPath = relPath.substring(normalizedRoot.length + 1);
}
uniqueFiles.push({ ...f, path: relPath });
}
state.docFiles = [];
state.docOpenTabs = [];
_externalChanges = {};
for (const f of uniqueFiles) {
state.docFiles.push({ path: f.path, name: f.name, status: "original", size: f.size });
}
renderDocTree();
renderDocTabs && renderDocTabs();
// 打开文件夹后刷新 git 状态着色
refreshGitStatus();
if (state.docFiles.length > 0) {
state.activeDocFile = state.docFiles[0].path;
state.activeDocTab = state.docFiles[0].path;
state.docOpenTabs = [{ path: state.docFiles[0].path, name: state.docFiles[0].name, modified: false }];
renderDocTabs && renderDocTabs();
}
if (window.electronAPI) {
watchDir(rootPath).then(() => {
onFileChanged(handleFsChange);
}).catch(() => {});
}
} catch (e) {
showToast("打开文件夹失败: " + (e.message || e));
}
}
export function showOpenButtons() {
state._docRootPath = null;
state._docRootName = null;
state.docFiles = [];
state.docOpenTabs = [];
_externalChanges = {};
renderDocTree();
renderDocTabs && renderDocTabs();
}
// ===== 标签页(保留用于打开状态追踪) =====
export function renderDocTabs() {
// 简化为无标签栏 - 文件点击直接预览
}
// ===== 文件点击预览 =====
export async function loadDocContent(filePath) {
if (!state._docRootPath) return;
const fileName = filePath.split(/[/\\]/).pop() || filePath;
try {
if (isPreviewableImage(fileName)) {
const res = await readBinaryFile(filePath, state._docRootPath);
if (res && res.status === 'ok' && res.data) {
const dataUrl = `data:${res.mime || 'image/png'};base64,${res.data}`;
const { showImagePreview } = await import('./toolcalls.js');
showImagePreview(dataUrl);
}
} else if (isPreviewableHtml(fileName)) {
const res = await readTextFile(filePath, state._docRootPath);
if (res && res.status === 'ok') {
openFilePreview(filePath, fileName, res.content || '',
{ onDownload: () => downloadFile(filePath, fileName) });
}
} else if (isPreviewableTable(fileName)) {
// XLSX/XLS:后端提取为 TSV 文本,走与 CSV/TSV 一致的按列着色预览
const absPath = (/^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith('/') || filePath.startsWith('\\'))
? filePath
: state._docRootPath.replace(/[\\/]+$/, '') + '/' + filePath.replace(/^[\\/]+/, '');
try {
const tsv = await api('/api/table/text?path=' + encodeURIComponent(absPath));
openFilePreview(absPath, fileName, (tsv && tsv.content) || '',
{ onDownload: () => downloadFile(absPath, fileName) });
} catch (e) {
openFilePreview(absPath, fileName, '(加载失败: ' + (e.message || e) + ')',
{ onDownload: () => downloadFile(absPath, fileName) });
}
} else if (isBinaryFile(fileName)) {
showToast('二进制文件不支持在线查看', 'warning');
} else {
const res = await readTextFile(filePath, state._docRootPath);
if (res && res.status === 'ok') {
openFilePreview(filePath, fileName, res.content || '',
{ onDownload: () => downloadFile(filePath, fileName) });
} else {
openFilePreview(filePath, fileName, '(无法读取文件)',
{ onDownload: () => downloadFile(filePath, fileName) });
}
}
} catch {
openFilePreview(filePath, fileName, '(加载失败)',
{ onDownload: () => downloadFile(filePath, fileName) });
}
}
/** 以纯文本模式打开文件(供右键"文本编辑"使用) */
export async function openFileAsText(filePath) {
if (!state._docRootPath) return;
const fileName = filePath.split(/[/\\]/).pop() || filePath;
if (isBinaryFile(fileName)) {
showToast('二进制文件不支持在线查看', 'warning');
return;
}
try {
const res = await readTextFile(filePath, state._docRootPath);
if (res && res.status === 'ok') {
openFilePreview(filePath, fileName, res.content || '',
{ onDownload: () => downloadFile(filePath, fileName), asText: true });
} else {
openFilePreview(filePath, fileName, '(无法读取文件)',
{ onDownload: () => downloadFile(filePath, fileName) });
}
} catch {
openFilePreview(filePath, fileName, '(加载失败)',
{ onDownload: () => downloadFile(filePath, fileName) });
}
}
async function downloadFile(filePath, fileName) {
try {
if (isPreviewableImage(fileName)) {
const res = await readBinaryFile(filePath, state._docRootPath);
if (res && res.status === 'ok' && res.data) {
const url = `data:${res.mime || 'image/png'};base64,${res.data}`;
const a = document.createElement('a');
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
} else if (isBinaryFile(fileName)) {
// 二进制文件(xlsx/docx/pdf 等):按 base64 还原字节后下载
const res = await readBinaryFile(filePath, state._docRootPath);
if (res && res.status === 'ok' && res.data) {
const bin = atob(res.data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const blob = new Blob([bytes], { type: res.mime || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} else {
const res = await readTextFile(filePath, state._docRootPath);
if (res && res.status === 'ok') {
const blob = new Blob([res.content || ''], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
} catch { /* ignore */ }
}
// ===== 外部文件变更(实时自动刷新,类似 VS Code) =====
function handleFsChange(events) {
if (_fsChangeTimer) clearTimeout(_fsChangeTimer);
_fsChangeTimer = setTimeout(() => applyFsChanges(events), 300);
}
async function applyFsChanges(events) {
if (_fsApplying || !state._docRootPath) return;
_fsApplying = true;
try {
const rootPath = state._docRootPath;
const listRes = await listDir(rootPath, 10);
const currentFiles = (listRes.files || []).filter((f) => !f.is_dir && isSupportedFile(f.name));
const currentMap = new Map(currentFiles.map((f) => [f.path, f]));
const existingMap = new Map(state.docFiles.map((f) => [f.path, f]));
const existingNames = new Set(existingMap.keys());
const currentNames = new Set(currentMap.keys());
let changed = false;
// 检测新增和修改的文件
for (const [fp, f] of currentMap) {
if (!existingNames.has(fp)) {
_externalChanges[fp] = { type: 'externally_added', name: f.name, size: f.size };
changed = true;
} else {
const old = existingMap.get(fp);
// 检测文件大小变化(即内容被外部修改)
if (old.size !== f.size) {
_externalChanges[fp] = { type: 'externally_modified', name: f.name, size: f.size, oldSize: old.size };
changed = true;
}
}
}
// 检测删除的文件
for (const fp of existingNames) {
if (!currentNames.has(fp)) {
_externalChanges[fp] = { type: 'externally_deleted', name: existingMap.get(fp).name };
changed = true;
}
}
if (changed) {
state.docFiles = currentFiles;
renderDocTree();
}
} finally {
_fsApplying = false;
}
}
export function clearExternalChange(filePath) {
delete _externalChanges[filePath];
renderDocTree();
}
// ===== 拖拽引用 =====
export function initDocReferences() {
// 文件树元素拖拽到输入框
if (docTree) {
docTree.addEventListener('dragstart', (e) => {
const item = e.target.closest('[data-file-path]');
if (!item) return;
const filePath = item.dataset.filePath;
// 用专用 mime type 传递路径,避免和 text/plain 冲突
e.dataTransfer.setData('application/doc-path', filePath);
e.dataTransfer.effectAllowed = 'copy';
});
}
// 输入区接收拖入
setupInputDropHandler();
}
function setupInputDropHandler() {
// 拖放目标设为整个 composer 输入区域,不只是 textarea
const composerInput = document.querySelector('.composer-input-shell');
const target = composerInput || $('textInput');
if (!target) return;
// 子元素间移动会成对触发 dragenter/dragleave,用计数器避免状态误清除
let dragDepth = 0;
const setDragState = (on) => target.classList.toggle('is-drag-attach', on);
const isAcceptedDrag = (types) => types.includes('application/doc-path') || types.includes('Files');
target.addEventListener('dragenter', (e) => {
if (isAcceptedDrag(Array.from(e.dataTransfer.types))) {
dragDepth++;
setDragState(true);
}
});
target.addEventListener('dragover', (e) => {
const types = Array.from(e.dataTransfer.types);
// 接受应用内文件/文件夹引用拖拽 或 外部文件拖拽
if (types.includes('application/doc-path') || types.includes('Files')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setDragState(true);
}
});
target.addEventListener('dragleave', () => {
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) setDragState(false);
});
target.addEventListener('drop', async (e) => {
e.preventDefault();
dragDepth = 0;
setDragState(false);
// 1. 内部拖拽(doc-tree 文件/文件夹引用)
// app.js 检测到 application/doc-path 也会 return,无需 stopPropagation
if (Array.from(e.dataTransfer.types).includes('application/doc-path')) {
const docPath = e.dataTransfer.getData('application/doc-path');
const isFolder = e.dataTransfer.getData('application/doc-folder') === '1';
if (isFolder) {
addFolderRef(docPath);
} else {
addDocRef(docPath);
}
return;
}
// 2. 聊天区图片/文件拖拽 — 交给 app.js 处理(不 stopPropagation,让事件冒泡)
if (e.dataTransfer.types.includes('application/x-chat-image') ||
e.dataTransfer.types.includes('application/x-chat-file')) {
return;
}
// 3. 外部拖拽(文件/文件夹)
// stopPropagation 防止 app.js 递归展开文件夹内所有文件
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
e.stopPropagation();
const items = Array.from(e.dataTransfer.items);
// 先收集所有 entry,判断是否包含文件夹(视频暂不支持,直接过滤)
const allEntries = [];
for (const item of items) {
if (item.kind !== 'file') continue;
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
const file = item.getAsFile();
allEntries.push({ entry, file });
}
const acceptedFiles = filterOutUnsupported(allEntries.map(({ file }) => file).filter(Boolean));
const filteredEntries = allEntries.filter(({ file }) => !file || acceptedFiles.includes(file));
const hasDirectory = filteredEntries.some(({ entry }) => entry && entry.isDirectory);
if (hasDirectory) {
// 有文件夹时:只处理文件夹项,跳过文件项(避免展开文件夹内容)
for (const { entry, file } of filteredEntries) {
if (entry && entry.isDirectory) {
const folderPath = (file && file.path) ? file.path : '';
const folderName = entry.name || (file ? file.name : '文件夹');
addExternalFolderRef(folderPath, folderName);
}
}
} else {
// 无文件夹:图片作为普通 File 上传(HumanMessage image_url),非图片作为 @file: 引用
// Electron 拖入的 File 带 path(绝对路径)且已包含二进制内容;无 path 时回退为普通上传
let needFallback = false;
for (const { file } of filteredEntries) {
if (!file) continue;
if (_isImageName(file.name)) {
// 图片:直接作为普通文件上传(File 已含二进制内容)
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
needFallback = true;
} else if (file.path) {
// 非图片:作为 @file: 绝对路径引用
addExternalDocRef(file.path, file.name);
} else {
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
needFallback = true;
}
}
if (needFallback) {
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
}
}
if (filteredEntries.length > 0) {
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
}
return;
}
// 4. 回退:dataTransfer.files(无 items 时)— 图片作为普通 File 上传,非图片作为 @file: 引用
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
e.stopPropagation();
const files = filterOutUnsupported(Array.from(e.dataTransfer.files));
let needFallback = false;
for (const file of files) {
if (_isImageName(file.name)) {
// 图片:直接作为普通文件上传
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
needFallback = true;
} else if (file.path) {
// 非图片:作为 @file: 绝对路径引用
addExternalDocRef(file.path, file.name);
} else {
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
needFallback = true;
}
}
if (files.length > 0) {
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
}
}
});
}
/** 判断文件名是否为图片(与后端 IMAGE_FILE_EXTENSIONS 对齐:png/jpg/jpeg/bmp/gif/webp/ico) */
function _isImageName(name) {
const ext = (name || '').toLowerCase();
return /\.(png|jpe?g|gif|webp|bmp|ico)$/.test(ext);
}
/** 读取工作区内图片文件二进制,构造为 File 并加入 pendingFiles(作为普通图片上传,HumanMessage image_url) */
async function _addImageFileAsUpload(relPath, fileName) {
const rootPath = state._docRootPath;
if (!rootPath) {
showToast('请先打开工作区');
return;
}
const name = fileName || relPath.split(/[/\\]/).pop() || relPath;
try {
const res = await readBinaryFile(relPath, rootPath);
if (!res || res.status !== 'ok' || !res.data) {
showToast('图片读取失败:' + name);
return;
}
const bytes = Uint8Array.from(atob(res.data), c => c.charCodeAt(0));
const blob = new Blob([bytes], { type: res.mime || 'image/png' });
const file = new File([blob], name, { type: blob.type });
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
} catch (e) {
showToast('图片读取失败:' + (e.message || e));
}
}
/** 添加一个文档引用 chip
* 图片(无行号引用):读取二进制作为普通图片 File 上传(HumanMessage image_url),不生成 @file: 文本。
* 非图片或带行号引用:作为 @file:path [Lx-Ly] 文本引用,只给 agent 绝对路径。
*/
export async function addDocRef(filePath, lineStart, lineEnd) {
if (!filePath) return;
const fileName = filePath.split(/[/\\]/).pop() || filePath;
// 不支持的类型(视频 / 旧版 .doc / .rtf):拒绝引用
if (isUnsupportedFile(fileName)) {
showToast(`暂不支持 ${fileName}(视频 / 旧版 .doc / .rtf)`);
return;
}
// 图片且无行号引用 → 作为普通图片上传
if (_isImageName(fileName) && !lineStart) {
// 防重复:若已作为普通文件或引用存在则跳过
const exists = state.pendingFiles.some((f) =>
(f.__isRef && f.refPath === filePath) || (!f.__isRef && f.name === fileName)
);
if (exists) return;
await _addImageFileAsUpload(filePath, fileName);
return;
}
// 非图片或带行号引用 → 作为 @file: 文本引用
if (state.pendingRefs.some((r) => r.path === filePath)) return;
const ref = {
path: filePath,
name: fileName,
startLine: lineStart || null,
endLine: lineEnd || null,
};
state.pendingRefs.push(ref);
// 同步到 pendingFiles(统一渲染),用 __isRef 标记区分
state.pendingFiles.push({
__isRef: true,
refPath: filePath,
refName: fileName + (lineStart ? ` L${lineStart}-L${lineEnd}` : ''),
type: 'ref/file',
name: fileName,
});
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
}
/** 添加工作区文件夹引用 chip(路径相对于 workspace root) */
export function addFolderRef(folderPath) {
// 防重复
if (state.pendingRefs.some((r) => r.isFolder && r.path === folderPath)) return;
const folderName = folderPath
? folderPath.split(/[/\\]/).pop()
: (state._docRootName || 'workspace');
const ref = {
path: folderPath,
name: folderName,
isFolder: true,
};
state.pendingRefs.push(ref);
state.pendingFiles.push({
__isRef: true,
refPath: folderPath,
refName: folderName,
type: 'ref/folder',
name: folderName,
isFolder: true,
});
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
}
/** 添加外部文件夹引用 chip(路径为绝对路径) */
export function addExternalFolderRef(absolutePath, folderName) {
if (!absolutePath && !folderName) return;
// 防重复
if (state.pendingRefs.some((r) => r.isFolder && r.path === absolutePath)) return;
const ref = {
path: absolutePath || '',
name: folderName || '文件夹',
isFolder: true,
isExternal: true,
};
state.pendingRefs.push(ref);
state.pendingFiles.push({
__isRef: true,
refPath: absolutePath || '',
refName: folderName || '文件夹',
type: 'ref/folder',
name: folderName || '文件夹',
isFolder: true,
isExternal: true,
});
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
}
/** 添加外部文件引用 chip(绝对路径)
* 图片:读取二进制作为普通图片 File 上传(HumanMessage image_url)。
* 非图片:作为 @file:abspath 文本引用,只给 agent 绝对路径不读内容。
*/
export async function addExternalDocRef(absolutePath, fileName) {
if (!absolutePath) return;
const name = fileName || absolutePath.split(/[/\\]/).pop() || absolutePath;
// 不支持的类型(视频 / 旧版 .doc / .rtf):拒绝引用
if (isUnsupportedFile(name)) {
showToast(`暂不支持 ${name}(视频 / 旧版 .doc / .rtf)`);
return;
}
// 图片 → 读取二进制作为普通图片上传
if (_isImageName(name)) {
const exists = state.pendingFiles.some((f) =>
(f.__isRef && f.refPath === absolutePath) || (!f.__isRef && f.name === name)
);
if (exists) return;
await _addExternalImageAsUpload(absolutePath, name);
return;
}
// 非图片 → 作为 @file: 文本引用
if (state.pendingRefs.some((r) => r.path === absolutePath)) return;
const ref = {
path: absolutePath,
name: name,
isExternal: true,
};
state.pendingRefs.push(ref);
state.pendingFiles.push({
__isRef: true,
refPath: absolutePath,
refName: name,
type: 'ref/file',
name: name,
isExternal: true,
});
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
}
/** 读取外部(绝对路径)图片二进制,构造为 File 并加入 pendingFiles */
async function _addExternalImageAsUpload(absolutePath, fileName) {
try {
const { readBinaryFile: _readBinary } = await import('./electron-api.js');
const res = await _readBinary(absolutePath, null);
if (!res || res.status !== 'ok' || !res.data) {
showToast('图片读取失败:' + fileName);
return;
}
const bytes = Uint8Array.from(atob(res.data), c => c.charCodeAt(0));
const blob = new Blob([bytes], { type: res.mime || 'image/png' });
const file = new File([blob], fileName, { type: blob.type });
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
} catch (e) {
showToast('图片读取失败:' + (e.message || e));
}
}
/** 清除所有引用 chip */
export function clearRefChips() {
state.pendingRefs = [];
state.pendingFiles = state.pendingFiles.filter((f) => !f.__isRef);
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
}
// ===== 清除状态 =====
export function clearEditState() {
_externalChanges = {};
closeFilePreview();
clearRefChips();
state.docFiles = [];
state.docOpenTabs = [];
state._docRootPath = null;
state._docRootName = null;
renderDocTree();
}
// ===== 发送 Edit 消息 =====
export async function sendEditChat() {
if (!state.sessionId || state.sending) return;
const textInput = $('textInput');
const trimmed = textInput ? textInput.value.trim() : "";
const hasInput = trimmed || state.pendingFiles.length > 0 || state.pendingRefs.length > 0;
if (!hasInput) return;
// 文档/文件夹引用(@file:/@folder:)由 sendChat 统一拼接为文本发送,
// 此处无需再注入 textInput,避免 chat/edit 双份引用
try {
const { sendChat } = await import('./send.js');
await sendChat();
} catch (err) {
throw err;
} finally {
// 无论成功失败都清除引用 chip,防止重复注入
clearRefChips();
}
}
// ===== 初始化 =====
export function initEditMode() {
// git 状态刷新事件(回撤/右栏打开/文件操作后触发)
window.addEventListener("git-status-changed", () => refreshGitStatus());
// 顶栏"定时任务"按钮:cron ↔ chat 切换(Edit 模式点击同样进入 cron)
modeCronToggleBtn?.addEventListener("click", () => {
if (state.mode !== "cron") switchToMode("cron");
else switchToMode("chat");
});
// 打开文件夹 - 移除按钮,改用工作空间选择器触发
// 文件树点击预览
docTree?.addEventListener("click", async (e) => {
const item = e.target.closest('[data-file-path]');
if (!item) return;
const filePath = item.dataset.filePath;
if (filePath) {
await loadDocContent(filePath);
}
});
// 文件树拖拽引用
initDocReferences();
// 全局快捷键(Ctrl+C/X/V/F2/Delete 文件操作)
document.addEventListener("keydown", (e) => {
if (!state.editMode) return;
const tag = document.activeElement?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
// 这些操作委托给 doc-tree.js
// doc-tree.js 中已有自己的事件绑定
}, { capture: true });
// 文件树依赖注入
setDocTreeDeps({
loadDocContent,
openFileAsText,
externalChanges: () => _externalChanges,
});
}