initial open-source release

This commit is contained in:
minorQAQ committed 2026-08-01 11:15:19 +08:00
commit 01c4605498
224 files changed
+55124

No files matched your search

+83
View File
@@ -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();
}
}
+65
View File
@@ -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 };
+159
View File
@@ -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,
};
+749
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+173
View File
@@ -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();
});
}
+235
View File
@@ -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 = '&#x2713;';
acceptBtn.title = '保留修改';
acceptBtn.addEventListener('click', () => acceptModification(filePath));
const rejectBtn = document.createElement('button');
rejectBtn.className = 'doc-mod-reject';
rejectBtn.innerHTML = '&#x2715;';
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));
}
});
}
}
+877
View File
@@ -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
+144
View File
@@ -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,
};
+246
View File
@@ -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);
}
}
+239
View File
@@ -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();
}
+236
View File
@@ -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,
};
+453
View File
@@ -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,
};
+106
View File
@@ -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,
};
+139
View File
@@ -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();
}
+518
View File
@@ -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 };
+262
View File
@@ -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
+299
View File
@@ -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,
};
+102
View File
@@ -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(空=新建)
};
+758
View File
@@ -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 {}
}
+109
View File
@@ -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 };
+246
View File
@@ -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
+176
View File
@@ -0,0 +1,176 @@
// utils.js -- 纯工具函数
const $ = (id) => document.getElementById(id);
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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,
};
+275
View File
@@ -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();
});
}
}