initial open-source release
This commit is contained in:
commit
01c4605498
224 files changed
+55124
No files matched your search
@@ -0,0 +1,83 @@
|
||||
// action-bar.js -- 操作图标按钮栏:TodoList / 人机交互 / 文档变更
|
||||
// 替代原来的 floating overlay 模式,改用图标按钮 toggle 面板
|
||||
|
||||
import { $ } from './utils.js';
|
||||
|
||||
/** 当前激活的面板类型 */
|
||||
let _activePanel = null;
|
||||
/** 连击保护:上次切换时间戳(ms) */
|
||||
let _lastToggleTime = 0;
|
||||
const TOGGLE_COOLDOWN = 300;
|
||||
|
||||
/**
|
||||
* 初始化操作按钮栏事件
|
||||
* @param {object} callbacks - { onToggleTodo, onTogglePending, onToggleDoc }
|
||||
*/
|
||||
export function initActionBar(callbacks = {}) {
|
||||
const todoBtn = $('actionTodoBtn');
|
||||
const pendingBtn = $('actionPendingBtn');
|
||||
const docBtn = $('actionDocBtn');
|
||||
|
||||
if (todoBtn) {
|
||||
todoBtn.addEventListener('click', () => {
|
||||
_togglePanel('todo', todoBtn, callbacks.onToggleTodo);
|
||||
});
|
||||
}
|
||||
if (pendingBtn) {
|
||||
pendingBtn.addEventListener('click', () => {
|
||||
_togglePanel('pending', pendingBtn, callbacks.onTogglePending);
|
||||
});
|
||||
}
|
||||
if (docBtn) {
|
||||
docBtn.addEventListener('click', () => {
|
||||
_togglePanel('doc', docBtn, callbacks.onToggleDoc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _togglePanel(name, btn, callback) {
|
||||
const now = Date.now();
|
||||
if (now - _lastToggleTime < TOGGLE_COOLDOWN) return;
|
||||
_lastToggleTime = now;
|
||||
|
||||
if (_activePanel === name) {
|
||||
_deactivateAll();
|
||||
if (callback) callback(false);
|
||||
return;
|
||||
}
|
||||
_deactivateAll();
|
||||
btn.classList.add('is-active');
|
||||
_activePanel = name;
|
||||
if (callback) callback(true);
|
||||
}
|
||||
|
||||
/** 关闭所有面板并取消按钮激活态 */
|
||||
export function deactivateAllActionPanels() {
|
||||
_deactivateAll();
|
||||
}
|
||||
|
||||
function _deactivateAll() {
|
||||
['actionTodoBtn', 'actionPendingBtn', 'actionDocBtn'].forEach((id) => {
|
||||
const btn = $(id);
|
||||
if (btn) btn.classList.remove('is-active');
|
||||
});
|
||||
_activePanel = null;
|
||||
}
|
||||
|
||||
/** 设置某个按钮的角标(红点) */
|
||||
export function setActionBadge(name, show) {
|
||||
const id = name === 'todo' ? 'actionTodoBtn'
|
||||
: name === 'pending' ? 'actionPendingBtn'
|
||||
: 'actionDocBtn';
|
||||
const btn = $(id);
|
||||
if (!btn) return;
|
||||
const existing = btn.querySelector('.badge-dot');
|
||||
if (show && !existing) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'badge-dot';
|
||||
btn.style.position = 'relative';
|
||||
btn.appendChild(dot);
|
||||
} else if (!show && existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// api.js -- API 通信层
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const r = await fetch(path, {
|
||||
...opts,
|
||||
headers: opts.body instanceof FormData ? {} : { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||||
});
|
||||
if (!r.ok) {
|
||||
let detail = r.statusText;
|
||||
try {
|
||||
const j = await r.json();
|
||||
if (j.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(detail || `HTTP ${r.status}`);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式 API 调用。
|
||||
* @param {string} path
|
||||
* @param {{ method?: string, body?: FormData, signal?: AbortSignal, onEvent?: (event: object) => void }} opts
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function streamApi(path, opts = {}) {
|
||||
const { onEvent, ...fetchOpts } = opts;
|
||||
const r = await fetch(path, {
|
||||
...fetchOpts,
|
||||
headers: fetchOpts.body instanceof FormData ? {} : { "Content-Type": "application/json", ...(fetchOpts.headers || {}) },
|
||||
});
|
||||
if (!r.ok) {
|
||||
let detail = r.statusText;
|
||||
try {
|
||||
const j = await r.json();
|
||||
if (j.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
|
||||
} catch { /* ignore */ }
|
||||
throw new Error(detail || `HTTP ${r.status}`);
|
||||
}
|
||||
|
||||
const reader = r.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const jsonStr = line.slice(6);
|
||||
try {
|
||||
const event = JSON.parse(jsonStr);
|
||||
if (onEvent) onEvent(event);
|
||||
} catch { /* ignore malformed JSON */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { api, streamApi };
|
||||
@@ -0,0 +1,159 @@
|
||||
// audio.js -- 麦克风录音(录制完成后自动发送)
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
let renderAttachmentChipsFn = null;
|
||||
export function setRenderAttachmentChips(fn) { renderAttachmentChipsFn = fn; }
|
||||
|
||||
const micBtn = $("micBtn");
|
||||
|
||||
function stopRecorderStream() {
|
||||
if (!state.recorderStream) return;
|
||||
state.recorderStream.getTracks().forEach((track) => {
|
||||
try { track.stop(); } catch { /* ignore */ }
|
||||
});
|
||||
state.recorderStream = null;
|
||||
}
|
||||
|
||||
function setRecordingUi(recording) {
|
||||
if (!micBtn) return;
|
||||
micBtn.classList.toggle("is-recording", recording);
|
||||
micBtn.setAttribute("aria-pressed", recording ? "true" : "false");
|
||||
|
||||
if (recording) {
|
||||
micBtn.title = "停止录音";
|
||||
micBtn.setAttribute("aria-label", "停止录音");
|
||||
} else {
|
||||
micBtn.title = "使用麦克风录音";
|
||||
micBtn.setAttribute("aria-label", "使用麦克风录音");
|
||||
}
|
||||
}
|
||||
|
||||
function inferRecordedExtension(mimeType) {
|
||||
const mt = String(mimeType || "").toLowerCase();
|
||||
if (mt.includes("wav")) return ".wav";
|
||||
if (mt.includes("mpeg")) return ".mp3";
|
||||
if (mt.includes("mp4") || mt.includes("mpeg4")) return ".m4a";
|
||||
if (mt.includes("ogg")) return ".ogg";
|
||||
if (mt.includes("webm")) return ".webm";
|
||||
return ".wav";
|
||||
}
|
||||
|
||||
function buildRecordedAudioFile(blob, mimeType) {
|
||||
const ext = inferRecordedExtension(mimeType || blob.type);
|
||||
const normalizedType = mimeType || blob.type || (ext === ".wav" ? "audio/wav" : "audio/webm");
|
||||
const file = new File([blob], `microphone${ext}`, { type: normalizedType });
|
||||
// 标记为语音输入(区别于上传的音频文件):发送时走 voice_input 字段并阻塞 ASR 转文本
|
||||
file.__voiceInput = true;
|
||||
return file;
|
||||
}
|
||||
|
||||
async function stopMicRecording() {
|
||||
if (!state.mediaRecorder) return;
|
||||
const recorder = state.mediaRecorder;
|
||||
const stopped = new Promise((resolve, reject) => {
|
||||
recorder.addEventListener("stop", () => {
|
||||
try {
|
||||
const blob = new Blob(state.recordedChunks, { type: state.recordingMimeType || recorder.mimeType || "audio/webm" });
|
||||
state.pendingFiles = state.pendingFiles.concat(buildRecordedAudioFile(blob, state.recordingMimeType || recorder.mimeType));
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
state.recordedChunks = [];
|
||||
state.recordingMimeType = "";
|
||||
state.mediaRecorder = null;
|
||||
stopRecorderStream();
|
||||
setRecordingUi(false);
|
||||
}
|
||||
}, { once: true });
|
||||
recorder.addEventListener("error", (event) => {
|
||||
state.recordedChunks = [];
|
||||
state.recordingMimeType = "";
|
||||
state.mediaRecorder = null;
|
||||
stopRecorderStream();
|
||||
setRecordingUi(false);
|
||||
reject(event.error || new Error("录音失败"));
|
||||
}, { once: true });
|
||||
});
|
||||
recorder.stop();
|
||||
await stopped;
|
||||
}
|
||||
|
||||
async function startMicRecording() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === "undefined") {
|
||||
throw new Error("当前浏览器不支持麦克风录音");
|
||||
}
|
||||
if (state.sending) return;
|
||||
|
||||
const preferredTypes = [
|
||||
"audio/wav",
|
||||
"audio/mp4",
|
||||
"audio/ogg;codecs=opus",
|
||||
"audio/ogg",
|
||||
"audio/webm;codecs=opus",
|
||||
"audio/webm",
|
||||
];
|
||||
const supportedType = preferredTypes.find((type) => {
|
||||
try { return MediaRecorder.isTypeSupported(type); } catch { return false; }
|
||||
}) || "";
|
||||
|
||||
state.recorderStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
state.recordedChunks = [];
|
||||
state.recordingMimeType = supportedType;
|
||||
state.mediaRecorder = supportedType
|
||||
? new MediaRecorder(state.recorderStream, { mimeType: supportedType })
|
||||
: new MediaRecorder(state.recorderStream);
|
||||
state.recordingMimeType = state.mediaRecorder.mimeType || supportedType;
|
||||
|
||||
state.mediaRecorder.addEventListener("dataavailable", (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
state.recordedChunks.push(event.data);
|
||||
}
|
||||
});
|
||||
state.mediaRecorder.start();
|
||||
setRecordingUi(true);
|
||||
}
|
||||
|
||||
async function toggleMicRecording() {
|
||||
if (!micBtn) return;
|
||||
// 防止连点:正在发送中 或 按钮已禁用时忽略
|
||||
if (state.sending || micBtn.disabled) return;
|
||||
micBtn.disabled = true;
|
||||
try {
|
||||
if (state.mediaRecorder && state.mediaRecorder.state === "recording") {
|
||||
await stopMicRecording();
|
||||
// 录音完成后自动发送(根据当前模式选择发送函数)
|
||||
if (state.pendingFiles.length > 0 && !state.sending) {
|
||||
if (state.editMode) {
|
||||
const { sendEditChat } = await import('./edit-mode.js');
|
||||
await sendEditChat();
|
||||
} else {
|
||||
const { sendChat } = await import('./send.js');
|
||||
await sendChat();
|
||||
const { refreshTokens } = await import('./token-bar.js');
|
||||
refreshTokens();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await startMicRecording();
|
||||
micBtn.disabled = false; // 开始录音成功后立即可用(供停止用)
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
// 仅在非录音状态时恢复按钮;sendChat/sendEditChat 完成后由各自负责恢复 micBtn
|
||||
if (!state.mediaRecorder || state.mediaRecorder.state !== "recording") {
|
||||
micBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
stopRecorderStream,
|
||||
setRecordingUi,
|
||||
inferRecordedExtension, buildRecordedAudioFile,
|
||||
stopMicRecording, startMicRecording, toggleMicRecording,
|
||||
};
|
||||
@@ -0,0 +1,749 @@
|
||||
// canvas-editor.js -- 画板编辑器浮窗
|
||||
// 支持:画笔/矩形/圆形/直线/文字/橡皮擦,颜色粗细可调
|
||||
// 交互:Ctrl+滚轮缩放画布,中键拖动平移,文字可拖拽/缩放/旋转
|
||||
// 撤销/重做针对绘制层(bg 层不动),文字独立管理,确定时合并导出
|
||||
|
||||
import { state } from './state.js';
|
||||
import { dedupePendingFiles, showToast } from './utils.js';
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
let _st = null; // 编辑器状态
|
||||
let _overlay = null; // 浮窗 DOM
|
||||
let _bgCanvas, _drawCanvas, _textLayer, _canvasWrap;
|
||||
let _bgCtx, _drawCtx;
|
||||
|
||||
/**
|
||||
* 打开画板编辑器
|
||||
* @param {Object} opts
|
||||
* mode: 'edit' | 'create'
|
||||
* imageSrc: String (edit 模式底图 URL)
|
||||
* originalIdx: Number (edit 模式原图在 pendingFiles 的索引,-1 表示无原图)
|
||||
* originalName: String
|
||||
* width: Number (create 模式画布宽度)
|
||||
* height: Number (create 模式画布高度)
|
||||
*/
|
||||
export function openCanvasEditor(opts = {}) {
|
||||
// 已存在则先关闭
|
||||
if (_overlay) closeCanvasEditor();
|
||||
|
||||
_st = {
|
||||
mode: opts.mode || 'create',
|
||||
canvasW: opts.width || 1536,
|
||||
canvasH: opts.height || 768,
|
||||
scale: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
tool: 'brush',
|
||||
color: '#000000',
|
||||
lineWidth: 3,
|
||||
isDrawing: false,
|
||||
isPanning: false,
|
||||
panStart: { x: 0, y: 0 },
|
||||
panOrigin: { x: 0, y: 0 },
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
snapshot: null,
|
||||
history: [],
|
||||
redoStack: [],
|
||||
textObjects: [],
|
||||
textCounter: 0,
|
||||
originalIdx: typeof opts.originalIdx === 'number' ? opts.originalIdx : -1,
|
||||
originalName: opts.originalName || '',
|
||||
imageSrc: opts.imageSrc || null,
|
||||
bgReady: false,
|
||||
};
|
||||
|
||||
_buildOverlay();
|
||||
document.body.appendChild(_overlay);
|
||||
_overlay.hidden = false;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
_initCanvas();
|
||||
_bindEvents();
|
||||
_updateZoomLabel();
|
||||
|
||||
// edit 模式加载底图
|
||||
if (_st.mode === 'edit' && _st.imageSrc) {
|
||||
_loadBgImage(_st.imageSrc);
|
||||
} else {
|
||||
// create 模式白色背景
|
||||
_bgCtx.fillStyle = '#ffffff';
|
||||
_bgCtx.fillRect(0, 0, _st.canvasW, _st.canvasH);
|
||||
_st.bgReady = true;
|
||||
_pushHistory();
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭画板编辑器(不保存) */
|
||||
export function closeCanvasEditor() {
|
||||
document.removeEventListener('keydown', _onKeyDown);
|
||||
if (_overlay && _overlay.parentNode) {
|
||||
_overlay.parentNode.removeChild(_overlay);
|
||||
}
|
||||
_overlay = null;
|
||||
_bgCanvas = _drawCanvas = _textLayer = _canvasWrap = null;
|
||||
_bgCtx = _drawCtx = null;
|
||||
_st = null;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// ==================== DOM 构建 ====================
|
||||
|
||||
function _buildOverlay() {
|
||||
_overlay = document.createElement('div');
|
||||
_overlay.className = 'canvas-editor-overlay';
|
||||
_overlay.innerHTML = `
|
||||
<div class="canvas-editor-dialog">
|
||||
<div class="canvas-editor-header">
|
||||
<span class="canvas-editor-title" id="ceTitle">${_st.mode === 'edit' ? '修改图片' : '画板'}</span>
|
||||
<div class="canvas-editor-actions">
|
||||
<button type="button" class="ce-btn ce-btn--icon" id="ceUndoBtn" title="撤销 (Undo)">↶</button>
|
||||
<button type="button" class="ce-btn ce-btn--icon" id="ceRedoBtn" title="重做 (Redo)">↷</button>
|
||||
<button type="button" class="ce-btn" id="ceClearBtn" title="清空绘制层">清空</button>
|
||||
<span class="ce-actions-sep"></span>
|
||||
<button type="button" class="ce-btn ce-btn--ghost" id="ceCloseBtn" title="关闭不保存">关闭</button>
|
||||
<button type="button" class="ce-btn ce-btn--primary" id="ceConfirmBtn">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-editor-toolbar">
|
||||
<div class="ce-tool-group">
|
||||
<button type="button" class="ce-tool-btn is-active" data-tool="brush" title="画笔">🖌</button>
|
||||
<button type="button" class="ce-tool-btn" data-tool="rect" title="矩形">▭</button>
|
||||
<button type="button" class="ce-tool-btn" data-tool="circle" title="圆形">○</button>
|
||||
<button type="button" class="ce-tool-btn" data-tool="line" title="直线">╱</button>
|
||||
<button type="button" class="ce-tool-btn" data-tool="text" title="文字">T</button>
|
||||
<button type="button" class="ce-tool-btn" data-tool="eraser" title="橡皮擦">⌫</button>
|
||||
</div>
|
||||
<div class="ce-tool-group">
|
||||
<label class="ce-label">颜色</label>
|
||||
<input type="color" id="ceColorInput" value="#000000" class="ce-color-input">
|
||||
</div>
|
||||
<div class="ce-tool-group">
|
||||
<label class="ce-label">粗细</label>
|
||||
<input type="range" id="ceSizeRange" min="1" max="40" value="3" class="ce-size-range">
|
||||
<span class="ce-size-val" id="ceSizeVal">3</span>
|
||||
</div>
|
||||
<div class="ce-tool-group ce-tool-group--info">
|
||||
<span class="ce-zoom-val" id="ceZoomVal">100%</span>
|
||||
<span class="ce-hint">Ctrl+滚轮缩放 · 中键拖动画布</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-editor-canvas-area" id="ceCanvasArea">
|
||||
<div class="canvas-editor-canvas-wrap" id="ceCanvasWrap">
|
||||
<canvas id="ceBgCanvas"></canvas>
|
||||
<canvas id="ceDrawCanvas"></canvas>
|
||||
<div id="ceTextLayer" class="canvas-text-layer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
function _initCanvas() {
|
||||
_bgCanvas = _overlay.querySelector('#ceBgCanvas');
|
||||
_drawCanvas = _overlay.querySelector('#ceDrawCanvas');
|
||||
_textLayer = _overlay.querySelector('#ceTextLayer');
|
||||
_canvasWrap = _overlay.querySelector('#ceCanvasWrap');
|
||||
|
||||
_bgCanvas.width = _st.canvasW;
|
||||
_bgCanvas.height = _st.canvasH;
|
||||
_drawCanvas.width = _st.canvasW;
|
||||
_drawCanvas.height = _st.canvasH;
|
||||
_canvasWrap.style.width = _st.canvasW + 'px';
|
||||
_canvasWrap.style.height = _st.canvasH + 'px';
|
||||
_textLayer.style.width = _st.canvasW + 'px';
|
||||
_textLayer.style.height = _st.canvasH + 'px';
|
||||
|
||||
_bgCtx = _bgCanvas.getContext('2d');
|
||||
_drawCtx = _drawCanvas.getContext('2d');
|
||||
|
||||
// 初始适配视口(让画布居中并缩放到可见区域)
|
||||
_fitToView();
|
||||
}
|
||||
|
||||
function _fitToView() {
|
||||
const area = _overlay.querySelector('#ceCanvasArea');
|
||||
if (!area) return;
|
||||
const rect = area.getBoundingClientRect();
|
||||
const sx = (rect.width - 40) / _st.canvasW;
|
||||
const sy = (rect.height - 40) / _st.canvasH;
|
||||
_st.scale = Math.min(sx, sy, 1);
|
||||
_st.offsetX = (rect.width - _st.canvasW * _st.scale) / 2;
|
||||
_st.offsetY = (rect.height - _st.canvasH * _st.scale) / 2;
|
||||
_applyTransform();
|
||||
}
|
||||
|
||||
function _applyTransform() {
|
||||
_canvasWrap.style.transform = `translate(${_st.offsetX}px, ${_st.offsetY}px) scale(${_st.scale})`;
|
||||
_updateZoomLabel();
|
||||
}
|
||||
|
||||
function _updateZoomLabel() {
|
||||
const el = _overlay.querySelector('#ceZoomVal');
|
||||
if (el) el.textContent = Math.round(_st.scale * 100) + '%';
|
||||
}
|
||||
|
||||
// ==================== 事件绑定 ====================
|
||||
|
||||
function _bindEvents() {
|
||||
// 顶部按钮
|
||||
_overlay.querySelector('#ceCloseBtn').addEventListener('click', closeCanvasEditor);
|
||||
_overlay.querySelector('#ceConfirmBtn').addEventListener('click', _onConfirm);
|
||||
_overlay.querySelector('#ceUndoBtn').addEventListener('click', _onUndo);
|
||||
_overlay.querySelector('#ceRedoBtn').addEventListener('click', _onRedo);
|
||||
_overlay.querySelector('#ceClearBtn').addEventListener('click', _onClear);
|
||||
|
||||
// 工具切换
|
||||
_overlay.querySelectorAll('.ce-tool-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => _setTool(btn.dataset.tool));
|
||||
});
|
||||
|
||||
// 颜色/粗细
|
||||
_overlay.querySelector('#ceColorInput').addEventListener('input', (e) => {
|
||||
_st.color = e.target.value;
|
||||
});
|
||||
const sizeRange = _overlay.querySelector('#ceSizeRange');
|
||||
const sizeVal = _overlay.querySelector('#ceSizeVal');
|
||||
sizeRange.addEventListener('input', (e) => {
|
||||
_st.lineWidth = parseInt(e.target.value, 10);
|
||||
sizeVal.textContent = _st.lineWidth;
|
||||
});
|
||||
|
||||
// 画布交互(绘制 + 缩放 + 平移)
|
||||
_drawCanvas.addEventListener('mousedown', _onCanvasMouseDown);
|
||||
_drawCanvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
_overlay.querySelector('#ceCanvasArea').addEventListener('wheel', _onWheel, { passive: false });
|
||||
_overlay.querySelector('#ceCanvasArea').addEventListener('mousedown', _onAreaMiddleDown);
|
||||
|
||||
// 文字层点击空白处取消选中
|
||||
_textLayer.addEventListener('mousedown', (e) => {
|
||||
if (e.target === _textLayer) {
|
||||
_deselectAllText();
|
||||
}
|
||||
});
|
||||
|
||||
// 点击背景关闭
|
||||
_overlay.addEventListener('click', (e) => {
|
||||
if (e.target === _overlay) closeCanvasEditor();
|
||||
});
|
||||
|
||||
// ESC 关闭
|
||||
_overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
// 编辑中的文字先失焦
|
||||
const editing = _textLayer.querySelector('.canvas-text-content[contenteditable="true"]');
|
||||
if (editing && document.activeElement === editing) {
|
||||
editing.blur();
|
||||
return;
|
||||
}
|
||||
closeCanvasEditor();
|
||||
}
|
||||
});
|
||||
|
||||
// Delete 键删除选中文字
|
||||
document.addEventListener('keydown', _onKeyDown);
|
||||
}
|
||||
|
||||
function _onKeyDown(e) {
|
||||
if (!_overlay || _overlay.hidden) return;
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
// 文字编辑中不拦截
|
||||
const editing = _textLayer.querySelector('.canvas-text-content[contenteditable="true"]');
|
||||
if (editing && document.activeElement === editing) return;
|
||||
const sel = _textLayer.querySelector('.canvas-text-obj.is-selected');
|
||||
if (sel) {
|
||||
e.preventDefault();
|
||||
_deleteTextObject(sel);
|
||||
}
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) _onRedo(); else _onUndo();
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
|
||||
e.preventDefault();
|
||||
_onRedo();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具切换 ====================
|
||||
|
||||
function _setTool(tool) {
|
||||
_st.tool = tool;
|
||||
_overlay.querySelectorAll('.ce-tool-btn').forEach((btn) => {
|
||||
btn.classList.toggle('is-active', btn.dataset.tool === tool);
|
||||
});
|
||||
// 切换光标
|
||||
const cursors = {
|
||||
brush: 'crosshair', eraser: 'cell', rect: 'crosshair',
|
||||
circle: 'crosshair', line: 'crosshair', text: 'text',
|
||||
};
|
||||
_drawCanvas.style.cursor = cursors[tool] || 'default';
|
||||
_deselectAllText();
|
||||
}
|
||||
|
||||
// ==================== 坐标转换 ====================
|
||||
|
||||
function _screenToCanvas(clientX, clientY) {
|
||||
const rect = _drawCanvas.getBoundingClientRect();
|
||||
// rect 已反映 CSS transform 后的位置和尺寸
|
||||
const x = (clientX - rect.left) * (_st.canvasW / rect.width);
|
||||
const y = (clientY - rect.top) * (_st.canvasH / rect.height);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// ==================== 缩放与平移 ====================
|
||||
|
||||
function _onWheel(e) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
_st.scale = Math.max(0.1, Math.min(8, _st.scale * factor));
|
||||
_applyTransform();
|
||||
}
|
||||
}
|
||||
|
||||
function _onAreaMiddleDown(e) {
|
||||
if (e.button !== 1) return; // 中键
|
||||
e.preventDefault();
|
||||
_st.isPanning = true;
|
||||
_st.panStart = { x: e.clientX, y: e.clientY };
|
||||
_st.panOrigin = { x: _st.offsetX, y: _st.offsetY };
|
||||
document.addEventListener('mousemove', _onAreaMiddleMove);
|
||||
document.addEventListener('mouseup', _onAreaMiddleUp);
|
||||
}
|
||||
|
||||
function _onAreaMiddleMove(e) {
|
||||
if (!_st.isPanning) return;
|
||||
_st.offsetX = _st.panOrigin.x + (e.clientX - _st.panStart.x);
|
||||
_st.offsetY = _st.panOrigin.y + (e.clientY - _st.panStart.y);
|
||||
_applyTransform();
|
||||
}
|
||||
|
||||
function _onAreaMiddleUp() {
|
||||
_st.isPanning = false;
|
||||
document.removeEventListener('mousemove', _onAreaMiddleMove);
|
||||
document.removeEventListener('mouseup', _onAreaMiddleUp);
|
||||
}
|
||||
|
||||
// ==================== 绘制 ====================
|
||||
|
||||
function _onCanvasMouseDown(e) {
|
||||
// 中键不触发绘制(交给平移)
|
||||
if (e.button === 1) return;
|
||||
// 右键不绘制
|
||||
if (e.button === 2) return;
|
||||
|
||||
const { x, y } = _screenToCanvas(e.clientX, e.clientY);
|
||||
_st.startX = x;
|
||||
_st.startY = y;
|
||||
_st.isDrawing = true;
|
||||
|
||||
if (_st.tool === 'text') {
|
||||
_st.isDrawing = false;
|
||||
_addTextObject(x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存快照用于图形预览
|
||||
if (_st.tool === 'rect' || _st.tool === 'circle' || _st.tool === 'line') {
|
||||
_st.snapshot = _drawCtx.getImageData(0, 0, _st.canvasW, _st.canvasH);
|
||||
}
|
||||
|
||||
if (_st.tool === 'brush' || _st.tool === 'eraser') {
|
||||
_drawCtx.beginPath();
|
||||
_drawCtx.moveTo(x, y);
|
||||
_drawCtx.lineCap = 'round';
|
||||
_drawCtx.lineJoin = 'round';
|
||||
// 画笔实时绘制单点
|
||||
if (_st.tool === 'brush') {
|
||||
_drawCtx.globalCompositeOperation = 'source-over';
|
||||
_drawCtx.strokeStyle = _st.color;
|
||||
} else {
|
||||
_drawCtx.globalCompositeOperation = 'destination-out';
|
||||
_drawCtx.strokeStyle = 'rgba(0,0,0,1)';
|
||||
}
|
||||
_drawCtx.lineWidth = _st.lineWidth;
|
||||
// 单点也绘制(避免点击无痕迹)
|
||||
_drawCtx.lineTo(x + 0.01, y + 0.01);
|
||||
_drawCtx.stroke();
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', _onCanvasMouseMove);
|
||||
document.addEventListener('mouseup', _onCanvasMouseUp);
|
||||
}
|
||||
|
||||
function _onCanvasMouseMove(e) {
|
||||
if (!_st.isDrawing) return;
|
||||
const { x, y } = _screenToCanvas(e.clientX, e.clientY);
|
||||
|
||||
if (_st.tool === 'brush' || _st.tool === 'eraser') {
|
||||
_drawCtx.lineTo(x, y);
|
||||
_drawCtx.stroke();
|
||||
} else if (_st.tool === 'rect' || _st.tool === 'circle' || _st.tool === 'line') {
|
||||
// 恢复快照后预览
|
||||
_drawCtx.putImageData(_st.snapshot, 0, 0);
|
||||
_drawCtx.globalCompositeOperation = 'source-over';
|
||||
_drawCtx.strokeStyle = _st.color;
|
||||
_drawCtx.lineWidth = _st.lineWidth;
|
||||
_drawCtx.lineCap = 'round';
|
||||
_drawCtx.lineJoin = 'round';
|
||||
_drawCtx.beginPath();
|
||||
if (_st.tool === 'rect') {
|
||||
_drawCtx.rect(_st.startX, _st.startY, x - _st.startX, y - _st.startY);
|
||||
} else if (_st.tool === 'circle') {
|
||||
const cx = (_st.startX + x) / 2;
|
||||
const cy = (_st.startY + y) / 2;
|
||||
const rx = Math.abs(x - _st.startX) / 2;
|
||||
const ry = Math.abs(y - _st.startY) / 2;
|
||||
_drawCtx.ellipse(cx, cy, Math.max(0.1, rx), Math.max(0.1, ry), 0, 0, Math.PI * 2);
|
||||
} else if (_st.tool === 'line') {
|
||||
_drawCtx.moveTo(_st.startX, _st.startY);
|
||||
_drawCtx.lineTo(x, y);
|
||||
}
|
||||
_drawCtx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function _onCanvasMouseUp() {
|
||||
if (!_st.isDrawing) return;
|
||||
_st.isDrawing = false;
|
||||
_drawCtx.globalCompositeOperation = 'source-over';
|
||||
document.removeEventListener('mousemove', _onCanvasMouseMove);
|
||||
document.removeEventListener('mouseup', _onCanvasMouseUp);
|
||||
_pushHistory();
|
||||
}
|
||||
|
||||
// ==================== 撤销 / 重做 ====================
|
||||
|
||||
function _pushHistory() {
|
||||
if (!_drawCtx) return;
|
||||
try {
|
||||
const data = _drawCtx.getImageData(0, 0, _st.canvasW, _st.canvasH);
|
||||
_st.history.push(data);
|
||||
if (_st.history.length > MAX_HISTORY) _st.history.shift();
|
||||
_st.redoStack = [];
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
function _onUndo() {
|
||||
if (_st.history.length <= 1) {
|
||||
// 撤销到初始状态:清空绘制层
|
||||
if (_st.history.length === 1) {
|
||||
_st.redoStack.push(_drawCtx.getImageData(0, 0, _st.canvasW, _st.canvasH));
|
||||
_st.history.pop();
|
||||
_drawCtx.clearRect(0, 0, _st.canvasW, _st.canvasH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_st.redoStack.push(_st.history.pop());
|
||||
const prev = _st.history[_st.history.length - 1];
|
||||
_drawCtx.putImageData(prev, 0, 0);
|
||||
}
|
||||
|
||||
function _onRedo() {
|
||||
if (_st.redoStack.length === 0) return;
|
||||
const next = _st.redoStack.pop();
|
||||
_st.history.push(next);
|
||||
_drawCtx.putImageData(next, 0, 0);
|
||||
}
|
||||
|
||||
function _onClear() {
|
||||
_pushHistory();
|
||||
_drawCtx.clearRect(0, 0, _st.canvasW, _st.canvasH);
|
||||
_pushHistory();
|
||||
}
|
||||
|
||||
// ==================== 文字对象 ====================
|
||||
|
||||
function _addTextObject(x, y) {
|
||||
const id = ++_st.textCounter;
|
||||
const obj = {
|
||||
id,
|
||||
x, y,
|
||||
text: '双击编辑',
|
||||
fontSize: 32,
|
||||
color: _st.color,
|
||||
rotation: 0,
|
||||
};
|
||||
const el = _createTextEl(obj);
|
||||
obj.el = el;
|
||||
_textLayer.appendChild(el);
|
||||
_st.textObjects.push(obj);
|
||||
_selectText(obj);
|
||||
// 立即进入编辑
|
||||
const content = el.querySelector('.canvas-text-content');
|
||||
if (content) {
|
||||
content.focus();
|
||||
// 全选文字
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(content);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
function _createTextEl(obj) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'canvas-text-obj';
|
||||
el.dataset.id = obj.id;
|
||||
el.style.left = obj.x + 'px';
|
||||
el.style.top = obj.y + 'px';
|
||||
el.style.transform = `rotate(${obj.rotation}deg)`;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'canvas-text-content';
|
||||
content.contentEditable = 'false';
|
||||
content.textContent = obj.text;
|
||||
content.style.color = obj.color;
|
||||
content.style.fontSize = obj.fontSize + 'px';
|
||||
el.appendChild(content);
|
||||
|
||||
// 旋转 handle(顶部)
|
||||
const rotHandle = document.createElement('div');
|
||||
rotHandle.className = 'canvas-text-handle canvas-text-handle--rotate';
|
||||
rotHandle.title = '旋转';
|
||||
el.appendChild(rotHandle);
|
||||
|
||||
// 缩放 handle(右下)
|
||||
const scaleHandle = document.createElement('div');
|
||||
scaleHandle.className = 'canvas-text-handle canvas-text-handle--scale';
|
||||
scaleHandle.title = '缩放';
|
||||
el.appendChild(scaleHandle);
|
||||
|
||||
// 删除 handle(右上)
|
||||
const delHandle = document.createElement('div');
|
||||
delHandle.className = 'canvas-text-handle canvas-text-handle--delete';
|
||||
delHandle.title = '删除';
|
||||
delHandle.textContent = '×';
|
||||
el.appendChild(delHandle);
|
||||
|
||||
// ---- 事件 ----
|
||||
// 选中
|
||||
el.addEventListener('mousedown', (e) => {
|
||||
if (e.target.classList.contains('canvas-text-handle')) return;
|
||||
if (content.contentEditable === 'true') return; // 编辑中不拖动
|
||||
e.stopPropagation();
|
||||
_selectText(obj);
|
||||
_startDragText(e, obj);
|
||||
});
|
||||
|
||||
// 双击编辑
|
||||
content.addEventListener('dblclick', (e) => {
|
||||
e.stopPropagation();
|
||||
content.contentEditable = 'true';
|
||||
content.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(content);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
});
|
||||
|
||||
// 失焦保存
|
||||
content.addEventListener('blur', () => {
|
||||
content.contentEditable = 'false';
|
||||
obj.text = content.textContent;
|
||||
});
|
||||
|
||||
// 阻止编辑时按键冒泡触发画板快捷键
|
||||
content.addEventListener('keydown', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// 旋转
|
||||
rotHandle.addEventListener('mousedown', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
_startRotateText(e, obj);
|
||||
});
|
||||
|
||||
// 缩放
|
||||
scaleHandle.addEventListener('mousedown', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
_startScaleText(e, obj);
|
||||
});
|
||||
|
||||
// 删除
|
||||
delHandle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_deleteTextObject(el);
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function _startDragText(e, obj) {
|
||||
const startClient = { x: e.clientX, y: e.clientY };
|
||||
const startObj = { x: obj.x, y: obj.y };
|
||||
const onMove = (ev) => {
|
||||
const dx = (ev.clientX - startClient.x) / _st.scale;
|
||||
const dy = (ev.clientY - startClient.y) / _st.scale;
|
||||
obj.x = startObj.x + dx;
|
||||
obj.y = startObj.y + dy;
|
||||
obj.el.style.left = obj.x + 'px';
|
||||
obj.el.style.top = obj.y + 'px';
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function _startRotateText(e, obj) {
|
||||
const rect = obj.el.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const startAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * 180 / Math.PI;
|
||||
const startRot = obj.rotation;
|
||||
const onMove = (ev) => {
|
||||
const angle = Math.atan2(ev.clientY - cy, ev.clientX - cx) * 180 / Math.PI;
|
||||
obj.rotation = startRot + (angle - startAngle);
|
||||
obj.el.style.transform = `rotate(${obj.rotation}deg)`;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function _startScaleText(e, obj) {
|
||||
const startClient = { x: e.clientX, y: e.clientY };
|
||||
const startSize = obj.fontSize;
|
||||
const onMove = (ev) => {
|
||||
const delta = (ev.clientX - startClient.x + ev.clientY - startClient.y) / _st.scale;
|
||||
obj.fontSize = Math.max(8, Math.min(200, startSize + delta * 0.5));
|
||||
const content = obj.el.querySelector('.canvas-text-content');
|
||||
if (content) content.style.fontSize = obj.fontSize + 'px';
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function _selectText(obj) {
|
||||
_deselectAllText();
|
||||
obj.el.classList.add('is-selected');
|
||||
}
|
||||
|
||||
function _deselectAllText() {
|
||||
if (!_textLayer) return;
|
||||
_textLayer.querySelectorAll('.canvas-text-obj.is-selected').forEach((el) => el.classList.remove('is-selected'));
|
||||
}
|
||||
|
||||
function _deleteTextObject(el) {
|
||||
const id = parseInt(el.dataset.id, 10);
|
||||
_st.textObjects = _st.textObjects.filter((t) => t.id !== id);
|
||||
el.remove();
|
||||
}
|
||||
|
||||
// ==================== 底图加载 ====================
|
||||
|
||||
function _loadBgImage(src) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
// 画布尺寸适配图片
|
||||
_st.canvasW = img.naturalWidth;
|
||||
_st.canvasH = img.naturalHeight;
|
||||
_bgCanvas.width = _st.canvasW;
|
||||
_bgCanvas.height = _st.canvasH;
|
||||
_drawCanvas.width = _st.canvasW;
|
||||
_drawCanvas.height = _st.canvasH;
|
||||
_canvasWrap.style.width = _st.canvasW + 'px';
|
||||
_canvasWrap.style.height = _st.canvasH + 'px';
|
||||
_textLayer.style.width = _st.canvasW + 'px';
|
||||
_textLayer.style.height = _st.canvasH + 'px';
|
||||
_bgCtx.drawImage(img, 0, 0);
|
||||
_st.bgReady = true;
|
||||
_fitToView();
|
||||
_pushHistory();
|
||||
};
|
||||
img.onerror = () => {
|
||||
showToast('底图加载失败');
|
||||
_bgCtx.fillStyle = '#ffffff';
|
||||
_bgCtx.fillRect(0, 0, _st.canvasW, _st.canvasH);
|
||||
_st.bgReady = true;
|
||||
_pushHistory();
|
||||
};
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
// ==================== 确定导出 ====================
|
||||
|
||||
function _onConfirm() {
|
||||
// 1. 把文字渲染到 drawCanvas
|
||||
_st.textObjects.forEach((t) => {
|
||||
if (!t.text) return;
|
||||
_drawCtx.save();
|
||||
_drawCtx.translate(t.x, t.y);
|
||||
_drawCtx.rotate(t.rotation * Math.PI / 180);
|
||||
_drawCtx.font = `${t.fontSize}px sans-serif`;
|
||||
_drawCtx.fillStyle = t.color;
|
||||
_drawCtx.textBaseline = 'top';
|
||||
// 多行支持
|
||||
const lines = t.text.split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
_drawCtx.fillText(line, 0, i * t.fontSize * 1.2);
|
||||
});
|
||||
_drawCtx.restore();
|
||||
});
|
||||
|
||||
// 2. 合并 bg + draw
|
||||
const merged = document.createElement('canvas');
|
||||
merged.width = _st.canvasW;
|
||||
merged.height = _st.canvasH;
|
||||
const mctx = merged.getContext('2d');
|
||||
mctx.drawImage(_bgCanvas, 0, 0);
|
||||
mctx.drawImage(_drawCanvas, 0, 0);
|
||||
|
||||
// 3. 导出 dataURL → File
|
||||
const dataUrl = merged.toDataURL('image/png');
|
||||
const blob = _dataUrlToBlob(dataUrl);
|
||||
let name;
|
||||
if (_st.mode === 'edit' && _st.originalName) {
|
||||
const base = _st.originalName.replace(/\.[^.]+$/, '');
|
||||
name = `${base}_edited.png`;
|
||||
} else {
|
||||
name = `canvas_${Date.now()}.png`;
|
||||
}
|
||||
const file = new File([blob], name, { type: 'image/png' });
|
||||
|
||||
// 4. 更新 pendingFiles
|
||||
if (_st.mode === 'edit' && _st.originalIdx >= 0 && _st.originalIdx < state.pendingFiles.length) {
|
||||
state.pendingFiles[_st.originalIdx] = file;
|
||||
} else {
|
||||
state.pendingFiles = dedupePendingFiles([...state.pendingFiles, file]);
|
||||
}
|
||||
|
||||
// 5. 重新渲染附件区(动态 import 避免循环依赖)
|
||||
import('./chat-render.js').then((m) => {
|
||||
if (m.renderAttachmentChips) m.renderAttachmentChips();
|
||||
}).catch(() => {});
|
||||
|
||||
// 6. 关闭
|
||||
closeCanvasEditor();
|
||||
showToast('图像已保存到附件区');
|
||||
}
|
||||
|
||||
function _dataUrlToBlob(dataUrl) {
|
||||
const arr = dataUrl.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)[1];
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8 = new Uint8Array(n);
|
||||
while (n--) u8[n] = bstr.charCodeAt(n);
|
||||
return new Blob([u8], { type: mime });
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+1307
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,173 @@
|
||||
// dialog.js -- 自定义弹窗(替代浏览器原生 alert/confirm/prompt)
|
||||
// 两端(Web + Electron)统一使用的应用内弹窗
|
||||
|
||||
function createOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'dialog-overlay';
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function createBox(type) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'dialog-box';
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示提示弹窗(替代 alert),用户点击"确定"后关闭。
|
||||
* @param {string} message
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function showAlert(message) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = createOverlay();
|
||||
const box = createBox('alert');
|
||||
|
||||
const msg = document.createElement('p');
|
||||
msg.className = 'dialog-message';
|
||||
msg.textContent = message;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.className = 'dialog-btn dialog-btn--ok';
|
||||
okBtn.textContent = '确定';
|
||||
|
||||
actions.appendChild(okBtn);
|
||||
box.appendChild(msg);
|
||||
box.appendChild(actions);
|
||||
overlay.appendChild(box);
|
||||
|
||||
const close = () => {
|
||||
try { overlay.remove(); } catch { overlay.style.display = 'none'; }
|
||||
resolve();
|
||||
};
|
||||
|
||||
okBtn.addEventListener('click', (e) => { e.stopPropagation(); close(); });
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
document.addEventListener('keydown', function onKey(e) {
|
||||
if (e.key === 'Escape' || e.key === 'Enter') {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
okBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认弹窗(替代 confirm),返回用户选择。
|
||||
* @param {string} message
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = createOverlay();
|
||||
const box = createBox('confirm');
|
||||
|
||||
const msg = document.createElement('p');
|
||||
msg.className = 'dialog-message';
|
||||
msg.textContent = message;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'dialog-btn dialog-btn--cancel';
|
||||
cancelBtn.textContent = '取消';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.className = 'dialog-btn dialog-btn--ok';
|
||||
okBtn.textContent = '确定';
|
||||
|
||||
actions.appendChild(cancelBtn);
|
||||
actions.appendChild(okBtn);
|
||||
box.appendChild(msg);
|
||||
box.appendChild(actions);
|
||||
overlay.appendChild(box);
|
||||
|
||||
const close = (result) => {
|
||||
try { overlay.remove(); } catch { overlay.style.display = 'none'; }
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
okBtn.addEventListener('click', (e) => { e.stopPropagation(); close(true); });
|
||||
cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); close(false); });
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close(false);
|
||||
});
|
||||
document.addEventListener('keydown', function onKey(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
close(false);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
okBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入弹窗(替代 prompt),返回用户输入的字符串,取消返回 null。
|
||||
* @param {string} message
|
||||
* @param {string} [defaultValue='']
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
export function showPrompt(message, defaultValue = '') {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = createOverlay();
|
||||
const box = createBox('prompt');
|
||||
|
||||
const msg = document.createElement('p');
|
||||
msg.className = 'dialog-message';
|
||||
msg.textContent = message;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'dialog-input';
|
||||
input.value = defaultValue;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'dialog-btn dialog-btn--cancel';
|
||||
cancelBtn.textContent = '取消';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.className = 'dialog-btn dialog-btn--ok';
|
||||
okBtn.textContent = '确定';
|
||||
|
||||
actions.appendChild(cancelBtn);
|
||||
actions.appendChild(okBtn);
|
||||
box.appendChild(msg);
|
||||
box.appendChild(input);
|
||||
box.appendChild(actions);
|
||||
overlay.appendChild(box);
|
||||
|
||||
const close = (result) => {
|
||||
try { overlay.remove(); } catch { overlay.style.display = 'none'; }
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
okBtn.addEventListener('click', (e) => { e.stopPropagation(); close(input.value); });
|
||||
cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); close(null); });
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close(null);
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') close(input.value);
|
||||
if (e.key === 'Escape') close(null);
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// doc-mod-panel.js -- 文档修改面板:展示会话中所有修改/新增/删除的文件
|
||||
// 每个文件右侧有 ✓(保留/接受)和 ✕(撤销/拒绝)按钮
|
||||
|
||||
import { $, escapeHtml, showToast } from './utils.js';
|
||||
|
||||
function _updateBadge(show) {
|
||||
try {
|
||||
import('./action-bar.js').then(m => m.setActionBadge('doc', show));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 文档修改列表,格式: { filePath: { original, current, type: 'new'|'modified'|'deleted' } } */
|
||||
let _modifications = {};
|
||||
|
||||
/**
|
||||
* 更新文档修改数据
|
||||
* @param {object} mods - 修改对象
|
||||
*/
|
||||
export function setDocModifications(mods) {
|
||||
_modifications = mods || {};
|
||||
_updateBadge(Object.keys(_modifications).length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染文档修改面板
|
||||
*/
|
||||
export function renderDocModPanel() {
|
||||
const list = $('docModList');
|
||||
const actions = $('docModActions');
|
||||
if (!list || !actions) return;
|
||||
|
||||
const entries = Object.entries(_modifications);
|
||||
if (entries.length === 0) {
|
||||
list.innerHTML = '<div style="padding:1rem;color:#94a3b8;font-size:0.82rem;text-align:center;">暂无文档变更</div>';
|
||||
actions.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
actions.hidden = false;
|
||||
list.innerHTML = '';
|
||||
|
||||
entries.forEach(async ([filePath, mod]) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = `doc-mod-item doc-mod-item--${mod.type}`;
|
||||
item.dataset.file = filePath;
|
||||
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'doc-mod-item-info';
|
||||
|
||||
const typeLabel = document.createElement('span');
|
||||
typeLabel.className = 'doc-mod-item-tag';
|
||||
typeLabel.textContent = mod.type === 'new' ? '新增' : mod.type === 'modified' ? '修改' : '删除';
|
||||
if (mod.snapshotIdx) typeLabel.title = '第 ' + mod.snapshotIdx + ' 步变更';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'doc-mod-item-name';
|
||||
const parts = filePath.replace(/[\\/]+$/, '').split(/[\\/]/);
|
||||
nameSpan.textContent = parts[parts.length - 1] || filePath;
|
||||
nameSpan.title = filePath;
|
||||
|
||||
infoDiv.appendChild(typeLabel);
|
||||
infoDiv.appendChild(nameSpan);
|
||||
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'doc-mod-item-actions';
|
||||
|
||||
const acceptBtn = document.createElement('button');
|
||||
acceptBtn.className = 'doc-mod-accept';
|
||||
acceptBtn.innerHTML = '✓';
|
||||
acceptBtn.title = '保留修改';
|
||||
acceptBtn.addEventListener('click', () => acceptModification(filePath));
|
||||
|
||||
const rejectBtn = document.createElement('button');
|
||||
rejectBtn.className = 'doc-mod-reject';
|
||||
rejectBtn.innerHTML = '✕';
|
||||
rejectBtn.title = '撤销修改';
|
||||
rejectBtn.addEventListener('click', () => rejectModification(filePath));
|
||||
|
||||
actionsDiv.appendChild(acceptBtn);
|
||||
actionsDiv.appendChild(rejectBtn);
|
||||
|
||||
item.appendChild(infoDiv);
|
||||
item.appendChild(actionsDiv);
|
||||
|
||||
// 如果有旧内容,计算并显示行级 diff
|
||||
// - modified: 新旧内容行级 diff(新增/修改行黄色,删除行红色)
|
||||
// - deleted: 整个旧内容按删除行(红色)展示
|
||||
if ((mod.type === 'modified' || mod.type === 'deleted') && mod.oldContent != null) {
|
||||
const diffDiv = document.createElement('div');
|
||||
diffDiv.className = 'doc-mod-item-diff';
|
||||
diffDiv.hidden = true;
|
||||
item.appendChild(diffDiv);
|
||||
|
||||
item.style.cursor = 'pointer';
|
||||
item.addEventListener('click', async (ev) => {
|
||||
if (ev.target.closest('.doc-mod-accept, .doc-mod-reject')) return;
|
||||
if (diffDiv.hidden) {
|
||||
try {
|
||||
const { computeLineDiff } = await import('./edit-mode.js');
|
||||
let diffs;
|
||||
if (mod.type === 'deleted') {
|
||||
// 删除文件:文件已不存在,整个旧内容按删除行(红色)展示
|
||||
const oldLines = String(mod.oldContent || '').split('\n');
|
||||
diffs = oldLines.length > 0
|
||||
? [{ type: 'remove', startLine: 1, endLine: oldLines.length, lines: oldLines }]
|
||||
: [];
|
||||
} else {
|
||||
// 修改文件:读取当前内容并与旧内容做行级 diff
|
||||
const rootPath = (await import('./state.js')).state._docRootPath;
|
||||
const { readTextFile } = await import('./electron-api.js');
|
||||
const currentContent = await readTextFile(filePath, rootPath);
|
||||
diffs = computeLineDiff(mod.oldContent, currentContent) || [];
|
||||
}
|
||||
diffDiv.innerHTML = diffs.map(d => {
|
||||
const cls = d.type === 'add' ? 'diff-add' : 'diff-remove';
|
||||
const info = d.type === 'add'
|
||||
? `+${d.startLine}${d.endLine !== d.startLine ? '-' + d.endLine : ''}`
|
||||
: `-${d.startLine}${d.endLine !== d.startLine ? '-' + d.endLine : ''}`;
|
||||
return `<div class="diff-line ${cls}"><span class="diff-ln">${info}</span><span class="diff-text">${escapeHtml(d.lines.join('\n'))}</span></div>`;
|
||||
}).join('') || '<div class="diff-line">无差异</div>';
|
||||
diffDiv.hidden = false;
|
||||
} catch { diffDiv.innerHTML = '<div class="diff-line">无法加载差异</div>'; diffDiv.hidden = false; }
|
||||
} else {
|
||||
diffDiv.hidden = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
/** 接受单个文件修改 */
|
||||
async function acceptModification(filePath) {
|
||||
try {
|
||||
const { acceptFileModification } = await import('./edit-mode.js');
|
||||
await acceptFileModification(filePath);
|
||||
// 从本地列表中移除
|
||||
delete _modifications[filePath];
|
||||
setDocModifications(_modifications);
|
||||
renderDocModPanel();
|
||||
const { renderDocTree } = await import('./edit-mode.js');
|
||||
if (renderDocTree) renderDocTree();
|
||||
showToast('已接受: ' + (filePath.replace(/[\\/]/g, '/').split('/').pop()));
|
||||
} catch (e) {
|
||||
showToast('接受修改失败: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
/** 撤销单个文件修改 */
|
||||
async function rejectModification(filePath) {
|
||||
try {
|
||||
const { rejectFileModification } = await import('./edit-mode.js');
|
||||
await rejectFileModification(filePath);
|
||||
delete _modifications[filePath];
|
||||
setDocModifications(_modifications);
|
||||
renderDocModPanel();
|
||||
const { renderDocTree } = await import('./edit-mode.js');
|
||||
if (renderDocTree) renderDocTree();
|
||||
showToast('已撤销: ' + (filePath.replace(/[\\/]/g, '/').split('/').pop()));
|
||||
} catch (e) {
|
||||
showToast('撤销修改失败: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开/关闭文档修改面板 */
|
||||
export function toggleDocModPanel(show) {
|
||||
const panel = $('docModPanel');
|
||||
if (!panel) return;
|
||||
if (show) {
|
||||
setDocModifications(_modifications);
|
||||
renderDocModPanel();
|
||||
}
|
||||
panel.hidden = !show;
|
||||
}
|
||||
|
||||
/** 关闭文档面板并同步按钮状态 */
|
||||
function _closeDocPanel() {
|
||||
const panel = $('docModPanel');
|
||||
if (panel) panel.hidden = true;
|
||||
const btn = $('actionDocBtn');
|
||||
if (btn) btn.classList.remove('is-active');
|
||||
import('./action-bar.js').then(m => m.deactivateAllActionPanels()).catch(() => {});
|
||||
}
|
||||
|
||||
/** 绑定面板事件 */
|
||||
export function bindDocModEvents() {
|
||||
const closeBtn = $('docModClose');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
_closeDocPanel();
|
||||
});
|
||||
}
|
||||
|
||||
const acceptAllBtn = $('docModAcceptAll');
|
||||
if (acceptAllBtn) {
|
||||
acceptAllBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const filePaths = Object.keys(_modifications);
|
||||
const { acceptFileModification } = await import('./edit-mode.js');
|
||||
for (const fp of filePaths) { await acceptFileModification(fp); }
|
||||
// 清空本地状态
|
||||
Object.keys(_modifications).forEach(k => delete _modifications[k]);
|
||||
setDocModifications(_modifications);
|
||||
renderDocModPanel();
|
||||
const { renderDocTree } = await import('./edit-mode.js');
|
||||
if (renderDocTree) renderDocTree();
|
||||
showToast('已全部接受 ' + filePaths.length + ' 个文件变更');
|
||||
_closeDocPanel();
|
||||
} catch (e) {
|
||||
showToast(e.message || String(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rejectAllBtn = $('docModRejectAll');
|
||||
if (rejectAllBtn) {
|
||||
rejectAllBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const filePaths = Object.keys(_modifications);
|
||||
const { rejectFileModification } = await import('./edit-mode.js');
|
||||
for (const fp of filePaths) { await rejectFileModification(fp); }
|
||||
Object.keys(_modifications).forEach(k => delete _modifications[k]);
|
||||
setDocModifications(_modifications);
|
||||
renderDocModPanel();
|
||||
const { renderDocTree } = await import('./edit-mode.js');
|
||||
if (renderDocTree) renderDocTree();
|
||||
showToast('已全部撤销 ' + filePaths.length + ' 个文件变更');
|
||||
_closeDocPanel();
|
||||
} catch (e) {
|
||||
showToast(e.message || String(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
// doc-tree.js -- 文档树组件(文件浏览/重命名/移动/复制粘贴/拖拽/删除/新增)
|
||||
|
||||
import { $, escapeHtml, showToast } from './utils.js';
|
||||
import { showConfirm, showPrompt } from './dialog.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
// 支持的文件扩展名映射
|
||||
const DOC_EXT_MAP = {
|
||||
".txt": "plain_text", ".json": "plain_text", ".md": "plain_text", ".cpp": "plain_text",
|
||||
".c": "plain_text", ".py": "plain_text", ".m": "plain_text", ".java": "plain_text", ".html": "plain_text",
|
||||
".svg": "plain_text", ".css": "plain_text", ".js": "plain_text", ".ts": "plain_text",
|
||||
".xml": "plain_text", ".yaml": "plain_text", ".yml": "plain_text", ".toml": "plain_text",
|
||||
".docx": "docx", ".pptx": "pptx", ".pdf": "pdf", ".csv": "tabular", ".xlsx": "tabular", ".xls": "tabular",
|
||||
};
|
||||
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp", ".ico"]);
|
||||
|
||||
function getFileType(name) {
|
||||
const dot = (name || "").toLowerCase().lastIndexOf(".");
|
||||
if (dot < 0) return "plain_text";
|
||||
const e = name.toLowerCase().slice(dot);
|
||||
if (IMAGE_EXT.has(e)) return "image";
|
||||
return DOC_EXT_MAP[e] || "plain_text";
|
||||
}
|
||||
|
||||
function getFileIcon(name) {
|
||||
const t = getFileType(name);
|
||||
const map = { plain_text: "\u{1F4C4}", docx: "\u{1F4D8}", pptx: "\u{1F4CA}", pdf: "\u{1F4D5}", tabular: "\u{1F4CA}", image: "\u{1F5BC}" };
|
||||
return map[t] || "\u{1F4C4}";
|
||||
}
|
||||
|
||||
// ---- clipboard ----
|
||||
let _clipboard = null; // { type: 'copy'|'cut', file: {...} }
|
||||
|
||||
// ---- 防重复引用 ----
|
||||
let renderDocTabsFn = null;
|
||||
let loadDocContentFn = null;
|
||||
let showEmptyEditorFn = null;
|
||||
let updateLineNumbersFn = null;
|
||||
let openFilePreviewFn = null;
|
||||
let openFileAsTextFn = null;
|
||||
export function setDocTreeDeps(deps) {
|
||||
renderDocTabsFn = deps.renderDocTabs;
|
||||
loadDocContentFn = deps.loadDocContent;
|
||||
showEmptyEditorFn = deps.showEmptyEditor;
|
||||
updateLineNumbersFn = deps.updateLineNumbers;
|
||||
openFilePreviewFn = deps.openFilePreview;
|
||||
openFileAsTextFn = deps.openFileAsText;
|
||||
}
|
||||
|
||||
const docTree = $("docTree");
|
||||
let currentContextMenu = null;
|
||||
|
||||
function removeContextMenu() {
|
||||
if (currentContextMenu) { currentContextMenu.remove(); currentContextMenu = null; }
|
||||
}
|
||||
|
||||
// ============ 渲染 ============
|
||||
|
||||
export function renderDocTree() {
|
||||
if (!docTree) return;
|
||||
docTree.innerHTML = "";
|
||||
removeContextMenu();
|
||||
if (!state.docFiles || !state.docFiles.length) {
|
||||
docTree.innerHTML = '<div class="doc-tree-empty">打开文件夹或拖入文件开始编辑</div>';
|
||||
return;
|
||||
}
|
||||
const root = buildHierarchy(state.docFiles);
|
||||
renderLevel(docTree, root, 0);
|
||||
|
||||
// 右键空白处:新建文件/文件夹
|
||||
docTree.addEventListener("contextmenu", (e) => {
|
||||
// 只在点击空白区域(非文件/文件夹元素)时显示
|
||||
const target = e.target;
|
||||
if (target === docTree || target.classList.contains("doc-tree-empty")) {
|
||||
e.preventDefault();
|
||||
showBlankSpaceMenu(e.clientX, e.clientY);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildHierarchy(files) {
|
||||
const root = { children: {}, files: [] };
|
||||
files.forEach((f) => {
|
||||
const parts = f.path.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
let cur = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur.children[parts[i]]) cur.children[parts[i]] = { children: {}, files: [] };
|
||||
cur = cur.children[parts[i]];
|
||||
}
|
||||
cur.files.push({ ...f, baseName: parts.length > 0 ? parts[parts.length - 1] : (f.name || f.path) });
|
||||
});
|
||||
// 如果设置了根文件夹名,将整棵树包裹在一个根节点下
|
||||
if (state._docRootName) {
|
||||
return { children: { [state._docRootName]: root }, files: [] };
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function renderLevel(container, node, level, parentPath = '') {
|
||||
if (!state._docTreeFolders) state._docTreeFolders = {};
|
||||
|
||||
// 文件夹
|
||||
Object.keys(node.children).sort((a, b) => a.localeCompare(b, void 0, { numeric: true })).forEach((name) => {
|
||||
// 计算文件夹完整相对路径(根文件夹路径为空字符串)
|
||||
const isRootFolder = (parentPath === '' && name === state._docRootName);
|
||||
const folderPath = isRootFolder ? '' : (parentPath ? parentPath + '/' + name : name);
|
||||
// 默认折叠:只有显式设为 true 才展开
|
||||
const expanded = state._docTreeFolders[name] === true;
|
||||
const div = document.createElement("div");
|
||||
div.className = "doc-tree-folder";
|
||||
div.style.setProperty("--level", level);
|
||||
div.setAttribute("data-folder", name);
|
||||
div.setAttribute("data-folder-path", folderPath);
|
||||
div.draggable = true;
|
||||
div.innerHTML = `<span class="doc-tree-icon">${expanded ? '\u{1F4C2}' : '\u{1F4C1}'}</span><span class="doc-tree-name">${escapeHtml(name)}</span>`;
|
||||
|
||||
div.addEventListener("click", () => {
|
||||
state._docTreeFolders[name] = !state._docTreeFolders[name];
|
||||
renderDocTree();
|
||||
});
|
||||
div.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
showFolderMenu(e.clientX, e.clientY, name, div);
|
||||
});
|
||||
|
||||
// 拖拽:文件夹可被拖拽到输入区(设置 doc-path + doc-folder 标记)
|
||||
div.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.setData('application/doc-path', folderPath);
|
||||
e.dataTransfer.setData('application/doc-folder', '1');
|
||||
e.dataTransfer.setData('text/plain', name);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
div.classList.add("doc-tree-folder--dragging");
|
||||
});
|
||||
div.addEventListener("dragend", () => div.classList.remove("doc-tree-folder--dragging"));
|
||||
|
||||
// 拖拽:文件夹作为目标(仅接收内部文件移入)
|
||||
div.addEventListener("dragover", (e) => {
|
||||
// 仅允许内部拖拽(application/doc-path),拒绝外部文件
|
||||
if (!Array.from(e.dataTransfer.types).includes('application/doc-path')) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
div.classList.add("doc-tree-folder--drop-target");
|
||||
});
|
||||
div.addEventListener("dragleave", () => div.classList.remove("doc-tree-folder--drop-target"));
|
||||
div.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
div.classList.remove("doc-tree-folder--drop-target");
|
||||
const srcPath = e.dataTransfer.getData("application/doc-path");
|
||||
const isFolderDrag = e.dataTransfer.getData("application/doc-folder") === '1';
|
||||
if (srcPath && !isFolderDrag && !srcPath.startsWith(name + "/")) {
|
||||
moveFileIntoFolder(srcPath, name);
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(div);
|
||||
if (expanded) renderLevel(container, node.children[name], level + 1, folderPath);
|
||||
});
|
||||
|
||||
// 文件
|
||||
node.files.sort((a, b) => a.baseName.localeCompare(b.baseName, void 0, { numeric: true })).forEach((f) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "doc-tree-file";
|
||||
div.style.setProperty("--level", level);
|
||||
div.setAttribute("data-path", f.path);
|
||||
div.draggable = f.status !== "deleted" && f.status !== "externally_deleted";
|
||||
|
||||
if (f.status === "modified") div.classList.add("doc-tree-file--modified");
|
||||
if (f.status === "new") div.classList.add("doc-tree-file--new");
|
||||
if (f.status === "deleted") div.classList.add("doc-tree-file--deleted");
|
||||
if (f.status === "externally_added") div.classList.add("doc-tree-file--externally-added");
|
||||
if (f.status === "externally_modified") div.classList.add("doc-tree-file--externally-modified");
|
||||
if (f.status === "externally_deleted") div.classList.add("doc-tree-file--externally-deleted");
|
||||
if (state.activeDocFile === f.path) div.classList.add("doc-tree-file--active");
|
||||
|
||||
const ft = getFileType(f.baseName);
|
||||
div.innerHTML = `<span class="doc-tree-icon doc-tree-icon--${ft}">${getFileIcon(f.baseName)}</span><span class="doc-tree-name">${escapeHtml(f.baseName)}</span>`;
|
||||
|
||||
// ---- 拖拽:拖动文件 ----
|
||||
div.addEventListener("dragstart", (e) => {
|
||||
if (f.status === "deleted") { e.preventDefault(); return; }
|
||||
e.dataTransfer.setData("application/doc-path", f.path);
|
||||
e.dataTransfer.setData("text/plain", f.path);
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
div.classList.add("doc-tree-file--dragging");
|
||||
});
|
||||
div.addEventListener("dragend", () => div.classList.remove("doc-tree-file--dragging"));
|
||||
|
||||
// ---- 拖拽:作为目标(仅接收内部文件移入) ----
|
||||
div.addEventListener("dragover", (e) => {
|
||||
// 仅允许内部拖拽(application/doc-path),拒绝外部文件
|
||||
if (!Array.from(e.dataTransfer.types).includes('application/doc-path')) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
div.classList.add("doc-tree-file--drop-target");
|
||||
});
|
||||
div.addEventListener("dragleave", () => div.classList.remove("doc-tree-file--drop-target"));
|
||||
div.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
div.classList.remove("doc-tree-file--drop-target");
|
||||
const srcPath = e.dataTransfer.getData("application/doc-path");
|
||||
if (srcPath && srcPath !== f.path) {
|
||||
moveFileIntoFolder(srcPath, f.path);
|
||||
}
|
||||
});
|
||||
|
||||
div.addEventListener("click", () => {
|
||||
if (f.status === "deleted") return;
|
||||
// 外部删除的文件仍可打开查看(会显示 banner)
|
||||
openDocFile(f);
|
||||
});
|
||||
div.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
showFileMenu(e.clientX, e.clientY, f);
|
||||
});
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 右键菜单 ============
|
||||
function showFileMenu(x, y, file) {
|
||||
removeContextMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "doc-context-menu";
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
|
||||
if (file.status === "deleted" || file.status === "externally_deleted") {
|
||||
addMenuItem(menu, "\u{21A9} 撤销删除", () => { undoDeleteFile(file.path); });
|
||||
addMenuItem(menu, "\u{1F5D1} 从工作区移除", () => { removeFileFromWorkspace(file.path); }, true);
|
||||
} else {
|
||||
addMenuItem(menu, "\u{270F} 重命名", () => { startRenameFile(file.path); });
|
||||
addMenuItem(menu, "\u{1F4CB} 复制", () => { _clipboard = { type: "copy", file: { ...file } }; showToast("已复制到剪贴板"); });
|
||||
addMenuItem(menu, "\u{2702} 剪切", () => { _clipboard = { type: "cut", file: { ...file } }; showToast("已剪切到剪贴板"); });
|
||||
if (_clipboard && _clipboard.file && _clipboard.file.path !== file.path) {
|
||||
addMenuItem(menu, "\u{1F4CE} 粘贴", () => { pasteFile(file.path); });
|
||||
}
|
||||
addMenuItem(menu, "\u{1F4C4} 复制副本", () => { duplicateFile(file.path); });
|
||||
// 引用
|
||||
addMenuItem(menu, "---", null);
|
||||
addMenuItem(menu, "\u{1F517} 引用", () => {
|
||||
import('./edit-mode.js').then(m => m.addDocRef(file.path)).catch(() => {});
|
||||
});
|
||||
// 文件属性
|
||||
addMenuItem(menu, "---", null);
|
||||
addMenuItem(menu, "\u{2139} 属性", () => { showFileProperties(file); });
|
||||
// HTML 右键:预览/文本编辑(SVG 已按普通文本文件处理,不再显示特殊菜单项)
|
||||
const isHtml = file.name.toLowerCase().endsWith(".html") || file.name.toLowerCase().endsWith(".htm");
|
||||
if (isHtml) {
|
||||
addMenuItem(menu, "---", null);
|
||||
addMenuItem(menu, "👁 预览", () => { openFilePreview(file.path); });
|
||||
addMenuItem(menu, "📝 文本编辑", () => { openFileAsText(file.path); });
|
||||
}
|
||||
addMenuItem(menu, "---", null);
|
||||
addMenuItem(menu, "\u{1F5D1} 删除", () => { markFileDeleted(file.path); }, true);
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
currentContextMenu = menu;
|
||||
bindMenuClose(menu);
|
||||
}
|
||||
|
||||
function showFolderMenu(x, y, folderName, folderEl) {
|
||||
removeContextMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "doc-context-menu";
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
|
||||
addMenuItem(menu, "\u{1F4C4} 新建文件", () => { createNewFileInFolder(folderName); });
|
||||
addMenuItem(menu, "\u{1F4C2} 新建子文件夹", () => { createNewFolderInFolder(folderName); });
|
||||
if (_clipboard && _clipboard.file) {
|
||||
addMenuItem(menu, "\u{1F4CE} 粘贴到此", () => { pasteIntoFolder(folderName); });
|
||||
}
|
||||
addMenuItem(menu, "---", null);
|
||||
addMenuItem(menu, "\u{270F} 重命名文件夹", () => { startRenameFolder(folderName); });
|
||||
addMenuItem(menu, "---", null);
|
||||
addMenuItem(menu, "\u{2139} 属性", () => { showFolderProperties(folderName); });
|
||||
addMenuItem(menu, "---", null);
|
||||
addMenuItem(menu, "\u{1F5D1} 删除文件夹", () => { deleteFolder(folderName); }, true);
|
||||
|
||||
document.body.appendChild(menu);
|
||||
currentContextMenu = menu;
|
||||
bindMenuClose(menu);
|
||||
}
|
||||
|
||||
function showBlankSpaceMenu(x, y) {
|
||||
removeContextMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "doc-context-menu";
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
|
||||
addMenuItem(menu, "\u{1F4C4} 新建文件", () => { createNewRootFile(); });
|
||||
addMenuItem(menu, "\u{1F4C2} 新建文件夹", () => { createNewRootFolder(); });
|
||||
if (_clipboard && _clipboard.file) {
|
||||
addMenuItem(menu, "\u{1F4CE} 粘贴", () => { pasteFileAtRoot(); });
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
currentContextMenu = menu;
|
||||
bindMenuClose(menu);
|
||||
}
|
||||
|
||||
function addMenuItem(menu, label, handler, danger) {
|
||||
if (label === "---") {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "doc-context-menu-sep";
|
||||
menu.appendChild(sep);
|
||||
return;
|
||||
}
|
||||
const item = document.createElement("div");
|
||||
item.className = "doc-context-menu-item" + (danger ? " doc-context-menu-item--danger" : "");
|
||||
item.textContent = label;
|
||||
if (handler) item.addEventListener("click", () => { handler(); removeContextMenu(); });
|
||||
menu.appendChild(item);
|
||||
}
|
||||
|
||||
function bindMenuClose(menu) {
|
||||
const h = (e) => {
|
||||
if (!menu.contains(e.target)) { removeContextMenu(); document.removeEventListener("click", h); }
|
||||
};
|
||||
setTimeout(() => document.addEventListener("click", h), 0);
|
||||
}
|
||||
|
||||
// ============ 文件操作 ============
|
||||
|
||||
/** 打开文档 */
|
||||
function openDocFile(file) {
|
||||
if (!file || file.status === "deleted") return;
|
||||
state.activeDocFile = file.path;
|
||||
if (!state.docOpenTabs) state.docOpenTabs = [];
|
||||
if (!state.docOpenTabs.find((t) => t.path === file.path)) {
|
||||
state.docOpenTabs.push({ path: file.path, name: file.name || file.baseName, modified: file.status === "modified" });
|
||||
}
|
||||
state.activeDocTab = file.path;
|
||||
renderDocTree();
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
if (loadDocContentFn) loadDocContentFn(file.path);
|
||||
}
|
||||
|
||||
/** 关闭文档 */
|
||||
function closeDocFile(filePath) {
|
||||
if (!state.docOpenTabs) return;
|
||||
state.docOpenTabs = state.docOpenTabs.filter((t) => t.path !== filePath);
|
||||
if (state.activeDocTab === filePath) state.activeDocTab = state.docOpenTabs[0]?.path || null;
|
||||
if (state.activeDocFile === filePath) state.activeDocFile = state.docOpenTabs[0]?.path || null;
|
||||
renderDocTree();
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
state.activeDocFile ? (loadDocContentFn && loadDocContentFn(state.activeDocFile)) : (showEmptyEditorFn && showEmptyEditorFn());
|
||||
}
|
||||
|
||||
/** 重命名文件(内联编辑) */
|
||||
function startRenameFile(filePath) {
|
||||
const fileEl = docTree.querySelector(`.doc-tree-file[data-path="${CSS.escape(filePath)}"]`);
|
||||
if (!fileEl) return;
|
||||
const nameSpan = fileEl.querySelector(".doc-tree-name");
|
||||
const oldName = nameSpan.textContent;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "doc-tree-rename-input";
|
||||
input.value = oldName;
|
||||
nameSpan.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const commit = () => {
|
||||
const newName = input.value.trim();
|
||||
if (newName && newName !== oldName) {
|
||||
const dir = filePath.replace(/[/\\][^/\\]*$/, "");
|
||||
const newPath = dir ? dir + "/" + newName : newName;
|
||||
renameDocFile(filePath, newPath, newName);
|
||||
}
|
||||
renderDocTree();
|
||||
};
|
||||
input.addEventListener("blur", commit);
|
||||
input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); input.blur(); } if (e.key === "Escape") { input.value = oldName; input.blur(); } });
|
||||
}
|
||||
|
||||
function startRenameFolder(folderName) {
|
||||
const folderEl = docTree.querySelector(`.doc-tree-folder[data-folder="${CSS.escape(folderName)}"]`);
|
||||
if (!folderEl) return;
|
||||
const nameSpan = folderEl.querySelector(".doc-tree-name");
|
||||
const oldName = nameSpan.textContent;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "doc-tree-rename-input";
|
||||
input.value = oldName;
|
||||
nameSpan.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const commit = () => {
|
||||
const newName = input.value.trim();
|
||||
if (newName && newName !== oldName) {
|
||||
renameFolder(oldName, newName);
|
||||
}
|
||||
renderDocTree();
|
||||
};
|
||||
input.addEventListener("blur", commit);
|
||||
input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); input.blur(); } if (e.key === "Escape") { input.value = oldName; input.blur(); } });
|
||||
}
|
||||
|
||||
function renameDocFile(oldPath, newPath, newName) {
|
||||
const files = state.docFiles || [];
|
||||
const file = files.find((f) => f.path === oldPath);
|
||||
if (file) { file.path = newPath; file.name = newName; file.status = "modified"; }
|
||||
// 更新内容映射
|
||||
const oldContent = docContents[oldPath];
|
||||
if (oldContent !== undefined) { docContents[newPath] = oldContent; delete docContents[oldPath]; }
|
||||
// 更新标签页
|
||||
if (state.docOpenTabs) {
|
||||
const tab = state.docOpenTabs.find((t) => t.path === oldPath);
|
||||
if (tab) { tab.path = newPath; tab.name = newName; }
|
||||
}
|
||||
if (state.activeDocFile === oldPath) state.activeDocFile = newPath;
|
||||
if (state.activeDocTab === oldPath) state.activeDocTab = newPath;
|
||||
}
|
||||
|
||||
function renameFolder(oldName, newName) {
|
||||
const files = state.docFiles || [];
|
||||
const prefix = oldName + "/";
|
||||
files.forEach((f) => {
|
||||
if (f.path.startsWith(prefix)) {
|
||||
const newPath = newName + "/" + f.path.slice(prefix.length);
|
||||
renameDocFile(f.path, newPath, f.name);
|
||||
}
|
||||
});
|
||||
// 更新 _docTreeFolders
|
||||
if (state._docTreeFolders && state._docTreeFolders[oldName] !== undefined) {
|
||||
state._docTreeFolders[newName] = state._docTreeFolders[oldName];
|
||||
delete state._docTreeFolders[oldName];
|
||||
}
|
||||
}
|
||||
|
||||
/** 标记删除(仅标记,不删磁盘文件;撤销删除即可恢复) */
|
||||
async function markFileDeleted(filePath) {
|
||||
const file = (state.docFiles || []).find((f) => f.path === filePath);
|
||||
if (file) file.status = "deleted";
|
||||
closeDocFile(filePath);
|
||||
// 不在此处删磁盘文件——撤销删除可恢复
|
||||
renderDocTree();
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
}
|
||||
|
||||
/** 撤销删除 */
|
||||
function undoDeleteFile(filePath) {
|
||||
const file = (state.docFiles || []).find((f) => f.path === filePath);
|
||||
if (file) file.status = "original"; // 恢复为原始状态,而非 modified
|
||||
// 清除外部变更记录
|
||||
import('./edit-mode.js').then(m => {
|
||||
if (m.clearExternalChange) m.clearExternalChange(filePath);
|
||||
}).catch(() => {});
|
||||
renderDocTree();
|
||||
}
|
||||
|
||||
/** 从工作区彻底移除(移入回收站) */
|
||||
function removeFileFromWorkspace(filePath) {
|
||||
state.docFiles = (state.docFiles || []).filter((f) => f.path !== filePath);
|
||||
if (state.docOpenTabs) state.docOpenTabs = state.docOpenTabs.filter((t) => t.path !== filePath);
|
||||
if (state.activeDocFile === filePath) {
|
||||
state.activeDocFile = state.docOpenTabs?.[0]?.path || null;
|
||||
state.activeDocTab = state.activeDocFile;
|
||||
}
|
||||
// 实际删除磁盘文件(移入回收站)
|
||||
if (window.electronAPI && state._docRootPath) {
|
||||
import('./electron-api.js').then(m => m.deleteFile(filePath, state._docRootPath)).catch(() => {});
|
||||
}
|
||||
// 清除外部变更记录
|
||||
import('./edit-mode.js').then(m => {
|
||||
if (m.clearExternalChange) m.clearExternalChange(filePath);
|
||||
}).catch(() => {});
|
||||
// 工作区清空后显示打开按钮
|
||||
if (!state.docFiles || state.docFiles.length === 0) {
|
||||
import('./edit-mode.js').then(m => m.showOpenButtons());
|
||||
}
|
||||
renderDocTree();
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
}
|
||||
|
||||
/** 复制/剪切 → 粘贴 */
|
||||
function pasteFile(targetPath) {
|
||||
if (!_clipboard || !_clipboard.file) return;
|
||||
const src = _clipboard.file;
|
||||
const dir = targetPath.replace(/[/\\][^/\\]*$/, "");
|
||||
const ext = src.baseName.includes(".") ? src.baseName.slice(src.baseName.lastIndexOf(".")) : "";
|
||||
const base = src.baseName.replace(ext, "");
|
||||
let newPath = dir ? dir + "/" + base + ext : base + ext;
|
||||
|
||||
// 避免重名
|
||||
let counter = 1;
|
||||
while ((state.docFiles || []).find((f) => f.path === newPath)) {
|
||||
newPath = dir ? dir + "/" + base + "_" + counter + ext : base + "_" + counter + ext;
|
||||
counter++;
|
||||
}
|
||||
|
||||
const content = docContents[src.path] || "";
|
||||
state.docFiles = state.docFiles || [];
|
||||
state.docFiles.push({ path: newPath, name: newPath.split(/[/\\]/).pop(), status: "new" });
|
||||
docContents[newPath] = content;
|
||||
|
||||
if (_clipboard.type === "cut") {
|
||||
markFileDeleted(src.path);
|
||||
_clipboard = null;
|
||||
}
|
||||
|
||||
renderDocTree();
|
||||
showToast("已粘贴");
|
||||
}
|
||||
|
||||
function pasteIntoFolder(folderName) {
|
||||
if (!_clipboard || !_clipboard.file) return;
|
||||
const src = _clipboard.file;
|
||||
const newPath = folderName + "/" + src.baseName;
|
||||
const content = docContents[src.path] || "";
|
||||
if (!(state.docFiles || []).find((f) => f.path === newPath)) {
|
||||
state.docFiles = state.docFiles || [];
|
||||
state.docFiles.push({ path: newPath, name: src.baseName, status: "new" });
|
||||
docContents[newPath] = content;
|
||||
}
|
||||
if (_clipboard.type === "cut") { markFileDeleted(src.path); _clipboard = null; }
|
||||
renderDocTree();
|
||||
showToast("已粘贴");
|
||||
}
|
||||
|
||||
/** 粘贴到根目录 */
|
||||
function pasteFileAtRoot() {
|
||||
if (!_clipboard || !_clipboard.file) return;
|
||||
const src = _clipboard.file;
|
||||
const content = docContents[src.path] || "";
|
||||
let newPath = src.baseName;
|
||||
let counter = 1;
|
||||
while ((state.docFiles || []).find((f) => f.path === newPath)) {
|
||||
const ext = src.baseName.includes(".") ? src.baseName.slice(src.baseName.lastIndexOf(".")) : "";
|
||||
const base = src.baseName.replace(ext, "");
|
||||
newPath = base + "_" + counter + ext;
|
||||
counter++;
|
||||
}
|
||||
state.docFiles = state.docFiles || [];
|
||||
state.docFiles.push({ path: newPath, name: src.baseName, status: "new" });
|
||||
docContents[newPath] = content;
|
||||
if (_clipboard.type === "cut") { markFileDeleted(src.path); _clipboard = null; }
|
||||
renderDocTree();
|
||||
showToast("已粘贴");
|
||||
}
|
||||
|
||||
// ===== 快捷键操作(供 edit-mode.js 调用) =====
|
||||
|
||||
/** Ctrl+C:复制当前选中的文件 */
|
||||
export function copySelectedFile() {
|
||||
const fp = state.activeDocFile;
|
||||
if (!fp) return;
|
||||
const file = (state.docFiles || []).find(f => f.path === fp);
|
||||
if (!file || file.status === "deleted" || file.status === "externally_deleted") return;
|
||||
_clipboard = { file, type: "copy" };
|
||||
showToast(`已复制:${file.baseName || file.name}`);
|
||||
}
|
||||
|
||||
/** Ctrl+X:剪切当前选中的文件 */
|
||||
export function cutSelectedFile() {
|
||||
const fp = state.activeDocFile;
|
||||
if (!fp) return;
|
||||
const file = (state.docFiles || []).find(f => f.path === fp);
|
||||
if (!file || file.status === "deleted" || file.status === "externally_deleted") return;
|
||||
_clipboard = { file, type: "cut" };
|
||||
showToast(`已剪切:${file.baseName || file.name}`);
|
||||
}
|
||||
|
||||
/** Ctrl+V:粘贴到选中文件所在目录或根目录 */
|
||||
export function pasteToSelected() {
|
||||
if (!_clipboard || !_clipboard.file) return;
|
||||
const fp = state.activeDocFile;
|
||||
if (fp) {
|
||||
const file = (state.docFiles || []).find(f => f.path === fp);
|
||||
if (file) {
|
||||
// 粘贴到选中文件的父目录
|
||||
const parentDir = fp.replace(/[/\\][^/\\]*$/, "");
|
||||
if (parentDir) {
|
||||
pasteIntoFolder(parentDir);
|
||||
} else {
|
||||
pasteFileAtRoot();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
pasteFileAtRoot();
|
||||
}
|
||||
|
||||
/** F2:重命名当前选中的文件 */
|
||||
export function renameSelectedFile() {
|
||||
const fp = state.activeDocFile;
|
||||
if (!fp) return;
|
||||
const file = (state.docFiles || []).find(f => f.path === fp);
|
||||
if (!file || file.status === "deleted") return;
|
||||
startRenameFile(fp);
|
||||
}
|
||||
|
||||
/** 复制副本 */
|
||||
function duplicateFile(filePath) {
|
||||
const file = (state.docFiles || []).find((f) => f.path === filePath);
|
||||
if (!file) return;
|
||||
const ext = file.baseName.includes(".") ? file.baseName.slice(file.baseName.lastIndexOf(".")) : "";
|
||||
const base = file.baseName.replace(ext, "");
|
||||
const dir = filePath.replace(/[/\\][^/\\]*$/, "");
|
||||
let newPath = dir ? dir + "/" + base + "_copy" + ext : base + "_copy" + ext;
|
||||
let counter = 1;
|
||||
while ((state.docFiles || []).find((f) => f.path === newPath)) {
|
||||
newPath = dir ? dir + "/" + base + "_copy" + counter + ext : base + "_copy" + counter + ext;
|
||||
counter++;
|
||||
}
|
||||
const content = docContents[filePath] || "";
|
||||
state.docFiles.push({ path: newPath, name: newPath.split(/[/\\]/).pop(), status: "new" });
|
||||
docContents[newPath] = content;
|
||||
renderDocTree();
|
||||
showToast("已创建副本");
|
||||
}
|
||||
|
||||
/** 拖拽移动:将 srcPath 文件移入 targetPath 所在目录 */
|
||||
function moveFileIntoFolder(srcPath, targetPath) {
|
||||
const srcFile = (state.docFiles || []).find((f) => f.path === srcPath);
|
||||
if (!srcFile) return;
|
||||
const targetDir = targetPath.replace(/[/\\][^/\\]*$/, "");
|
||||
const newPath = targetDir ? targetDir + "/" + srcFile.baseName : srcFile.baseName;
|
||||
if (newPath === srcPath) return;
|
||||
|
||||
// 避免重名
|
||||
let finalPath = newPath;
|
||||
let counter = 1;
|
||||
while ((state.docFiles || []).find((f) => f.path === finalPath && f.path !== srcPath)) {
|
||||
const ext = srcFile.baseName.includes(".") ? srcFile.baseName.slice(srcFile.baseName.lastIndexOf(".")) : "";
|
||||
const base = srcFile.baseName.replace(ext, "");
|
||||
finalPath = targetDir ? targetDir + "/" + base + "_" + counter + ext : base + "_" + counter + ext;
|
||||
counter++;
|
||||
}
|
||||
|
||||
renameDocFile(srcPath, finalPath, finalPath.split(/[/\\]/).pop());
|
||||
renderDocTree();
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
showToast("文件已移动");
|
||||
}
|
||||
|
||||
/** 新建文件 */
|
||||
async function createNewFileInFolder(folderName) {
|
||||
const name = await showPrompt("输入新文件名:", "untitled.txt");
|
||||
if (!name) return;
|
||||
const path = folderName + "/" + name;
|
||||
if ((state.docFiles || []).find((f) => f.path === path)) { showToast("文件已存在"); return; }
|
||||
state.docFiles = state.docFiles || [];
|
||||
state.docFiles.push({ path, name, status: "new" });
|
||||
docContents[path] = "";
|
||||
renderDocTree();
|
||||
openDocFile({ path, name, status: "new", baseName: name });
|
||||
}
|
||||
|
||||
async function createNewFolderInFolder(folderName) {
|
||||
const name = await showPrompt("输入新文件夹名:", "new_folder");
|
||||
if (!name) return;
|
||||
// 通过创建一个占位文件来建立文件夹
|
||||
const path = folderName + "/" + name + "/.gitkeep";
|
||||
if ((state.docFiles || []).find((f) => f.path.startsWith(folderName + "/" + name + "/"))) { showToast("文件夹已存在"); return; }
|
||||
state.docFiles = state.docFiles || [];
|
||||
state.docFiles.push({ path, name: ".gitkeep", status: "new" });
|
||||
docContents[path] = "";
|
||||
// 确保文件夹展开
|
||||
if (!state._docTreeFolders) state._docTreeFolders = {};
|
||||
state._docTreeFolders[name] = true;
|
||||
state._docTreeFolders[folderName] = true;
|
||||
renderDocTree();
|
||||
showToast("文件夹已创建");
|
||||
}
|
||||
|
||||
async function deleteFolder(folderName) {
|
||||
if (!await showConfirm(`确认删除文件夹 "${folderName}" 及其所有内容?`)) return;
|
||||
const prefix = folderName + "/";
|
||||
const files = state.docFiles || [];
|
||||
files.forEach((f) => {
|
||||
if (f.path.startsWith(prefix) || f.path === folderName) {
|
||||
f.status = "deleted";
|
||||
closeDocFile(f.path);
|
||||
}
|
||||
});
|
||||
// 实际删除磁盘文件
|
||||
if (window.electronAPI && state._docRootPath) {
|
||||
try {
|
||||
const { deleteFile } = await import('./electron-api.js');
|
||||
await deleteFile(folderName, state._docRootPath);
|
||||
} catch {}
|
||||
}
|
||||
if (state._docTreeFolders) delete state._docTreeFolders[folderName];
|
||||
renderDocTree();
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
}
|
||||
|
||||
// ============ 内容管理 ============
|
||||
let docContents = {};
|
||||
let docOriginalContents = {};
|
||||
|
||||
export function setDocContent(filePath, content) {
|
||||
docContents[filePath] = content;
|
||||
if (!(filePath in docOriginalContents)) docOriginalContents[filePath] = content;
|
||||
}
|
||||
export function getDocContent(filePath) { return docContents[filePath] || ""; }
|
||||
|
||||
// ============ 快照回退 ============
|
||||
let docSnapshots = [];
|
||||
export function saveDocSnapshot() {
|
||||
docSnapshots.push({
|
||||
contents: { ...docContents },
|
||||
files: JSON.parse(JSON.stringify(state.docFiles || [])),
|
||||
});
|
||||
if (docSnapshots.length > 100) docSnapshots.shift();
|
||||
}
|
||||
export function undoDocSnapshot() {
|
||||
if (docSnapshots.length === 0) return false;
|
||||
const snap = docSnapshots.pop();
|
||||
docContents = snap.contents;
|
||||
state.docFiles = snap.files;
|
||||
const ta = $("editEditorTextarea");
|
||||
if (ta && state.activeDocFile) {
|
||||
ta.value = docContents[state.activeDocFile] || "";
|
||||
if (updateLineNumbersFn) updateLineNumbersFn(ta.value);
|
||||
}
|
||||
renderDocTree();
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
return true;
|
||||
}
|
||||
export function getDocSnapshots() { return docSnapshots; }
|
||||
|
||||
// ============ 新建根级文件/文件夹 ============
|
||||
export async function createNewRootFile() {
|
||||
const name = await showPrompt("输入文件名:", "untitled.txt");
|
||||
if (!name) return;
|
||||
const path = name;
|
||||
if ((state.docFiles || []).find((f) => f.path === path)) { showToast("文件已存在"); return; }
|
||||
state.docFiles = state.docFiles || [];
|
||||
state.docFiles.push({ path, name, status: "new" });
|
||||
docContents[path] = "";
|
||||
renderDocTree();
|
||||
openDocFile({ path, name, status: "new", baseName: name });
|
||||
}
|
||||
|
||||
export async function createNewRootFolder() {
|
||||
const name = await showPrompt("输入文件夹名:", "new_folder");
|
||||
if (!name) return;
|
||||
const placeholderPath = name + "/.gitkeep";
|
||||
if ((state.docFiles || []).find((f) => f.path.startsWith(name + "/"))) { showToast("文件夹已存在"); return; }
|
||||
state.docFiles = state.docFiles || [];
|
||||
state.docFiles.push({ path: placeholderPath, name: ".gitkeep", status: "new" });
|
||||
docContents[placeholderPath] = "";
|
||||
if (!state._docTreeFolders) state._docTreeFolders = {};
|
||||
state._docTreeFolders[name] = true;
|
||||
renderDocTree();
|
||||
showToast("文件夹已创建");
|
||||
}
|
||||
|
||||
function openFilePreview(filePath) {
|
||||
if (openFilePreviewFn) openFilePreviewFn(filePath);
|
||||
}
|
||||
|
||||
function openFileAsText(filePath) {
|
||||
if (openFileAsTextFn) openFileAsTextFn(filePath);
|
||||
}
|
||||
|
||||
export { getFileType, getFileIcon, showFileProperties };
|
||||
|
||||
/** 弹出文件属性信息 */
|
||||
async function showFileProperties(file) {
|
||||
if (!file) return;
|
||||
// 构造系统绝对路径
|
||||
let absPath = file.path;
|
||||
if (state._docRootPath) {
|
||||
const sep = state._docRootPath.includes("\\") ? "\\" : "/";
|
||||
absPath = state._docRootPath.replace(/\/$/, "").replace(/\\$/, "") + sep + file.path.replace(/\//g, sep);
|
||||
}
|
||||
|
||||
let sizeStr = file.size != null ? formatSize(file.size) : "未知";
|
||||
let info = `文件:${file.name || file.baseName || file.path}\n\n`;
|
||||
info += `路径:${absPath}\n`;
|
||||
info += `大小:${sizeStr}\n`;
|
||||
info += `状态:${file.status || "original"}`;
|
||||
|
||||
// 通过 Electron API 获取更多信息
|
||||
if (window.electronAPI && state._docRootPath) {
|
||||
try {
|
||||
const { statFile } = await import('./electron-api.js');
|
||||
// 使用系统分隔符构造绝对路径
|
||||
const sep = state._docRootPath.includes("\\") ? "\\" : "/";
|
||||
const sysPath = state._docRootPath.replace(/\/$/, "").replace(/\\$/, "") + sep + file.path.replace(/\//g, sep);
|
||||
const stat = await statFile(sysPath, null);
|
||||
if (stat && stat.exists) {
|
||||
const mtime = new Date(stat.mtime).toLocaleString();
|
||||
info += `\n修改时间:${mtime}`;
|
||||
if (stat.is_dir) info += `\n类型:文件夹`;
|
||||
else info += `\n类型:文件`;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const { showAlert } = await import('./dialog.js');
|
||||
await showAlert(info);
|
||||
}
|
||||
|
||||
/** 弹出文件夹属性信息 */
|
||||
async function showFolderProperties(folderName) {
|
||||
if (!folderName) return;
|
||||
let absPath = folderName;
|
||||
if (state._docRootPath) {
|
||||
const sep = state._docRootPath.includes("\\") ? "\\" : "/";
|
||||
absPath = state._docRootPath.replace(/\/$/, "").replace(/\\$/, "") + sep + folderName.replace(/\//g, sep);
|
||||
}
|
||||
|
||||
let info = `文件夹:${folderName}\n\n`;
|
||||
info += `路径:${absPath}\n`;
|
||||
|
||||
// 统计文件夹内文件数
|
||||
const prefix = folderName + "/";
|
||||
const fileCount = (state.docFiles || []).filter((f) => f.path.startsWith(prefix)).length;
|
||||
info += `包含文件:${fileCount} 个`;
|
||||
|
||||
// 通过 Electron API 获取更多信息
|
||||
if (window.electronAPI && state._docRootPath) {
|
||||
try {
|
||||
const { statFile } = await import('./electron-api.js');
|
||||
const sep = state._docRootPath.includes("\\") ? "\\" : "/";
|
||||
const sysPath = state._docRootPath.replace(/\/$/, "").replace(/\\$/, "") + sep + folderName.replace(/\//g, sep);
|
||||
const stat = await statFile(sysPath, null);
|
||||
if (stat && stat.exists) {
|
||||
const mtime = new Date(stat.mtime).toLocaleString();
|
||||
info += `\n修改时间:${mtime}`;
|
||||
info += `\n类型:文件夹`;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const { showAlert } = await import('./dialog.js');
|
||||
await showAlert(info);
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes == null) return "未知";
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / 1048576).toFixed(2) + " MB";
|
||||
}
|
||||
|
||||
// ===== Delete 键快捷删除 =====
|
||||
// 第一次按 Delete:标记为已删除(软删除,可撤销)
|
||||
// 已经标记删除的文件再按 Delete:从工作区彻底移除(移入回收站)
|
||||
document.addEventListener("keydown", function (e) {
|
||||
// 只在编辑模式下响应,且不在 input/textarea/contentEditable 中
|
||||
if (e.key !== "Delete" && e.key !== "Del") return;
|
||||
|
||||
const tag = document.activeElement?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||
if (document.activeElement?.isContentEditable) return;
|
||||
// 不在 Monaco 编辑器中
|
||||
if (document.activeElement?.closest(".monaco-editor")) return;
|
||||
|
||||
const activeFile = state.activeDocFile;
|
||||
if (!activeFile) return;
|
||||
|
||||
const file = (state.docFiles || []).find((f) => f.path === activeFile);
|
||||
if (!file) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (file.status === "deleted") {
|
||||
// 二次按 Delete:彻底移除
|
||||
removeFileFromWorkspace(activeFile);
|
||||
showToast("\u{1F5D1} 已从工作区移除: " + (file.name || activeFile));
|
||||
} else {
|
||||
// 第一次按 Delete:标记删除
|
||||
markFileDeleted(activeFile);
|
||||
showToast("\u{1F5D1} 已标记删除: " + (file.name || activeFile) + " (右键移出工作区彻底移除)");
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,144 @@
|
||||
// electron-api.js — 文件操作适配层(桌面版)
|
||||
// 所有文件系统操作通过此模块统一调用
|
||||
|
||||
const _api = window.electronAPI;
|
||||
if (_api) { document.body.classList.add('is-electron'); }
|
||||
|
||||
// ===== 路径工具 =====
|
||||
function ensureAbsPath(filePath, rootPath) {
|
||||
if (!filePath) return filePath;
|
||||
// 已是绝对路径(Windows 盘符 / Unix 根 / UNC)直接返回,避免错误拼接 root
|
||||
if (/^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith('/') || filePath.startsWith('\\')) {
|
||||
return filePath;
|
||||
}
|
||||
if (!rootPath) return filePath;
|
||||
const root = rootPath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
return root + '/' + filePath;
|
||||
}
|
||||
|
||||
// ===== 文件读写 =====
|
||||
|
||||
/** 读取文本文件 */
|
||||
export async function readTextFile(filePath, rootPath) {
|
||||
const absPath = ensureAbsPath(filePath, rootPath);
|
||||
return _api.readFile(absPath);
|
||||
}
|
||||
|
||||
/** 写入文本文件 */
|
||||
export async function writeTextFile(filePath, content, rootPath) {
|
||||
const absPath = ensureAbsPath(filePath, rootPath);
|
||||
return _api.writeFile(absPath, content);
|
||||
}
|
||||
|
||||
/** 列出目录(递归) */
|
||||
export async function listDir(dirPath, depth = 10) {
|
||||
return _api.readDir(dirPath, depth);
|
||||
}
|
||||
|
||||
/** 读取二进制文件(图片等),返回 base64 + mime */
|
||||
export async function readBinaryFile(filePath, rootPath) {
|
||||
const absPath = ensureAbsPath(filePath, rootPath);
|
||||
return _api.readBinaryFile(absPath);
|
||||
}
|
||||
|
||||
/** 获取文件原始 URL(图片/HTML/PDF 预览用) */
|
||||
export function getFileUrl(filePath, rootPath) {
|
||||
const absPath = ensureAbsPath(filePath, rootPath);
|
||||
// Electron 下使用 file:// 协议直接加载本地文件
|
||||
return 'file:///' + absPath.replace(/\\/g, '/').replace(/^\//, '');
|
||||
}
|
||||
|
||||
/** 获取文件 stat 信息 */
|
||||
export async function statFile(filePath, rootPath) {
|
||||
const absPath = ensureAbsPath(filePath, rootPath);
|
||||
return _api.stat(absPath);
|
||||
}
|
||||
|
||||
/** 检查文件/目录是否存在 */
|
||||
export async function fileExists(filePath, rootPath) {
|
||||
const absPath = ensureAbsPath(filePath, rootPath);
|
||||
return _api.exists(absPath);
|
||||
}
|
||||
|
||||
// ===== 文件操作 =====
|
||||
|
||||
/** 重命名/移动文件 */
|
||||
export async function renameFile(oldPath, newPath, rootPath) {
|
||||
const oldAbs = ensureAbsPath(oldPath, rootPath);
|
||||
const newAbs = ensureAbsPath(newPath, rootPath);
|
||||
return _api.rename(oldAbs, newAbs);
|
||||
}
|
||||
|
||||
/** 删除文件/目录 */
|
||||
export async function deleteFile(filePath, rootPath) {
|
||||
const absPath = ensureAbsPath(filePath, rootPath);
|
||||
return _api.deleteFile(absPath);
|
||||
}
|
||||
|
||||
/** 创建目录 */
|
||||
export async function mkdir(dirPath, rootPath) {
|
||||
const absPath = ensureAbsPath(dirPath, rootPath);
|
||||
return _api.mkdir(absPath);
|
||||
}
|
||||
|
||||
/** 将外部文件复制到目标目录 */
|
||||
export async function copyExternalFile(srcPath, destDir) {
|
||||
return _api.copyFile(srcPath, destDir);
|
||||
}
|
||||
|
||||
/** 将 base64 内容写入目标目录(用于拖拽导入) */
|
||||
export async function writeFromBuffer(destDir, fileName, base64Data) {
|
||||
return _api.writeFromBuffer(destDir, fileName, base64Data);
|
||||
}
|
||||
|
||||
// ===== 对话框 =====
|
||||
|
||||
/** 弹出文件夹选择器 */
|
||||
export async function pickFolder() {
|
||||
const result = await _api.pickFolder();
|
||||
if (result.canceled) return null;
|
||||
return result.path;
|
||||
}
|
||||
|
||||
/** 弹出文件选择器 */
|
||||
export async function openFileDialog() {
|
||||
return _api.openFile();
|
||||
}
|
||||
|
||||
/** 弹出保存对话框 */
|
||||
export async function saveFileDialog(defaultPath) {
|
||||
return _api.saveFile(defaultPath);
|
||||
}
|
||||
|
||||
// ===== 文件系统监听 =====
|
||||
/** 开始监听目录变化 */
|
||||
export async function watchDir(dirPath) {
|
||||
return _api.watchDir(dirPath);
|
||||
}
|
||||
|
||||
/** 停止监听 */
|
||||
export async function unwatchDir() {
|
||||
return _api.unwatchDir();
|
||||
}
|
||||
|
||||
/** 监听文件变化事件 */
|
||||
export function onFileChanged(cb) {
|
||||
_api.onFileChanged(cb);
|
||||
}
|
||||
|
||||
// ===== 默认导出 =====
|
||||
export default {
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
listDir,
|
||||
getFileUrl,
|
||||
statFile,
|
||||
fileExists,
|
||||
renameFile,
|
||||
deleteFile,
|
||||
mkdir,
|
||||
pickFolder,
|
||||
openFileDialog,
|
||||
saveFileDialog,
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
// file-preview.js -- 文件浮窗预览(替代原 edit 模式下的 Monaco 编辑器)
|
||||
// 点击文件树中的文件或聊天中的文件卡片时弹窗查看内容
|
||||
|
||||
import { $, escapeHtml } from './utils.js';
|
||||
|
||||
const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico']);
|
||||
const HTML_EXTS = new Set(['.html', '.htm']);
|
||||
const 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', '.svg']);
|
||||
|
||||
let _currentFilePath = null;
|
||||
let _currentContent = null;
|
||||
|
||||
/** 判断是否为图片(非SVG) */
|
||||
function isImage(fileName) {
|
||||
const ext = (fileName || '').toLowerCase();
|
||||
const dot = ext.lastIndexOf('.');
|
||||
return dot >= 0 && IMG_EXTS.has(ext.substring(dot));
|
||||
}
|
||||
|
||||
/** 判断HTML */
|
||||
function isHtml(fileName) {
|
||||
const ext = (fileName || '').toLowerCase();
|
||||
const dot = ext.lastIndexOf('.');
|
||||
return dot >= 0 && HTML_EXTS.has(ext.substring(dot));
|
||||
}
|
||||
|
||||
/** 判断文本文件 */
|
||||
function isTextFile(fileName) {
|
||||
const ext = (fileName || '').toLowerCase();
|
||||
const dot = ext.lastIndexOf('.');
|
||||
return dot >= 0 && TEXT_EXTS.has(ext.substring(dot));
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开文件预览浮窗
|
||||
* @param {string} filePath - 文件路径
|
||||
* @param {string} fileName - 文件名
|
||||
* @param {string} content - 文件内容(文本)或 base64 data URL(图片)
|
||||
* @param {object} opts - { status: 'new'|'modified'|'deleted'|'', onDownload: fn }
|
||||
*/
|
||||
export function openFilePreview(filePath, fileName, content, opts = {}) {
|
||||
_currentFilePath = filePath;
|
||||
_currentContent = content;
|
||||
|
||||
const overlay = $('filePreviewOverlay');
|
||||
const nameEl = $('filePreviewName');
|
||||
const statusEl = $('filePreviewStatus');
|
||||
const body = $('filePreviewBody');
|
||||
const downloadBtn = $('filePreviewDownload');
|
||||
|
||||
if (!overlay || !body) return;
|
||||
|
||||
// 设置文件名
|
||||
if (nameEl) nameEl.textContent = fileName || filePath;
|
||||
|
||||
// 设置状态标签
|
||||
if (statusEl) {
|
||||
statusEl.className = 'file-preview-status';
|
||||
const status = opts.status || '';
|
||||
if (status) {
|
||||
statusEl.textContent = status === 'new' ? '新增' : status === 'modified' ? '已修改' : '已删除';
|
||||
statusEl.classList.add(`file-preview-status--${status}`);
|
||||
} else {
|
||||
statusEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 下载按钮
|
||||
if (downloadBtn) {
|
||||
downloadBtn.onclick = () => {
|
||||
if (opts.onDownload) {
|
||||
opts.onDownload(filePath, fileName, content);
|
||||
} else {
|
||||
_downloadFile(fileName, content);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 渲染内容
|
||||
body.innerHTML = '';
|
||||
if (isImage(fileName)) {
|
||||
_renderImage(body, content, fileName);
|
||||
} else if (isHtml(fileName) && !opts.asText) {
|
||||
_renderPlainText(body, content);
|
||||
} else if (isTextFile(fileName)) {
|
||||
_renderPlainText(body, content);
|
||||
} else if (content && content.startsWith && content.startsWith('data:')) {
|
||||
_renderImage(body, content, fileName);
|
||||
} else {
|
||||
_renderPlainText(body, content || '(空文件)');
|
||||
}
|
||||
|
||||
overlay.hidden = false;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 点击背景关闭
|
||||
overlay.onclick = (e) => {
|
||||
if (e.target === overlay) closeFilePreview();
|
||||
};
|
||||
|
||||
// ESC 关闭
|
||||
document.addEventListener('keydown', _onEscClose);
|
||||
}
|
||||
|
||||
/** 关闭文件预览 */
|
||||
export function closeFilePreview() {
|
||||
const overlay = $('filePreviewOverlay');
|
||||
if (overlay) overlay.hidden = true;
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', _onEscClose);
|
||||
_currentFilePath = null;
|
||||
_currentContent = null;
|
||||
}
|
||||
|
||||
function _onEscClose(e) {
|
||||
if (e.key === 'Escape') closeFilePreview();
|
||||
}
|
||||
|
||||
function _renderImage(body, content, fileName) {
|
||||
const img = document.createElement('img');
|
||||
img.src = content;
|
||||
img.alt = fileName;
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.display = 'block';
|
||||
img.style.margin = '0 auto';
|
||||
body.appendChild(img);
|
||||
}
|
||||
|
||||
function _renderPlainText(body, content) {
|
||||
const text = content || '';
|
||||
const lines = text.split('\n');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'fp-code-wrap';
|
||||
|
||||
// 行号列
|
||||
const lineNos = document.createElement('div');
|
||||
lineNos.className = 'fp-line-nos';
|
||||
for (let i = 1; i <= lines.length; i++) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = i;
|
||||
lineNos.appendChild(span);
|
||||
}
|
||||
|
||||
// 代码列(每行独立 span,可精确选中)
|
||||
const codeLines = document.createElement('div');
|
||||
codeLines.className = 'fp-code-lines';
|
||||
lines.forEach((line, i) => {
|
||||
const span = document.createElement('span');
|
||||
span.setAttribute('data-line', i + 1);
|
||||
span.textContent = line || '\u00A0';
|
||||
codeLines.appendChild(span);
|
||||
});
|
||||
|
||||
wrap.appendChild(lineNos);
|
||||
wrap.appendChild(codeLines);
|
||||
body.appendChild(wrap);
|
||||
|
||||
// 右键菜单:引用选中行
|
||||
codeLines.addEventListener('contextmenu', (e) => _onCodeContextMenu(e));
|
||||
}
|
||||
|
||||
function _onCodeContextMenu(e) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
|
||||
|
||||
// 找到选中区域的起止行
|
||||
const range = sel.getRangeAt(0);
|
||||
const startEl = range.startContainer?.parentElement?.closest?.('[data-line]');
|
||||
const endEl = range.endContainer?.parentElement?.closest?.('[data-line]');
|
||||
if (!startEl || !endEl) return;
|
||||
|
||||
const s = parseInt(startEl.getAttribute('data-line'));
|
||||
const endLine = parseInt(endEl.getAttribute('data-line'));
|
||||
if (isNaN(s) || isNaN(endLine)) return;
|
||||
|
||||
const minLine = Math.min(s, endLine);
|
||||
const maxLine = Math.max(s, endLine);
|
||||
|
||||
e.preventDefault();
|
||||
_showCodeRefMenu(e.clientX, e.clientY, minLine, maxLine);
|
||||
}
|
||||
|
||||
function _showCodeRefMenu(x, y, startLine, endLine) {
|
||||
const existing = document.querySelector('.fp-context-menu');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'doc-context-menu fp-context-menu';
|
||||
menu.style.left = x + 'px';
|
||||
menu.style.top = y + 'px';
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.zIndex = '1100';
|
||||
|
||||
const label = startLine === endLine ? `L${startLine}` : `L${startLine}-L${endLine}`;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'doc-context-menu-item';
|
||||
item.textContent = `\u{1F517} 引用选中内容 (${label})`;
|
||||
item.addEventListener('click', () => {
|
||||
const filePath = _currentFilePath;
|
||||
if (filePath) {
|
||||
import('./edit-mode.js').then(m => m.addDocRef(filePath, startLine, endLine)).catch(() => {});
|
||||
}
|
||||
menu.remove();
|
||||
closeFilePreview();
|
||||
});
|
||||
menu.appendChild(item);
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
const closeHandler = (ev) => {
|
||||
if (!menu.contains(ev.target)) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
}
|
||||
|
||||
function _downloadFile(fileName, content) {
|
||||
let url, type;
|
||||
if (content && content.startsWith && content.startsWith('data:')) {
|
||||
url = content;
|
||||
} else {
|
||||
const blob = new Blob([content || ''], { type: 'text/plain;charset=utf-8' });
|
||||
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);
|
||||
if (!content?.startsWith?.('data:')) URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** 绑定关闭按钮 */
|
||||
export function bindFilePreviewEvents() {
|
||||
const closeBtn = $('filePreviewClose');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', closeFilePreview);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// i18n.js -- 多语言系统(中文 / English)
|
||||
|
||||
const LANG_KEY = "minor_language";
|
||||
|
||||
export const LANGS = {
|
||||
"zh-CN": {
|
||||
name: "中文",
|
||||
// 通用
|
||||
app_title: "多模态Agent",
|
||||
sidebar_sessions: "会话",
|
||||
sidebar_hint: "鼠标悬停可重命名或删除会话。",
|
||||
sidebar_hint_edit: "在此管理文档文件,支持拖拽和引用。",
|
||||
new_session: "新建会话",
|
||||
edit_mode: "Edit",
|
||||
chat_mode: "Chat",
|
||||
send: "发送",
|
||||
pause: "暂停",
|
||||
clear: "清空",
|
||||
output_mode: "输出模式",
|
||||
text_mode: "文字模式",
|
||||
voice_mode: "语音模式",
|
||||
agent_mode: "Agent 模式",
|
||||
auto_input: "自动语音输入",
|
||||
input_placeholder: "输入消息或添加附件,Enter 发送(Shift+Enter 换行)",
|
||||
settings: "系统配置",
|
||||
// 设置
|
||||
tab_agent: "Agent",
|
||||
tab_tool: "工具",
|
||||
tab_env: "环境变量",
|
||||
tab_skills: "Skills",
|
||||
tab_gui: "GUI设置",
|
||||
tab_theme: "主题/语言",
|
||||
theme_label: "主题",
|
||||
lang_label: "语言",
|
||||
theme_restart_note: "主题即时生效,语言需刷新页面。",
|
||||
// Edit
|
||||
edit_open_folder: "打开文件夹",
|
||||
edit_empty_title: "打开文件开始编辑",
|
||||
edit_empty_hint: "点击左侧打开文件夹,或将文件拖入此区域",
|
||||
edit_tree_empty: "打开文件夹或拖入文件开始编辑",
|
||||
edit_new_file: "新建文件",
|
||||
edit_new_folder: "新建文件夹",
|
||||
// 文件操作
|
||||
file_rename: "重命名",
|
||||
file_copy: "复制",
|
||||
file_cut: "剪切",
|
||||
file_paste: "粘贴",
|
||||
file_duplicate: "复制副本",
|
||||
file_delete: "删除",
|
||||
file_close: "关闭文件",
|
||||
file_undo_delete: "撤销删除",
|
||||
file_remove_workspace: "从工作区移除",
|
||||
file_paste_here: "粘贴到此",
|
||||
file_new_file: "新建文件",
|
||||
file_new_folder: "新建文件夹",
|
||||
file_rename_folder: "重命名文件夹",
|
||||
file_delete_folder: "删除文件夹",
|
||||
// Toast
|
||||
toast_copied: "已复制到剪贴板",
|
||||
toast_cut: "已剪切到剪贴板",
|
||||
toast_pasted: "已粘贴",
|
||||
toast_duplicated: "已创建副本",
|
||||
toast_moved: "文件已移动",
|
||||
toast_folder_created: "文件夹已创建",
|
||||
toast_file_exists: "文件已存在",
|
||||
toast_folder_exists: "文件夹已存在",
|
||||
toast_no_supported: "文件夹中没有支持的文件类型",
|
||||
toast_unsupported: "不支持的文件类型",
|
||||
toast_folder_opened: "已打开文件夹,共 {n} 个文件",
|
||||
toast_file_opened: "已打开:{name}",
|
||||
toast_snapshot_saved: "文档快照已保存",
|
||||
toast_undo: "已回退到上一步",
|
||||
toast_no_undo: "没有可回退的步骤",
|
||||
toast_agent_done: "Agent 执行完成",
|
||||
toast_agent_error: "错误: {msg}",
|
||||
// 确认
|
||||
confirm_delete_folder: '确认删除文件夹 "{name}" 及其所有内容?',
|
||||
new_file_prompt: "输入新文件名:",
|
||||
new_folder_prompt: "输入新文件夹名:",
|
||||
// 预览附件
|
||||
upload_attachment: "上传附件",
|
||||
// 连接错误
|
||||
connect_error: "无法连接服务",
|
||||
connect_error_desc: '请从 Agent 目录执行:<code>uvicorn web.server:app --reload --host 127.0.0.1 --port 8765</code>,并保证 PYTHONPATH 包含 <code>src</code>。',
|
||||
// 侧栏
|
||||
collapse_sidebar: "收起侧栏",
|
||||
expand_sidebar: "展开侧栏",
|
||||
},
|
||||
"en-US": {
|
||||
name: "English",
|
||||
app_title: "Multimodal Agent",
|
||||
sidebar_sessions: "Sessions",
|
||||
sidebar_hint: "Hover to rename or delete sessions.",
|
||||
sidebar_hint_edit: "Manage document files here. Drag & drop supported.",
|
||||
new_session: "New Session",
|
||||
edit_mode: "Edit",
|
||||
chat_mode: "Chat",
|
||||
send: "Send",
|
||||
pause: "Pause",
|
||||
clear: "Clear",
|
||||
output_mode: "Output",
|
||||
text_mode: "Text",
|
||||
voice_mode: "Voice",
|
||||
agent_mode: "Agent Mode",
|
||||
auto_input: "Auto Input",
|
||||
input_placeholder: "Type a message or add attachments, Enter to send (Shift+Enter for newline)",
|
||||
settings: "Settings",
|
||||
tab_agent: "Agent",
|
||||
tab_tool: "Tools",
|
||||
tab_env: "Env",
|
||||
tab_skills: "Skills",
|
||||
tab_gui: "GUI",
|
||||
tab_theme: "Theme/Lang",
|
||||
theme_label: "Theme",
|
||||
lang_label: "Language",
|
||||
theme_restart_note: "Theme applies instantly. Language requires page refresh.",
|
||||
edit_open_folder: "Open Folder",
|
||||
edit_empty_title: "Open a file to start editing",
|
||||
edit_empty_hint: "Click Open Folder on the left, or drag files here",
|
||||
edit_tree_empty: "Open a folder or drag files to start",
|
||||
edit_new_file: "New File",
|
||||
edit_new_folder: "New Folder",
|
||||
file_rename: "Rename",
|
||||
file_copy: "Copy",
|
||||
file_cut: "Cut",
|
||||
file_paste: "Paste",
|
||||
file_duplicate: "Duplicate",
|
||||
file_delete: "Delete",
|
||||
file_close: "Close",
|
||||
file_undo_delete: "Undo Delete",
|
||||
file_remove_workspace: "Remove from Workspace",
|
||||
file_paste_here: "Paste Here",
|
||||
file_new_file: "New File",
|
||||
file_new_folder: "New Folder",
|
||||
file_rename_folder: "Rename Folder",
|
||||
file_delete_folder: "Delete Folder",
|
||||
toast_copied: "Copied to clipboard",
|
||||
toast_cut: "Cut to clipboard",
|
||||
toast_pasted: "Pasted",
|
||||
toast_duplicated: "Duplicate created",
|
||||
toast_moved: "File moved",
|
||||
toast_folder_created: "Folder created",
|
||||
toast_file_exists: "File already exists",
|
||||
toast_folder_exists: "Folder already exists",
|
||||
toast_no_supported: "No supported files in folder",
|
||||
toast_unsupported: "Unsupported file type",
|
||||
toast_folder_opened: "Folder opened, {n} files total",
|
||||
toast_file_opened: "Opened: {name}",
|
||||
toast_snapshot_saved: "Snapshot saved",
|
||||
toast_undo: "Undone to previous step",
|
||||
toast_no_undo: "Nothing to undo",
|
||||
toast_agent_done: "Agent execution complete",
|
||||
toast_agent_error: "Error: {msg}",
|
||||
confirm_delete_folder: 'Confirm delete folder "{name}" and all contents?',
|
||||
new_file_prompt: "Enter new file name:",
|
||||
new_folder_prompt: "Enter new folder name:",
|
||||
upload_attachment: "Upload",
|
||||
connect_error: "Cannot connect to server",
|
||||
connect_error_desc: 'Run from Agent directory: <code>uvicorn web.server:app --reload --host 127.0.0.1 --port 8765</code>, with PYTHONPATH including <code>src</code>.',
|
||||
collapse_sidebar: "Collapse Sidebar",
|
||||
expand_sidebar: "Expand Sidebar",
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_LANG = "zh-CN";
|
||||
let _currentLang = DEFAULT_LANG;
|
||||
|
||||
/** 获取已保存语言 */
|
||||
function getLang() {
|
||||
try { return localStorage.getItem(LANG_KEY) || DEFAULT_LANG; }
|
||||
catch { return DEFAULT_LANG; }
|
||||
}
|
||||
|
||||
/** 获取翻译文本 */
|
||||
export function t(key, params) {
|
||||
const lang = _currentLang;
|
||||
const dict = LANGS[lang] || LANGS[DEFAULT_LANG];
|
||||
let text = dict[key];
|
||||
if (text === undefined) text = (LANGS[DEFAULT_LANG] || {})[key] || key;
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
text = text.replace(`{${k}}`, v);
|
||||
});
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 切换语言 */
|
||||
export function setLanguage(langId) {
|
||||
if (!LANGS[langId]) return;
|
||||
_currentLang = langId;
|
||||
try { localStorage.setItem(LANG_KEY, langId); } catch {}
|
||||
applyLanguage();
|
||||
}
|
||||
|
||||
/** 应用语言到 DOM */
|
||||
export function applyLanguage() {
|
||||
const lang = getLang();
|
||||
_currentLang = lang;
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
const map = {
|
||||
"#mainHeaderText h1": "app_title",
|
||||
"#mainHeaderText .subtitle": "app_subtitle",
|
||||
"#textInput": { attr: "placeholder", key: "input_placeholder" },
|
||||
"#openFolderBtn": "edit_open_folder",
|
||||
"#newFileBtn": "edit_new_file",
|
||||
"#newFolderBtn": "edit_new_folder",
|
||||
".sidebar-hint": "sidebar_hint",
|
||||
"#editEmptyState p:first-child": "edit_empty_title",
|
||||
".edit-empty-hint": "edit_empty_hint",
|
||||
};
|
||||
|
||||
Object.entries(map).forEach(([sel, val]) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return;
|
||||
if (typeof val === "object" && val.attr) {
|
||||
el.setAttribute(val.attr, t(val.key));
|
||||
} else {
|
||||
const text = t(val);
|
||||
if (!text.includes("<")) el.textContent = text;
|
||||
}
|
||||
});
|
||||
|
||||
// 设置 tabs
|
||||
document.querySelectorAll(".settings-tab").forEach((tab) => {
|
||||
const dt = tab.getAttribute("data-tab");
|
||||
const keys = { agent: "tab_agent", tool: "tab_tool", env: "tab_env", skills: "tab_skills", other: "tab_gui", theme: "tab_theme" };
|
||||
if (keys[dt]) tab.textContent = t(keys[dt]);
|
||||
});
|
||||
|
||||
// 设置页标题
|
||||
document.title = t("app_title");
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
export function initI18n() {
|
||||
applyLanguage();
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// layout.js -- 侧栏 / 背景 / 标题栏 / 响应式
|
||||
|
||||
import { $ } from './utils.js';
|
||||
import { state, STORAGE_DOF, STORAGE_VIG, STORAGE_HEADER_COLLAPSED } from './state.js';
|
||||
|
||||
const appRoot = $("app");
|
||||
const mainPanel = $("mainPanel");
|
||||
const expandRail = $("expandRail");
|
||||
const menuBtn = $("menuBtn");
|
||||
const drawerBackdrop = $("drawerBackdrop");
|
||||
const bgImageLayer = $("bgImageLayer");
|
||||
const headerToggleBtn = $("headerToggleBtn");
|
||||
const dofRange = $("dofRange");
|
||||
const dofValue = $("dofValue");
|
||||
const vignetteRange = $("vignetteRange");
|
||||
const vignetteValue = $("vignetteValue");
|
||||
|
||||
const STORAGE_SIDEBAR_W = "minor_sidebar_width";
|
||||
const SIDEBAR_MIN_W = 150;
|
||||
const SIDEBAR_MAX_W = 420;
|
||||
|
||||
function setSidebarCollapsed(collapsed) {
|
||||
state.sidebarCollapsed = collapsed;
|
||||
if (!appRoot) return;
|
||||
appRoot.classList.toggle("sidebar-collapsed", collapsed);
|
||||
if (expandRail) expandRail.setAttribute("aria-hidden", collapsed ? "false" : "true");
|
||||
}
|
||||
|
||||
function setDrawerOpen(open) {
|
||||
if (!appRoot) return;
|
||||
appRoot.classList.toggle("drawer-open", open);
|
||||
if (menuBtn) menuBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (drawerBackdrop) drawerBackdrop.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
}
|
||||
|
||||
function updateMobileLayout() {
|
||||
if (!appRoot) return;
|
||||
const mobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
appRoot.classList.toggle("is-mobile", mobile);
|
||||
if (!mobile) setDrawerOpen(false);
|
||||
}
|
||||
|
||||
function closeDrawerIfMobile() {
|
||||
if (!appRoot) return;
|
||||
if (appRoot.classList.contains("is-mobile")) setDrawerOpen(false);
|
||||
}
|
||||
|
||||
// ===== 标题栏 =====
|
||||
function setHeaderCollapsed(collapsed) {
|
||||
if (!mainPanel || !headerToggleBtn) return;
|
||||
mainPanel.classList.toggle("header-collapsed", collapsed);
|
||||
headerToggleBtn.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
headerToggleBtn.title = collapsed ? "展开标题栏" : "收起标题栏";
|
||||
headerToggleBtn.textContent = collapsed ? "\u25BE" : "\u25B4";
|
||||
try {
|
||||
localStorage.setItem(STORAGE_HEADER_COLLAPSED, collapsed ? "1" : "0");
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function loadHeaderPref() {
|
||||
if (!mainPanel || !headerToggleBtn) return;
|
||||
try {
|
||||
if (localStorage.getItem(STORAGE_HEADER_COLLAPSED) === "1") setHeaderCollapsed(true);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ===== 背景控制 =====
|
||||
function applyBgVars() {
|
||||
const dofPx = dofRange ? String(dofRange.value) : "0";
|
||||
const vig = vignetteRange ? Number(vignetteRange.value) / 100 : 0.35;
|
||||
document.documentElement.style.setProperty("--bg-dof", `${dofPx}px`);
|
||||
document.documentElement.style.setProperty("--bg-vignette", String(Math.max(0, Math.min(1, vig))));
|
||||
if (dofValue) dofValue.textContent = `${dofPx} px`;
|
||||
if (vignetteValue) vignetteValue.textContent = `${vignetteRange ? vignetteRange.value : 35} %`;
|
||||
applyBackgroundFit();
|
||||
}
|
||||
|
||||
function loadBgPrefs() {
|
||||
try {
|
||||
const d = localStorage.getItem(STORAGE_DOF);
|
||||
if (d != null && dofRange) dofRange.value = d;
|
||||
const v = localStorage.getItem(STORAGE_VIG);
|
||||
if (v != null && vignetteRange) vignetteRange.value = v;
|
||||
} catch { /* ignore */ }
|
||||
applyBgVars();
|
||||
}
|
||||
|
||||
let bgNaturalW = 0;
|
||||
let bgNaturalH = 0;
|
||||
let bgFitTimer = null;
|
||||
|
||||
function backgroundImageHref() {
|
||||
try {
|
||||
return new URL("image/background.jpg", window.location.href).href;
|
||||
} catch {
|
||||
return "image/background.jpg";
|
||||
}
|
||||
}
|
||||
|
||||
function readDofPx() {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue("--bg-dof").trim();
|
||||
const n = parseFloat(raw);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function applyBackgroundFit() {
|
||||
if (!bgImageLayer || !bgNaturalW || !bgNaturalH) return;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const s = Math.max(vw / bgNaturalW, vh / bgNaturalH);
|
||||
const dof = readDofPx();
|
||||
const bleed = 1 + Math.min(Math.max(dof, 0), 24) * 0.018;
|
||||
const bw = bgNaturalW * s * bleed;
|
||||
const bh = bgNaturalH * s * bleed;
|
||||
bgImageLayer.style.backgroundSize = `${bw}px ${bh}px`;
|
||||
bgImageLayer.style.backgroundPosition = "center center";
|
||||
}
|
||||
|
||||
function scheduleBackgroundFit() {
|
||||
if (bgFitTimer) clearTimeout(bgFitTimer);
|
||||
bgFitTimer = setTimeout(() => {
|
||||
bgFitTimer = null;
|
||||
applyBackgroundFit();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function wireBackgroundImage() {
|
||||
if (!bgImageLayer) return;
|
||||
const href = backgroundImageHref();
|
||||
bgImageLayer.style.backgroundImage = `url(${JSON.stringify(href)})`;
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
bgNaturalW = probe.naturalWidth;
|
||||
bgNaturalH = probe.naturalHeight;
|
||||
if (bgNaturalW > 0 && bgNaturalH > 0) applyBackgroundFit();
|
||||
};
|
||||
probe.onerror = () => {
|
||||
bgNaturalW = 0;
|
||||
bgNaturalH = 0;
|
||||
bgImageLayer.style.backgroundSize = "cover";
|
||||
};
|
||||
probe.src = href;
|
||||
}
|
||||
|
||||
function onResizeLayout() {
|
||||
updateMobileLayout();
|
||||
scheduleBackgroundFit();
|
||||
}
|
||||
|
||||
// 初始化背景与标题栏
|
||||
loadBgPrefs();
|
||||
loadHeaderPref();
|
||||
|
||||
if (dofRange) {
|
||||
dofRange.addEventListener("input", () => {
|
||||
applyBgVars();
|
||||
applyBackgroundFit();
|
||||
try { localStorage.setItem(STORAGE_DOF, dofRange.value); } catch { /* ignore */ }
|
||||
});
|
||||
}
|
||||
if (vignetteRange) {
|
||||
vignetteRange.addEventListener("input", () => {
|
||||
applyBgVars();
|
||||
try { localStorage.setItem(STORAGE_VIG, vignetteRange.value); } catch { /* ignore */ }
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("resize", onResizeLayout);
|
||||
updateMobileLayout();
|
||||
wireBackgroundImage();
|
||||
|
||||
// ===== 侧栏拖动调整宽度 =====
|
||||
function initSidebarResize() {
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const handle = document.getElementById("sidebarResizeHandle");
|
||||
if (!sidebar || !handle) return;
|
||||
|
||||
// 恢复保存的宽度
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_SIDEBAR_W);
|
||||
if (saved) {
|
||||
const w = parseInt(saved, 10);
|
||||
if (w >= SIDEBAR_MIN_W && w <= SIDEBAR_MAX_W) {
|
||||
document.documentElement.style.setProperty("--sidebar-w", w + "px");
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
let dragging = false;
|
||||
let startX = 0;
|
||||
let startW = 0;
|
||||
|
||||
handle.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
if (appRoot && appRoot.classList.contains("sidebar-collapsed")) return;
|
||||
dragging = true;
|
||||
startX = e.clientX;
|
||||
startW = sidebar.getBoundingClientRect().width;
|
||||
// 拖动时禁用 transition 以保证流畅
|
||||
sidebar.style.transition = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
});
|
||||
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (!dragging) return;
|
||||
const dx = e.clientX - startX;
|
||||
let newW = startW + dx;
|
||||
newW = Math.max(SIDEBAR_MIN_W, Math.min(SIDEBAR_MAX_W, newW));
|
||||
document.documentElement.style.setProperty("--sidebar-w", newW + "px");
|
||||
});
|
||||
|
||||
window.addEventListener("mouseup", () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
// 恢复 transition
|
||||
sidebar.style.transition = "";
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
// 保存宽度
|
||||
const finalW = sidebar.getBoundingClientRect().width;
|
||||
try { localStorage.setItem(STORAGE_SIDEBAR_W, String(Math.round(finalW))); } catch { /* ignore */ }
|
||||
});
|
||||
}
|
||||
|
||||
initSidebarResize();
|
||||
|
||||
if (headerToggleBtn) {
|
||||
headerToggleBtn.addEventListener("click", () => {
|
||||
setHeaderCollapsed(!mainPanel.classList.contains("header-collapsed"));
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
setSidebarCollapsed, setDrawerOpen, closeDrawerIfMobile,
|
||||
};
|
||||
@@ -0,0 +1,453 @@
|
||||
// pending-overlay.js -- 待确认浮窗(HumanInteraction + ToolCall 确认)
|
||||
// 直接接收后端 API 返回的 pending_actions 数组,按 agent_name 分组展示。
|
||||
// 与 Todo 浮窗并排显示。
|
||||
|
||||
import { $, escapeHtml, showToast } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import { handleHumanAction } from './pending.js';
|
||||
|
||||
function _updateBadge(show) {
|
||||
try {
|
||||
import('./action-bar.js').then(m => m.setActionBadge('pending', show));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串中的 \\n 转为实际换行符(修复 LLM 输出双转义问题)
|
||||
*/
|
||||
function unescapeNewlines(s) {
|
||||
if (!s) return s;
|
||||
return String(s).replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 pending 浮窗:按 agent_name 分组,每个 agent 一个分组区。
|
||||
* @param {Array} pendingActions - 后端返回的未解决 pending_action 列表
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function buildPendingOverlay(pendingActions) {
|
||||
clearPendingOverlay();
|
||||
if (!pendingActions || !pendingActions.length) return null;
|
||||
|
||||
// 按 agent_name 分组(保留出现顺序)
|
||||
const groups = {};
|
||||
const groupOrder = [];
|
||||
pendingActions.forEach((p) => {
|
||||
const name = p.agent_name || "主Agent";
|
||||
if (!groups[name]) { groups[name] = []; groupOrder.push(name); }
|
||||
groups[name].push(p);
|
||||
});
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pending-overlay";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "pending-overlay-header";
|
||||
header.textContent = "\u270D\uFE0F 需要确认";
|
||||
header.addEventListener("click", () => {
|
||||
overlay.classList.toggle("pending-overlay--collapsed");
|
||||
});
|
||||
overlay.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "pending-overlay-body";
|
||||
|
||||
let hasSelectionBatch = false;
|
||||
|
||||
groupOrder.forEach((name) => {
|
||||
const items = groups[name];
|
||||
const isSub = items.some((p) => p.is_sub_agent);
|
||||
|
||||
const group = document.createElement("div");
|
||||
group.className = "pending-overlay-agent-group";
|
||||
|
||||
const groupHeader = document.createElement("div");
|
||||
groupHeader.className = "pending-overlay-agent-header";
|
||||
groupHeader.textContent = name;
|
||||
group.appendChild(groupHeader);
|
||||
|
||||
items.forEach((pending) => {
|
||||
if (pending.type === "human_interaction" && (pending.interaction_type || "information") === "selection") {
|
||||
hasSelectionBatch = true;
|
||||
}
|
||||
group.appendChild(buildPendingCard(pending, isSub));
|
||||
});
|
||||
|
||||
body.appendChild(group);
|
||||
});
|
||||
|
||||
overlay.appendChild(body);
|
||||
|
||||
// 全局提交按钮(仅当存在 selection 类型时显示,用于批量确认)
|
||||
if (hasSelectionBatch) {
|
||||
const submitRow = document.createElement("div");
|
||||
submitRow.className = "pending-overlay-submit-row";
|
||||
const submitBtn = document.createElement("button");
|
||||
submitBtn.type = "button";
|
||||
submitBtn.className = "pending-overlay-submit-btn";
|
||||
submitBtn.textContent = "确认全部选择";
|
||||
submitBtn.addEventListener("click", () => submitAllPending(overlay, pendingActions));
|
||||
submitRow.appendChild(submitBtn);
|
||||
overlay.appendChild(submitRow);
|
||||
}
|
||||
|
||||
state._currentPendingOverlay = overlay;
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建决策按钮
|
||||
* @param {string} label
|
||||
* @param {string} decision - approve | reject | skip | edit
|
||||
* @param {string} cls - 按钮附加类名
|
||||
* @param {Function} onClick
|
||||
*/
|
||||
function makeDecisionBtn(label, decision, cls, onClick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `pending-overlay-btn pending-overlay-btn--${decision} ${cls || ""}`.trim();
|
||||
btn.textContent = label;
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为单个 pending_action 构建卡片
|
||||
* @param {Object} pending - pending_action 数据
|
||||
* @param {boolean} isSubAgent - 是否属于子 Agent
|
||||
*/
|
||||
function buildPendingCard(pending, isSubAgent) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "pending-overlay-card";
|
||||
card.setAttribute("data-pending-id", pending.id);
|
||||
|
||||
const pendingType = pending.type || "human_interaction";
|
||||
const interactionType = pending.interaction_type || "information";
|
||||
|
||||
// 标题栏(不折叠,纯展示)
|
||||
const cardHeader = document.createElement("div");
|
||||
cardHeader.className = "pending-overlay-card-header";
|
||||
const icon = pendingType === "tool_call" ? "\u{1F527}" : "\u{1F4DD}";
|
||||
const titleText = pending.title || (pendingType === "tool_call" ? `待确认执行工具:${pending.tool_name || ""}` : "需要确认");
|
||||
cardHeader.innerHTML = `<span class="pending-overlay-card-icon">${icon}</span><span class="pending-overlay-card-title">${escapeHtml(titleText)}</span>`;
|
||||
card.appendChild(cardHeader);
|
||||
|
||||
// 内容区
|
||||
const cardBody = document.createElement("div");
|
||||
cardBody.className = "pending-overlay-card-body";
|
||||
|
||||
if (pending.prompt) {
|
||||
const promptEl = document.createElement("div");
|
||||
promptEl.className = "pending-overlay-prompt";
|
||||
promptEl.textContent = unescapeNewlines(pending.prompt);
|
||||
cardBody.appendChild(promptEl);
|
||||
}
|
||||
|
||||
// ===== tool_call 类型:展示参数(只读)+ 决策按钮 =====
|
||||
if (pendingType === "tool_call") {
|
||||
// 参数 JSON(只读)
|
||||
if (pending.args !== undefined && pending.args !== null) {
|
||||
let argsStr;
|
||||
try {
|
||||
argsStr = typeof pending.args === "string" ? pending.args : JSON.stringify(pending.args, null, 2);
|
||||
} catch (_) {
|
||||
argsStr = String(pending.args);
|
||||
}
|
||||
const argsEl = document.createElement("div");
|
||||
argsEl.className = "pending-overlay-tool-args";
|
||||
argsEl.textContent = argsStr;
|
||||
cardBody.appendChild(argsEl);
|
||||
}
|
||||
// 指令输入(可选,用于 approve 时附带说明)
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "pending-overlay-textarea";
|
||||
textarea.placeholder = "可选:补充指令...";
|
||||
textarea.value = pending.instruction || "";
|
||||
textarea.rows = 2;
|
||||
textarea.setAttribute("data-field", "instruction");
|
||||
cardBody.appendChild(textarea);
|
||||
|
||||
// 按钮行:主Agent = 同意/拒绝/跳过;子Agent = 确认/跳过
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.className = "pending-overlay-btn-row";
|
||||
const onDecide = (decision) => () => {
|
||||
const val = textarea.value.trim();
|
||||
submitSingle(pending.id, decision, val, card);
|
||||
};
|
||||
btnRow.appendChild(makeDecisionBtn(isSubAgent ? "确认" : "同意", "approve", "", onDecide("approve")));
|
||||
if (!isSubAgent) {
|
||||
btnRow.appendChild(makeDecisionBtn("拒绝", "reject", "", onDecide("reject")));
|
||||
}
|
||||
btnRow.appendChild(makeDecisionBtn("跳过", "skip", "", onDecide("skip")));
|
||||
cardBody.appendChild(btnRow);
|
||||
|
||||
card.appendChild(cardBody);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ===== human_interaction 类型 =====
|
||||
if (interactionType === "information") {
|
||||
// 文本输入
|
||||
const inputRow = document.createElement("div");
|
||||
inputRow.className = "pending-overlay-input-row";
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "pending-overlay-textarea";
|
||||
textarea.placeholder = "在这里补充信息...";
|
||||
textarea.value = pending.instruction || "";
|
||||
textarea.rows = 3;
|
||||
textarea.setAttribute("data-field", "instruction");
|
||||
inputRow.appendChild(textarea);
|
||||
|
||||
const sendBtn = document.createElement("button");
|
||||
sendBtn.type = "button";
|
||||
sendBtn.className = "pending-overlay-send-btn";
|
||||
sendBtn.textContent = "确认";
|
||||
sendBtn.addEventListener("click", () => {
|
||||
const val = textarea.value.trim();
|
||||
if (!val) return;
|
||||
submitSingle(pending.id, "approve", val, card);
|
||||
});
|
||||
inputRow.appendChild(sendBtn);
|
||||
|
||||
cardBody.appendChild(inputRow);
|
||||
|
||||
// 跳过按钮
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.className = "pending-overlay-btn-row";
|
||||
btnRow.appendChild(makeDecisionBtn("跳过", "skip", "", () => submitSingle(pending.id, "skip", "", card)));
|
||||
cardBody.appendChild(btnRow);
|
||||
} else if (interactionType === "selection") {
|
||||
if (pending.questions && Array.isArray(pending.questions) && pending.questions.length > 0) {
|
||||
pending.questions.forEach((q) => {
|
||||
const qBlock = document.createElement("div");
|
||||
qBlock.className = "pending-overlay-question";
|
||||
|
||||
const qHeader = document.createElement("div");
|
||||
qHeader.className = "pending-overlay-question-header";
|
||||
qHeader.innerHTML = `<span class="pending-overlay-q-icon">\u2753</span><span>${escapeHtml(q.question || "")}</span>`;
|
||||
qHeader.addEventListener("click", () => {
|
||||
qBlock.classList.toggle("pending-overlay-question--collapsed");
|
||||
});
|
||||
qBlock.appendChild(qHeader);
|
||||
|
||||
const qBody = document.createElement("div");
|
||||
qBody.className = "pending-overlay-question-body";
|
||||
|
||||
if (q.options && Array.isArray(q.options)) {
|
||||
const pillsRow = document.createElement("div");
|
||||
pillsRow.className = "pending-overlay-option-pills";
|
||||
|
||||
q.options.forEach((opt) => {
|
||||
const pill = document.createElement("button");
|
||||
pill.type = "button";
|
||||
pill.className = "pending-overlay-option-pill";
|
||||
pill.textContent = String(opt);
|
||||
pill.addEventListener("click", () => {
|
||||
const prevSelected = pillsRow.querySelector(".pending-overlay-option-pill--selected");
|
||||
if (prevSelected === pill) {
|
||||
pill.classList.remove("pending-overlay-option-pill--selected");
|
||||
return;
|
||||
}
|
||||
if (prevSelected) prevSelected.classList.remove("pending-overlay-option-pill--selected");
|
||||
pill.classList.add("pending-overlay-option-pill--selected");
|
||||
});
|
||||
pillsRow.appendChild(pill);
|
||||
});
|
||||
qBody.appendChild(pillsRow);
|
||||
}
|
||||
|
||||
// "其他" 输入
|
||||
const otherRow = document.createElement("div");
|
||||
otherRow.className = "pending-overlay-other-row";
|
||||
const otherInput = document.createElement("input");
|
||||
otherInput.type = "text";
|
||||
otherInput.className = "pending-overlay-other-input";
|
||||
otherInput.placeholder = "其他自定义...";
|
||||
otherRow.appendChild(otherInput);
|
||||
qBody.appendChild(otherRow);
|
||||
|
||||
qBlock.appendChild(qBody);
|
||||
cardBody.appendChild(qBlock);
|
||||
});
|
||||
} else {
|
||||
// 简单选项列表
|
||||
const pillsRow = document.createElement("div");
|
||||
pillsRow.className = "pending-overlay-option-pills";
|
||||
(pending.options || []).forEach((opt) => {
|
||||
const pill = document.createElement("button");
|
||||
pill.type = "button";
|
||||
pill.className = "pending-overlay-option-pill";
|
||||
pill.textContent = String(opt);
|
||||
pill.addEventListener("click", () => {
|
||||
const prev = pillsRow.querySelector(".pending-overlay-option-pill--selected");
|
||||
if (prev === pill) { pill.classList.remove("pending-overlay-option-pill--selected"); return; }
|
||||
if (prev) prev.classList.remove("pending-overlay-option-pill--selected");
|
||||
pill.classList.add("pending-overlay-option-pill--selected");
|
||||
});
|
||||
pillsRow.appendChild(pill);
|
||||
});
|
||||
cardBody.appendChild(pillsRow);
|
||||
}
|
||||
|
||||
// 跳过按钮
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.className = "pending-overlay-btn-row";
|
||||
btnRow.appendChild(makeDecisionBtn("跳过", "skip", "", () => submitSingle(pending.id, "skip", "", card)));
|
||||
cardBody.appendChild(btnRow);
|
||||
}
|
||||
|
||||
card.appendChild(cardBody);
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集所有 selection 类型 pending 的答案并提交
|
||||
*/
|
||||
function submitAllPending(overlay, pendingActions) {
|
||||
const results = {};
|
||||
|
||||
pendingActions.forEach((pending) => {
|
||||
if (pending.type === "tool_call") return;
|
||||
const card = overlay.querySelector(`[data-pending-id="${pending.id}"]`);
|
||||
if (!card) return;
|
||||
|
||||
const interactionType = pending.interaction_type || "information";
|
||||
if (interactionType === "information") {
|
||||
const textarea = card.querySelector(".pending-overlay-textarea");
|
||||
results[pending.id] = textarea ? textarea.value.trim() : "";
|
||||
} else if (interactionType === "selection") {
|
||||
if (pending.questions && Array.isArray(pending.questions)) {
|
||||
const qResponses = {};
|
||||
const questionBlocks = card.querySelectorAll(".pending-overlay-question");
|
||||
pending.questions.forEach((q, qIdx) => {
|
||||
const qBlock = questionBlocks[qIdx];
|
||||
if (!qBlock) return;
|
||||
const otherInput = qBlock.querySelector(".pending-overlay-other-input");
|
||||
let answer = otherInput ? otherInput.value.trim() : "";
|
||||
if (!answer) {
|
||||
const selectedPill = qBlock.querySelector(".pending-overlay-option-pill--selected");
|
||||
if (selectedPill) answer = selectedPill.textContent || "";
|
||||
}
|
||||
qResponses[q.question || ""] = answer;
|
||||
});
|
||||
results[pending.id] = JSON.stringify(qResponses);
|
||||
} else {
|
||||
const selectedPill = card.querySelector(".pending-overlay-option-pill--selected");
|
||||
results[pending.id] = selectedPill ? selectedPill.textContent || "" : "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const submitNext = (idx) => {
|
||||
if (idx >= pendingActions.length) return;
|
||||
const pending = pendingActions[idx];
|
||||
if (pending.type === "tool_call") { submitNext(idx + 1); return; }
|
||||
const card = overlay.querySelector(`[data-pending-id="${pending.id}"]`);
|
||||
const answer = results[pending.id] || "";
|
||||
const instruction = typeof answer === "string" ? answer : JSON.stringify(answer);
|
||||
handleHumanAction(pending.id, "edit", instruction, card).then(() => {
|
||||
if (card) card.classList.add("pending-overlay-card--resolved");
|
||||
setTimeout(() => submitNext(idx + 1), 100);
|
||||
}).catch((e) => {
|
||||
showToast(e.message || String(e));
|
||||
});
|
||||
};
|
||||
|
||||
submitNext(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单独提交一个 pending_action
|
||||
*/
|
||||
function submitSingle(approvalId, decision, instruction, card) {
|
||||
handleHumanAction(approvalId, decision, instruction, card).then(() => {
|
||||
if (card) card.classList.add("pending-overlay-card--resolved");
|
||||
}).catch((e) => {
|
||||
showToast(e.message || String(e));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新浮窗:直接接收 pending_actions 数组
|
||||
* @param {Array|null} pendingActions - API 响应的 pending_actions 数组
|
||||
*/
|
||||
function updatePendingOverlay(pendingActions) {
|
||||
_updateBadge(!!(pendingActions && pendingActions.length));
|
||||
if (!pendingActions || !pendingActions.length) {
|
||||
clearPendingOverlay();
|
||||
return;
|
||||
}
|
||||
const overlay = buildPendingOverlay(pendingActions);
|
||||
if (!overlay) return;
|
||||
|
||||
const container = getOverlayContainer();
|
||||
if (!container) return;
|
||||
const existing = container.querySelector(".pending-overlay");
|
||||
if (existing && existing !== overlay) existing.remove();
|
||||
if (!overlay.parentNode) container.appendChild(overlay);
|
||||
updateOverlayLayout();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 pending 浮窗
|
||||
*/
|
||||
function clearPendingOverlay() {
|
||||
_updateBadge(false);
|
||||
state._pendingHumanAction = false;
|
||||
if (state._currentPendingOverlay) {
|
||||
state._currentPendingOverlay.hidden = true;
|
||||
}
|
||||
document.querySelectorAll(".pending-overlay").forEach((el) => el.hidden = true);
|
||||
updateOverlayLayout();
|
||||
}
|
||||
|
||||
function togglePendingOverlay() {
|
||||
if (state._currentPendingOverlay) {
|
||||
state._currentPendingOverlay.hidden = !state._currentPendingOverlay.hidden;
|
||||
} else {
|
||||
const el = document.querySelector(".pending-overlay");
|
||||
if (el) {
|
||||
el.hidden = !el.hidden;
|
||||
state._currentPendingOverlay = el;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 overlay 挂载容器(与 todo overlay 相同层级)
|
||||
*/
|
||||
function getOverlayContainer() {
|
||||
const chatMessages = $("chatMessages");
|
||||
const container = chatMessages ? (chatMessages.closest(".chat-area") || chatMessages.parentElement) : null;
|
||||
if (!container) {
|
||||
return document.body;
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 overlay 布局(与 todo 浮窗并排)
|
||||
*/
|
||||
function updateOverlayLayout() {
|
||||
const todoOverlay = document.querySelector(".todo-list-overlay");
|
||||
const pendingOverlay = document.querySelector(".pending-overlay");
|
||||
const todoVisible = todoOverlay && !todoOverlay.hidden;
|
||||
const pendingVisible = pendingOverlay && !pendingOverlay.hidden;
|
||||
|
||||
if (todoVisible && pendingVisible) {
|
||||
todoOverlay.classList.add("todo-list-overlay--dual");
|
||||
pendingOverlay.classList.add("pending-overlay--dual");
|
||||
} else {
|
||||
if (todoOverlay) todoOverlay.classList.remove("todo-list-overlay--dual");
|
||||
if (pendingOverlay) pendingOverlay.classList.remove("pending-overlay--dual");
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
buildPendingOverlay,
|
||||
updatePendingOverlay,
|
||||
clearPendingOverlay,
|
||||
togglePendingOverlay,
|
||||
updateOverlayLayout,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
// pending.js -- 待确认决策处理(卡片渲染已迁移至 pending-overlay.js)
|
||||
|
||||
import { $ } from './utils.js';
|
||||
import { api } from './api.js';
|
||||
import { startPollLiveToolCalls } from './toolcalls.js';
|
||||
import { state } from './state.js';
|
||||
import { updatePendingOverlay } from './pending-overlay.js';
|
||||
|
||||
let renderSessionsFn = null;
|
||||
let appendThinkingIndicatorFn = null;
|
||||
let renderMessagesFn = null;
|
||||
let streamAssistantMessageFn = null;
|
||||
let injectLiveToolCallsFn = null;
|
||||
let scrollChatToBottomFn = null;
|
||||
|
||||
export function setRenderFnDeps(deps) {
|
||||
renderSessionsFn = deps.renderSessions;
|
||||
appendThinkingIndicatorFn = deps.appendThinkingIndicator;
|
||||
renderMessagesFn = deps.renderMessages;
|
||||
streamAssistantMessageFn = deps.streamAssistantMessage;
|
||||
injectLiveToolCallsFn = deps.injectLiveToolCalls;
|
||||
scrollChatToBottomFn = deps.scrollChatToBottom;
|
||||
}
|
||||
|
||||
async function handleHumanAction(approvalId, decision, instruction, card) {
|
||||
if (!approvalId || state.sending) return;
|
||||
state.sending = true;
|
||||
const sendBtn = $("sendBtn");
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
if (card) card.classList.add("pending-action-card--resolved");
|
||||
const outputModeTrigger = $("outputModeTrigger");
|
||||
|
||||
// 复用 sendChat 留下的现有 thinking 气泡(不删除不重建)
|
||||
const chatMessages = $("chatMessages");
|
||||
let typingEl = chatMessages ? chatMessages.querySelector(".msg-typing") : null;
|
||||
if (!typingEl) {
|
||||
// 兜底:如果没有现有气泡(如页面刷新后),创建一个新的
|
||||
typingEl = appendThinkingIndicatorFn ? appendThinkingIndicatorFn() : null;
|
||||
}
|
||||
|
||||
// 将 pending 工具调用标记为完成,带上用户输入的结果
|
||||
if (typingEl && injectLiveToolCallsFn) {
|
||||
const resultText = instruction
|
||||
|| (decision === "reject" ? "用户拒绝" : decision === "skip" ? "用户已跳过" : "用户已确认");
|
||||
injectLiveToolCallsFn(typingEl, [{
|
||||
name: "human_interaction",
|
||||
status: "done",
|
||||
result_text: resultText,
|
||||
}]);
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
fd.append("approval_id", approvalId);
|
||||
fd.append("decision", decision);
|
||||
fd.append("instruction", instruction || "");
|
||||
fd.append("output_type", outputModeTrigger ? (outputModeTrigger.dataset.value || "text") : "text");
|
||||
|
||||
const stopToolPoll = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn);
|
||||
|
||||
try {
|
||||
const done = await api("/api/human-action", { method: "POST", body: fd });
|
||||
stopToolPoll();
|
||||
// Update state
|
||||
state.sessionId = done.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(done.sessions || []));
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
const messages = done.messages || [];
|
||||
const last = messages[messages.length - 1];
|
||||
const hasNewPending = done.pending_actions && done.pending_actions.length > 0;
|
||||
|
||||
if (last && last.role === "assistant") {
|
||||
// 最终回复:移除 thinking 气泡,渲染 assistant
|
||||
if (typingEl && typingEl.parentNode) typingEl.remove();
|
||||
typingEl = null;
|
||||
if (renderMessagesFn) renderMessagesFn(messages.slice(0, -1));
|
||||
if (streamAssistantMessageFn) await streamAssistantMessageFn(last);
|
||||
} else if (hasNewPending) {
|
||||
// 又产生了 pending:保留 thinking 气泡,更新浮窗
|
||||
// typingEl 不删除,继续用于下一次交互
|
||||
} else {
|
||||
if (typingEl && typingEl.parentNode) typingEl.remove();
|
||||
typingEl = null;
|
||||
if (renderMessagesFn) renderMessagesFn(messages);
|
||||
}
|
||||
updatePendingOverlay(done.pending_actions);
|
||||
state._pendingHumanAction = hasNewPending;
|
||||
} finally {
|
||||
if (typingEl && typingEl.parentNode && !state._pendingHumanAction) {
|
||||
typingEl.remove();
|
||||
}
|
||||
state.sending = false;
|
||||
if (!state._pendingHumanAction) {
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.innerHTML = '<img src="/image/发送.svg" alt="" aria-hidden="true" class="send-btn-icon" />';
|
||||
sendBtn.classList.remove("btn-pause");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
handleHumanAction,
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
// rollback.js -- 统一回撤机制
|
||||
// Chat 模式回撤:清空 todolist / 人机交互,回滚历史,恢复输入区,清理本地 JSON 记录,docSnapshots 回退
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import { clearPendingOverlay } from './pending-overlay.js';
|
||||
|
||||
// 依赖注入
|
||||
let renderMessagesFn = null;
|
||||
let renderSessionsFn = null;
|
||||
let renderAttachmentChipsFn = null;
|
||||
let clearTodoOverlayFn = null;
|
||||
let undoDocSnapshotFn = null;
|
||||
let getDocSnapshotsFn = null;
|
||||
let saveDocSnapshotFn = null;
|
||||
let setDocContentFn = null;
|
||||
let getDocContentFn = null;
|
||||
let renderDocTreeFn = null;
|
||||
let renderDocTabsFn = null;
|
||||
let loadDocContentFn = null;
|
||||
let setComposerCenteredFn = null;
|
||||
|
||||
export function setRollbackDeps(deps) {
|
||||
renderMessagesFn = deps.renderMessages;
|
||||
renderSessionsFn = deps.renderSessions;
|
||||
renderAttachmentChipsFn = deps.renderAttachmentChips;
|
||||
clearTodoOverlayFn = deps.clearTodoOverlay;
|
||||
undoDocSnapshotFn = deps.undoDocSnapshot;
|
||||
getDocSnapshotsFn = deps.getDocSnapshots;
|
||||
saveDocSnapshotFn = deps.saveDocSnapshot;
|
||||
setDocContentFn = deps.setDocContent;
|
||||
getDocContentFn = deps.getDocContent;
|
||||
renderDocTreeFn = deps.renderDocTree;
|
||||
renderDocTabsFn = deps.renderDocTabs;
|
||||
loadDocContentFn = deps.loadDocContent;
|
||||
setComposerCenteredFn = deps.setComposerCentered;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Chat 模式回撤
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 将聊天历史回撤到指定消息处
|
||||
* @param {number} rollbackIndex - 要回退到的消息在渲染列表中的索引(包含该消息)
|
||||
*/
|
||||
export async function rollbackChat(rollbackIndex) {
|
||||
// 保护:Agent 运行中不允许回撤
|
||||
if (state.sending) {
|
||||
showToast("Agent 运行中,无法回撤。请先暂停任务。");
|
||||
return;
|
||||
}
|
||||
if (!state.sessionId) {
|
||||
showToast("无当前会话");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await showConfirm(
|
||||
"确认回退到此消息?\n\n将清除此消息之后的所有对话历史、TodoList、人机交互内容、工具调用记录。"
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
// 1. 调用后端回撤 API
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
fd.append("rollback_index", rollbackIndex);
|
||||
const data = await api("/api/chat/rollback-to-message", { method: "POST", body: fd });
|
||||
|
||||
// 2. 更新会话状态
|
||||
state.sessionId = data.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
|
||||
// 3. 清除 TodoList 和 人机交互 浮窗
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
clearPendingOverlay();
|
||||
|
||||
// 4. 回撤文件修改(通过 docSnapshots)
|
||||
await rollbackDocSnapshots();
|
||||
|
||||
// 5. 刷新视图
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages || []);
|
||||
|
||||
// 6. 恢复回撤点用户消息到输入区
|
||||
const rollbackMsg = data.rollbackMessage;
|
||||
if (rollbackMsg && rollbackMsg.role === "user") {
|
||||
restoreUserInput(rollbackMsg);
|
||||
}
|
||||
|
||||
showToast("已回退到该消息");
|
||||
} catch (e) {
|
||||
showToast("回退失败: " + (e.message || "未知错误"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退 docSnapshots,恢复到文件修改前的状态
|
||||
*/
|
||||
async function rollbackDocSnapshots() {
|
||||
if (!undoDocSnapshotFn || !getDocSnapshotsFn) return;
|
||||
// 如果不在 edit 模式或没有打开文件夹,跳过
|
||||
if (!state.editMode || !state._docRootPath) return;
|
||||
|
||||
const snapshotsCount = (getDocSnapshotsFn() || []).length;
|
||||
if (snapshotsCount === 0) return;
|
||||
|
||||
// 撤销所有待处理的快照
|
||||
let undone = 0;
|
||||
const maxUndo = snapshotsCount;
|
||||
while (undone < maxUndo && (getDocSnapshotsFn() || []).length > 0) {
|
||||
undoDocSnapshotFn();
|
||||
undone++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将回撤点的用户消息内容恢复到输入区
|
||||
*/
|
||||
function restoreUserInput(msg) {
|
||||
const textInput = $("textInput");
|
||||
if (!textInput) return;
|
||||
|
||||
// 恢复文本
|
||||
if (typeof msg.content === "string") {
|
||||
textInput.value = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
const textParts = msg.content
|
||||
.filter((p) => p && p.type === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join("\n");
|
||||
textInput.value = textParts;
|
||||
}
|
||||
|
||||
textInput.focus();
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// send.js -- 发送消息 / 暂停任务
|
||||
|
||||
import { $, inferUploadKind, buildOptimisticUserContent, revokeBlobUrlStack, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { api, streamApi } from './api.js';
|
||||
import { startPollLiveToolCalls } from './toolcalls.js';
|
||||
import { state } from './state.js';
|
||||
import { updatePendingOverlay, clearPendingOverlay } from './pending-overlay.js';
|
||||
import { renderVoiceTranscribingPlaceholder } from './chat-render.js';
|
||||
|
||||
let renderSessionsFn = null;
|
||||
let renderMessagesFn = null;
|
||||
let renderAttachmentChipsFn = null;
|
||||
let appendThinkingIndicatorFn = null;
|
||||
let injectLiveToolCallsFn = null;
|
||||
let updateTodoOverlayFn = null;
|
||||
let clearTodoOverlayFn = null;
|
||||
let showPendingActionInThinkingFn = null;
|
||||
let streamAssistantMessageFn = null;
|
||||
let onSendStartFn = null;
|
||||
let _audioAbort = null; // 音频流的独立 AbortController,供打断用
|
||||
|
||||
/** 清理思考气泡:停止内部计时器后移除 DOM */
|
||||
function _cleanupTypingEl(el) {
|
||||
if (!el) return;
|
||||
const wrap = el.querySelector(".tool-call-list-wrap");
|
||||
if (wrap && typeof wrap._stopTimer === "function") wrap._stopTimer();
|
||||
if (el.parentNode) el.remove();
|
||||
}
|
||||
let _audioGeneration = 0; // 递增计数器,旧回调检测到不匹配后自动忽略
|
||||
let _sendGeneration = 0; // 递增计数器,防止旧 sendChat 的 finally/catch 污染共享状态
|
||||
|
||||
// ======================== 流式 TTS Web Audio 播放 ========================
|
||||
|
||||
/** 全局播放上下文 */
|
||||
let _ttsContext = null;
|
||||
|
||||
function _ensureTtsContext(sampleRate) {
|
||||
if (!_ttsContext || _ttsContext.sampleRate !== sampleRate) {
|
||||
if (_ttsContext) {
|
||||
try { _ttsContext.close(); } catch { /* ignore */ }
|
||||
}
|
||||
_ttsContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate });
|
||||
}
|
||||
if (_ttsContext.state === "suspended") {
|
||||
_ttsContext.resume();
|
||||
}
|
||||
return _ttsContext;
|
||||
}
|
||||
|
||||
function _stopAllTtsPlayback() {
|
||||
if (_ttsContext) {
|
||||
try { _ttsContext.close(); } catch { /* ignore */ }
|
||||
_ttsContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放 PCM f32le base64 音频块
|
||||
* @param {AudioContext} ctx
|
||||
* @param {string} b64Data - base64 编码的 f32le 数据
|
||||
* @param {number} sampleRate
|
||||
* @param {number} startTime - 从何时开始播放(AudioContext.currentTime)
|
||||
* @returns {number} 该块的时长(秒)
|
||||
*/
|
||||
function _playPcmChunk(ctx, b64Data, sampleRate, startTime) {
|
||||
const raw = Uint8Array.from(atob(b64Data), c => c.charCodeAt(0));
|
||||
const float32 = new Float32Array(raw.buffer);
|
||||
const buffer = ctx.createBuffer(1, float32.length, sampleRate);
|
||||
buffer.getChannelData(0).set(float32);
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
const s = Math.max(startTime, ctx.currentTime + 0.01);
|
||||
source.start(s);
|
||||
return float32.length / sampleRate;
|
||||
}
|
||||
|
||||
export function setSendDeps(deps) {
|
||||
renderSessionsFn = deps.renderSessions;
|
||||
renderMessagesFn = deps.renderMessages;
|
||||
renderAttachmentChipsFn = deps.renderAttachmentChips;
|
||||
appendThinkingIndicatorFn = deps.appendThinkingIndicator;
|
||||
injectLiveToolCallsFn = deps.injectLiveToolCalls;
|
||||
updateTodoOverlayFn = deps.updateTodoOverlay;
|
||||
clearTodoOverlayFn = deps.clearTodoOverlay;
|
||||
showPendingActionInThinkingFn = deps.showPendingActionInThinking;
|
||||
streamAssistantMessageFn = deps.streamAssistantMessage;
|
||||
onSendStartFn = deps.onSendStart;
|
||||
}
|
||||
|
||||
const textInput = $("textInput");
|
||||
const fileInput = $("fileInput");
|
||||
const sendBtn = $("sendBtn");
|
||||
const outputModeTrigger = $("outputModeTrigger");
|
||||
const agentModeTrigger = $("agentModeTrigger");
|
||||
|
||||
function setSendBtnPauseMode(pauseMode) {
|
||||
const btn = sendBtn || $("sendBtn");
|
||||
if (!btn) return;
|
||||
if (pauseMode) {
|
||||
btn.classList.add("btn-pause");
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="4" height="12" rx="1"/><rect x="9" y="2" width="4" height="12" rx="1"/></svg>';
|
||||
} else {
|
||||
btn.classList.remove("btn-pause");
|
||||
btn.innerHTML = '<img src="/image/发送.svg" alt="" aria-hidden="true" class="send-btn-icon" />';
|
||||
}
|
||||
}
|
||||
|
||||
let _lastSendTime = 0;
|
||||
let _pausing = false; // 防重入:防止 handlePauseClick 被并发调用导致多个确认对话框堆叠
|
||||
|
||||
async function handlePauseClick() {
|
||||
if (!state.sessionId || !state.sending || !state.abortController) return;
|
||||
// 防止发送后立即连击触发暂停
|
||||
if (Date.now() - _lastSendTime < 600) return;
|
||||
// 防止并发调用(例如双击导致多个 showConfirm 对话框堆叠)
|
||||
if (_pausing) return;
|
||||
_pausing = true;
|
||||
try {
|
||||
if (!await showConfirm("确认暂停当前任务?\n\n当前轮已完成的工具调用和思考将被保留,本轮以「已被用户手动暂停」结束。")) {
|
||||
_pausing = false;
|
||||
return;
|
||||
}
|
||||
// 先中断正在进行的请求,避免 sendChat 的 AbortError handler 与后续渲染产生竞态
|
||||
if (state.abortController) {
|
||||
state.abortController.abort();
|
||||
state.abortController = null;
|
||||
}
|
||||
// 通知后端结束本轮(保留已完成记录,追加暂停消息)
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
const data = await api("/api/chat/abort", { method: "POST", body: fd });
|
||||
state.sessionId = data.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
clearPendingOverlay();
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages || []);
|
||||
} catch (e) {
|
||||
// 回退
|
||||
}
|
||||
state.sending = false;
|
||||
setSendBtnPauseMode(false);
|
||||
const pauseBtn = sendBtn || $("sendBtn");
|
||||
if (pauseBtn) pauseBtn.disabled = false;
|
||||
_pausing = false;
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
if (!state.sessionId || state.sending) return;
|
||||
const myGen = ++_sendGeneration; // 标记本次 sendChat,防止旧实例的 finally/catch 污染状态
|
||||
const trimmed = (textInput || {}).value ? textInput.value.trim() : "";
|
||||
if (!trimmed && state.pendingFiles.length === 0) return;
|
||||
// 首次发送时,输入框从居中移至底部
|
||||
if (onSendStartFn) onSendStartFn();
|
||||
_lastSendTime = Date.now();
|
||||
state.sending = true;
|
||||
const sBtn = sendBtn || $("sendBtn");
|
||||
if (sBtn) sBtn.disabled = true;
|
||||
setSendBtnPauseMode(true);
|
||||
|
||||
// Agent 运行中锁定会话UI:禁止切换/新建会话,但允许切换到 edit 列表
|
||||
const nsb = $("newSessionBtn");
|
||||
if (nsb) { nsb.disabled = true; nsb.style.pointerEvents = "none"; nsb.style.opacity = "0.35"; }
|
||||
const sl = $("sessionList");
|
||||
if (sl) sl.classList.add("session-locked");
|
||||
|
||||
// 任务前文件快照(不阻塞 UI 更新)
|
||||
try { const m = await import('./edit-mode.js'); m.snapshotFilesForTask(); } catch {}
|
||||
|
||||
const savedTextRaw = textInput ? textInput.value : "";
|
||||
const savedFiles = state.pendingFiles.slice();
|
||||
// 分离语音输入(麦克风录音)和普通文件(含上传的音频文件),排除文件夹占位
|
||||
const voiceInputFile = savedFiles.find((f) => f.__voiceInput) || null;
|
||||
const regularFiles = savedFiles.filter((f) => !f.__voiceInput && !f.__isRef && !(f.type && f.type.startsWith("folder/")));
|
||||
const hasVoiceInput = !!voiceInputFile;
|
||||
const priorMessages = JSON.parse(JSON.stringify(state.lastRenderedMessages));
|
||||
const blobUrls = [];
|
||||
const hadOptimisticMedia = regularFiles.length > 0 || hasVoiceInput;
|
||||
let sessionIdAtStart = state.sessionId;
|
||||
// 清除上次遗留的 human interaction 状态
|
||||
state._pendingHumanAction = false;
|
||||
clearPendingOverlay();
|
||||
|
||||
// 打断正在播放的音频
|
||||
_audioGeneration++; // 使旧回调失效
|
||||
_stopAllTtsPlayback();
|
||||
if (_audioAbort) {
|
||||
_audioAbort.abort();
|
||||
_audioAbort = null;
|
||||
}
|
||||
|
||||
if (hadOptimisticMedia) {
|
||||
// 构建乐观渲染内容(普通文件 + 可选文本)
|
||||
const fileParts = regularFiles.map((f) => {
|
||||
const kind = inferUploadKind(f);
|
||||
if (kind === "image" || kind === "audio") {
|
||||
const u = URL.createObjectURL(f);
|
||||
blobUrls.push(u);
|
||||
return { kind, url: u, name: f.name };
|
||||
}
|
||||
return { kind, name: f.name };
|
||||
});
|
||||
|
||||
if (hasVoiceInput) {
|
||||
// 语音输入:先渲染已有消息(含普通文件 + 用户输入的文本),再追加"语音转文字中"占位
|
||||
const optimisticContent = buildOptimisticUserContent(trimmed, fileParts);
|
||||
if (renderMessagesFn) {
|
||||
if (optimisticContent != null) {
|
||||
renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]);
|
||||
} else {
|
||||
renderMessagesFn(priorMessages);
|
||||
}
|
||||
}
|
||||
renderVoiceTranscribingPlaceholder();
|
||||
} else {
|
||||
// 仅普通文件:乐观渲染图片/文件
|
||||
const optimisticContent = buildOptimisticUserContent(trimmed, fileParts);
|
||||
if (optimisticContent != null && renderMessagesFn) {
|
||||
renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]);
|
||||
}
|
||||
}
|
||||
if (textInput) textInput.value = "";
|
||||
state.pendingFiles.length = 0;
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
} else if (trimmed) {
|
||||
// 纯文本消息:立即乐观渲染用户消息,避免等待 API /start 响应
|
||||
if (renderMessagesFn) {
|
||||
renderMessagesFn([...priorMessages, { role: "user", content: trimmed }]);
|
||||
}
|
||||
if (textInput) textInput.value = "";
|
||||
}
|
||||
|
||||
state.abortController = new AbortController();
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
fd.append("text", trimmed);
|
||||
// 普通文件追加到 files 字段;语音输入单独走 voice_input 字段(后端阻塞 ASR)
|
||||
regularFiles.forEach((f) => fd.append("files", f, f.name));
|
||||
if (voiceInputFile) {
|
||||
fd.append("voice_input", voiceInputFile, voiceInputFile.name);
|
||||
}
|
||||
let typingEl = null;
|
||||
try {
|
||||
const startData = await api("/api/chat/start", { method: "POST", body: fd });
|
||||
|
||||
state.sessionId = startData.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(startData.sessions || []));
|
||||
if (!hadOptimisticMedia) {
|
||||
if (textInput) textInput.value = "";
|
||||
state.pendingFiles.length = 0;
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
}
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
const _isVoice = (outputModeTrigger && outputModeTrigger.dataset.value === "voice");
|
||||
// 语音模式下只追加用户消息气泡(保留旧消息),完整列表等流式 text 事件再刷新
|
||||
// 但若已通过乐观渲染显示了用户消息(含语音输入占位),则重新渲染全部消息以替换占位
|
||||
if (_isVoice && renderMessagesFn && !hadOptimisticMedia) {
|
||||
const startMsgs = startData.messages || [];
|
||||
const userMsg = startMsgs[startMsgs.length - 1];
|
||||
if (userMsg && userMsg.role === "user") {
|
||||
const current = (state.lastRenderedMessages || []).slice();
|
||||
current.push(userMsg);
|
||||
renderMessagesFn(current);
|
||||
}
|
||||
} else if (renderMessagesFn) {
|
||||
// 包括:文本模式、或语音输出模式但有乐观渲染/语音输入占位需要替换
|
||||
renderMessagesFn(startData.messages || []);
|
||||
}
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
// 移除旧思考气泡(停止计时器后移除),避免双份
|
||||
document.querySelectorAll('.msg-typing').forEach(el => _cleanupTypingEl(el));
|
||||
typingEl = appendThinkingIndicatorFn ? appendThinkingIndicatorFn() : null;
|
||||
|
||||
// Reset tool call expansion state
|
||||
state.liveToolCallExpanded = false;
|
||||
state.thinkExpanded = false;
|
||||
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
clearPendingOverlay();
|
||||
|
||||
// 启动实时工具调用与反思轮询
|
||||
const stopToolPoll = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn);
|
||||
|
||||
// 启动 todo_list 实时轮询
|
||||
let todoPollActive = true;
|
||||
let todoPollTimer = null;
|
||||
const pollTodoList = async () => {
|
||||
if (!todoPollActive) return;
|
||||
try {
|
||||
const todoData = await api(`/api/todo-list/${encodeURIComponent(state.sessionId)}`);
|
||||
if (todoData && todoData.has_todo && todoData.data && updateTodoOverlayFn) {
|
||||
updateTodoOverlayFn(todoData.data);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (todoPollActive) todoPollTimer = setTimeout(pollTodoList, 2000);
|
||||
};
|
||||
pollTodoList();
|
||||
|
||||
const fd2 = new FormData();
|
||||
fd2.append("session_id", state.sessionId);
|
||||
fd2.append("output_type", outputModeTrigger ? (outputModeTrigger.dataset.value || "text") : "text");
|
||||
fd2.append("agent_mode", agentModeTrigger ? (agentModeTrigger.dataset.value || "agent") : "agent");
|
||||
if (state.selectedModelId) {
|
||||
fd2.append("model_id", state.selectedModelId);
|
||||
}
|
||||
sessionIdAtStart = state.sessionId;
|
||||
|
||||
if (_isVoice) {
|
||||
// ====== 流式 TTS ======
|
||||
_stopAllTtsPlayback();
|
||||
const gen = ++_audioGeneration; // 当前请求的 generation,旧回调检测到不匹配就忽略
|
||||
let textRendered = false;
|
||||
let streamSessionId = sessionIdAtStart;
|
||||
let streamSessions = [];
|
||||
let streamPending = [];
|
||||
let nextPlayTime = 0;
|
||||
let sampleRate = 24000;
|
||||
let ctx = null;
|
||||
|
||||
// 用独立的 AbortController 控制音频流
|
||||
_audioAbort = state.abortController;
|
||||
|
||||
const streamPromise = streamApi("/api/chat/stream", {
|
||||
method: "POST",
|
||||
body: fd2,
|
||||
signal: state.abortController.signal,
|
||||
onEvent: (event) => {
|
||||
if (state.sessionId !== sessionIdAtStart) return;
|
||||
if (_audioGeneration !== gen) return; // 被新请求打断,忽略旧回调
|
||||
|
||||
if (event.type === "text" && !textRendered) {
|
||||
textRendered = true;
|
||||
streamSessionId = event.sessionId || sessionIdAtStart;
|
||||
streamSessions = event.sessions || [];
|
||||
streamPending = event.pending_actions || [];
|
||||
const msgs = event.messages || [];
|
||||
const last = msgs[msgs.length - 1];
|
||||
|
||||
if (last && last.role === "assistant") {
|
||||
const head = msgs.slice(0, -1);
|
||||
if (renderMessagesFn) renderMessagesFn(head);
|
||||
// 同步 lastRenderedMessages 为完整消息列表,确保后续打断时 assistant 消息不丢失
|
||||
state.lastRenderedMessages.length = 0;
|
||||
state.lastRenderedMessages.push(...JSON.parse(JSON.stringify(msgs)));
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
if (streamAssistantMessageFn) streamAssistantMessageFn(last);
|
||||
} else {
|
||||
if (renderMessagesFn) renderMessagesFn(msgs);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
}
|
||||
|
||||
// 文字渲染完毕,释放按钮
|
||||
state.sending = false;
|
||||
setSendBtnPauseMode(false);
|
||||
const vBtn = sendBtn || $("sendBtn");
|
||||
if (vBtn) vBtn.disabled = false;
|
||||
|
||||
state.sessionId = streamSessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...streamSessions);
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
updatePendingOverlay(streamPending);
|
||||
if (streamPending && streamPending.length > 0) {
|
||||
state._pendingHumanAction = true;
|
||||
}
|
||||
|
||||
stopToolPoll();
|
||||
todoPollActive = false;
|
||||
if (todoPollTimer) clearTimeout(todoPollTimer);
|
||||
if (typingEl) _cleanupTypingEl(typingEl);
|
||||
typingEl = null;
|
||||
}
|
||||
|
||||
if (event.type === "audio.start") {
|
||||
sampleRate = event.sample_rate || 24000;
|
||||
ctx = _ensureTtsContext(sampleRate);
|
||||
nextPlayTime = ctx.currentTime;
|
||||
}
|
||||
if (event.type === "audio.chunk" && event.data) {
|
||||
if (!ctx) ctx = _ensureTtsContext(sampleRate);
|
||||
nextPlayTime += _playPcmChunk(ctx, event.data, sampleRate, nextPlayTime);
|
||||
}
|
||||
if (event.type === "audio.error") {
|
||||
_stopAllTtsPlayback();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await streamPromise;
|
||||
} catch (e) {
|
||||
if (_audioGeneration !== gen) {
|
||||
// 已被新请求替代,清理残留
|
||||
if (typingEl) _cleanupTypingEl(typingEl);
|
||||
return;
|
||||
}
|
||||
if (!(e && e.name === "AbortError")) {
|
||||
_stopAllTtsPlayback();
|
||||
}
|
||||
}
|
||||
if (_audioAbort === state.abortController) _audioAbort = null;
|
||||
|
||||
} else {
|
||||
// ====== 普通文字模式 ======
|
||||
const done = await api("/api/chat/complete", { method: "POST", body: fd2, signal: state.abortController.signal });
|
||||
|
||||
if (state.sessionId !== sessionIdAtStart) {
|
||||
stopToolPoll();
|
||||
todoPollActive = false;
|
||||
if (todoPollTimer) clearTimeout(todoPollTimer);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const todoData = await api(`/api/todo-list/${encodeURIComponent(state.sessionId)}`);
|
||||
if (todoData && todoData.has_todo && todoData.data && updateTodoOverlayFn) {
|
||||
updateTodoOverlayFn(todoData.data);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
stopToolPoll();
|
||||
todoPollActive = false;
|
||||
if (todoPollTimer) clearTimeout(todoPollTimer);
|
||||
|
||||
state.sessionId = done.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(done.sessions || []));
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
|
||||
const messages = done.messages || [];
|
||||
const last = messages[messages.length - 1];
|
||||
const hasPending = done.pending_actions && done.pending_actions.length > 0;
|
||||
|
||||
if (last && last.role === "assistant" && last.meta && last.meta.pending_action && typingEl) {
|
||||
if (showPendingActionInThinkingFn) showPendingActionInThinkingFn(typingEl, last);
|
||||
updatePendingOverlay(done.pending_actions);
|
||||
typingEl = null;
|
||||
} else if (hasPending && typingEl) {
|
||||
state._pendingHumanAction = true;
|
||||
// typingEl 中已有的 live 工具调用记录保持不变(由轮询积累),
|
||||
// 不调用 injectLiveToolCallsFn 以避免清除已展示的工具调用
|
||||
updatePendingOverlay(done.pending_actions);
|
||||
} else {
|
||||
if (typingEl) _cleanupTypingEl(typingEl);
|
||||
typingEl = null;
|
||||
if (last && last.role === "assistant") {
|
||||
const head = messages.slice(0, -1);
|
||||
if (renderMessagesFn) renderMessagesFn(head);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
if (streamAssistantMessageFn) await streamAssistantMessageFn(last);
|
||||
} else {
|
||||
if (renderMessagesFn) renderMessagesFn(messages);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
}
|
||||
updatePendingOverlay(done.pending_actions);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
// 已被新请求替代,静默清理,不做任何渲染
|
||||
if (_sendGeneration !== myGen) {
|
||||
if (typingEl) _cleanupTypingEl(typingEl);
|
||||
return;
|
||||
}
|
||||
// 如果会话已切换,不渲染任何内容
|
||||
if (state.sessionId !== sessionIdAtStart) {
|
||||
if (typingEl) _cleanupTypingEl(typingEl);
|
||||
return;
|
||||
}
|
||||
if (e && e.name === "AbortError") {
|
||||
// 暂停由 handlePauseClick 统一处理渲染,此处不覆盖
|
||||
return;
|
||||
} else if (hadOptimisticMedia) {
|
||||
if (textInput) textInput.value = savedTextRaw;
|
||||
state.pendingFiles.length = 0;
|
||||
state.pendingFiles.push(...savedFiles);
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
if (renderMessagesFn) renderMessagesFn(priorMessages);
|
||||
}
|
||||
if (typingEl) _cleanupTypingEl(typingEl);
|
||||
if (!(e && e.name === "AbortError")) {
|
||||
showToast(e.message || String(e));
|
||||
}
|
||||
} finally {
|
||||
// 仅当本次 sendChat 仍是最新请求时才清理共享状态
|
||||
if (_sendGeneration !== myGen) return;
|
||||
state.sending = false;
|
||||
state.abortController = null;
|
||||
// 解锁会话UI:恢复切换/新建会话能力
|
||||
const nsb2 = $("newSessionBtn");
|
||||
if (nsb2) { nsb2.disabled = false; nsb2.style.pointerEvents = ""; nsb2.style.opacity = ""; }
|
||||
const sl2 = $("sessionList");
|
||||
if (sl2) sl2.classList.remove("session-locked");
|
||||
if (state._pendingHumanAction) {
|
||||
// 保持"暂停"文字和样式,但不禁用按钮
|
||||
const pendBtn = sendBtn || $("sendBtn");
|
||||
if (pendBtn) pendBtn.disabled = false;
|
||||
} else {
|
||||
setSendBtnPauseMode(false);
|
||||
const fBtn = sendBtn || $("sendBtn");
|
||||
if (fBtn) fBtn.disabled = false;
|
||||
}
|
||||
// 延迟检测文件变更(等待文件监听器处理完)
|
||||
setTimeout(() => {
|
||||
try { import('./edit-mode.js').then(m => m.detectTaskFileChanges()); } catch {}
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
export { sendChat, handlePauseClick, setSendBtnPauseMode };
|
||||
@@ -0,0 +1,262 @@
|
||||
// sessions.js -- 会话管理(支持重命名、按项删除)
|
||||
|
||||
import { $ } from './utils.js';
|
||||
import { showConfirm, showPrompt } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import { updatePendingOverlay, clearPendingOverlay } from './pending-overlay.js';
|
||||
import { refreshTokens } from './token-bar.js';
|
||||
|
||||
let renderSessionsFnRef = null;
|
||||
let renderMessagesFn = null;
|
||||
let renderAttachmentChipsFn = null;
|
||||
let clearTodoOverlayFn = null;
|
||||
|
||||
export function setSessionDeps(deps) {
|
||||
renderSessionsFnRef = deps.renderSessions || renderSessions;
|
||||
renderMessagesFn = deps.renderMessages;
|
||||
renderAttachmentChipsFn = deps.renderAttachmentChips;
|
||||
clearTodoOverlayFn = deps.clearTodoOverlay;
|
||||
}
|
||||
|
||||
const sessionList = $("sessionList");
|
||||
const fileInput = $("fileInput");
|
||||
|
||||
// ---------- 会话显示名称(localStorage) ----------
|
||||
const SESSION_NAMES_KEY = "minor_session_names";
|
||||
|
||||
function loadSessionNames() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SESSION_NAMES_KEY) || "{}");
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function saveSessionNames(names) {
|
||||
localStorage.setItem(SESSION_NAMES_KEY, JSON.stringify(names));
|
||||
}
|
||||
|
||||
function getSessionDisplayName(id) {
|
||||
const names = loadSessionNames();
|
||||
return names[id] || id;
|
||||
}
|
||||
|
||||
function setSessionDisplayName(id, name) {
|
||||
const names = loadSessionNames();
|
||||
if (name && name.trim() && name.trim() !== id) {
|
||||
names[id] = name.trim();
|
||||
} else {
|
||||
delete names[id];
|
||||
}
|
||||
saveSessionNames(names);
|
||||
}
|
||||
|
||||
// ---------- 渲染 ----------
|
||||
export function renderSessions() {
|
||||
if (!sessionList) return;
|
||||
sessionList.innerHTML = "";
|
||||
state.sessions.forEach((id) => {
|
||||
const displayName = getSessionDisplayName(id);
|
||||
const isActive = id === state.sessionId;
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.className = "session-item" + (isActive ? " active" : "");
|
||||
li.setAttribute("role", "option");
|
||||
li.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
|
||||
// 名称区域(点击切换会话)
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "session-item-name";
|
||||
nameSpan.textContent = displayName;
|
||||
nameSpan.title = `ID: ${id}`;
|
||||
nameSpan.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
selectSession(id);
|
||||
});
|
||||
|
||||
// 操作图标区域(hover 显示)
|
||||
const actionsDiv = document.createElement("span");
|
||||
actionsDiv.className = "session-item-actions";
|
||||
|
||||
// 编辑图标
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "session-item-action-btn";
|
||||
editBtn.title = "重命名";
|
||||
editBtn.innerHTML = '<img src="/image/编辑.svg" alt="编辑" width="16" height="16" />';
|
||||
editBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
renameSession(id);
|
||||
});
|
||||
|
||||
// 删除图标
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "session-item-action-btn danger";
|
||||
delBtn.title = "删除";
|
||||
delBtn.innerHTML = '<img src="/image/删除.svg" alt="删除" width="16" height="16" />';
|
||||
delBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
deleteSessionItem(id);
|
||||
});
|
||||
|
||||
actionsDiv.appendChild(editBtn);
|
||||
actionsDiv.appendChild(delBtn);
|
||||
|
||||
li.appendChild(nameSpan);
|
||||
li.appendChild(actionsDiv);
|
||||
sessionList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 重命名 ----------
|
||||
async function renameSession(id) {
|
||||
const currentName = getSessionDisplayName(id);
|
||||
const newName = await showPrompt("输入新名称(留空则恢复为 ID)", currentName === id ? "" : currentName);
|
||||
if (newName === null) return; // 用户取消
|
||||
|
||||
setSessionDisplayName(id, newName);
|
||||
renderSessions();
|
||||
}
|
||||
|
||||
// ---------- 按项删除 ----------
|
||||
async function deleteSessionItem(id) {
|
||||
if (!id) return;
|
||||
const displayName = getSessionDisplayName(id);
|
||||
if (!await showConfirm(`确认删除会话「${displayName}」吗?该会话的所有记录和文件都将被永久删除。`)) return;
|
||||
|
||||
const deletingSessionId = id;
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", id);
|
||||
const data = await api("/api/sessions/delete", { method: "POST", body: fd });
|
||||
|
||||
// 如果删除的是当前会话,切换到服务端返回的新会话
|
||||
if (id === state.sessionId) {
|
||||
state.sessionId = data.sessionId;
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages || []);
|
||||
// 清理当前会话的 UI 状态
|
||||
state.pendingFiles.length = 0;
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
clearPendingOverlay();
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
updatePendingOverlay(data.pending_actions || null);
|
||||
}
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []).filter((sid) => sid !== deletingSessionId));
|
||||
|
||||
renderSessions();
|
||||
|
||||
// 清理 localStorage 中的名称记录
|
||||
const names = loadSessionNames();
|
||||
if (names[deletingSessionId]) {
|
||||
delete names[deletingSessionId];
|
||||
saveSessionNames(names);
|
||||
}
|
||||
|
||||
// close drawer on mobile
|
||||
const _ar4 = $("app");
|
||||
if (_ar4 && _ar4.classList.contains("is-mobile")) _ar4.classList.remove("drawer-open");
|
||||
}
|
||||
|
||||
// ---------- 启动 ----------
|
||||
export async function bootstrap() {
|
||||
const data = await api("/api/bootstrap");
|
||||
state.sessionId = data.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
state.currentTokens = data.tokens || 0;
|
||||
if (renderSessionsFnRef) renderSessionsFnRef();
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages);
|
||||
updatePendingOverlay(data.pending_actions || null);
|
||||
// 设置居中状态
|
||||
const msgs = data.messages || [];
|
||||
import('../app.js').then((m) => m.setComposerCentered(msgs.length === 0));
|
||||
}
|
||||
|
||||
async function selectSession(id) {
|
||||
if (!id || id === state.sessionId) return;
|
||||
if (state.sending) return; // Agent 运行中禁止切换会话
|
||||
const data = await api(`/api/sessions/${encodeURIComponent(id)}/messages`);
|
||||
state.sessionId = data.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
state.currentTokens = data.tokens || 0;
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
clearPendingOverlay();
|
||||
if (renderSessionsFnRef) renderSessionsFnRef();
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages);
|
||||
updatePendingOverlay(data.pending_actions || null);
|
||||
refreshTokens();
|
||||
// 设置居中状态
|
||||
const msgs = data.messages || [];
|
||||
import('../app.js').then((m) => m.setComposerCentered(msgs.length === 0));
|
||||
const _ar = $("app");
|
||||
if (_ar && _ar.classList.contains("is-mobile")) _ar.classList.remove("drawer-open");
|
||||
}
|
||||
|
||||
let _creatingSession = false;
|
||||
|
||||
async function newSession() {
|
||||
// 防止并发调用
|
||||
if (_creatingSession) return;
|
||||
if (state.sending) return; // Agent 运行中禁止新建会话
|
||||
_creatingSession = true;
|
||||
try {
|
||||
// 中止正在执行的 Agent 任务,防止其回调覆盖新会话
|
||||
if (state.abortController) {
|
||||
state.abortController.abort();
|
||||
state.abortController = null;
|
||||
}
|
||||
state.sending = false;
|
||||
// 重置发送按钮状态
|
||||
const sendBtn = document.getElementById("sendBtn");
|
||||
if (sendBtn) {
|
||||
sendBtn.classList.remove("btn-pause");
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.innerHTML = '<img src="/image/发送.svg" alt="" aria-hidden="true" class="send-btn-icon" />';
|
||||
}
|
||||
|
||||
// 立即清空聊天区域并将 sessionId 置空,
|
||||
// 这样 sendChat 的 abort catch 中 state.sessionId !== sessionIdAtStart 必为 true,
|
||||
// 不会在 await 期间把旧消息重新渲染到已清空的聊天区
|
||||
state.lastRenderedMessages.length = 0;
|
||||
state.sessionId = "";
|
||||
const cm = document.getElementById("chatMessages");
|
||||
const cp = document.getElementById("chatPlaceholder");
|
||||
if (cm) { cm.innerHTML = ""; cm.hidden = true; cm.style.display = "none"; }
|
||||
if (cp) { cp.hidden = false; }
|
||||
|
||||
const data = await api("/api/sessions/new", { method: "POST" });
|
||||
state.sessionId = data.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
state.currentTokens = 0;
|
||||
state.pendingFiles.length = 0;
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
clearPendingOverlay();
|
||||
if (renderSessionsFnRef) renderSessionsFnRef();
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages || []);
|
||||
// 新会话设为居中状态
|
||||
import('../app.js').then((m) => m.setComposerCentered(true));
|
||||
const _ar2 = $("app");
|
||||
if (_ar2 && _ar2.classList.contains("is-mobile")) _ar2.classList.remove("drawer-open");
|
||||
} finally {
|
||||
_creatingSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 保留旧 deleteSession() 兼容性(删除当前活跃会话)
|
||||
async function deleteSession() {
|
||||
if (!state.sessionId) return;
|
||||
await deleteSessionItem(state.sessionId);
|
||||
}
|
||||
|
||||
async function clearChat() {
|
||||
if (!state.sessionId) return;
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
const data = await api("/api/sessions/clear", { method: "POST", body: fd });
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages);
|
||||
}
|
||||
|
||||
export { newSession, deleteSession, clearChat };
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,299 @@
|
||||
// skills.js -- Skills 管理面板
|
||||
|
||||
import { $, escapeHtml } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
const skillsListEl = $("skillsList");
|
||||
const skillEditorOverlay = $("skillEditorOverlay");
|
||||
const skillEditorTitle = $("skillEditorTitle");
|
||||
const skillNameInput = $("skillNameInput");
|
||||
const skillDescInput = $("skillDescInput");
|
||||
const skillVersionInput = $("skillVersionInput");
|
||||
const skillAuthorInput = $("skillAuthorInput");
|
||||
const skillTagsInput = $("skillTagsInput");
|
||||
const skillContentInput = $("skillContentInput");
|
||||
const skillAttachmentsList = $("skillAttachmentsList");
|
||||
const skillFileInput = $("skillFileInput");
|
||||
const skillEditorStatus = $("skillEditorStatus");
|
||||
const skillEditorSave = $("skillEditorSave");
|
||||
const skillEditorCancel = $("skillEditorCancel");
|
||||
const skillEditorClose = $("skillEditorClose");
|
||||
const skillEnableCheck = $("skillEnableCheck");
|
||||
|
||||
function setSkillEditorStatus(msg, type) {
|
||||
if (!skillEditorStatus) return;
|
||||
skillEditorStatus.textContent = msg;
|
||||
skillEditorStatus.className = "skill-editor-status" + (type ? " " + type : "");
|
||||
}
|
||||
|
||||
function openSkillEditor(skillNameToEdit) {
|
||||
state.editingSkillName = skillNameToEdit || "";
|
||||
state.editingSkillAttachments.length = 0;
|
||||
|
||||
if (state.editingSkillName) {
|
||||
skillEditorTitle.textContent = "\u7F16\u8F91 Skill: " + state.editingSkillName;
|
||||
skillNameInput.value = state.editingSkillName;
|
||||
skillNameInput.disabled = true;
|
||||
api("/api/skills/" + encodeURIComponent(state.editingSkillName)).then((res) => {
|
||||
const s = res.skill;
|
||||
skillDescInput.value = s.description || "";
|
||||
skillVersionInput.value = s.version || "1.0.0";
|
||||
skillAuthorInput.value = s.author || "";
|
||||
skillTagsInput.value = (s.tags || []).join(", ");
|
||||
skillContentInput.value = s.content || "";
|
||||
skillEnableCheck.checked = s.enable !== false;
|
||||
state.editingSkillAttachments.push(...(s.attachment_files || []));
|
||||
renderSkillAttachments();
|
||||
}).catch((e) => {
|
||||
setSkillEditorStatus("\u52A0\u8F7D\u5931\u8D25: " + e.message, "error");
|
||||
});
|
||||
} else {
|
||||
skillEditorTitle.textContent = "\u521B\u5EFA Skill";
|
||||
skillNameInput.value = "";
|
||||
skillNameInput.disabled = false;
|
||||
skillDescInput.value = "";
|
||||
skillVersionInput.value = "1.0.0";
|
||||
skillAuthorInput.value = "";
|
||||
skillTagsInput.value = "";
|
||||
skillContentInput.value = "";
|
||||
skillEnableCheck.checked = true;
|
||||
state.editingSkillAttachments.length = 0;
|
||||
renderSkillAttachments();
|
||||
}
|
||||
setSkillEditorStatus("");
|
||||
skillEditorOverlay.hidden = false;
|
||||
}
|
||||
|
||||
function closeSkillEditor() {
|
||||
skillEditorOverlay.hidden = true;
|
||||
state.editingSkillName = "";
|
||||
state.editingSkillAttachments.length = 0;
|
||||
}
|
||||
|
||||
function renderSkillAttachments() {
|
||||
if (!skillAttachmentsList) return;
|
||||
skillAttachmentsList.innerHTML = "";
|
||||
state.editingSkillAttachments.forEach((fname) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "skill-attachment-item";
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.textContent = fname;
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.textContent = "\u00D7";
|
||||
delBtn.title = "\u5220\u9664\u9644\u4EF6";
|
||||
delBtn.addEventListener("click", async () => {
|
||||
if (!await showConfirm("\u786E\u5B9A\u5220\u9664\u9644\u4EF6 " + fname + " \uFF1F")) return;
|
||||
try {
|
||||
await api("/api/skills/" + encodeURIComponent(state.editingSkillName) + "/attachments/" + encodeURIComponent(fname), { method: "DELETE" });
|
||||
const idx = state.editingSkillAttachments.indexOf(fname);
|
||||
if (idx >= 0) state.editingSkillAttachments.splice(idx, 1);
|
||||
renderSkillAttachments();
|
||||
setSkillEditorStatus("\u9644\u4EF6\u5DF2\u5220\u9664", "success");
|
||||
} catch (e) {
|
||||
setSkillEditorStatus("\u5220\u9664\u5931\u8D25: " + e.message, "error");
|
||||
}
|
||||
});
|
||||
chip.appendChild(nameSpan);
|
||||
chip.appendChild(delBtn);
|
||||
skillAttachmentsList.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSkill() {
|
||||
const name = (skillNameInput.value || "").trim();
|
||||
if (!name) {
|
||||
setSkillEditorStatus("\u540D\u79F0\u4E0D\u80FD\u4E3A\u7A7A", "error");
|
||||
return;
|
||||
}
|
||||
setSkillEditorStatus("\u6B63\u5728\u4FDD\u5B58...", "");
|
||||
const formData = new FormData();
|
||||
formData.append("name", name);
|
||||
formData.append("description", skillDescInput.value);
|
||||
formData.append("content", skillContentInput.value);
|
||||
formData.append("version", skillVersionInput.value);
|
||||
formData.append("author", skillAuthorInput.value);
|
||||
formData.append("tags", skillTagsInput.value);
|
||||
formData.append("enable", skillEnableCheck.checked ? "true" : "false");
|
||||
|
||||
try {
|
||||
await api("/api/skills", { method: "POST", body: formData });
|
||||
if (state.editingSkillName && state.editingSkillName !== name) {
|
||||
try { await api("/api/skills/" + encodeURIComponent(state.editingSkillName), { method: "DELETE" }); } catch {}
|
||||
}
|
||||
setSkillEditorStatus("\u4FDD\u5B58\u6210\u529F", "success");
|
||||
state.editingSkillName = name;
|
||||
skillNameInput.disabled = true;
|
||||
await renderSkills();
|
||||
} catch (e) {
|
||||
setSkillEditorStatus("\u4FDD\u5B58\u5931\u8D25: " + e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSkill(name) {
|
||||
if (!await showConfirm("\u786E\u5B9A\u5220\u9664 Skill \"" + name + "\" \u53CA\u5176\u6240\u6709\u9644\u4EF6\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\u3002")) return;
|
||||
try {
|
||||
await api("/api/skills/" + encodeURIComponent(name), { method: "DELETE" });
|
||||
await renderSkills();
|
||||
const setStatus = (await import('./settings.js')).setStatus;
|
||||
if (setStatus) setStatus("Skill \u5DF2\u5220\u9664", "success");
|
||||
} catch (e) {
|
||||
console.error("\u5220\u9664 Skill \u5931\u8D25:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSkills() {
|
||||
if (!skillsListEl) return;
|
||||
try {
|
||||
const res = await api("/api/skills");
|
||||
state.cachedSkills.length = 0;
|
||||
state.cachedSkills.push(...(res.skills || []));
|
||||
} catch (e) {
|
||||
skillsListEl.innerHTML = '<p style="color:#dc2626;">\u52A0\u8F7D Skills \u5931\u8D25: ' + escapeHtml(e.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
skillsListEl.innerHTML = "";
|
||||
if (state.cachedSkills.length === 0) {
|
||||
skillsListEl.innerHTML = '<p style="color:#94a3b8;text-align:center;padding:2rem;">\u6682\u65E0 Skill\u3002<br>\u70B9\u51FB"+ \u521B\u5EFA Skill"\u5F00\u59CB\u521B\u5EFA\u4F60\u7684\u7B2C\u4E00\u4E2A\u64CD\u4F5C\u6307\u5357\u3002</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
state.cachedSkills.forEach((skill) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "skill-card";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "skill-card-header";
|
||||
const h4 = document.createElement("h4");
|
||||
h4.textContent = skill.name;
|
||||
if (skill.enable === false) {
|
||||
const badge = document.createElement("span");
|
||||
badge.style.cssText = "font-size:0.65rem;color:#dc2626;background:rgba(220,38,38,0.1);padding:0.1rem 0.4rem;border-radius:999px;margin-left:0.4rem;font-weight:400;";
|
||||
badge.textContent = "\u5DF2\u7981\u7528";
|
||||
h4.appendChild(badge);
|
||||
}
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "skill-card-actions";
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "btn btn-secondary";
|
||||
editBtn.style.cssText = "padding:0.2rem 0.5rem;font-size:0.75rem;";
|
||||
editBtn.textContent = "\u7F16\u8F91";
|
||||
editBtn.addEventListener("click", () => openSkillEditor(skill.name));
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger";
|
||||
delBtn.style.cssText = "padding:0.2rem 0.5rem;font-size:0.75rem;";
|
||||
delBtn.textContent = "\u5220\u9664";
|
||||
delBtn.addEventListener("click", () => deleteSkill(skill.name));
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(delBtn);
|
||||
header.appendChild(h4);
|
||||
header.appendChild(actions);
|
||||
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "skill-card-desc";
|
||||
desc.textContent = skill.description || "(\u65E0\u63CF\u8FF0)";
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "skill-card-meta";
|
||||
meta.innerHTML = (skill.version ? '<span>v' + escapeHtml(skill.version) + '</span>' : '') +
|
||||
(skill.author ? '<span>by ' + escapeHtml(skill.author) + '</span>' : '');
|
||||
|
||||
const tagsWrap = document.createElement("div");
|
||||
tagsWrap.className = "skill-card-tags";
|
||||
(skill.tags || []).forEach((tag) => {
|
||||
const tagEl = document.createElement("span");
|
||||
tagEl.className = "skill-tag";
|
||||
tagEl.textContent = tag;
|
||||
tagsWrap.appendChild(tagEl);
|
||||
});
|
||||
|
||||
let attachWrap = null;
|
||||
if (skill.attachment_files && skill.attachment_files.length > 0) {
|
||||
attachWrap = document.createElement("div");
|
||||
attachWrap.className = "skill-card-attachments";
|
||||
skill.attachment_files.forEach((fname) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "skill-attachment-chip";
|
||||
chip.textContent = fname;
|
||||
attachWrap.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
card.appendChild(header);
|
||||
card.appendChild(desc);
|
||||
card.appendChild(meta);
|
||||
card.appendChild(tagsWrap);
|
||||
if (attachWrap) card.appendChild(attachWrap);
|
||||
skillsListEl.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function bindSkillEditorEvents() {
|
||||
const addSkillBtn = $("addSkillBtn");
|
||||
if (addSkillBtn) addSkillBtn.addEventListener("click", () => openSkillEditor(""));
|
||||
if (skillEditorClose) skillEditorClose.addEventListener("click", closeSkillEditor);
|
||||
if (skillEditorCancel) skillEditorCancel.addEventListener("click", closeSkillEditor);
|
||||
if (skillEditorOverlay) {
|
||||
skillEditorOverlay.addEventListener("click", (e) => {
|
||||
if (e.target === skillEditorOverlay) closeSkillEditor();
|
||||
});
|
||||
}
|
||||
if (skillEditorSave) skillEditorSave.addEventListener("click", saveSkill);
|
||||
|
||||
const skillEnableLabel = $("skillEnableLabel");
|
||||
if (skillEnableLabel && skillEnableCheck) {
|
||||
skillEnableLabel.addEventListener("click", () => { skillEnableCheck.click(); });
|
||||
}
|
||||
|
||||
if (skillFileInput) {
|
||||
skillFileInput.addEventListener("change", async () => {
|
||||
if (!state.editingSkillName) {
|
||||
setSkillEditorStatus("\u8BF7\u5148\u4FDD\u5B58 Skill \u540E\u518D\u4E0A\u4F20\u9644\u4EF6", "error");
|
||||
skillFileInput.value = "";
|
||||
return;
|
||||
}
|
||||
const files = skillFileInput.files;
|
||||
if (!files || files.length === 0) return;
|
||||
setSkillEditorStatus("\u6B63\u5728\u4E0A\u4F20...", "");
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
for (const file of files) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
await api("/api/skills/" + encodeURIComponent(state.editingSkillName) + "/attachments", { method: "POST", body: fd });
|
||||
if (!state.editingSkillAttachments.includes(file.name)) {
|
||||
state.editingSkillAttachments.push(file.name);
|
||||
}
|
||||
successCount++;
|
||||
} catch {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
skillFileInput.value = "";
|
||||
renderSkillAttachments();
|
||||
if (failCount === 0) {
|
||||
setSkillEditorStatus("\u4E0A\u4F20\u6210\u529F " + successCount + " \u4E2A\u6587\u4EF6", "success");
|
||||
} else {
|
||||
setSkillEditorStatus("\u4E0A\u4F20: " + successCount + " \u6210\u529F, " + failCount + " \u5931\u8D25", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && skillEditorOverlay && !skillEditorOverlay.hidden) {
|
||||
const settingsOverlay = $("settingsOverlay");
|
||||
if (settingsOverlay && !settingsOverlay.hidden) {
|
||||
closeSkillEditor();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
renderSkills, openSkillEditor, closeSkillEditor,
|
||||
saveSkill, deleteSkill, renderSkillAttachments,
|
||||
setSkillEditorStatus, bindSkillEditorEvents,
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
// state.js -- 全局共享状态
|
||||
// 所有可变状态封装在 state 对象中,避免 ES module 只读绑定问题
|
||||
|
||||
export const STORAGE_DOF = "minor_web_bg_dof";
|
||||
export const STORAGE_VIG = "minor_web_bg_vignette";
|
||||
export const STORAGE_HEADER_COLLAPSED = "minor_web_header_collapsed";
|
||||
|
||||
// 声音阈值配置(已优化)
|
||||
export const START_THRESHOLD = 0.18;
|
||||
export const STOP_THRESHOLD = 0.06;
|
||||
export const AUTO_START_DELAY = 100;
|
||||
export const AUTO_STOP_DELAY = 800;
|
||||
|
||||
export const state = {
|
||||
/** @type {string} */
|
||||
sessionId: "",
|
||||
/** @type {string[]} */
|
||||
sessions: [],
|
||||
/** @type {File[]} */
|
||||
pendingFiles: [],
|
||||
pendingRefs: [], // Edit 模式引用:[{path, name, startLine, endLine, text}]
|
||||
sending: false,
|
||||
abortController: null,
|
||||
mediaRecorder: null,
|
||||
recorderStream: null,
|
||||
recordedChunks: [],
|
||||
recordingMimeType: "",
|
||||
sidebarCollapsed: false,
|
||||
|
||||
// 自动语音输入相关变量
|
||||
autoInputEnabled: false,
|
||||
audioContext: null,
|
||||
analyser: null,
|
||||
dataArray: null,
|
||||
mediaStreamSource: null,
|
||||
autoInputStream: null,
|
||||
autoRecordingTimeout: null,
|
||||
silenceTimeout: null,
|
||||
|
||||
/** 最近一次 renderMessages 的 JSON 可序列化快照 */
|
||||
lastRenderedMessages: [],
|
||||
activeAudioController: null,
|
||||
|
||||
// 工具调用 / think 展开状态
|
||||
liveToolCallExpanded: false,
|
||||
thinkExpanded: false,
|
||||
|
||||
// 设置面板缓存
|
||||
cachedAgentConfigs: [],
|
||||
cachedToolConfigs: [],
|
||||
cachedEnvConfig: {},
|
||||
cachedModels: [],
|
||||
registeredToolNames: [],
|
||||
cachedGuiConfig: { monitors: [], selected_name: "", gui_model_id: "", models: [] },
|
||||
editingModelIdx: -1,
|
||||
|
||||
// Skills 缓存
|
||||
cachedSkills: [],
|
||||
editingSkillName: "",
|
||||
editingSkillAttachments: [],
|
||||
|
||||
// Todo 悬浮窗引用
|
||||
_currentTodoOverlay: null,
|
||||
|
||||
// HumanInteraction 浮窗引用
|
||||
_currentPendingOverlay: null,
|
||||
|
||||
// HumanInteraction 等待中标记(暂停按钮不恢复)
|
||||
_pendingHumanAction: false,
|
||||
|
||||
// 当前会话的 token 用量
|
||||
currentTokens: 0,
|
||||
|
||||
// 模型选择
|
||||
selectedModelId: "",
|
||||
|
||||
// 工作空间
|
||||
workspacePath: "", // 当前选中的工作空间路径(空=使用默认)
|
||||
workspaceList: [], // 已保存的工作空间路径列表
|
||||
workspaceDefault: "", // 默认工作空间路径
|
||||
|
||||
// Edit 模式
|
||||
editMode: false,
|
||||
docFiles: [],
|
||||
docOpenTabs: [],
|
||||
activeDocFile: null,
|
||||
activeDocTab: null,
|
||||
_docTreeFolders: {},
|
||||
_lastSelection: null,
|
||||
_docRootPath: null, // 打开文件夹的根路径
|
||||
|
||||
// 当前模式:chat | edit | cron(统一源,editMode 保留向后兼容)
|
||||
mode: "chat",
|
||||
|
||||
// Cron 定时任务模式
|
||||
cronTasks: [], // 任务列表(summary)
|
||||
activeCronTaskId: "", // 当前选中的任务 ID
|
||||
cronRunning: false, // 是否有任务正在运行(控制发送按钮)
|
||||
_cronLiveStopFn: null, // 当前 cron SSE 连接的停止函数
|
||||
_cronPollTimer: null, // cron 模式下的状态轮询定时器
|
||||
_cronEditingTaskId: "", // 正在编辑的任务 ID(空=新建)
|
||||
};
|
||||
@@ -0,0 +1,758 @@
|
||||
// themes.js -- 主题管理系统(10 个预设 + CSS 变量注入)
|
||||
|
||||
import { api } from './api.js';
|
||||
|
||||
const DARK_SURFACE = {
|
||||
"--surface": "rgba(30,30,35,0.94)",
|
||||
"--surface-hover": "rgba(255,255,255,0.06)",
|
||||
"--bg-tertiary": "rgba(148,163,184,0.06)",
|
||||
"--text-tertiary": "#64748b",
|
||||
"--border": "rgba(148,163,184,0.15)",
|
||||
"--divider": "rgba(148,163,184,0.1)",
|
||||
"--overlay-bg": "rgba(8,8,16,0.55)",
|
||||
"--titlebar-bg": "#111118",
|
||||
"--shadow": "rgba(0,0,0,0.5)",
|
||||
"--scrollbar-thumb": "rgba(148,163,184,0.2)",
|
||||
"--scrollbar-track": "transparent",
|
||||
"--danger": "#ef4444",
|
||||
"--danger-hover": "#f87171",
|
||||
"--danger-bg": "rgba(239,68,68,0.12)",
|
||||
"--success": "#22c55e",
|
||||
"--success-bg": "rgba(34,197,94,0.12)",
|
||||
"--warning": "#f59e0b",
|
||||
"--warning-bg": "rgba(245,158,11,0.12)",
|
||||
};
|
||||
|
||||
const LIGHT_SURFACE = {
|
||||
"--surface": "rgba(255,255,255,0.94)",
|
||||
"--surface-hover": "rgba(0,0,0,0.04)",
|
||||
"--bg-tertiary": "rgba(0,0,0,0.04)",
|
||||
"--text-tertiary": "#94a3b8",
|
||||
"--border": "rgba(0,0,0,0.1)",
|
||||
"--divider": "rgba(0,0,0,0.08)",
|
||||
"--overlay-bg": "rgba(0,0,0,0.18)",
|
||||
"--titlebar-bg": "#e8e8ec",
|
||||
"--shadow": "rgba(0,0,0,0.15)",
|
||||
"--scrollbar-thumb": "rgba(0,0,0,0.15)",
|
||||
"--scrollbar-track": "transparent",
|
||||
"--danger": "#dc2626",
|
||||
"--danger-hover": "#b91c1c",
|
||||
"--danger-bg": "rgba(220,38,38,0.08)",
|
||||
"--success": "#16a34a",
|
||||
"--success-bg": "rgba(22,163,74,0.08)",
|
||||
"--warning": "#ca8a04",
|
||||
"--warning-bg": "rgba(202,138,4,0.08)",
|
||||
};
|
||||
|
||||
export const THEMES = {
|
||||
"vscode-dark": {
|
||||
name: "VS Code Dark",
|
||||
dark: true,
|
||||
root: {
|
||||
"--text": "#d4d4d4",
|
||||
"--muted": "#808080",
|
||||
"--accent": "#007acc",
|
||||
"--accent-hover": "#1a8ad4",
|
||||
"--bubble-user": "rgba(38,79,120,0.55)",
|
||||
"--bubble-assistant": "rgba(45,45,45,0.7)",
|
||||
"--bubble-audio-only": "rgba(45,45,45,0.3)",
|
||||
"--audio-accent": "#14b8a6",
|
||||
"--radius": "14px",
|
||||
"--bg-dof": "0px",
|
||||
"--bg-vignette": "0.55",
|
||||
...DARK_SURFACE,
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#1e1e1e",
|
||||
"--edit-tab-bg": "#252526",
|
||||
"--edit-tab-active-bg": "#1e1e1e",
|
||||
"--edit-tab-border": "#3c3c3c",
|
||||
"--edit-text": "#d4d4d4",
|
||||
"--edit-line-num": "#858585",
|
||||
"--edit-line-num-bg": "#1e1e1e",
|
||||
"--edit-line-num-border": "#333333",
|
||||
"--edit-selection": "rgba(38,79,120,0.7)",
|
||||
"--edit-toolcall-bg": "#2d2d2d",
|
||||
"--edit-step-border": "#2d2d2d",
|
||||
"--edit-tree-text": "#d4d4d4",
|
||||
"--edit-tree-hover": "rgba(255,255,255,0.15)",
|
||||
"--edit-tab-text": "#969696",
|
||||
"--edit-tab-active-text": "#ffffff",
|
||||
"--edit-empty-text": "#6a6a6a",
|
||||
"--sidebar-bg": "transparent",
|
||||
"--sidebar-border": "rgba(148,163,184,0.35)",
|
||||
"--sidebar-text": "#d4d4d4",
|
||||
}
|
||||
},
|
||||
|
||||
"vscode-light": {
|
||||
name: "VS Code Light",
|
||||
dark: false,
|
||||
root: {
|
||||
"--text": "#1e1e1e",
|
||||
"--muted": "#6e6e6e",
|
||||
"--accent": "#005fb8",
|
||||
"--accent-hover": "#0070d6",
|
||||
"--bubble-user": "rgba(0,95,184,0.12)",
|
||||
"--bubble-assistant": "rgba(255,255,255,0.8)",
|
||||
"--bubble-audio-only": "rgba(255,255,255,0.5)",
|
||||
"--audio-accent": "#0e7490",
|
||||
"--bg-dof": "0px",
|
||||
"--bg-vignette": "0.15",
|
||||
...LIGHT_SURFACE,
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#ffffff",
|
||||
"--edit-tab-bg": "#f3f3f3",
|
||||
"--edit-tab-active-bg": "#ffffff",
|
||||
"--edit-tab-border": "#d4d4d4",
|
||||
"--edit-text": "#1e1e1e",
|
||||
"--edit-line-num": "#999999",
|
||||
"--edit-line-num-bg": "#ffffff",
|
||||
"--edit-line-num-border": "#e8e8e8",
|
||||
"--edit-selection": "rgba(0,95,184,0.25)",
|
||||
"--edit-toolcall-bg": "#f0f0f0",
|
||||
"--edit-step-border": "#e8e8e8",
|
||||
"--edit-tree-text": "#1e1e1e",
|
||||
"--edit-tree-hover": "rgba(0,0,0,0.06)",
|
||||
"--edit-tab-text": "#6e6e6e",
|
||||
"--edit-tab-active-text": "#1e1e1e",
|
||||
"--edit-empty-text": "#999999",
|
||||
"--sidebar-bg": "#f3f3f3",
|
||||
"--sidebar-border": "rgba(0,0,0,0.12)",
|
||||
"--sidebar-text": "#1e1e1e",
|
||||
}
|
||||
},
|
||||
|
||||
"solarized-light": {
|
||||
name: "Solarized Light",
|
||||
dark: false,
|
||||
root: {
|
||||
"--text": "#586e75",
|
||||
"--muted": "#839496",
|
||||
"--accent": "#268bd2",
|
||||
"--accent-hover": "#2aa0e8",
|
||||
"--bubble-user": "rgba(38,139,210,0.12)",
|
||||
"--bubble-assistant": "rgba(253,246,227,0.8)",
|
||||
"--bubble-audio-only": "rgba(253,246,227,0.5)",
|
||||
"--audio-accent": "#2aa198",
|
||||
"--bg-dof": "0px",
|
||||
"--bg-vignette": "0.1",
|
||||
...LIGHT_SURFACE,
|
||||
"--surface": "rgba(253,246,227,0.94)",
|
||||
"--titlebar-bg": "#eee8d5",
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#fdf6e3",
|
||||
"--edit-tab-bg": "#eee8d5",
|
||||
"--edit-tab-active-bg": "#fdf6e3",
|
||||
"--edit-tab-border": "#d3cbb7",
|
||||
"--edit-text": "#586e75",
|
||||
"--edit-line-num": "#93a1a1",
|
||||
"--edit-line-num-bg": "#fdf6e3",
|
||||
"--edit-line-num-border": "#eee8d5",
|
||||
"--edit-selection": "rgba(38,139,210,0.18)",
|
||||
"--edit-toolcall-bg": "#eee8d5",
|
||||
"--edit-step-border": "#eee8d5",
|
||||
"--edit-tree-text": "#586e75",
|
||||
"--edit-tree-hover": "rgba(0,0,0,0.05)",
|
||||
"--edit-tab-text": "#839496",
|
||||
"--edit-tab-active-text": "#586e75",
|
||||
"--edit-empty-text": "#93a1a1",
|
||||
"--sidebar-bg": "#eee8d5",
|
||||
"--sidebar-border": "rgba(147,161,161,0.3)",
|
||||
"--sidebar-text": "#586e75",
|
||||
}
|
||||
},
|
||||
|
||||
"github-light": {
|
||||
name: "GitHub Light",
|
||||
dark: false,
|
||||
root: {
|
||||
"--text": "#24292e",
|
||||
"--muted": "#6a737d",
|
||||
"--accent": "#0366d6",
|
||||
"--accent-hover": "#0550b2",
|
||||
"--bubble-user": "rgba(3,102,214,0.1)",
|
||||
"--bubble-assistant": "rgba(255,255,255,0.85)",
|
||||
"--bubble-audio-only": "rgba(255,255,255,0.5)",
|
||||
"--audio-accent": "#22863a",
|
||||
"--bg-dof": "0px",
|
||||
"--bg-vignette": "0.1",
|
||||
...LIGHT_SURFACE,
|
||||
"--surface": "rgba(255,255,255,0.94)",
|
||||
"--titlebar-bg": "#f6f8fa",
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#ffffff",
|
||||
"--edit-tab-bg": "#f6f8fa",
|
||||
"--edit-tab-active-bg": "#ffffff",
|
||||
"--edit-tab-border": "#e1e4e8",
|
||||
"--edit-text": "#24292e",
|
||||
"--edit-line-num": "#959da5",
|
||||
"--edit-line-num-bg": "#ffffff",
|
||||
"--edit-line-num-border": "#eaecef",
|
||||
"--edit-selection": "rgba(3,102,214,0.18)",
|
||||
"--edit-toolcall-bg": "#f6f8fa",
|
||||
"--edit-step-border": "#eaecef",
|
||||
"--edit-tree-text": "#24292e",
|
||||
"--edit-tree-hover": "rgba(0,0,0,0.04)",
|
||||
"--edit-tab-text": "#6a737d",
|
||||
"--edit-tab-active-text": "#24292e",
|
||||
"--edit-empty-text": "#959da5",
|
||||
"--sidebar-bg": "#f6f8fa",
|
||||
"--sidebar-border": "rgba(27,31,35,0.12)",
|
||||
"--sidebar-text": "#24292e",
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 新增主题 =====
|
||||
|
||||
"cyber-neon": {
|
||||
name: "赛博终端・霓虹青",
|
||||
dark: true,
|
||||
root: {
|
||||
"--text": "#c9d1d9",
|
||||
"--muted": "#6e7681",
|
||||
"--accent": "#00f0ff",
|
||||
"--accent-hover": "#5ff8ff",
|
||||
"--bubble-user": "rgba(0,240,255,0.16)",
|
||||
"--bubble-assistant": "rgba(20,28,40,0.7)",
|
||||
"--bubble-audio-only": "rgba(20,28,40,0.3)",
|
||||
"--audio-accent": "#00f0ff",
|
||||
"--radius": "12px",
|
||||
"--bg-dof": "3px",
|
||||
"--bg-vignette": "0.6",
|
||||
"--surface": "rgba(10,14,20,0.94)",
|
||||
"--surface-hover": "rgba(0,240,255,0.06)",
|
||||
"--border": "rgba(0,240,255,0.18)",
|
||||
"--overlay-bg": "rgba(0,8,16,0.6)",
|
||||
"--titlebar-bg": "#060a10",
|
||||
"--danger": "#ff477e",
|
||||
"--danger-hover": "#ff6b9d",
|
||||
"--danger-bg": "rgba(255,71,126,0.15)",
|
||||
"--success": "#00e5ff",
|
||||
"--success-bg": "rgba(0,229,255,0.15)",
|
||||
"--warning": "#ffb347",
|
||||
"--warning-bg": "rgba(255,179,71,0.15)",
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#0a0e14",
|
||||
"--edit-tab-bg": "#0f1620",
|
||||
"--edit-tab-active-bg": "#0a0e14",
|
||||
"--edit-tab-border": "#1c2632",
|
||||
"--edit-text": "#c9d1d9",
|
||||
"--edit-line-num": "#4a5a6a",
|
||||
"--edit-line-num-bg": "#0a0e14",
|
||||
"--edit-line-num-border": "#161c24",
|
||||
"--edit-selection": "rgba(0,240,255,0.28)",
|
||||
"--edit-toolcall-bg": "#0f1620",
|
||||
"--edit-step-border": "#1c2632",
|
||||
"--edit-tree-text": "#c9d1d9",
|
||||
"--edit-tree-hover": "rgba(0,240,255,0.08)",
|
||||
"--edit-tab-text": "#6e7681",
|
||||
"--edit-tab-active-text": "#c9d1d9",
|
||||
"--edit-empty-text": "#4a5a6a",
|
||||
"--sidebar-bg": "transparent",
|
||||
"--sidebar-border": "rgba(0,240,255,0.18)",
|
||||
"--sidebar-text": "#c9d1d9",
|
||||
}
|
||||
},
|
||||
|
||||
"deep-space-indigo": {
|
||||
name: "理性深空・靛蓝紫",
|
||||
dark: true,
|
||||
root: {
|
||||
"--text": "#e2e8f0",
|
||||
"--muted": "#8b90b8",
|
||||
"--accent": "#7c83ff",
|
||||
"--accent-hover": "#9aa0ff",
|
||||
"--bubble-user": "rgba(124,131,255,0.18)",
|
||||
"--bubble-assistant": "rgba(30,30,50,0.72)",
|
||||
"--bubble-audio-only": "rgba(30,30,50,0.3)",
|
||||
"--audio-accent": "#a78bfa",
|
||||
"--radius": "14px",
|
||||
"--bg-dof": "2px",
|
||||
"--bg-vignette": "0.55",
|
||||
"--surface": "rgba(22,22,38,0.95)",
|
||||
"--surface-hover": "rgba(124,131,255,0.07)",
|
||||
"--border": "rgba(124,131,255,0.16)",
|
||||
"--overlay-bg": "rgba(10,10,22,0.6)",
|
||||
"--titlebar-bg": "#12121f",
|
||||
"--danger": "#f472b6",
|
||||
"--danger-hover": "#f9a8d4",
|
||||
"--danger-bg": "rgba(244,114,182,0.15)",
|
||||
"--success": "#a78bfa",
|
||||
"--success-bg": "rgba(167,139,250,0.15)",
|
||||
"--warning": "#fbbf24",
|
||||
"--warning-bg": "rgba(251,191,36,0.15)",
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#16162a",
|
||||
"--edit-tab-bg": "#1d1d36",
|
||||
"--edit-tab-active-bg": "#16162a",
|
||||
"--edit-tab-border": "#2a2a48",
|
||||
"--edit-text": "#e2e8f0",
|
||||
"--edit-line-num": "#6b7099",
|
||||
"--edit-line-num-bg": "#16162a",
|
||||
"--edit-line-num-border": "#2a2a48",
|
||||
"--edit-selection": "rgba(124,131,255,0.3)",
|
||||
"--edit-toolcall-bg": "#1d1d36",
|
||||
"--edit-step-border": "#2a2a48",
|
||||
"--edit-tree-text": "#e2e8f0",
|
||||
"--edit-tree-hover": "rgba(124,131,255,0.08)",
|
||||
"--edit-tab-text": "#8b90b8",
|
||||
"--edit-tab-active-text": "#e2e8f0",
|
||||
"--edit-empty-text": "#6b7099",
|
||||
"--sidebar-bg": "transparent",
|
||||
"--sidebar-border": "rgba(124,131,255,0.2)",
|
||||
"--sidebar-text": "#e2e8f0",
|
||||
}
|
||||
},
|
||||
|
||||
"warm-terracotta": {
|
||||
name: "暖调编辑・赤陶土",
|
||||
dark: false,
|
||||
root: {
|
||||
"--text": "#4a3528",
|
||||
"--muted": "#8a6f5c",
|
||||
"--accent": "#c8553d",
|
||||
"--accent-hover": "#d9664a",
|
||||
"--bubble-user": "rgba(200,85,61,0.12)",
|
||||
"--bubble-assistant": "rgba(255,250,243,0.8)",
|
||||
"--bubble-audio-only": "rgba(255,250,243,0.5)",
|
||||
"--audio-accent": "#c8553d",
|
||||
"--radius": "12px",
|
||||
"--bg-dof": "0px",
|
||||
"--bg-vignette": "0.12",
|
||||
"--surface": "rgba(253,247,240,0.95)",
|
||||
"--surface-hover": "rgba(200,85,61,0.06)",
|
||||
"--border": "rgba(200,85,61,0.18)",
|
||||
"--overlay-bg": "rgba(120,80,60,0.18)",
|
||||
"--titlebar-bg": "#f3e9da",
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#fdf7f0",
|
||||
"--edit-tab-bg": "#f3e9da",
|
||||
"--edit-tab-active-bg": "#fdf7f0",
|
||||
"--edit-tab-border": "#e0d0bc",
|
||||
"--edit-text": "#4a3528",
|
||||
"--edit-line-num": "#b09a86",
|
||||
"--edit-line-num-bg": "#fdf7f0",
|
||||
"--edit-line-num-border": "#ece0cd",
|
||||
"--edit-selection": "rgba(200,85,61,0.2)",
|
||||
"--edit-toolcall-bg": "#f3e9da",
|
||||
"--edit-step-border": "#ece0cd",
|
||||
"--edit-tree-text": "#4a3528",
|
||||
"--edit-tree-hover": "rgba(200,85,61,0.06)",
|
||||
"--edit-tab-text": "#8a6f5c",
|
||||
"--edit-tab-active-text": "#4a3528",
|
||||
"--edit-empty-text": "#b09a86",
|
||||
"--sidebar-bg": "#f3e9da",
|
||||
"--sidebar-border": "rgba(200,85,61,0.18)",
|
||||
"--sidebar-text": "#4a3528",
|
||||
}
|
||||
},
|
||||
|
||||
"pixel-gold": {
|
||||
name: "像素史诗・金币黄",
|
||||
dark: true,
|
||||
root: {
|
||||
"--text": "#f5e6c8",
|
||||
"--muted": "#a8945c",
|
||||
"--accent": "#ffc83d",
|
||||
"--accent-hover": "#ffd966",
|
||||
"--bubble-user": "rgba(255,200,61,0.16)",
|
||||
"--bubble-assistant": "rgba(40,32,18,0.74)",
|
||||
"--bubble-audio-only": "rgba(40,32,18,0.3)",
|
||||
"--audio-accent": "#ffc83d",
|
||||
"--radius": "8px",
|
||||
"--bg-dof": "4px",
|
||||
"--bg-vignette": "0.58",
|
||||
"--surface": "rgba(26,20,12,0.95)",
|
||||
"--surface-hover": "rgba(255,200,61,0.07)",
|
||||
"--border": "rgba(255,200,61,0.18)",
|
||||
"--overlay-bg": "rgba(16,12,6,0.6)",
|
||||
"--titlebar-bg": "#14100a",
|
||||
"--danger": "#ff8c00",
|
||||
"--danger-hover": "#ffa940",
|
||||
"--danger-bg": "rgba(255,140,0,0.15)",
|
||||
"--success": "#d4af37",
|
||||
"--success-bg": "rgba(212,175,55,0.15)",
|
||||
"--warning": "#ff6b35",
|
||||
"--warning-bg": "rgba(255,107,53,0.15)",
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#1a140c",
|
||||
"--edit-tab-bg": "#241c10",
|
||||
"--edit-tab-active-bg": "#1a140c",
|
||||
"--edit-tab-border": "#3a2e18",
|
||||
"--edit-text": "#f5e6c8",
|
||||
"--edit-line-num": "#8a7444",
|
||||
"--edit-line-num-bg": "#1a140c",
|
||||
"--edit-line-num-border": "#3a2e18",
|
||||
"--edit-selection": "rgba(255,200,61,0.26)",
|
||||
"--edit-toolcall-bg": "#241c10",
|
||||
"--edit-step-border": "#3a2e18",
|
||||
"--edit-tree-text": "#f5e6c8",
|
||||
"--edit-tree-hover": "rgba(255,200,61,0.08)",
|
||||
"--edit-tab-text": "#a8945c",
|
||||
"--edit-tab-active-text": "#f5e6c8",
|
||||
"--edit-empty-text": "#8a7444",
|
||||
"--sidebar-bg": "transparent",
|
||||
"--sidebar-border": "rgba(255,200,61,0.18)",
|
||||
"--sidebar-text": "#f5e6c8",
|
||||
}
|
||||
},
|
||||
|
||||
"neo-brutalist": {
|
||||
name: "新野兽派・黑白撞",
|
||||
dark: false,
|
||||
root: {
|
||||
"--text": "#000000",
|
||||
"--muted": "#555555",
|
||||
"--accent": "#111111",
|
||||
"--accent-hover": "#000000",
|
||||
"--bubble-user": "rgba(0,0,0,0.06)",
|
||||
"--bubble-assistant": "rgba(255,255,255,0.9)",
|
||||
"--bubble-audio-only": "rgba(255,255,255,0.6)",
|
||||
"--audio-accent": "#111111",
|
||||
"--radius": "4px",
|
||||
"--bg-dof": "0px",
|
||||
"--bg-vignette": "0",
|
||||
"--surface": "rgba(255,255,255,0.97)",
|
||||
"--surface-hover": "rgba(0,0,0,0.05)",
|
||||
"--border": "#000000",
|
||||
"--overlay-bg": "rgba(0,0,0,0.25)",
|
||||
"--titlebar-bg": "#ffffff",
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#ffffff",
|
||||
"--edit-tab-bg": "#f0f0f0",
|
||||
"--edit-tab-active-bg": "#ffffff",
|
||||
"--edit-tab-border": "#000000",
|
||||
"--edit-text": "#000000",
|
||||
"--edit-line-num": "#999999",
|
||||
"--edit-line-num-bg": "#ffffff",
|
||||
"--edit-line-num-border": "#000000",
|
||||
"--edit-selection": "rgba(0,0,0,0.12)",
|
||||
"--edit-toolcall-bg": "#f0f0f0",
|
||||
"--edit-step-border": "#000000",
|
||||
"--edit-tree-text": "#000000",
|
||||
"--edit-tree-hover": "rgba(0,0,0,0.06)",
|
||||
"--edit-tab-text": "#555555",
|
||||
"--edit-tab-active-text": "#000000",
|
||||
"--edit-empty-text": "#999999",
|
||||
"--sidebar-bg": "#f0f0f0",
|
||||
"--sidebar-border": "#000000",
|
||||
"--sidebar-text": "#000000",
|
||||
}
|
||||
},
|
||||
|
||||
"glassmorphism-aurora": {
|
||||
name: "玻璃拟态・极光紫",
|
||||
dark: false,
|
||||
root: {
|
||||
"--text": "#2d2440",
|
||||
"--muted": "#6b5e8a",
|
||||
"--accent": "#9d4edd",
|
||||
"--accent-hover": "#b065f0",
|
||||
"--bubble-user": "rgba(157,78,221,0.14)",
|
||||
"--bubble-assistant": "rgba(255,255,255,0.6)",
|
||||
"--bubble-audio-only": "rgba(255,255,255,0.4)",
|
||||
"--audio-accent": "#9d4edd",
|
||||
"--radius": "16px",
|
||||
"--bg-dof": "6px",
|
||||
"--bg-vignette": "0.2",
|
||||
"--surface": "rgba(255,255,255,0.55)",
|
||||
"--surface-hover": "rgba(157,78,221,0.08)",
|
||||
"--border": "rgba(157,78,221,0.2)",
|
||||
"--overlay-bg": "rgba(80,40,120,0.18)",
|
||||
"--titlebar-bg": "rgba(240,230,250,0.8)",
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "rgba(250,245,255,0.7)",
|
||||
"--edit-tab-bg": "rgba(240,230,250,0.6)",
|
||||
"--edit-tab-active-bg": "rgba(250,245,255,0.7)",
|
||||
"--edit-tab-border": "rgba(157,78,221,0.2)",
|
||||
"--edit-text": "#2d2440",
|
||||
"--edit-line-num": "#9d8ab8",
|
||||
"--edit-line-num-bg": "rgba(250,245,255,0.5)",
|
||||
"--edit-line-num-border": "rgba(157,78,221,0.15)",
|
||||
"--edit-selection": "rgba(157,78,221,0.2)",
|
||||
"--edit-toolcall-bg": "rgba(240,230,250,0.6)",
|
||||
"--edit-step-border": "rgba(157,78,221,0.15)",
|
||||
"--edit-tree-text": "#2d2440",
|
||||
"--edit-tree-hover": "rgba(157,78,221,0.08)",
|
||||
"--edit-tab-text": "#6b5e8a",
|
||||
"--edit-tab-active-text": "#2d2440",
|
||||
"--edit-empty-text": "#9d8ab8",
|
||||
"--sidebar-bg": "rgba(240,230,250,0.5)",
|
||||
"--sidebar-border": "rgba(157,78,221,0.2)",
|
||||
"--sidebar-text": "#2d2440",
|
||||
}
|
||||
},
|
||||
|
||||
"beige-cream": {
|
||||
name: "米杏色+奶油绿",
|
||||
dark: false,
|
||||
root: {
|
||||
"--text": "#3d4a3a",
|
||||
"--muted": "#7a8a76",
|
||||
"--accent": "#7fa67a",
|
||||
"--accent-hover": "#94bb8e",
|
||||
"--bubble-user": "rgba(127,166,122,0.14)",
|
||||
"--bubble-assistant": "rgba(255,252,243,0.8)",
|
||||
"--bubble-audio-only": "rgba(255,252,243,0.5)",
|
||||
"--audio-accent": "#7fa67a",
|
||||
"--radius": "14px",
|
||||
"--bg-dof": "0px",
|
||||
"--bg-vignette": "0.12",
|
||||
"--surface": "rgba(250,245,232,0.95)",
|
||||
"--surface-hover": "rgba(127,166,122,0.08)",
|
||||
"--border": "rgba(127,166,122,0.2)",
|
||||
"--overlay-bg": "rgba(120,110,80,0.16)",
|
||||
"--titlebar-bg": "#ede5d0",
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#faf5e8",
|
||||
"--edit-tab-bg": "#ede5d0",
|
||||
"--edit-tab-active-bg": "#faf5e8",
|
||||
"--edit-tab-border": "#d8ceae",
|
||||
"--edit-text": "#3d4a3a",
|
||||
"--edit-line-num": "#a89e80",
|
||||
"--edit-line-num-bg": "#faf5e8",
|
||||
"--edit-line-num-border": "#e0d6bc",
|
||||
"--edit-selection": "rgba(127,166,122,0.2)",
|
||||
"--edit-toolcall-bg": "#ede5d0",
|
||||
"--edit-step-border": "#e0d6bc",
|
||||
"--edit-tree-text": "#3d4a3a",
|
||||
"--edit-tree-hover": "rgba(127,166,122,0.08)",
|
||||
"--edit-tab-text": "#7a8a76",
|
||||
"--edit-tab-active-text": "#3d4a3a",
|
||||
"--edit-empty-text": "#a89e80",
|
||||
"--sidebar-bg": "#ede5d0",
|
||||
"--sidebar-border": "rgba(127,166,122,0.2)",
|
||||
"--sidebar-text": "#3d4a3a",
|
||||
}
|
||||
},
|
||||
|
||||
"khaki-klein": {
|
||||
name: "卡其加克莱因蓝",
|
||||
dark: false,
|
||||
root: {
|
||||
"--text": "#2b2b2b",
|
||||
"--muted": "#6b6b5a",
|
||||
"--accent": "#002fa7",
|
||||
"--accent-hover": "#0a3fb8",
|
||||
"--bubble-user": "rgba(0,47,167,0.1)",
|
||||
"--bubble-assistant": "rgba(252,250,240,0.8)",
|
||||
"--bubble-audio-only": "rgba(252,250,240,0.5)",
|
||||
"--audio-accent": "#002fa7",
|
||||
"--radius": "12px",
|
||||
"--bg-dof": "0px",
|
||||
"--bg-vignette": "0.14",
|
||||
"--surface": "rgba(245,241,224,0.95)",
|
||||
"--surface-hover": "rgba(0,47,167,0.06)",
|
||||
"--border": "rgba(0,47,167,0.2)",
|
||||
"--overlay-bg": "rgba(60,55,40,0.18)",
|
||||
"--titlebar-bg": "#e8e4d0",
|
||||
},
|
||||
edit: {
|
||||
"--edit-bg": "#f5f1e0",
|
||||
"--edit-tab-bg": "#e8e4d0",
|
||||
"--edit-tab-active-bg": "#f5f1e0",
|
||||
"--edit-tab-border": "#cfc8a8",
|
||||
"--edit-text": "#2b2b2b",
|
||||
"--edit-line-num": "#9a947a",
|
||||
"--edit-line-num-bg": "#f5f1e0",
|
||||
"--edit-line-num-border": "#ddd5b8",
|
||||
"--edit-selection": "rgba(0,47,167,0.18)",
|
||||
"--edit-toolcall-bg": "#e8e4d0",
|
||||
"--edit-step-border": "#ddd5b8",
|
||||
"--edit-tree-text": "#2b2b2b",
|
||||
"--edit-tree-hover": "rgba(0,47,167,0.06)",
|
||||
"--edit-tab-text": "#6b6b5a",
|
||||
"--edit-tab-active-text": "#2b2b2b",
|
||||
"--edit-empty-text": "#9a947a",
|
||||
"--sidebar-bg": "#e8e4d0",
|
||||
"--sidebar-border": "rgba(0,47,167,0.2)",
|
||||
"--sidebar-text": "#2b2b2b",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_THEME = "vscode-dark";
|
||||
const STORAGE_KEY = "minor_theme";
|
||||
const STORAGE_BG_KEY = "minor_bg_image";
|
||||
const STORAGE_FONT_KEY = "minor_font";
|
||||
const STORAGE_FONT_SIZE_KEY = "minor_font_size";
|
||||
const STORAGE_BLUR_KEY = "minor_bg_blur";
|
||||
const STORAGE_VIGNETTE_KEY = "minor_bg_vignette";
|
||||
|
||||
// 字体预设
|
||||
export const FONT_PRESETS = {
|
||||
"system": { name: "系统默认", family: 'system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif' },
|
||||
"sans": { name: "无衬线", family: '"Inter", "PingFang SC", "Microsoft YaHei", sans-serif' },
|
||||
"serif": { name: "衬线", family: 'Georgia, "Noto Serif SC", "SimSun", serif' },
|
||||
"mono": { name: "等宽", family: '"Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace' },
|
||||
"zcool": { name: "站酷快乐体", family: '"ZCOOL KuaiLe", "Microsoft YaHei", sans-serif' },
|
||||
"mashan": { name: "马山正楷", family: '"Ma Shan Zheng", "STKaiti", "KaiTi", serif' },
|
||||
"longcang": { name: "龙藏手写", family: '"Long Cang", "STKaiti", "KaiTi", cursive' },
|
||||
"zcool-qy": { name: "站酷庆科黄油体", family: '"ZCOOL QingKe HuangYou", "Microsoft YaHei", sans-serif' },
|
||||
};
|
||||
export const FONT_SIZE_PRESETS = {
|
||||
"12px": "12px",
|
||||
"13px": "小 (13px)",
|
||||
"14px": "中 (14px)",
|
||||
"15px": "大 (15px)",
|
||||
"16px": "16px",
|
||||
"18px": "特大 (18px)",
|
||||
"20px": "20px",
|
||||
};
|
||||
|
||||
const getFontKey = (k) => ({ family: STORAGE_FONT_KEY, size: STORAGE_FONT_SIZE_KEY }[k]);
|
||||
|
||||
export function getFontSetting(key) {
|
||||
try { return localStorage.getItem(getFontKey(key)) || ""; } catch { return ""; }
|
||||
}
|
||||
export function setFontSetting(key, val) {
|
||||
try { localStorage.setItem(getFontKey(key), val || ""); } catch {}
|
||||
applyFontSetting(key, val);
|
||||
}
|
||||
export function applyFontSetting(key, val) {
|
||||
const r = document.documentElement;
|
||||
if (key === 'family') {
|
||||
r.style.setProperty('--font-family', val || FONT_PRESETS.system.family);
|
||||
} else if (key === 'size') {
|
||||
r.style.setProperty('--font-size', val || '14px');
|
||||
}
|
||||
}
|
||||
|
||||
/** 将本地路径转为 API 代理 URL */
|
||||
function localPathToUrl(localPath) {
|
||||
// base64 编码避免路径中中文/特殊字符被 URL 编码破坏
|
||||
const utf8 = unescape(encodeURIComponent(localPath));
|
||||
return `/api/bg-image?path=${btoa(utf8)}`;
|
||||
}
|
||||
|
||||
/** 获取/设置自定义背景图 */
|
||||
export function getBgImage() {
|
||||
try { return localStorage.getItem(STORAGE_BG_KEY) || ""; }
|
||||
catch { return ""; }
|
||||
}
|
||||
export function setBgImage(url) {
|
||||
try { localStorage.setItem(STORAGE_BG_KEY, url || ""); }
|
||||
catch {}
|
||||
applyBgImage(url);
|
||||
// 异步同步到服务端
|
||||
try { api("/api/config/theme", { method: "POST", body: JSON.stringify({ bg_image: url || "" }) }); } catch {}
|
||||
}
|
||||
export function applyBgImage(url) {
|
||||
const r = document.documentElement;
|
||||
const layer = document.getElementById('bgImageLayer');
|
||||
if (url) {
|
||||
// 本地绝对路径 → API 代理(浏览器安全策略禁止 HTTP 页面直接加载 file://)
|
||||
const displayUrl = /^[a-zA-Z]:[\\/]/.test(url) ? localPathToUrl(url) : url;
|
||||
r.style.setProperty('--bg-image-override', `url("${displayUrl}")`);
|
||||
// 同步更新 bgImageLayer 的内联样式,覆盖 wireBackgroundImage 设置的默认值
|
||||
if (layer) layer.style.backgroundImage = `url("${displayUrl}")`;
|
||||
} else {
|
||||
r.style.removeProperty('--bg-image-override');
|
||||
// 恢复默认背景
|
||||
if (layer) layer.style.backgroundImage = '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前保存的主题 */
|
||||
export function getCurrentTheme() {
|
||||
try { return localStorage.getItem(STORAGE_KEY) || DEFAULT_THEME; }
|
||||
catch { return DEFAULT_THEME; }
|
||||
}
|
||||
|
||||
/** 应用主题到页面 */
|
||||
export function applyTheme(themeId) {
|
||||
const theme = THEMES[themeId] || THEMES[DEFAULT_THEME];
|
||||
const r = document.documentElement;
|
||||
|
||||
// 全局变量
|
||||
if (theme.root) {
|
||||
Object.entries(theme.root).forEach(([k, v]) => r.style.setProperty(k, v));
|
||||
}
|
||||
|
||||
// Edit 模式变量
|
||||
if (theme.edit) {
|
||||
Object.entries(theme.edit).forEach(([k, v]) => r.style.setProperty(k, v));
|
||||
}
|
||||
|
||||
// 持久化
|
||||
try { localStorage.setItem(STORAGE_KEY, themeId); } catch {}
|
||||
|
||||
// 触发 body 类名切换(用于全局暗/亮模式判断)
|
||||
// 优先使用主题对象的 dark 字段;缺失时回退到 id 字符串推断
|
||||
const isDark = theme.dark ?? !(themeId.includes("light") || themeId.includes("solarized"));
|
||||
document.body.classList.toggle("theme-dark", isDark);
|
||||
document.body.classList.toggle("theme-light", !isDark);
|
||||
}
|
||||
|
||||
/** 初始化:从 localStorage 恢复主题,并从服务端同步背景图 */
|
||||
export function initTheme() {
|
||||
const id = getCurrentTheme();
|
||||
applyTheme(id);
|
||||
// 先从 localStorage 快读,再从服务端同步(异步)
|
||||
applyBgImage(getBgImage());
|
||||
applyFontSetting('family', getFontSetting('family'));
|
||||
applyFontSetting('size', getFontSetting('size'));
|
||||
applyBgBlur(getBgBlur());
|
||||
applyBgVignette(getBgVignette());
|
||||
// 异步从服务端同步主题配置
|
||||
loadThemeConfig();
|
||||
return id;
|
||||
}
|
||||
|
||||
// ===== 背景模糊度 =====
|
||||
export function getBgBlur() {
|
||||
try { return localStorage.getItem(STORAGE_BLUR_KEY) || ""; } catch { return ""; }
|
||||
}
|
||||
export function setBgBlur(val) {
|
||||
try { localStorage.setItem(STORAGE_BLUR_KEY, val || ""); } catch {}
|
||||
applyBgBlur(val);
|
||||
}
|
||||
export function applyBgBlur(val) {
|
||||
// val 为空字符串时使用主题默认值(由 applyTheme 设置),否则覆盖
|
||||
const r = document.documentElement;
|
||||
if (val !== "" && val !== null && val !== undefined) {
|
||||
r.style.setProperty('--bg-dof', val + 'px');
|
||||
} else {
|
||||
r.style.removeProperty('--bg-dof');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 背景暗角 =====
|
||||
export function getBgVignette() {
|
||||
try { return localStorage.getItem(STORAGE_VIGNETTE_KEY) || ""; } catch { return ""; }
|
||||
}
|
||||
export function setBgVignette(val) {
|
||||
try { localStorage.setItem(STORAGE_VIGNETTE_KEY, val || ""); } catch {}
|
||||
applyBgVignette(val);
|
||||
}
|
||||
export function applyBgVignette(val) {
|
||||
const r = document.documentElement;
|
||||
if (val !== "" && val !== null && val !== undefined) {
|
||||
r.style.setProperty('--bg-vignette', val);
|
||||
} else {
|
||||
r.style.removeProperty('--bg-vignette');
|
||||
}
|
||||
}
|
||||
|
||||
/** 从服务端加载主题配置(覆盖 localStorage 缓存) */
|
||||
export async function loadThemeConfig() {
|
||||
try {
|
||||
const res = await api("/api/config/theme");
|
||||
if (res.bg_image !== undefined) {
|
||||
const img = res.bg_image || "";
|
||||
try { localStorage.setItem(STORAGE_BG_KEY, img); } catch {}
|
||||
applyBgImage(img);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// todo.js -- Todo List 悬浮窗
|
||||
|
||||
import { $, escapeHtml } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
function _updateBadge(show) {
|
||||
try {
|
||||
import('./action-bar.js').then(m => m.setActionBadge('todo', show));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function buildTodoListOverlay(todoData) {
|
||||
if (!todoData || !todoData.steps || !todoData.steps.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "todo-list-overlay";
|
||||
overlay.hidden = true; // 默认隐藏,按钮点击后显示
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "todo-list-header";
|
||||
header.textContent = "\u{1F4CB} Todo List";
|
||||
header.addEventListener("click", () => {
|
||||
overlay.classList.toggle("todo-list-overlay--collapsed");
|
||||
});
|
||||
overlay.appendChild(header);
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "todo-list-body";
|
||||
todoData.steps.forEach((s, i) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "todo-list-item";
|
||||
const statusIcon = todoData.done_steps && todoData.done_steps.includes(i)
|
||||
? '<span class="todo-check">\u2713</span>'
|
||||
: '<span class="todo-spinner"></span>';
|
||||
item.innerHTML = `${statusIcon}<span class="todo-label">${escapeHtml(s)}</span>`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
overlay.appendChild(list);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function _getContainer() {
|
||||
const chatMessages = $("chatMessages");
|
||||
return chatMessages ? (chatMessages.closest(".chat-area") || chatMessages.parentElement) : null;
|
||||
}
|
||||
|
||||
function updateTodoOverlay(todoData) {
|
||||
_updateBadge(!!(todoData && todoData.steps && todoData.steps.length));
|
||||
|
||||
// 如果已有 overlay,更新其内容
|
||||
if (state._currentTodoOverlay) {
|
||||
const existing = state._currentTodoOverlay;
|
||||
// 更新列表内容
|
||||
const list = existing.querySelector(".todo-list-body");
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
if (todoData && todoData.steps && todoData.steps.length) {
|
||||
todoData.steps.forEach((s, i) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "todo-list-item";
|
||||
const statusIcon = todoData.done_steps && todoData.done_steps.includes(i)
|
||||
? '<span class="todo-check">\u2713</span>'
|
||||
: '<span class="todo-spinner"></span>';
|
||||
item.innerHTML = `${statusIcon}<span class="todo-label">${escapeHtml(s)}</span>`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
}
|
||||
existing.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!todoData || !todoData.steps || !todoData.steps.length) {
|
||||
_updateBadge(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = buildTodoListOverlay(todoData);
|
||||
if (!overlay) return;
|
||||
|
||||
const container = _getContainer();
|
||||
if (!container) return;
|
||||
|
||||
// 移除旧 overlay(如果有)
|
||||
const old = container.querySelector(".todo-list-overlay");
|
||||
if (old) old.remove();
|
||||
|
||||
// 新增(初始隐藏,等待按钮点击)
|
||||
overlay.hidden = false;
|
||||
container.appendChild(overlay);
|
||||
state._currentTodoOverlay = overlay;
|
||||
|
||||
// 同步双浮窗布局
|
||||
try { import('./pending-overlay.js').then(m => m.updateOverlayLayout && m.updateOverlayLayout()); } catch {}
|
||||
}
|
||||
|
||||
function clearTodoOverlay() {
|
||||
_updateBadge(false);
|
||||
if (state._currentTodoOverlay) {
|
||||
state._currentTodoOverlay.hidden = true;
|
||||
}
|
||||
// 也隐藏 DOM 中可能存在的其他 todo overlay
|
||||
document.querySelectorAll(".todo-list-overlay").forEach((el) => el.hidden = true);
|
||||
}
|
||||
|
||||
export { buildTodoListOverlay, updateTodoOverlay, clearTodoOverlay };
|
||||
@@ -0,0 +1,246 @@
|
||||
// token-bar.js -- Token 上下文进度条(含压缩游标 + 手动压缩)
|
||||
// 在输入框下方展示当前 token 用量 / 最大上下文的动态进度条,
|
||||
// 两个可拖拽游标对应 MINI_COMPRESS_RATE / HARD_COMPRESS_RATE
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
let _pollTimer = null;
|
||||
let _settings = null; // { context_window, mini_rate, hard_rate, current_turns }
|
||||
let _currentTokens = 0;
|
||||
let _dragging = null; // 'mini' | 'hard' | null
|
||||
|
||||
// ===== DOM refs =====
|
||||
let _barTrack, _barFill, _barSegGreen, _barSegYellow, _barSegRed;
|
||||
let _handleMini, _handleHard;
|
||||
let _labelText;
|
||||
|
||||
// ===== 初始化 =====
|
||||
export function initTokenBar() {
|
||||
_barTrack = $("tokenBarTrack");
|
||||
_barFill = $("tokenBarFill");
|
||||
_barSegGreen = $("tokenBarSegGreen");
|
||||
_barSegYellow = $("tokenBarSegYellow");
|
||||
_barSegRed = $("tokenBarSegRed");
|
||||
_handleMini = $("tokenHandleMini");
|
||||
_handleHard = $("tokenHandleHard");
|
||||
_labelText = $("tokenLabel");
|
||||
|
||||
if (!_barTrack) return;
|
||||
|
||||
// 从 state 恢复当前会话的 token 用量
|
||||
_currentTokens = state.currentTokens || 0;
|
||||
|
||||
// 加载压缩设置
|
||||
loadSettings().then(() => {
|
||||
updateBarUI();
|
||||
attachHandleEvents();
|
||||
});
|
||||
|
||||
// 立即拉一次最新数据,然后启动轮询
|
||||
pollTokens();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
// ===== 加载设置 =====
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const data = await api("/api/config/compress-settings");
|
||||
_settings = data;
|
||||
} catch {
|
||||
_settings = { context_window: 131072, mini_compress_rate: 0.4, hard_compress_rate: 0.7 };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 轮询 token 用量 =====
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
_pollTimer = setInterval(pollTokens, 2000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
}
|
||||
|
||||
async function pollTokens() {
|
||||
if (!state.sessionId) return;
|
||||
try {
|
||||
const data = await api(`/api/chat/tokens/${state.sessionId}`);
|
||||
_currentTokens = data.tokens || 0;
|
||||
if (data.context_window) {
|
||||
_settings = {
|
||||
context_window: data.context_window,
|
||||
mini_rate: data.mini_rate,
|
||||
hard_rate: data.hard_rate,
|
||||
};
|
||||
}
|
||||
updateBarUI();
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 更新进度条 =====
|
||||
function updateBarUI() {
|
||||
if (!_barTrack || !_settings) return;
|
||||
|
||||
const cw = _settings.context_window || 131072;
|
||||
const miniRate = _settings.mini_rate || 0.4;
|
||||
const hardRate = _settings.hard_rate || 0.7;
|
||||
const tokens = _currentTokens;
|
||||
|
||||
// 进度条填充位置(cap at 100%)
|
||||
const pct = Math.min(100, (tokens / cw) * 100);
|
||||
const miniPct = miniRate * 100;
|
||||
const hardPct = hardRate * 100;
|
||||
|
||||
// 颜色分段宽仅延伸到实际 token 用量位置
|
||||
const greenW = Math.min(pct, miniPct);
|
||||
const yellowW = Math.max(0, Math.min(pct - miniPct, hardPct - miniPct));
|
||||
const redW = Math.max(0, pct - hardPct);
|
||||
|
||||
if (_barSegGreen) _barSegGreen.style.width = `${greenW}%`;
|
||||
if (_barSegYellow) _barSegYellow.style.width = `${yellowW}%`;
|
||||
if (_barSegRed) _barSegRed.style.width = `${redW}%`;
|
||||
|
||||
// 填充条隐藏(分段已承担可视化角色)
|
||||
if (_barFill) _barFill.style.width = '0%';
|
||||
|
||||
// 游标位置
|
||||
if (_handleMini) _handleMini.style.left = `${miniPct}%`;
|
||||
if (_handleHard) _handleHard.style.left = `${hardPct}%`;
|
||||
|
||||
// 标签文字
|
||||
if (_labelText) {
|
||||
_labelText.textContent = `${_fmtNum(tokens)} / ${_fmtNum(cw)} tokens`;
|
||||
// 颜色提示
|
||||
const cls = tokens > cw * hardRate ? 'danger' : tokens > cw * miniRate ? 'warn' : 'safe';
|
||||
_labelText.className = `token-label token-label--${cls}`;
|
||||
}
|
||||
|
||||
// 游标超出反馈
|
||||
if (_handleMini) {
|
||||
_handleMini.classList.toggle('overshot', tokens > cw * miniRate);
|
||||
}
|
||||
if (_handleHard) {
|
||||
_handleHard.classList.toggle('overshot', tokens > cw * hardRate);
|
||||
}
|
||||
}
|
||||
|
||||
function _fmtNum(n) {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// ===== 游标拖拽 =====
|
||||
function attachHandleEvents() {
|
||||
if (!_handleMini || !_handleHard || !_barTrack) return;
|
||||
|
||||
const handles = [
|
||||
{ el: _handleMini, key: 'mini' },
|
||||
{ el: _handleHard, key: 'hard' },
|
||||
];
|
||||
|
||||
for (const h of handles) {
|
||||
h.el.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
_dragging = h.key;
|
||||
h.el.classList.add('dragging');
|
||||
document.addEventListener('mousemove', onDragMove);
|
||||
document.addEventListener('mouseup', onDragEnd);
|
||||
});
|
||||
|
||||
// 触摸支持
|
||||
h.el.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
_dragging = h.key;
|
||||
h.el.classList.add('dragging');
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onDragEndTouch);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getPctFromEvent(clientX) {
|
||||
const rect = _barTrack.getBoundingClientRect();
|
||||
return Math.max(5, Math.min(95, ((clientX - rect.left) / rect.width) * 100));
|
||||
}
|
||||
|
||||
function onDragMove(e) {
|
||||
if (!_dragging) return;
|
||||
const pct = getPctFromEvent(e.clientX);
|
||||
setHandlePct(_dragging, pct);
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (!_dragging) return;
|
||||
e.preventDefault();
|
||||
const pct = getPctFromEvent(e.touches[0].clientX);
|
||||
setHandlePct(_dragging, pct);
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
endDrag();
|
||||
document.removeEventListener('mousemove', onDragMove);
|
||||
document.removeEventListener('mouseup', onDragEnd);
|
||||
}
|
||||
|
||||
function onDragEndTouch() {
|
||||
endDrag();
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onDragEndTouch);
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (!_dragging) return;
|
||||
const key = _dragging;
|
||||
_dragging = null;
|
||||
if (_handleMini) _handleMini.classList.remove('dragging');
|
||||
if (_handleHard) _handleHard.classList.remove('dragging');
|
||||
// 保存到后端
|
||||
saveRateSetting(key);
|
||||
}
|
||||
|
||||
function setHandlePct(key, pct) {
|
||||
const rate = Math.round(pct) / 100;
|
||||
if (key === 'mini') {
|
||||
_settings.mini_rate = rate;
|
||||
// mini 不能超过 hard
|
||||
if (_settings.hard_rate && rate >= _settings.hard_rate) {
|
||||
_settings.hard_rate = Math.min(1, rate + 0.05);
|
||||
}
|
||||
} else if (key === 'hard') {
|
||||
_settings.hard_rate = rate;
|
||||
// hard 不能小于 mini
|
||||
if (_settings.mini_rate && rate <= _settings.mini_rate) {
|
||||
_settings.mini_rate = Math.max(0.05, rate - 0.05);
|
||||
}
|
||||
}
|
||||
updateBarUI();
|
||||
}
|
||||
|
||||
async function saveRateSetting(key) {
|
||||
const body = {};
|
||||
if (key === 'mini') body.mini_compress_rate = _settings.mini_rate;
|
||||
if (key === 'hard') body.hard_compress_rate = _settings.hard_rate;
|
||||
try {
|
||||
await api("/api/config/compress-settings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
showToast("保存压缩设置失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 公开:外部调用 =====
|
||||
/** 强制立即刷新 token 计数(发送消息后调用) */
|
||||
export function refreshTokens() {
|
||||
pollTokens();
|
||||
}
|
||||
|
||||
/** 停止轮询(页面卸载时调用) */
|
||||
export function stopTokenPolling() {
|
||||
stopPolling();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,176 @@
|
||||
// utils.js -- 纯工具函数
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function clamp(n, min, max) {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
function formatAudioDuration(seconds) {
|
||||
const n = Number(seconds);
|
||||
if (!Number.isFinite(n) || n <= 0) return "";
|
||||
if (n < 10) return `${n.toFixed(1)}″`;
|
||||
return `${Math.round(n)}″`;
|
||||
}
|
||||
|
||||
function getAudioBubbleWidth(seconds) {
|
||||
const n = Number(seconds);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const width = 132 + Math.sqrt(n) * 46;
|
||||
return `${Math.round(clamp(width, 148, 312))}px`;
|
||||
}
|
||||
|
||||
function inferUploadKind(file) {
|
||||
const name = String(file?.name || "").toLowerCase();
|
||||
const type = String(file?.type || "").toLowerCase();
|
||||
const filePath = String(file?.path || "").toLowerCase();
|
||||
if (type.startsWith("folder/")) return "folder";
|
||||
// SVG 不作为图片处理(按普通文本文件渲染);排除 image/svg+xml
|
||||
const isSvg = /\.svg$/.test(name) || /\.svg$/.test(filePath) || type.indexOf("svg") >= 0;
|
||||
if (!isSvg && (type.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|ico)$/.test(name) || /\.(png|jpe?g|gif|webp|bmp|ico)$/.test(filePath))) return "image";
|
||||
if (type.startsWith("audio/") || /\.(wav|mp3|m4a|flac|ogg|webm|aac)$/.test(name)) return "audio";
|
||||
return "file";
|
||||
}
|
||||
|
||||
function getTextFromContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((part) => part && part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getMediaParts(content) {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.filter((part) => part && part.type !== "text");
|
||||
}
|
||||
|
||||
function isAudioOnlyAssistantMessage(msg) {
|
||||
if (!msg || msg.role !== "assistant") return false;
|
||||
const text = getTextFromContent(msg.content);
|
||||
const mediaParts = getMediaParts(msg.content);
|
||||
const hasOnlyAudioMedia = mediaParts.length > 0 && mediaParts.every((part) => part.type === "audio" && part.url);
|
||||
return hasOnlyAudioMedia && !text;
|
||||
}
|
||||
|
||||
function buildOptimisticUserContent(text, fileParts) {
|
||||
const parts = [];
|
||||
(fileParts || []).forEach((filePart) => {
|
||||
if (filePart && filePart.kind === "image" && filePart.url) {
|
||||
parts.push({ type: "image", url: filePart.url, name: filePart.name || "图片" });
|
||||
} else if (filePart && filePart.kind === "audio" && filePart.url) {
|
||||
parts.push({ type: "audio", url: filePart.url, name: filePart.name || "音频" });
|
||||
} else if (filePart && filePart.kind === "folder") {
|
||||
parts.push({ type: "folder", name: filePart.name || "文件夹" });
|
||||
} else if (filePart && filePart.name) {
|
||||
parts.push({ type: "file", name: filePart.name });
|
||||
}
|
||||
});
|
||||
const t = (text || "").trim();
|
||||
if (t) parts.push({ type: "text", text: t });
|
||||
if (parts.length === 0) return null;
|
||||
if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
|
||||
return parts;
|
||||
}
|
||||
|
||||
function revokeBlobUrlStack(urlStack) {
|
||||
(urlStack || []).forEach((u) => {
|
||||
try { URL.revokeObjectURL(u); } catch { /* ignore */ }
|
||||
});
|
||||
urlStack.length = 0;
|
||||
}
|
||||
|
||||
function makeId() {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
|
||||
function codeBlockNode(lang, code) {
|
||||
const raw = code.replace(/\n$/, "");
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "code-block-wrap";
|
||||
const head = document.createElement("div");
|
||||
head.className = "code-block-head";
|
||||
const langSpan = document.createElement("span");
|
||||
langSpan.className = "code-block-lang";
|
||||
langSpan.textContent = (lang && lang.trim()) || "text";
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "code-block-copy";
|
||||
btn.textContent = "复制全部";
|
||||
btn.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(raw).catch(() => {});
|
||||
});
|
||||
head.appendChild(langSpan);
|
||||
head.appendChild(btn);
|
||||
const pre = document.createElement("pre");
|
||||
const codeEl = document.createElement("code");
|
||||
codeEl.textContent = raw;
|
||||
pre.appendChild(codeEl);
|
||||
wrap.appendChild(head);
|
||||
wrap.appendChild(pre);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示一个 3 秒自动消失的浮窗通知。
|
||||
* @param {string} text - 通知文字。
|
||||
*/
|
||||
function showToast(text) {
|
||||
const container = document.getElementById("toastContainer");
|
||||
if (!container) return;
|
||||
const item = document.createElement("div");
|
||||
item.className = "toast-item";
|
||||
item.textContent = text;
|
||||
container.appendChild(item);
|
||||
setTimeout(() => {
|
||||
if (item.parentNode) item.parentNode.removeChild(item);
|
||||
}, 3100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 pendingFiles 去重:
|
||||
* - 普通 File 对象:按 name + size + lastModified 比较
|
||||
* - __isRef 引用:按 refPath 比较
|
||||
* - 文件夹占位:按 name + type 比较
|
||||
*/
|
||||
function dedupePendingFiles(files) {
|
||||
const seen = new Set();
|
||||
return files.filter((f) => {
|
||||
let key;
|
||||
if (f.__isRef) {
|
||||
// 包含 type 以区分 ref/file、ref/image、ref/folder
|
||||
key = 'ref:' + (f.type || '') + ':' + (f.refPath || '');
|
||||
} else if (f.type && f.type.startsWith('folder/')) {
|
||||
key = 'folder:' + (f.name || '');
|
||||
} else if (f instanceof File) {
|
||||
key = (f.name || '') + '|' + (f.size || 0);
|
||||
} else {
|
||||
key = 'other:' + JSON.stringify(f);
|
||||
}
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
$,
|
||||
escapeHtml,
|
||||
clamp, formatAudioDuration, getAudioBubbleWidth,
|
||||
inferUploadKind,
|
||||
getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage,
|
||||
buildOptimisticUserContent, revokeBlobUrlStack,
|
||||
makeId, codeBlockNode,
|
||||
showToast,
|
||||
dedupePendingFiles,
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
// workspace.js -- 工作空间选择器
|
||||
// 工作空间列表统一从后端 workspace_config.json 读写,不再使用 localStorage
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
let _dropdownOpen = false;
|
||||
let _addingWorkspace = false;
|
||||
|
||||
/** 初始化工作空间数据 */
|
||||
export async function initWorkspace() {
|
||||
// 从后端加载当前选中的工作空间和默认值(列表也由后端 workspace_config.json 提供)
|
||||
try {
|
||||
const data = await api("/api/workspace");
|
||||
state.workspacePath = data.current || "";
|
||||
state.workspaceDefault = data.default || "";
|
||||
state.workspaceList = data.list || [];
|
||||
// 设置快照存储基础目录
|
||||
if (data.snapshots_base) {
|
||||
try {
|
||||
const { setSnapBaseDir } = await import('./edit-mode.js');
|
||||
setSnapBaseDir(data.snapshots_base);
|
||||
} catch {}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
updateWsBtnState();
|
||||
}
|
||||
|
||||
/** 更新按钮激活状态 */
|
||||
function updateWsBtnState() {
|
||||
const btn = $("wsBtn");
|
||||
if (!btn) return;
|
||||
if (state.workspacePath) {
|
||||
btn.classList.add("is-active");
|
||||
const parts = state.workspacePath.replace(/[\\/]+$/, "").split(/[\\/]/);
|
||||
btn.title = state.workspacePath;
|
||||
btn.setAttribute("aria-label", `工作空间: ${parts[parts.length - 1]}`);
|
||||
} else {
|
||||
btn.classList.remove("is-active");
|
||||
btn.title = "选择工作空间(当前使用默认)";
|
||||
btn.setAttribute("aria-label", "选择工作空间");
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换下拉框 */
|
||||
function toggleDropdown() {
|
||||
if (_dropdownOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开下拉框 */
|
||||
async function openDropdown() {
|
||||
const dd = $("wsDropdown");
|
||||
if (!dd) return;
|
||||
// 刷新:从后端获取最新当前选中和列表
|
||||
try {
|
||||
const data = await api("/api/workspace");
|
||||
state.workspacePath = data.current || "";
|
||||
state.workspaceDefault = data.default || "";
|
||||
state.workspaceList = data.list || [];
|
||||
} catch { /* ignore */ }
|
||||
// 欢迎区(居中态)向下展开,否则向上展开
|
||||
const composer = document.querySelector(".composer");
|
||||
if (composer && composer.classList.contains("composer--centered")) {
|
||||
dd.classList.add("ws-dropdown--down");
|
||||
} else {
|
||||
dd.classList.remove("ws-dropdown--down");
|
||||
}
|
||||
renderDropdown();
|
||||
dd.hidden = false;
|
||||
_dropdownOpen = true;
|
||||
updateWsBtnState();
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", onOutsideClick, { once: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** 关闭下拉框 */
|
||||
function closeDropdown() {
|
||||
const dd = $("wsDropdown");
|
||||
if (dd) dd.hidden = true;
|
||||
_dropdownOpen = false;
|
||||
document.removeEventListener("click", onOutsideClick);
|
||||
}
|
||||
|
||||
function onOutsideClick(e) {
|
||||
const wrap = $("wsBtnWrap");
|
||||
if (wrap && !wrap.contains(e.target)) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", onOutsideClick, { once: true });
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染下拉列表 */
|
||||
function renderDropdown() {
|
||||
const list = $("wsDropdownList");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = "";
|
||||
|
||||
// 判断当前选中是否就是默认工作空间(path 为空 或 path 与默认路径一致)
|
||||
const defaultPath = (state.workspaceDefault || "").replace(/[\\/]+$/, "").replace(/\\/g, "/").toLowerCase();
|
||||
const currentNorm = (state.workspacePath || "").replace(/[\\/]+$/, "").replace(/\\/g, "/").toLowerCase();
|
||||
const isUsingDefault = !state.workspacePath || currentNorm === defaultPath;
|
||||
|
||||
// 默认项
|
||||
const defaultItem = document.createElement("div");
|
||||
defaultItem.className = "ws-dropdown-item ws-dropdown-item--default" +
|
||||
(isUsingDefault ? " is-selected" : "");
|
||||
defaultItem.innerHTML = `
|
||||
<img src="/image/文件夹.svg" alt="" class="ws-dropdown-item-icon" />
|
||||
<span class="ws-dropdown-item-name">默认工作空间</span>
|
||||
`;
|
||||
defaultItem.title = state.workspaceDefault || "使用 env_config.json 中配置的默认工作空间";
|
||||
defaultItem.addEventListener("click", () => selectWorkspace(""));
|
||||
list.appendChild(defaultItem);
|
||||
|
||||
// 最近打开文件夹(过滤掉与默认工作空间重复的条目)
|
||||
const folders = state.workspaceList;
|
||||
const filtered = folders.filter((fp) => {
|
||||
const norm = (fp || "").replace(/[\\/]+$/, "").replace(/\\/g, "/").toLowerCase();
|
||||
return norm !== defaultPath;
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "ws-dropdown-item";
|
||||
empty.style.cssText = "color:#94a3b8;font-size:0.78rem;cursor:default;";
|
||||
empty.textContent = "暂无最近文件夹";
|
||||
list.appendChild(empty);
|
||||
} else {
|
||||
filtered.forEach((fp) => {
|
||||
const parts = fp.replace(/[\\/]+$/, "").split(/[\\/]/);
|
||||
const name = parts[parts.length - 1] || fp;
|
||||
|
||||
const item = document.createElement("div");
|
||||
item.className = "ws-dropdown-item" +
|
||||
(fp === state.workspacePath ? " is-selected" : "");
|
||||
item.title = fp;
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.src = "/image/文件夹.svg";
|
||||
icon.alt = "";
|
||||
icon.className = "ws-dropdown-item-icon";
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.className = "ws-dropdown-item-name";
|
||||
span.textContent = name;
|
||||
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.type = "button";
|
||||
delBtn.className = "ws-dropdown-item-delete";
|
||||
delBtn.title = "从列表移除";
|
||||
delBtn.innerHTML = '<img src="/image/删除.svg" alt="删除" />';
|
||||
delBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
removeWorkspace(fp);
|
||||
});
|
||||
|
||||
item.appendChild(icon);
|
||||
item.appendChild(span);
|
||||
item.appendChild(delBtn);
|
||||
item.addEventListener("click", () => selectWorkspace(fp));
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择工作空间 */
|
||||
async function selectWorkspace(path) {
|
||||
try {
|
||||
const data = await api("/api/workspace/select", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
state.workspacePath = data.current || "";
|
||||
updateWsBtnState();
|
||||
closeDropdown();
|
||||
// 自动切换到 Edit 模式并打开该文件夹
|
||||
// 选中具体路径时直接用该路径,选中"默认工作空间"时用 default
|
||||
const targetPath = path || state.workspaceDefault;
|
||||
if (targetPath) {
|
||||
try {
|
||||
const { switchToMode, openFolder } = await import('./edit-mode.js');
|
||||
switchToMode("edit");
|
||||
await openFolder(targetPath);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加工作空间(打开系统文件夹选择器) */
|
||||
async function addWorkspace() {
|
||||
if (_addingWorkspace) return;
|
||||
_addingWorkspace = true;
|
||||
try {
|
||||
let folderPath = "";
|
||||
try {
|
||||
const { pickFolder } = await import('./electron-api.js');
|
||||
folderPath = await pickFolder();
|
||||
} catch {
|
||||
// 非 Electron 环境或调用失败,回退到手动输入
|
||||
}
|
||||
if (!folderPath) {
|
||||
folderPath = prompt("请输入工作空间的绝对路径:", "");
|
||||
}
|
||||
if (!folderPath) return;
|
||||
folderPath = folderPath.trim();
|
||||
if (!folderPath) return;
|
||||
|
||||
// 添加到后端 workspace_config.json
|
||||
try {
|
||||
const data = await api("/api/workspace/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path: folderPath }),
|
||||
});
|
||||
state.workspaceList = data.list || [];
|
||||
} catch (e) {
|
||||
showToast(e.message || String(e));
|
||||
}
|
||||
|
||||
// 自动选中
|
||||
await selectWorkspace(folderPath);
|
||||
|
||||
// 重新渲染下拉列表
|
||||
renderDropdown();
|
||||
} finally {
|
||||
_addingWorkspace = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从列表移除工作空间 */
|
||||
async function removeWorkspace(path) {
|
||||
try {
|
||||
const data = await api("/api/workspace/remove", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
state.workspaceList = data.list || [];
|
||||
state.workspacePath = data.current || "";
|
||||
} catch (e) {
|
||||
showToast(e.message || String(e));
|
||||
}
|
||||
|
||||
renderDropdown();
|
||||
updateWsBtnState();
|
||||
}
|
||||
|
||||
/** 绑定事件 */
|
||||
export function bindWorkspaceEvents() {
|
||||
const wsBtn = $("wsBtn");
|
||||
const wsAddBtn = $("wsAddBtn");
|
||||
|
||||
if (wsBtn) {
|
||||
wsBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
if (wsAddBtn) {
|
||||
wsAddBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
addWorkspace();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user