修复一些已知问题

This commit is contained in:
minorQAQ committed 2026-08-11 09:16:03 +08:00
1 parent 4a11e448cf
commit b59db6879c
31 files changed
+1316 -772

No files matched your search

+6 -6
View File
@@ -129,7 +129,7 @@ function writeEnvConfig(userInput) {
fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8'); fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
console.log('[Electron] 配置已写入:', CONFIG_PATH); console.log('[Electron] CONFIG_PATH:', CONFIG_PATH);
} }
// ==================== Python 检测 ==================== // ==================== Python 检测 ====================
@@ -217,15 +217,15 @@ function startFastAPI() {
}); });
pythonProcess.on('error', (err) => { pythonProcess.on('error', (err) => {
console.error('[Electron] FastAPI 启动失败:', err.message); console.error('[Electron] FastAPI Failed:', err.message);
reject(err); reject(err);
}); });
pythonProcess.on('exit', (code) => { pythonProcess.on('exit', (code) => {
console.log(`[Electron] FastAPI 退出,码: ${code}`); console.log(`[Electron] FastAPI Exit Code: ${code}`);
pythonProcess = null; pythonProcess = null;
if (!isQuitting && code !== 0 && code !== null && mainWindow) { if (!isQuitting && code !== 0 && code !== null && mainWindow) {
dialog.showErrorBox('后端服务异常', `FastAPI 意外退出(退出码: ${code})。`); dialog.showErrorBox('Backend Service Exception', `FastAPI Exit Code: ${code}`);
} }
}); });
@@ -242,7 +242,7 @@ function waitForReady(resolve, reject, retriesLeft) {
} }
const req = http.get(`${BASE_URL}/api/health`, (res) => { const req = http.get(`${BASE_URL}/api/health`, (res) => {
if (res.statusCode === 200) { if (res.statusCode === 200) {
console.log('[Electron] FastAPI 就绪'); console.log('[Electron] FastAPI Ready');
resolve(); resolve();
} else { } else {
retry(); retry();
@@ -260,7 +260,7 @@ function stopFastAPI() {
killPortProcess(PORT); killPortProcess(PORT);
// 再通过 PID 杀一次(确保主进程被杀) // 再通过 PID 杀一次(确保主进程被杀)
if (pythonProcess) { if (pythonProcess) {
console.log('[Electron] 关闭 FastAPI...'); console.log('[Electron] Killing FastAPI Process...');
try { try {
if (process.platform === 'win32') { if (process.platform === 'win32') {
execSync(`taskkill /pid ${pythonProcess.pid} /f /t 2>nul`, { stdio: 'ignore' }); execSync(`taskkill /pid ${pythonProcess.pid} /f /t 2>nul`, { stdio: 'ignore' });
+41 -22
View File
@@ -85,6 +85,14 @@ def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime:
else: else:
agent_tools = all_tools agent_tools = all_tools
# 2.1 强制为主 Agent 加载 doc_tool(不可通过配置移除)
if cfg.role == "main":
if not any(t.name == "doc_tool" for t in agent_tools):
for t in all_tools:
if t.name == "doc_tool":
agent_tools.append(t)
break
# 3. bind_tools # 3. bind_tools
model_with_tools = agent_llm.bind_tools(agent_tools, tool_choice="auto") model_with_tools = agent_llm.bind_tools(agent_tools, tool_choice="auto")
@@ -109,6 +117,29 @@ def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime:
) )
def _refresh_subagent_tool_description() -> None:
"""在运行时构建完成后刷新 CallSubAgentTool 的描述,使主 Agent 知晓可委派的子 Agent。
通过传入 sub_names 和 sub_descriptions 参数避免从 agent_runtime 自身重新导入(循环导入)。
在 build_all_agent_runtimes() 和 reload_all_agent_runtimes() 之后调用。
"""
sub_names = []
sub_descriptions = {}
for name, rt in _all_agent_runtimes.items():
if name != _main_agent_name:
sub_names.append(name)
if rt.description:
sub_descriptions[name] = rt.description
from agent.tools.agent_call import CallSubAgentTool
main_rt = _all_agent_runtimes.get(_main_agent_name)
if main_rt is None:
return
for tool in main_rt.tools:
if isinstance(tool, CallSubAgentTool):
tool._refresh_description(sub_names, sub_descriptions)
break
def build_all_agent_runtimes() -> dict[str, AgentRuntime]: def build_all_agent_runtimes() -> dict[str, AgentRuntime]:
"""从 agent_config.json 读取所有启用 Agent 并构建运行时字典。 """从 agent_config.json 读取所有启用 Agent 并构建运行时字典。
@@ -140,6 +171,16 @@ def build_all_agent_runtimes() -> dict[str, AgentRuntime]:
_main_agent_name = first_name _main_agent_name = first_name
print(f"[AgentFactory] 未找到 role='main' 的 Agent,自动将 '{first_name}' 设为主 Agent。") print(f"[AgentFactory] 未找到 role='main' 的 Agent,自动将 '{first_name}' 设为主 Agent。")
# 所有子 Agent 就绪后,刷新 call_subagent 工具描述并重建主 Agent,
# 使主 Agent bind_tools 时携带最新的子 Agent 列表与能力描述
_refresh_subagent_tool_description()
main_cfg = next((c for c in configs if c.enabled and c.role == "main"), None)
if main_cfg:
try:
_all_agent_runtimes[_main_agent_name] = _build_agent_runtime(main_cfg)
except Exception as e:
print(f"[AgentFactory] 重建主 Agent '{_main_agent_name}' 失败: {e}")
return _all_agent_runtimes return _all_agent_runtimes
@@ -248,25 +289,3 @@ agent_graph = main_agent_runtime.graph
tools = main_agent_runtime.tools tools = main_agent_runtime.tools
TOOLS_BY_NAME = main_agent_runtime.tools_by_name TOOLS_BY_NAME = main_agent_runtime.tools_by_name
llm = main_agent_runtime.llm llm = main_agent_runtime.llm
def _refresh_subagent_tool_description() -> None:
"""在运行时构建完成后刷新 CallSubAgentTool 的描述,使主 Agent 知晓可委派的子 Agent。
通过传入 sub_names 和 sub_descriptions 参数避免从 agent_runtime 自身重新导入(循环导入)。
在 build_all_agent_runtimes() 和 reload_all_agent_runtimes() 之后调用。
"""
sub_names = []
sub_descriptions = {}
for name, rt in _all_agent_runtimes.items():
if name != _main_agent_name:
sub_names.append(name)
if rt.description:
sub_descriptions[name] = rt.description
from agent.tools.agent_call import CallSubAgentTool
for tool in tools:
if isinstance(tool, CallSubAgentTool):
tool._refresh_description(sub_names, sub_descriptions)
break
_refresh_subagent_tool_description()
+2
View File
@@ -6,6 +6,8 @@
"max_iterations": 200, "max_iterations": 200,
"llm_model_id": "affiaa7r", "llm_model_id": "affiaa7r",
"tools": [ "tools": [
"doc_tool",
"call_subagent",
"date_query" "date_query"
], ],
"enabled": true, "enabled": true,
+6 -1
View File
@@ -1,3 +1,8 @@
{ {
"bg_image": "" "theme": "solarized-light",
"bg_image": "",
"font_family": "\"ZCOOL KuaiLe\", \"Microsoft YaHei\", sans-serif",
"font_size": "14px",
"bg_blur": "",
"bg_vignette": ""
} }
+1 -1
View File
@@ -105,7 +105,7 @@
"permission": "direct", "permission": "direct",
"auto_execute_rules": [], "auto_execute_rules": [],
"enabled": true, "enabled": true,
"timeout": 300 "timeout": 900
}, },
{ {
"name": "doc_tool", "name": "doc_tool",
+2 -3
View File
@@ -1,7 +1,6 @@
{ {
"current": "C:\\Users\\86166\\Desktop\\新建文件夹", "current": "",
"list": [ "list": [
"C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace", "C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace"
"C:\\Users\\86166\\Desktop\\新建文件夹"
] ]
} }
+2 -11
View File
@@ -175,17 +175,8 @@ class ChatQwen(ChatOpenAI):
if ext in IMAGE_FILE_EXTENSIONS: if ext in IMAGE_FILE_EXTENSIONS:
new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}})) new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}}))
else: else:
try: # 非图片附件只注入文件路径提示,内容由 Agent 通过 doc_tool 读取
file_text, file_images = Documents_process.process(file_path) new_content.append({"type": "text", "text": make_attachment_text("附件文件", normalized_path)})
new_content.append({"type": "text", "text": make_attachment_text("文件", normalized_path, f"以下是文件 {normalized_path} 的内容:\n{file_text.strip()}" if file_text.strip() else "")})
for image_bytes in file_images or []:
image_part = image_bytes_to_openai_image_url_part(image_bytes)
if image_part is not None:
new_content.append(image_part)
if not file_text.strip() and not file_images:
new_content.append({"type": "text", "text": make_attachment_text("附件文件", normalized_path)})
except Exception as e:
new_content.append({"type": "text", "text": f"[附件处理失败: {normalized_path}: {e}]"})
else: else:
text = assistant_text(part) text = assistant_text(part)
if text: if text:
+26 -13
View File
@@ -60,27 +60,40 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
规则: 规则:
- 保留所有 SystemMessage、HumanMessage(非合成图片)、AIMessage、ToolMessage - 保留所有 SystemMessage、HumanMessage(非合成图片)、AIMessage、ToolMessage
- 仅 process_tool_artifact 生成的合成图片 HumanMessage 按 trajectory_rounds 裁剪 - 仅 process_tool_artifact 生成的合成图片 HumanMessage 按 trajectory_rounds 裁剪
- 每轮由一个 AIMessage(含 tool_calls)定义 - 只对实际包含图片截图的轮次计数,无截图的轮次跳过不计数
""" """
if trajectory_rounds <= 0: if trajectory_rounds <= 0:
return list(messages) return list(messages)
# 从后向前遍历,统计已遇到的含 tool_calls 的 AIMessage 轮数 # 从后向前遍历,先标记哪些轮次包含图片截图
rounds_seen = 0 # 一"轮"由一个 AIMessage(含 tool_calls)之后的消息(ToolMessage + synthetic HumanMessage)组成
img_rounds_seen = 0
kept_indices = set() kept_indices = set()
for i in range(len(messages) - 1, -1, -1): for i in range(len(messages) - 1, -1, -1):
msg = messages[i] msg = messages[i]
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None): # 合成图片消息:仅保留最近 trajectory_rounds 轮(有图片的轮才计数)
rounds_seen += 1
# 合成图片消息:仅保留最近 trajectory_rounds 轮
if isinstance(msg, HumanMessage) and msg.additional_kwargs.get("synthetic") and _has_image_content(msg): if isinstance(msg, HumanMessage) and msg.additional_kwargs.get("synthetic") and _has_image_content(msg):
if rounds_seen < trajectory_rounds: if img_rounds_seen < trajectory_rounds:
kept_indices.add(i) kept_indices.add(i)
else: continue
kept_indices.add(i)
# 遇到 AIMessage(含 tool_calls)时,检查本轮是否有图片来决定是否增加计数
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None):
# 向后搜索本轮是否有截图(从该 AIMessage 到下一个 AIMessage 之间)
has_image = False
for j in range(i + 1, len(messages)):
mj = messages[j]
if isinstance(mj, AIMessage) and getattr(mj, "tool_calls", None):
break
if isinstance(mj, HumanMessage) and mj.additional_kwargs.get("synthetic") and _has_image_content(mj):
has_image = True
break
if has_image:
img_rounds_seen += 1
kept_indices.add(i)
return [messages[i] for i in sorted(kept_indices)] return [messages[i] for i in sorted(kept_indices)]
@@ -209,12 +222,12 @@ def _compress_messages_in_loop(state: dict, response: Any) -> None:
if total <= 0: if total <= 0:
return return
# 存储最新 token 用量供前端轮询 # 存储最新 token 用量供前端轮询(仅统计主 Agent,子 Agent 上下文不计入)
_sid = "" _sid = ""
try: try:
from agent.history.tool_call_recorder import get_current_session, set_session_tokens from agent.history.tool_call_recorder import get_current_session, is_sub_agent_context, set_session_tokens
_sid = get_current_session() or "" _sid = get_current_session() or ""
if _sid: if _sid and not is_sub_agent_context():
set_session_tokens(_sid, total) set_session_tokens(_sid, total)
except Exception: except Exception:
pass pass
+26 -19
View File
@@ -71,24 +71,31 @@ _OOM_MESSAGE = "OOM: 已达到最大迭代次数限制,任务未能完成。
def _inject_previous_turn_context(session_id: str, messages: list) -> None: def _inject_previous_turn_context(session_id: str, messages: list) -> None:
"""从上一轮 tool_calling 记录中读取工具调用上下文,注入为合成消息。""" """从最近5轮(不含当前轮)tool_calling 记录中读取工具调用上下文,注入为合成消息。"""
try: try:
from agent.history.tool_call_recorder import get_latest_turn from agent.history.tool_call_recorder import list_turns
latest = get_latest_turn(session_id) # 取6轮(当前轮 + 前5轮历史)
if not latest: turns = list_turns(session_id, limit=6)
if not turns:
return return
tool_calls = latest.get("tool_calls", []) # 跳过最近一轮(当前正在执行或刚结束的 turn),取前面最多5轮历史
if not tool_calls: recent_turns = turns[1:6] if len(turns) > 1 else []
return if not recent_turns:
for tc in tool_calls: # 首次对话场景:只有1轮,用唯一那一轮
tn = tc.get("name", "unknown") recent_turns = [turns[0]]
ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300] for turn in recent_turns:
tr = str(tc.get("result_text", ""))[:500] tool_calls = turn.get("tool_calls", [])
if tn and tr: if not tool_calls:
messages.append(HumanMessage( continue
content=f"[上轮工具调用] {tn}({ta}) → {tr}", for tc in tool_calls:
additional_kwargs={"synthetic": True}, tn = tc.get("name", "unknown")
)) ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300]
tr = str(tc.get("result_text", ""))[:500]
if tn and tr:
messages.append(HumanMessage(
content=f"[历史工具调用] {tn}({ta}) → {tr}",
additional_kwargs={"synthetic": True},
))
except Exception: except Exception:
pass pass
@@ -703,7 +710,7 @@ def _resume_sub_agent(
sub_call_id = sub_tool_call.get("id") or f"sub_{approval_id}" sub_call_id = sub_tool_call.get("id") or f"sub_{approval_id}"
# 1) 构造子图续跑的 ToolMessage # 1) 构造子图续跑的 ToolMessage
if decision == "skip": if decision == "skip" and sub_type != "human_interaction":
sub_messages.append(ToolMessage( sub_messages.append(ToolMessage(
content=f"用户已跳过此工具调用({sub_tool_name}),未执行。", content=f"用户已跳过此工具调用({sub_tool_name}),未执行。",
tool_call_id=sub_call_id, name=sub_tool_name, tool_call_id=sub_call_id, name=sub_tool_name,
@@ -812,8 +819,8 @@ def continue_after_human_action(
sync_turn_from_chat_history(session_id, chat_history, "") sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history return chat_history
# ===== 跳过:不执行工具,写跳过 ToolMessage 后续跑图(tool_call / human_interaction 通用)===== # ===== 跳过:仅 tool_call 类型支持,human_interaction 不支持跳过 =====
if decision == "skip": if decision == "skip" and pending.get("type") != "human_interaction":
tool_call = dict(pending.get("tool_call") or {}) tool_call = dict(pending.get("tool_call") or {})
tool_name = tool_call.get("name") or "unknown_tool" tool_name = tool_call.get("name") or "unknown_tool"
call_id = tool_call.get("id") or f"manual_{approval_id}" call_id = tool_call.get("id") or f"manual_{approval_id}"
+14 -14
View File
@@ -65,7 +65,7 @@ def set_cron_time_period(minutes: float) -> float:
global _CRON_TIME_PERIOD_MINUTES global _CRON_TIME_PERIOD_MINUTES
val = float(minutes) val = float(minutes)
if val <= 0: if val <= 0:
raise ValueError("时间段长度必须为正数") raise ValueError("Time period must be a positive number")
_CRON_TIME_PERIOD_MINUTES = val _CRON_TIME_PERIOD_MINUTES = val
try: try:
from agent.core.config_manager import load_env_config, save_env_config from agent.core.config_manager import load_env_config, save_env_config
@@ -73,7 +73,7 @@ def set_cron_time_period(minutes: float) -> float:
cfg["CRON_TIME_PERIOD_MINUTES"] = int(val) if float(val).is_integer() else val cfg["CRON_TIME_PERIOD_MINUTES"] = int(val) if float(val).is_integer() else val
save_env_config(cfg) save_env_config(cfg)
except Exception as e: except Exception as e:
print(f"[CronScheduler] 保存 CRON_TIME_PERIOD_MINUTES 失败: {e}") print(f"[CronScheduler] Failed to save CRON_TIME_PERIOD_MINUTES: {e}")
return val return val
@@ -228,7 +228,7 @@ class CronScheduler:
_load_time_period_minutes() _load_time_period_minutes()
self._thread = threading.Thread(target=self._run_loop, name="cron-scheduler", daemon=True) self._thread = threading.Thread(target=self._run_loop, name="cron-scheduler", daemon=True)
self._thread.start() self._thread.start()
print("[CronScheduler] 调度线程已启动") print("[CronScheduler] Scheduler thread started")
def shutdown(self) -> None: def shutdown(self) -> None:
if not self._started: if not self._started:
@@ -245,7 +245,7 @@ class CronScheduler:
if self._thread: if self._thread:
self._thread.join(timeout=3.0) self._thread.join(timeout=3.0)
self._started = False self._started = False
print("[CronScheduler] 调度线程已停止") print("[CronScheduler] Scheduler thread stopped")
# ---------- 主循环 ---------- # ---------- 主循环 ----------
def _run_loop(self) -> None: def _run_loop(self) -> None:
@@ -253,13 +253,13 @@ class CronScheduler:
try: try:
self._on_startup() self._on_startup()
except Exception as e: except Exception as e:
print(f"[CronScheduler] 启动扫描异常: {e}") print(f"[CronScheduler] Startup scan error: {e}")
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
self._scan_once() self._scan_once()
except Exception as e: except Exception as e:
print(f"[CronScheduler] 扫描异常: {e}") print(f"[CronScheduler] Scan error: {e}")
self._stop_event.wait(_SCAN_INTERVAL) self._stop_event.wait(_SCAN_INTERVAL)
def _on_startup(self) -> None: def _on_startup(self) -> None:
@@ -288,12 +288,12 @@ class CronScheduler:
"started_at": trigger.run_at, "started_at": trigger.run_at,
"ended_at": now_iso(), "ended_at": now_iso(),
"status": "expired", "status": "expired",
"error": "应用未运行,已错过执行时间", "error": "App was not running, missed execution time",
"trigger": "schedule", "trigger": "schedule",
"turn_id": "", "turn_id": "",
}) })
repo.update_status(task.task_id, "expired", repo.update_status(task.task_id, "expired",
error="应用未运行,已错过执行时间", error="App was not running, missed execution time",
next_run_at="") next_run_at="")
task.enabled = False task.enabled = False
task.last_status = "expired" task.last_status = "expired"
@@ -318,7 +318,7 @@ class CronScheduler:
"started_at": task.next_run_at, "started_at": task.next_run_at,
"ended_at": now_iso(), "ended_at": now_iso(),
"status": "expired", "status": "expired",
"error": "应用未运行错过该次触发", "error": "App was not running, missed this trigger",
"trigger": "schedule", "trigger": "schedule",
"turn_id": "", "turn_id": "",
}) })
@@ -390,10 +390,10 @@ class CronScheduler:
# 2) 上一个任务启动后仍在 CRON_TIME_PERIOD_MINUTES 时段窗口内 → 跳过。 # 2) 上一个任务启动后仍在 CRON_TIME_PERIOD_MINUTES 时段窗口内 → 跳过。
now_ts = time.time() now_ts = time.time()
if self._running_procs: if self._running_procs:
print(f"[CronScheduler] 时段内已有任务运行,跳过 {task.task_id}") print(f"[CronScheduler] Task already running in period, skipping {task.task_id}")
return False return False
if self._period_start_ts is not None and (now_ts - self._period_start_ts) < _CRON_TIME_PERIOD_MINUTES * 60: if self._period_start_ts is not None and (now_ts - self._period_start_ts) < _CRON_TIME_PERIOD_MINUTES * 60:
print(f"[CronScheduler] 时段内已有任务,跳过 {task.task_id}") print(f"[CronScheduler] Task exists in period, skipping {task.task_id}")
return False return False
# 占用新时段 # 占用新时段
self._period_start_ts = now_ts self._period_start_ts = now_ts
@@ -426,8 +426,8 @@ class CronScheduler:
creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0, creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0,
) )
except Exception as e: except Exception as e:
print(f"[CronScheduler] 拉起子进程失败 task={task.task_id}: {e}") print(f"[CronScheduler] Failed to spawn subprocess task={task.task_id}: {e}")
get_task_repository().update_status(task.task_id, "failed", error=f"拉起子进程失败: {e}") get_task_repository().update_status(task.task_id, "failed", error=f"Failed to spawn subprocess: {e}")
# 子进程未启动成功,释放占用的时段(避免空占时段阻塞后续任务) # 子进程未启动成功,释放占用的时段(避免空占时段阻塞后续任务)
with self._procs_lock: with self._procs_lock:
if not self._running_procs: if not self._running_procs:
@@ -478,7 +478,7 @@ class CronScheduler:
error = "" error = ""
else: else:
status = "failed" status = "failed"
error = f"子进程异常退出(exit code {exit_code})" error = f"Subprocess exited abnormally (exit code {exit_code})"
repo.update_status(task_id, status, error=error, last_run_at=started_iso) repo.update_status(task_id, status, error=error, last_run_at=started_iso)
repo.append_execution(task_id, { repo.append_execution(task_id, {
"started_at": started_iso, "started_at": started_iso,
+100
View File
@@ -82,6 +82,9 @@ class SessionRecordRepository(Protocol):
def list_session_ids(self) -> list[str]: ... def list_session_ids(self) -> list[str]: ...
def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None: ... def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None: ...
def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None: ... def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None: ...
def list_turn_records(self, session_id: str, limit: int) -> list[dict[str, Any]]: ...
def get_session_extra(self, session_id: str) -> dict[str, Any]: ...
def save_session_extra(self, session_id: str, extra: dict[str, Any]) -> None: ...
def get_last_turn_id(self, session_id: str) -> str: ... def get_last_turn_id(self, session_id: str) -> str: ...
def delete_turns_after(self, session_id: str, keep_until_turn_id: str) -> None: ... def delete_turns_after(self, session_id: str, keep_until_turn_id: str) -> None: ...
def delete_session(self, session_id: str) -> None: ... def delete_session(self, session_id: str) -> None: ...
@@ -97,6 +100,23 @@ class MysqlSessionRecordRepository:
def __init__(self) -> None: def __init__(self) -> None:
ensure_tables(_SESSION_TABLE_DDLS) ensure_tables(_SESSION_TABLE_DDLS)
# 向后兼容:为已有 sessions 表补充 extra 列
self._ensure_extra_column()
def _ensure_extra_column(self) -> None:
"""幂等添加 sessions.extra JSON 列(存储 tokens + agent_meta)。"""
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"ALTER TABLE sessions ADD COLUMN extra JSON DEFAULT NULL"
)
conn.commit()
except Exception:
# 列已存在 → 忽略
pass
finally:
conn.close()
def save_turn(self, session_id: str, turn_id: str, messages: list[dict]) -> None: def save_turn(self, session_id: str, turn_id: str, messages: list[dict]) -> None:
if not session_id or not turn_id: if not session_id or not turn_id:
@@ -210,6 +230,54 @@ class MysqlSessionRecordRepository:
finally: finally:
conn.close() conn.close()
def list_turn_records(self, session_id: str, limit: int) -> list[dict[str, Any]]:
"""按 turn_id 倒序返回最近 limit 条 turn 记录(与 tool_*.json 同构)。"""
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT turn_id, tool_calls, meta FROM session_tool_calls "
"WHERE session_id=%s ORDER BY turn_id DESC LIMIT %s",
(session_id, limit),
)
rows = cur.fetchall() or []
return [self._row_to_record(r, session_id, r["turn_id"]) for r in rows]
finally:
conn.close()
def get_session_extra(self, session_id: str) -> dict[str, Any]:
"""读取 sessions.extra JSON 列(含 tokens + agent_meta),不存在时返回 {}。"""
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT extra FROM sessions WHERE session_id=%s",
(session_id,),
)
row = cur.fetchone()
raw = (row or {}).get("extra")
return self._loads(raw) if raw is not None else {}
finally:
conn.close()
def save_session_extra(self, session_id: str, extra: dict[str, Any]) -> None:
"""写入 sessions.extra JSON 列;session 行不存在时先创建占位行。"""
conn = mysql_conn()
try:
with conn.cursor() as cur:
now = _now_iso()
cur.execute(
"""
INSERT INTO sessions (session_id, created_at, updated_at, extra)
VALUES (%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE updated_at=%s, extra=VALUES(extra)
""",
(session_id, now, now, json.dumps(extra, ensure_ascii=False), now),
)
conn.commit()
finally:
conn.close()
def get_last_turn_id(self, session_id: str) -> str: def get_last_turn_id(self, session_id: str) -> str:
conn = mysql_conn() conn = mysql_conn()
try: try:
@@ -381,6 +449,38 @@ def get_latest_turn_record(session_id: str) -> dict[str, Any] | None:
return None return None
def list_turn_records(session_id: str, limit: int = 5) -> list[dict[str, Any]] | None:
"""mysql 后端返回最近 limit 条 turn 记录(turn_id 倒序);json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().list_turn_records(session_id, limit)
except Exception as e:
print(f"[session_storage] list_turn_records 失败: {e}", file=sys.stderr)
return None
def load_session_extra(session_id: str) -> dict[str, Any] | None:
"""mysql 后端返回 sessions.extra(tokens + agent_meta);json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().get_session_extra(session_id)
except Exception as e:
print(f"[session_storage] load_session_extra 失败: {e}", file=sys.stderr)
return None
def save_session_extra(session_id: str, extra: dict[str, Any]) -> None:
"""mysql 后端下写入 sessions.extra;json 后端 no-op。"""
if not is_mysql():
return
try:
get_session_record_repository().save_session_extra(session_id, extra)
except Exception as e:
print(f"[session_storage] save_session_extra 失败: {e}", file=sys.stderr)
def get_last_turn_id(session_id: str) -> str | None: def get_last_turn_id(session_id: str) -> str | None:
"""mysql 后端返回最后 turn_id;json 后端返回 None。""" """mysql 后端返回最后 turn_id;json 后端返回 None。"""
if not is_mysql(): if not is_mysql():
+61 -6
View File
@@ -57,9 +57,18 @@ def sub_agent_context():
子 Agent 的工具调用记录写入独立的 _sub_live_records[session_id], 子 Agent 的工具调用记录写入独立的 _sub_live_records[session_id],
get_live_tool_calls 将其注入到 call_subagent 条目的 sub_tool_calls 中, get_live_tool_calls 将其注入到 call_subagent 条目的 sub_tool_calls 中,
前端在 call_subagent 展开时实时渲染嵌套的子工具调用列表。 前端在 call_subagent 展开时实时渲染嵌套的子工具调用列表。
进入时清空上一子 Agent 遗留的实时记录与待消费思考,
避免同一轮内多个子 Agent 顺序执行时发生记忆串扰。
""" """
token = _sub_agent_context.set(True) token = _sub_agent_context.set(True)
try: try:
_sid = get_current_session()
if _sid:
with _sub_live_lock:
_sub_live_records.pop(_sid, None)
with _sub_pending_thinking_lock:
_sub_pending_thinking.pop(_sid, None)
yield yield
finally: finally:
_sub_agent_context.reset(token) _sub_agent_context.reset(token)
@@ -100,14 +109,34 @@ def _token_file(session_id: str) -> Path:
def _persist_token(session_id: str, tokens: int) -> None: def _persist_token(session_id: str, tokens: int) -> None:
"""将 token 写入磁盘文件。""" """将 token 写入存储(优先 MySQL,回退 JSON 文件)。"""
# mysql 后端:写入 sessions.extra.tokens
try:
from agent.history import session_storage
if session_storage.is_mysql():
existing = session_storage.load_session_extra(session_id) or {}
existing["tokens"] = tokens
session_storage.save_session_extra(session_id, existing)
return
except Exception:
pass
# json 后端:写入文件
tf = _token_file(session_id) tf = _token_file(session_id)
tf.parent.mkdir(parents=True, exist_ok=True) tf.parent.mkdir(parents=True, exist_ok=True)
tf.write_text(json.dumps({"session_id": session_id, "tokens": tokens}), encoding="utf-8") tf.write_text(json.dumps({"session_id": session_id, "tokens": tokens}), encoding="utf-8")
def _load_token_from_disk(session_id: str) -> int: def _load_token_from_disk(session_id: str) -> int:
"""从磁盘读取 token(若文件不存在返回 0)。""" """从磁盘/数据库读取 token(若不存在返回 0)。"""
# mysql 后端:从 sessions.extra 读取
try:
from agent.history import session_storage
_db_extra = session_storage.load_session_extra(session_id)
if _db_extra is not None:
return int(_db_extra.get("tokens", 0))
except Exception:
pass
# json 后端:从文件读取
tf = _token_file(session_id) tf = _token_file(session_id)
if not tf.exists(): if not tf.exists():
return 0 return 0
@@ -142,9 +171,20 @@ def get_session_tokens(session_id: str) -> int:
def clear_session_tokens(session_id: str) -> None: def clear_session_tokens(session_id: str) -> None:
"""清除指定会话的 token 记录(内存 + 磁盘)。""" """清除指定会话的 token 记录(内存 + 存储)。"""
with _session_tokens_lock: with _session_tokens_lock:
_session_tokens.pop(session_id, None) _session_tokens.pop(session_id, None)
# mysql 后端:设置 tokens=0
try:
from agent.history import session_storage
if session_storage.is_mysql():
existing = session_storage.load_session_extra(session_id) or {}
existing["tokens"] = 0
session_storage.save_session_extra(session_id, existing)
return
except Exception:
pass
# json 后端:删除文件
tf = _token_file(session_id) tf = _token_file(session_id)
if tf.exists(): if tf.exists():
tf.unlink(missing_ok=True) tf.unlink(missing_ok=True)
@@ -977,8 +1017,23 @@ def _inject_sub_traces_to_record(tool_calls: list[dict[str, Any]], session_id: s
tc["sub_tool_calls"] = sub_tc tc["sub_tool_calls"] = sub_tc
def list_turns(session_id: str) -> list[dict[str, Any]]: def list_turns(session_id: str, limit: int = 5) -> list[dict[str, Any]]:
"""列出会话下所有 turn(按时间倒序,最近5轮)。""" """列出会话下所有 turn(按时间倒序,默认最近5轮)。
输入:
session_id: 会话 ID。
limit: 返回的最大轮数,默认 5。
"""
# mysql 后端:从数据库读取
try:
from agent.history import session_storage
_db_records = session_storage.list_turn_records(session_id, limit)
if _db_records is not None:
return _db_records
except Exception as _e:
import sys as _sys
print(f"[tool_call_recorder] list_turns DB 读取失败: {_e}", file=_sys.stderr)
# json 后端:从文件读取
session_dir = session_tool_dir(session_id) session_dir = session_tool_dir(session_id)
if not session_dir.is_dir(): if not session_dir.is_dir():
return [] return []
@@ -989,7 +1044,7 @@ def list_turns(session_id: str) -> list[dict[str, Any]]:
turns.append(json.load(fh)) turns.append(json.load(fh))
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass pass
return turns[:5] return turns[:limit]
def get_latest_turn(session_id: str) -> dict[str, Any] | None: def get_latest_turn(session_id: str) -> dict[str, Any] | None:
+2 -7
View File
@@ -2,14 +2,9 @@ _BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手
【核心行为准则】 【核心行为准则】
- 你的首要职责是理解用户的意图,并高效地完成用户的任务。 - 你的首要职责是理解用户的意图,并高效地完成用户的任务。
- 对于任何用户上传的图片、音频或文件,直接利用你的多模态能力分析并回答,不要为此调用截图等无关工具。 - 对于任何用户上传的图片、音频或文件,直接利用你的多模态能力分析并回答,不要为此调用截图等无关工具。
- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。例如:需要查看当前屏幕状态、需要执行 GUI 操作、需要搜索网络信息等。 - 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。
- **【强制规则】面对涉及 PPT 制作、代码项目等复杂产出类或系统操作类任务时,你必须先调用 skill_router 工具查询是否有对应的 Skill 操作指南。
如果 skill_router 返回了匹配的 Skill,你必须将其视为最高优先级的执行规范,严格遵循 Skill 中定义的完整工作流,严禁跳过任何步骤,
严禁用你自身的编程经验(如直接写 python-pptx 代码、用终端脚本批量生成等)替代 Skill 规范。Skill 规范的地位高于任何工具描述中的建议,当工具描述与 Skill 规范冲突时,以 Skill 规范为准。**
- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。 - 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。
- 进行浏览器操作时,慎用刷新工具,避免刷新导致页面状态丢失。 - 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。
- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用,注意单次调用最多包含5个动作。
- 如果不确定是否需要工具,优先选择不调用,用已有信息给出最佳答案。
- 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。 - 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。
- 当任务完成时,向用户总结执行过程和结果。 - 当任务完成时,向用户总结执行过程和结果。
""".strip() """.strip()
@@ -161,7 +161,12 @@
**交付**: **交付**:
- 使用 `doc_tool create` 逐页写出 `{N}.html` - 使用 `doc_tool create` 逐页写出 `{N}.html`
- 运行 `python {SKILL_DIR}/attachments/complete_htmls_to_base64.py {workspace_dir}` 将外部图片引用转为 base64,产出 `{N}_complete.html` - 运行 `python {SKILL_DIR}/attachments/complete_htmls_to_base64.py {workspace_dir}` 将外部图片引用转为 base64,产出 `{N}_complete.html`
- 向用户汇报最终交付:`{N}_complete.html`(所有图片已内联,每页可在浏览器中独立打开预览) - **(可选)合并为单文件 deck**:如果用户需要将多页合并为一个 HTML 文件(方便演示 / 导入 fange-ai-toolbox 编辑器导出 PPTX / 上传飞书),询问用户是否需要合并。若需要,运行:
```
python {SKILL_DIR}/attachments/merge_to_deck.py {workspace_dir} --complete
```
产出 `deck.html`,支持键盘 ← → 翻页、鼠标点击前进、触摸滑动。合并后向用户说明操作方式。
- 向用户汇报最终交付:`{N}_complete.html`(所有图片已内联,每页可在浏览器中独立打开预览),以及可选的 `deck.html`(单文件翻页版)
- 提示用户打开 `{N}_complete.html` 即可逐页浏览全部幻灯片 - 提示用户打开 `{N}_complete.html` 即可逐页浏览全部幻灯片
--- ---
@@ -179,6 +184,7 @@
| 动效引擎(Executor) | `08-animation-system.md` | | 动效引擎(Executor) | `08-animation-system.md` |
| QA & 交付 | `09-quality-checklist.md` | | QA & 交付 | `09-quality-checklist.md` |
| 图片内联固化 | `attachments/complete_htmls_to_base64.py`(运行即可) | | 图片内联固化 | `attachments/complete_htmls_to_base64.py`(运行即可) |
| 单文件合并 | `attachments/merge_to_deck.py`(可选,交付前最后一步) |
--- ---
@@ -212,3 +218,4 @@
| `09-quality-checklist.md` | 质检清单 — HTML 质量检查项与交付规范 | QA 阶段 | | `09-quality-checklist.md` | 质检清单 — HTML 质量检查项与交付规范 | QA 阶段 |
| `templates/` | 模板库 — 预置 HTML 模板(封面、过渡页、内容页、结束页) | Executor 参考复用 | | `templates/` | 模板库 — 预置 HTML 模板(封面、过渡页、内容页、结束页) | Executor 参考复用 |
| `attachments/complete_htmls_to_base64.py` | 固化脚本 — 将外部图片引用转为 base64 内联 | 交付前最后一步 | | `attachments/complete_htmls_to_base64.py` | 固化脚本 — 将外部图片引用转为 base64 内联 | 交付前最后一步 |
| `attachments/merge_to_deck.py` | 合并脚本 — 将多个独立 HTML 合并为单文件 deck(可选) | 交付前最后一步(用户需要合并时) |
@@ -0,0 +1,511 @@
#!/usr/bin/env python3
"""
merge_to_deck.py
将 ppt_maker 生成的多个独立 HTML 文件(1.html, 2.html, ...)合并为单个 deck 格式 HTML。
合并后支持键盘(← →)翻页 + 鼠标点击前进 + 触摸滑动,同时兼容 fange-ai-toolbox 编辑器。
用法:
python merge_to_deck.py workspace/ppt_my_topic/
python merge_to_deck.py workspace/ppt_my_topic/ --output my_deck.html
python merge_to_deck.py workspace/ppt_my_topic/ --complete # 使用 _complete.html 文件
输出:
单个 deck.html,每个原页面作为 <section class="slide"> 嵌入
"""
import os
import sys
import re
import argparse
from datetime import datetime
from pathlib import Path
def find_html_files(directory: str, use_complete: bool = False) -> list:
"""查找目录下所有纯数字命名的 HTML 文件,按页码排序。"""
html_dir = Path(directory)
if not html_dir.is_dir():
# 尝试 workspace 下查找
workspace = Path(os.getcwd()) / "workspace" / directory
if workspace.is_dir():
html_dir = workspace
else:
raise FileNotFoundError(f"目录不存在: {directory}")
suffix = "_complete.html" if use_complete else ".html"
files = []
for f in sorted(html_dir.iterdir(), key=lambda x: x.name):
name = f.name
if use_complete:
if name.endswith(suffix):
try:
num = int(name.replace(suffix, ""))
files.append((num, str(f)))
except ValueError:
pass
else:
# 匹配纯数字 .html(如 1.html, 2.html),排除 _complete 等
m = re.match(r'^(\d+)\.html$', name)
if m:
files.append((int(m.group(1)), str(f)))
files.sort(key=lambda x: x[0])
return files
def extract_root_content(html: str) -> str:
"""
提取页面核心内容。
优先提取 .ppt-root 或 body > div(1280x720 容器)的内部内容,
去掉外层的 body 灰底包装和独立 script。
"""
# 1) 尝试匹配 <div class="ppt-root"> ... </div> 或 <div style="...1280...720..." ...>
ppt_root_re = re.compile(
r'<div[^>]*\b(?:class\s*=\s*["\']ppt-root["\']|style\s*=\s*["\'][^"\']*width\s*:\s*1280px[^"\']*height\s*:\s*720px[^"\']*)'
r'[^>]*>(.*?)</div>\s*(?:<script[\s\S]*?</script>\s*)?\s*</body>',
re.DOTALL | re.IGNORECASE
)
m = ppt_root_re.search(html)
if m:
inner = m.group(1)
# 去掉页面内嵌的 <script>(动效引擎+导航),deck 统一管理
inner = re.sub(r'<script[\s\S]*?</script>', '', inner, flags=re.DOTALL)
return inner.strip()
# 2) 回退:提取 <body> 内第一个宽高固定的 div 的内容
body_re = re.compile(r'<body[^>]*>(.*?)</body>', re.DOTALL | re.IGNORECASE)
m = body_re.search(html)
if not m:
return ""
body = m.group(1)
# 找第一个 1280x720 的 div(可能嵌套在灰色背景 div 内)
container_re = re.compile(
r'<div[^>]*style\s*=\s*["\'][^"\']*width\s*:\s*1280px[^"\']*height\s*:\s*720px[^"\']*["\'][^>]*>(.*?)</div>\s*</div>',
re.DOTALL | re.IGNORECASE
)
m2 = container_re.search(body)
if m2:
inner = m2.group(1)
inner = re.sub(r'<script[\s\S]*?</script>', '', inner, flags=re.DOTALL)
return inner.strip()
# 3) 最后回退:body 内去掉 script 后的全部内容
body_clean = re.sub(r'<script[\s\S]*?</script>', '', body, flags=re.DOTALL)
return body_clean.strip()
def extract_css_variables(html: str) -> str:
"""提取 :root 中的 CSS 变量定义。"""
m = re.search(r':root\s*\{([^}]*)\}', html, re.DOTALL)
if m:
return m.group(1).strip()
return ""
def scope_css_rules(css_text: str, slide_id: str) -> str:
"""给 CSS 规则加上 slide 作用域前缀,避免不同页面间的样式冲突。"""
def scope_rule(match):
rule = match.group(0)
brace_pos = rule.index('{')
selector_text = rule[:brace_pos]
body_text = rule[brace_pos:]
parts = [s.strip() for s in selector_text.split(',')]
scoped_parts = []
for part in parts:
if not part:
continue
scoped = f'#{slide_id} {part}'
# body.loaded 针对的是 deck 的 <body>,需放在 #slide-N 外面
scoped = re.sub(rf'^#{slide_id}\s+body\.loaded\b', f'body.loaded #{slide_id}', scoped)
scoped_parts.append(scoped)
return ', '.join(scoped_parts) + ' ' + body_text
result = re.sub(r'([^{}]+\{[^{}]*\})', lambda m: scope_rule(m) + '\n', css_text)
return result.strip()
def build_deck(html_dir: str, use_complete: bool = False, title: str = None) -> str:
"""构建合并后的 deck HTML。"""
files = find_html_files(html_dir, use_complete)
if not files:
raise ValueError(f"在 {html_dir} 中未找到 HTML 文件")
total = len(files)
slides_html = []
css_variables = ""
if title is None:
title = datetime.now().strftime("演示文稿 %Y-%m-%d %H:%M:%S")
for idx, (num, filepath) in enumerate(files, 1):
with open(filepath, "r", encoding="utf-8") as f:
html = f.read()
# 使用第一个页面的 CSS 变量作为全局变量
if not css_variables:
css_variables = extract_css_variables(html)
content = extract_root_content(html)
if not content:
print(f" [警告] {filepath}: 未能提取内容,跳过")
continue
# 提取页面内的 <style> 块(保留非 :root 的样式)
style_blocks = re.findall(r'<style[^>]*>(.*?)</style>', html, re.DOTALL | re.IGNORECASE)
page_styles = ""
for sb in style_blocks:
# 去掉 :root 块(已全局统一),去掉 body {} 规则(deck 自行管理)
cleaned = re.sub(r':root\s*\{[^}]*\}', '', sb, re.DOTALL)
cleaned = re.sub(r'\bbody\s*\{[^}]*\}', '', cleaned, flags=re.DOTALL)
cleaned = cleaned.strip()
if cleaned:
page_styles += cleaned + "\n"
# 给 CSS 规则加上 slide 作用域,避免不同页面同名 class 冲突
slide_id = f"slide-{idx}"
if page_styles:
page_styles = scope_css_rules(page_styles, slide_id)
slide = f'<section class="slide" id="slide-{idx}" data-slide="{idx}">\n'
if page_styles:
slide += f" <style>\n{page_styles} </style>\n"
slide += f" {content}\n"
slide += "</section>"
slides_html.append(slide)
if not slides_html:
raise ValueError("未能提取任何页面内容")
# 构建完整 deck
root_block = f":root {{\n {css_variables}\n}}" if css_variables.strip() else ""
deck = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
{root_block}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
overflow: hidden;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
}}
#deck {{
position: relative;
width: 1280px;
height: 720px;
}}
.slide {{
position: absolute;
top: 0;
left: 0;
width: 1280px;
height: 720px;
overflow: hidden;
background: var(--bg, #FAFBFC);
opacity: 0;
pointer-events: none;
transition: opacity 0.35s ease;
}}
.slide.active {{
opacity: 1;
pointer-events: auto;
z-index: 1;
}}
/* 页码指示器 */
#page-indicator {{
position: fixed;
bottom: 20px;
right: 30px;
color: rgba(255,255,255,0.5);
font-size: 14px;
font-family: 'Consolas', 'Fira Code', monospace;
z-index: 100;
transition: opacity 0.3s;
pointer-events: none;
}}
/* 翻页提示箭头 */
#nav-hint {{
position: fixed;
top: 50%;
transform: translateY(-50%);
color: rgba(255,255,255,0.2);
font-size: 48px;
z-index: 100;
pointer-events: none;
transition: opacity 0.3s;
}}
#nav-hint.left {{ left: 20px; }}
#nav-hint.right {{ right: 20px; }}
</style>
</head>
<body class="loaded">
<div id="deck">
{chr(10).join(slides_html)}
</div>
<div id="page-indicator">1 / {total}</div>
<div id="nav-hint" class="left">◀</div>
<div id="nav-hint" class="right">▶</div>
<script>
(function() {{
var slides = document.querySelectorAll('.slide');
var total = slides.length;
if (total === 0) return;
var current = 0;
var indicator = document.getElementById('page-indicator');
var hintL = document.getElementById('nav-hint').parentElement
? document.querySelector('#nav-hint.left')
: null;
var hintR = document.querySelector('#nav-hint.right');
// ===== 动效引擎(按 slide 隔离) =====
function triggerAnimations(slide) {{
// 找到 slide 内所有 data-anim 元素
var els = slide.querySelectorAll('[data-anim]');
var staggerContainers = slide.querySelectorAll('[data-anim-stagger]');
var seqContainers = slide.querySelectorAll('[data-anim-sequence]');
var clickGroups = {{}};
// 初始化:设置初始状态
els.forEach(function(el) {{
var anim = el.getAttribute('data-anim') || 'fade-up';
var dur = el.getAttribute('data-anim-duration') || '0.6s';
el.style.transition = 'opacity ' + dur + ' ease, transform ' + dur + ' ease, clip-path ' + dur + ' ease';
el.style.opacity = '0';
if (anim.includes('up')) el.style.transform = 'translateY(30px)';
else if (anim.includes('left')) el.style.transform = 'translateX(-60px)';
else if (anim.includes('right')) el.style.transform = 'translateX(60px)';
else if (anim === 'scale-in' || anim === 'zoom-in') el.style.transform = 'scale(0.8)';
else if (anim === 'wipe-right') el.style.clipPath = 'inset(0 100% 0 0)';
else if (anim === 'pulse-soft') el.style.transform = 'scale(0.9)';
else el.style.transform = 'translateY(0)';
}});
function showEl(el) {{
el.style.opacity = '1';
el.style.transform = 'translate(0,0) scale(1)';
el.style.clipPath = 'inset(0 0 0 0)';
}}
// 延迟出现(非 stagger/sequence/click-group 的元素)
els.forEach(function(el) {{
if (el.closest('[data-anim-stagger]') || el.closest('[data-anim-sequence]') || el.getAttribute('data-anim-click-group')) return;
var delay = parseFloat(el.getAttribute('data-anim-delay') || '0') * 1000;
setTimeout(function() {{ showEl(el); }}, delay + 30);
}});
// stagger 容器
staggerContainers.forEach(function(container) {{
var children = container.querySelectorAll('[data-anim]');
children.forEach(function(ch, i) {{
setTimeout(function() {{ showEl(ch); }}, i * 50 + 80);
}});
}});
// sequence 容器
seqContainers.forEach(function(container) {{
var children = container.querySelectorAll('[data-anim]');
var totalDelay = 0;
children.forEach(function(ch) {{
var d = parseFloat(ch.getAttribute('data-anim-duration') || '0.6') * 1000;
var delay = parseFloat(ch.getAttribute('data-anim-delay') || '0') * 1000;
totalDelay += delay;
setTimeout(function() {{ showEl(ch); }}, totalDelay + 80);
totalDelay += d;
}});
}});
// 点击分组
els.forEach(function(el) {{
var grp = el.getAttribute('data-anim-click-group');
if (grp) {{
if (!clickGroups[grp]) clickGroups[grp] = [];
clickGroups[grp].push(el);
}}
}});
// 返回 clickGroups 引用,供点击事件使用
slide._clickGroups = clickGroups;
slide._clickGroupIdx = 0;
}}
// ===== 翻页逻辑 =====
function goToSlide(idx) {{
if (idx < 0 || idx >= total || idx === current) return;
// 隐藏当前
slides[current].classList.remove('active');
slides[current]._clickGroupIdx = 0;
// 重置当前 slide 的动效状态(以便再次进入时重新播放)
var prevEls = slides[current].querySelectorAll('[data-anim]');
prevEls.forEach(function(el) {{
el.style.opacity = '';
el.style.transform = '';
el.style.clipPath = '';
el.style.transition = '';
}});
// 显示目标
current = idx;
slides[current].classList.add('active');
triggerAnimations(slides[current]);
// 更新 UI
indicator.textContent = (current + 1) + ' / ' + total;
// 首尾隐藏对应方向箭头
if (hintL) hintL.style.opacity = current === 0 ? '0' : '0.2';
if (hintR) hintR.style.opacity = current === total - 1 ? '0' : '0.2';
}}
function nextSlide() {{ goToSlide(current + 1); }}
function prevSlide() {{ goToSlide(current - 1); }}
// ===== 事件监听 =====
// 键盘
document.addEventListener('keydown', function(e) {{
if (e.key === 'ArrowRight' || e.key === 'Right' || e.key === 'ArrowDown' || e.key === 'Down' || e.key === ' ') {{
e.preventDefault();
nextSlide();
}} else if (e.key === 'ArrowLeft' || e.key === 'Left' || e.key === 'ArrowUp' || e.key === 'Up') {{
e.preventDefault();
prevSlide();
}} else if (e.key === 'Home') {{
e.preventDefault();
goToSlide(0);
}} else if (e.key === 'End') {{
e.preventDefault();
goToSlide(total - 1);
}}
}});
// 鼠标点击(点击 slide 区域 → 前进;非 slide 区域已有键盘,不额外处理)
document.addEventListener('click', function(e) {{
// 先处理当前 slide 的 click-group 动效
var activeSlide = slides[current];
if (activeSlide._clickGroups) {{
activeSlide._clickGroupIdx++;
var group = activeSlide._clickGroups[activeSlide._clickGroupIdx];
if (group) {{
group.forEach(function(el) {{
el.style.opacity = '1';
el.style.transform = 'translate(0,0) scale(1)';
el.style.clipPath = 'inset(0 0 0 0)';
}});
return; // 还有 click-group 待展示,不翻页
}}
}}
// 所有 click-group 播完 → 翻到下一页
nextSlide();
}});
// 触摸滑动(移动端)
var touchStartX = 0, touchStartY = 0;
document.addEventListener('touchstart', function(e) {{
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}});
document.addEventListener('touchend', function(e) {{
var dx = e.changedTouches[0].clientX - touchStartX;
var dy = e.changedTouches[0].clientY - touchStartY;
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) {{
if (dx < 0) nextSlide();
else prevSlide();
}}
}});
// ===== 启动:显示第一页 =====
slides[0].classList.add('active');
triggerAnimations(slides[0]);
indicator.textContent = '1 / ' + total;
if (hintL) hintL.style.opacity = '0';
if (hintR) hintR.style.opacity = total > 1 ? '0.2' : '0';
}})();
</script>
</body>
</html>'''
return deck
def main():
parser = argparse.ArgumentParser(
description="将 ppt_maker 生成的多个 HTML 合并为单文件 deck",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python merge_to_deck.py workspace/ppt_my_topic/
python merge_to_deck.py workspace/ppt_my_topic/ --output my_deck.html
python merge_to_deck.py workspace/ppt_my_topic/ --complete
"""
)
parser.add_argument("directory", help="HTML 文件所在目录")
parser.add_argument("--output", "-o", default=None,
help="输出文件名(默认: deck.html,放在输入目录下)")
parser.add_argument("--title", "-t", default=None,
help="deck 标题(默认: 当前时间戳)")
parser.add_argument("--complete", action="store_true",
help="使用 _complete.html 文件(而非原始 .html)")
args = parser.parse_args()
html_dir = Path(args.directory)
if not html_dir.is_dir():
workspace = Path(os.getcwd()) / "workspace" / args.directory
if workspace.is_dir():
html_dir = workspace
else:
print(f"错误: 目录不存在: {args.directory}")
sys.exit(1)
output_path = args.output or str(html_dir / "deck.html")
if not os.path.isabs(output_path):
output_path = str(html_dir / output_path)
try:
print(f"合并目录: {html_dir}")
deck = build_deck(str(html_dir), args.complete, args.title)
with open(output_path, "w", encoding="utf-8") as f:
f.write(deck)
print(f"已生成: {output_path}")
print(f"共 {deck.count('<section class=\"slide\"')} 页")
print()
print("操作方式:")
print(" 键盘 ← → 翻页")
print(" 键盘 ↑ ↓ 翻页")
print(" 键盘 Space 下一页")
print(" 键盘 Home 首页")
print(" 键盘 End 末页")
print(" 鼠标点击 下一页")
print(" 触摸滑动 翻页(移动端)")
except Exception as e:
print(f"错误: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+3 -1
View File
@@ -71,6 +71,7 @@
| `09-quality-checklist.md` | 质检清单 | HTML 质量检查项与交付规范 | QA 阶段 | | `09-quality-checklist.md` | 质检清单 | HTML 质量检查项与交付规范 | QA 阶段 |
| `templates/` | 模板库 | 预置 HTML 模板(封面、过渡页、内容页、结束页) | Executor 可参考复用 | | `templates/` | 模板库 | 预置 HTML 模板(封面、过渡页、内容页、结束页) | Executor 可参考复用 |
| `attachments/complete_htmls_to_base64.py` | 固化脚本 — 将所有 HTML 文件的外部图片引用转为 base64 内联,产出 `{N}_complete.html`,原始文件保留 | 交付前最后一步 | | `attachments/complete_htmls_to_base64.py` | 固化脚本 — 将所有 HTML 文件的外部图片引用转为 base64 内联,产出 `{N}_complete.html`,原始文件保留 | 交付前最后一步 |
| `attachments/merge_to_deck.py` | 合并脚本 — 将多个独立 HTML 合并为单文件 deck(`deck.html`),支持键盘翻页+点击+触摸滑动。可通过 `--title` 指定标题(默认时间戳) | 交付前最后一步(用户需要合并时) |
--- ---
@@ -101,5 +102,6 @@
├── 按 quality-checklist 逐项检查 ├── 按 quality-checklist 逐项检查
├── 确认每页 HTML 可独立在浏览器中打开 ├── 确认每页 HTML 可独立在浏览器中打开
├── 运行 attachments/complete_htmls_to_base64.py 固化图片为 base64(命令:python {SKILL_DIR}/attachments/complete_htmls_to_base64.py {workspace_dir}) ├── 运行 attachments/complete_htmls_to_base64.py 固化图片为 base64(命令:python {SKILL_DIR}/attachments/complete_htmls_to_base64.py {workspace_dir})
└── 交付所有 `{N}_complete.html` 文件 ├── (可选)如用户需要单文件合并为 deck,运行 merge_to_deck.py。可通过 `--title` 指定 deck 标题(`python {SKILL_DIR}/attachments/merge_to_deck.py {workspace_dir} --complete --title "自定义标题"`,不指定则默认为当前时间戳)
└── 交付所有 `{N}_complete.html` 文件(及可选的 `deck.html`)
``` ```
+22 -2
View File
@@ -158,7 +158,16 @@ def _session_meta_path(session_id: str) -> str:
def _load_session_meta(session_id: str) -> dict: def _load_session_meta(session_id: str) -> dict:
"""加载整个 session_meta.json 文件。""" """加载整个 session_meta 数据(优先 MySQL,回退 JSON 文件)。"""
# mysql 后端:从 sessions.extra.agent_meta 读取
try:
from agent.history import session_storage
_db_extra = session_storage.load_session_extra(session_id)
if _db_extra is not None:
return _db_extra.get("agent_meta", {})
except Exception:
pass
# json 后端:从文件读取
mp = _session_meta_path(session_id) mp = _session_meta_path(session_id)
if not os.path.isfile(mp): if not os.path.isfile(mp):
return {} return {}
@@ -170,7 +179,18 @@ def _load_session_meta(session_id: str) -> dict:
def _save_session_meta(session_id: str, meta: dict) -> None: def _save_session_meta(session_id: str, meta: dict) -> None:
"""保存整个 session_meta.json 文件。""" """保存整个 session_meta 数据(优先 MySQL,回退 JSON 文件)。"""
# mysql 后端:写入 sessions.extra.agent_meta(不覆盖 tokens)
try:
from agent.history import session_storage
if session_storage.is_mysql():
existing = session_storage.load_session_extra(session_id) or {}
existing["agent_meta"] = meta
session_storage.save_session_extra(session_id, existing)
return
except Exception:
pass
# json 后端:写入文件
mp = _session_meta_path(session_id) mp = _session_meta_path(session_id)
os.makedirs(os.path.dirname(mp), exist_ok=True) os.makedirs(os.path.dirname(mp), exist_ok=True)
with open(mp, "w", encoding="utf-8") as f: with open(mp, "w", encoding="utf-8") as f:
-281
View File
@@ -1,7 +1,6 @@
"""PPT 读取与预览工具:自主实现 PPTX 解析,不依赖外部 ppt-master 项目。 """PPT 读取与预览工具:自主实现 PPTX 解析,不依赖外部 ppt-master 项目。
支持: 支持:
- pptx → SVG: 将 PPTX 每页转为自包含 SVG(扁平渲染,用于前端预览) - pptx → SVG: 将 PPTX 每页转为自包含 SVG(扁平渲染,用于前端预览)
- pptx → HTML: 将 PPTX 转为带翻页导航的 HTML 预览页
渲染范围(聚焦核心功能): 渲染范围(聚焦核心功能):
- 形状: rect, roundRect, ellipse, line, text, image, path, polygon, group - 形状: rect, roundRect, ellipse, line, text, image, path, polygon, group
@@ -17,7 +16,6 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import json
import os import os
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import zipfile import zipfile
@@ -739,282 +737,3 @@ def _convert_group_to_svg(node: ShapeNode, slide: _SlideRef, pkg: OoxmlPackage,
parts.append(child_svg) parts.append(child_svg)
parts.append("</g>") parts.append("</g>")
return "\n".join(parts) return "\n".join(parts)
# ============================================================
# 14. PPTX → HTML 预览(python-pptx)
# ============================================================
def convert_pptx_to_html(pptx_path: Path) -> str:
"""将 PPTX 文件转为带翻页导航的完整 HTML 页面。
使用 python-pptx 解析,提取文本/图片/形状/表格,
生成绝对定位 HTML + 翻页控件 + 键盘/滚轮导航 + 拖动/缩放。
"""
from pptx import Presentation # noqa: F811
from pptx.enum.text import PP_ALIGN
def _emu2px(emu):
if emu is None:
return 0
return round(float(emu) / 914400 * 96, 1)
def _fill_style(shape):
try:
f = shape.fill
if f.type is not None and f.type != 0:
if hasattr(f, 'fore_color') and f.fore_color.type is not None:
try:
return f"background:{f.fore_color.rgb};"
except Exception:
pass
except Exception:
pass
return ""
def _line_style(shape):
try:
ln = shape.line
if ln.fill.type is not None and ln.fill.type != 0:
w = _emu2px(ln.width) if ln.width else 0.5
c = "#999"
try:
if hasattr(ln, 'color') and ln.color.type is not None:
c = str(ln.color.rgb)
except Exception:
pass
return f"border:{w}px solid {c};"
except Exception:
pass
return ""
def _run_style(run):
s = []
f = run.font
if f.size:
s.append(f"font-size:{_emu2px(f.size)}px")
if f.bold:
s.append("font-weight:bold")
if f.italic:
s.append("font-style:italic")
if f.underline:
s.append("text-decoration:underline")
try:
if f.color and f.color.type is not None:
s.append(f"color:{f.color.rgb}")
except Exception:
pass
if f.name:
s.append(f'font-family:"{f.name}",sans-serif')
return ";".join(s)
def _align(para):
a = para.alignment
if a == PP_ALIGN.CENTER:
return "text-align:center;"
elif a == PP_ALIGN.RIGHT:
return "text-align:right;"
elif a == PP_ALIGN.JUSTIFY:
return "text-align:justify;"
return ""
def _esc(s):
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _render_text(tf, l, t, w, h):
parts = []
for para in tf.paragraphs:
al = _align(para)
runs = []
for run in para.runs:
st = _run_style(run)
txt = _esc(run.text).replace("\n", "<br>")
runs.append(f'<span style="{st}">{txt}</span>' if st else txt)
line = "".join(runs) or "&nbsp;"
m = ""
if para.space_after:
m += f"margin-bottom:{_emu2px(para.space_after)}px;"
if para.space_before:
m += f"margin-top:{_emu2px(para.space_before)}px;"
parts.append(f'<p style="{al}{m}">{line}</p>')
inner = "".join(parts)
pad = ""
try:
pad = f"padding-left:{_emu2px(tf.margin_left or 0)}px;padding-right:{_emu2px(tf.margin_right or 0)}px;padding-top:{_emu2px(tf.margin_top or 0)}px;"
except Exception:
pass
return (
f'<div style="position:absolute;left:{l}px;top:{t}px;width:{w}px;height:{h}px;'
f'overflow:hidden;word-wrap:break-word;{pad}">{inner}</div>'
)
def _render_pic(shape):
l, t, w, h = _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width), _emu2px(shape.height)
try:
img = shape.image
ext = img.content_type.split("/")[-1]
if ext == "jpeg":
ext = "jpg"
b64 = base64.b64encode(img.blob).decode()
src = f"data:image/{ext};base64,{b64}"
except Exception:
src = ""
return f'<img src="{src}" style="position:absolute;left:{l}px;top:{t}px;width:{w}px;height:{h}px;object-fit:contain;" />'
def _render_shape(shape):
l, t, w, h = _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width), _emu2px(shape.height)
fill = _fill_style(shape)
line = _line_style(shape)
rad = ""
try:
if shape.auto_shape_type and "ROUNDED" in str(shape.auto_shape_type):
rad = "border-radius:6px;"
except Exception:
pass
if shape.has_text_frame:
inner = _render_text(shape.text_frame, 0, 0, w, h)
return (
f'<div style="position:absolute;left:{l}px;top:{t}px;width:{w}px;height:{h}px;'
f'{fill}{line}{rad}overflow:hidden;">{inner.replace("position:absolute;", "position:relative;")}</div>'
)
return f'<div style="position:absolute;left:{l}px;top:{t}px;width:{w}px;height:{h}px;{fill}{line}{rad}"></div>'
def _render_table(shape):
l, t, w = _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width)
rows = []
for row in shape.table.rows:
cells = []
for cell in row.cells:
txt = _esc(cell.text) or "&nbsp;"
cells.append(f'<td style="border:1px solid #555;padding:3px 6px;font-size:11px;vertical-align:top;color:#ddd;">{txt}</td>')
rows.append(f'<tr>{"".join(cells)}</tr>')
return f'<table style="position:absolute;left:{l}px;top:{t}px;width:{w}px;border-collapse:collapse;background:#2a2a2a;font-family:sans-serif;">{"".join(rows)}</table>'
def _render_group(shape):
l, t, w, h = _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width), _emu2px(shape.height)
kids = []
try:
for child in shape.shapes:
kids.append(_el(child))
except Exception:
pass
return f'<div style="position:absolute;left:{l}px;top:{t}px;width:{w}px;height:{h}px;overflow:visible;">{"".join(kids)}</div>'
def _el(shape):
if shape.shape_type is None:
return ""
st = str(shape.shape_type)
if "PICTURE" in st:
return _render_pic(shape)
elif "TABLE" in st:
return _render_table(shape)
elif "GROUP" in st:
return _render_group(shape)
elif "PLACEHOLDER" in st or "TEXT_BOX" in st:
if shape.has_text_frame:
return _render_text(shape.text_frame, _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width), _emu2px(shape.height))
return ""
elif "AUTO_SHAPE" in st or "FREEFORM" in st or "RECTANGLE" in st or "OVAL" in st:
return _render_shape(shape)
else:
if hasattr(shape, 'has_text_frame') and shape.has_text_frame and shape.text_frame.text.strip():
return _render_text(shape.text_frame, _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width), _emu2px(shape.height))
return ""
# ---- main ----
prs = Presentation(str(pptx_path))
sw = _emu2px(prs.slide_width)
sh = _emu2px(prs.slide_height)
slides_data = []
for slide in prs.slides:
elems = []
for shape in slide.shapes:
h = _el(shape)
if h:
elems.append(h)
slides_data.append(
f'<div style="position:relative;width:{sw}px;height:{sh}px;'
f'background:#ffffff;overflow:hidden;flex-shrink:0;">'
f'{"".join(elems)}</div>'
)
total = len(slides_data)
slides_json = json.dumps(slides_data)
name = pptx_path.name
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{name}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#1a1a1a;height:100vh;overflow:hidden;user-select:none;position:relative}}
#vp{{position:absolute;inset:0;overflow:hidden}}
#wrap{{transform-origin:0 0;transition:none;will-change:transform}}
#wrap>div{{box-shadow:0 2px 20px rgba(0,0,0,.6)}}
</style></head>
<body>
<div id="vp"><div id="wrap"></div></div>
<script>
var slides={slides_json};
var total={total},cur=0;
var wrap=document.getElementById("wrap");
var vp=document.getElementById("vp");
var scale=1,ox=0,oy=0;
var dragging=false,dsX=0,dsY=0,doX=0,doY=0;
var fitT=null;
function apply(){{
wrap.style.transform="translate("+ox+"px,"+oy+"px) scale("+scale+")";
}}
function show(n){{
if(n<0||n>=total)return;
cur=n;wrap.innerHTML=slides[cur];
doFit();
}}
function doFit(){{
var sw={sw},sh={sh};
var vw=vp.clientWidth,vh=vp.clientHeight;
scale=Math.min(vw/sw,vh/sh,1.0);
ox=(vw-sw*scale)/2;oy=(vh-sh*scale)/2;
apply();
}}
vp.addEventListener("wheel",function(e){{
if(e.ctrlKey||e.metaKey){{
e.preventDefault();
var r=vp.getBoundingClientRect();
var mx=e.clientX-r.left,my=e.clientY-r.top;
var f=e.deltaY>0?.9:1.1,ns=Math.max(.2,Math.min(5,scale*f));
ox=mx-(mx-ox)*(ns/scale);oy=my-(my-oy)*(ns/scale);
scale=ns;apply();
return;
}}
if(Math.abs(e.deltaY)>30){{show(cur+(e.deltaY>0?1:-1));e.preventDefault()}}
}},{{passive:false}});
vp.addEventListener("mousedown",function(e){{
if(e.button===1||(e.button===0&&e.altKey)){{
e.preventDefault();dragging=true;dsX=e.clientX;dsY=e.clientY;doX=ox;doY=oy;vp.style.cursor="grabbing";
}}
}});
window.addEventListener("mousemove",function(e){{
if(!dragging)return;ox=doX+(e.clientX-dsX);oy=doY+(e.clientY-dsY);apply();
}});
window.addEventListener("mouseup",function(){{if(dragging){{dragging=false;vp.style.cursor="";}}}});
document.addEventListener("keydown",function(e){{
if(e.key==="ArrowLeft"||e.key==="ArrowUp"){{show(cur-1);e.preventDefault()}}
if(e.key==="ArrowRight"||e.key==="ArrowDown"||e.key===" "){{show(cur+1);e.preventDefault()}}
if(e.key==="Home"){{show(0)}}
if(e.key==="End"){{show(total-1)}}
if(e.ctrlKey&&e.key==="0"){{doFit();e.preventDefault()}}
}});
window.addEventListener("resize",function(){{clearTimeout(fitT);fitT=setTimeout(doFit,100);}});
show(0);
</script>
</body></html>"""
+46 -8
View File
@@ -221,6 +221,30 @@
position: relative; position: relative;
} }
/* 拖拽附件到输入区时:显示"添加附件"提示 */
.composer-input-shell.is-drag-attach {
outline: 2px dashed color-mix(in srgb, var(--accent) 55%, transparent);
outline-offset: 3px;
border-radius: 12px;
transition: outline-color 0.15s ease;
}
.composer-input-shell.is-drag-attach::after {
content: "添加附件";
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
border-radius: 12px;
pointer-events: none;
}
.mm-input textarea { .mm-input textarea {
width: 100%; width: 100%;
resize: vertical; resize: vertical;
@@ -1086,6 +1110,18 @@
background: color-mix(in srgb, var(--accent) 20%, transparent); background: color-mix(in srgb, var(--accent) 20%, transparent);
} }
/* 文件预览:表格按列着色(覆盖上面的 display:block,单元格保持 inline) */
.fp-table-wrap .fp-code-lines {
word-break: normal;
}
.fp-code-lines .fp-tbl-cell {
display: inline;
padding-right: 0.5em;
}
.fp-code-lines .fp-tbl-cell--header {
font-weight: 600;
}
.file-preview-body svg { .file-preview-body svg {
max-width: 100%; max-width: 100%;
max-height: 75vh; max-height: 75vh;
@@ -1319,18 +1355,18 @@
pointer-events: none; pointer-events: none;
} }
.token-bar-seg--green { .token-bar-seg--green {
background: var(--accent, #0e7490); background: var(--success, #22c55e);
opacity: 0.45; opacity: 0.45;
z-index: 1; z-index: 1;
border-radius: 3px 0 0 3px; border-radius: 3px 0 0 3px;
} }
.token-bar-seg--yellow { .token-bar-seg--yellow {
background: var(--accent, #0e7490); background: var(--warning, #f59e0b);
opacity: 0.7; opacity: 0.7;
z-index: 1; z-index: 1;
} }
.token-bar-seg--red { .token-bar-seg--red {
background: var(--accent, #0e7490); background: var(--danger, #ef4444);
opacity: 0.85; opacity: 0.85;
z-index: 1; z-index: 1;
border-radius: 0 3px 3px 0; border-radius: 0 3px 3px 0;
@@ -1374,16 +1410,16 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
} }
.token-handle--hard .token-handle-dot { .token-handle--hard .token-handle-dot {
background: #d4756b; background: var(--danger, #d4756b);
box-shadow: 0 0 0 2px rgba(212, 117, 107, 0.2); box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 25%, transparent);
} }
.token-handle.overshot .token-handle-dot { .token-handle.overshot .token-handle-dot {
animation: handlePulse 0.6s ease-in-out infinite alternate; animation: handlePulse 0.6s ease-in-out infinite alternate;
} }
@keyframes handlePulse { @keyframes handlePulse {
from { box-shadow: 0 0 0 2px rgba(212, 117, 107, 0.15); } from { box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 15%, transparent); }
to { box-shadow: 0 0 0 5px rgba(212, 117, 107, 0.1); } to { box-shadow: 0 0 0 5px color-mix(in srgb, var(--danger) 10%, transparent); }
} }
.token-label { .token-label {
@@ -1397,4 +1433,6 @@
transition: opacity 0.25s, color 0.25s; transition: opacity 0.25s, color 0.25s;
} }
.token-label--safe { color: #22c55e; } .token-label--safe { color: var(--success, #22c55e); }
.token-label--warn { color: var(--warning, #f59e0b); }
.token-label--danger { color: var(--danger, #ef4444); }
+35
View File
@@ -87,6 +87,41 @@
margin-top: 0.4rem; margin-top: 0.4rem;
} }
/* ===== 背景图片选择器 ===== */
.bg-input-row {
display: flex;
gap: 0.4rem;
align-items: center;
}
.bg-input-row input[type="text"] {
flex: 1;
}
.bg-picker-btn {
flex-shrink: 0;
width: 2.2rem;
height: 2.2rem;
padding: 0;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-tertiary);
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.bg-picker-btn:hover {
background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--accent);
border-color: var(--accent);
}
/* ===== 设置按钮 ===== */ /* ===== 设置按钮 ===== */
.settings-btn { .settings-btn {
display: inline-flex; display: inline-flex;
+13 -2
View File
@@ -43,7 +43,13 @@
overflow: hidden; overflow: hidden;
} }
.tool-call-list-wrap--expanded .tc-list-toggle-body { .tool-call-list-wrap--expanded .tc-list-toggle-body {
max-height: 8000px; overflow-y: auto;
max-height: 75vh;
}
/* flex 子项保持自然高度,避免工具记录多时被 flex-shrink 压缩导致内容不可见,
由 .tc-list-toggle-body 自身滚动展示全部记录 */
.tc-list-toggle-body > * {
flex-shrink: 0;
} }
/* 单条工具调用行(无框) */ /* 单条工具调用行(无框) */
@@ -179,9 +185,14 @@
overflow: hidden; overflow: hidden;
} }
.tool-call-row--expanded + .tc-detail { .tool-call-row--expanded + .tc-detail {
max-height: 600px; max-height: 45vh;
overflow-y: auto;
padding: 0.3rem 0.45rem; padding: 0.3rem 0.45rem;
} }
/* .tc-detail 同为 flex column,内部子项保持自然高度,避免被压缩导致结果不可见 */
.tc-detail > * {
flex-shrink: 0;
}
.tc-detail-text { .tc-detail-text {
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

+5 -1
View File
@@ -457,7 +457,11 @@
<h4 class="settings-section-title" style="margin-bottom:0.3rem;">自定义背景</h4> <h4 class="settings-section-title" style="margin-bottom:0.3rem;">自定义背景</h4>
<p class="settings-hint">输入本地图片路径或 URL(支持 jpg/png/webp),留空恢复默认。</p> <p class="settings-hint">输入本地图片路径或 URL(支持 jpg/png/webp),留空恢复默认。</p>
<div class="setting-field" style="margin-bottom:0.4rem;"> <div class="setting-field" style="margin-bottom:0.4rem;">
<input type="text" id="bgImageInput" placeholder="例如:C:\wallpaper.jpg 或 https://..." /> <div class="bg-input-row">
<input type="text" id="bgImageInput" placeholder="例如:C:\wallpaper.jpg 或 https://..." />
<button type="button" class="bg-picker-btn" id="bgImagePickerBtn" title="选择图片">⋮</button>
<input type="file" id="bgImageFileInput" accept="image/*" hidden />
</div>
</div> </div>
<div style="display:flex;gap:0.5rem;"> <div style="display:flex;gap:0.5rem;">
<button type="button" class="btn btn-primary" id="bgImageApplyBtn">应用</button> <button type="button" class="btn btn-primary" id="bgImageApplyBtn">应用</button>
+74 -1
View File
@@ -59,6 +59,12 @@ function isPreviewableHtml(name) {
return (name || '').toLowerCase().endsWith('.html') || (name || '').toLowerCase().endsWith('.htm'); return (name || '').toLowerCase().endsWith('.html') || (name || '').toLowerCase().endsWith('.htm');
} }
/** 二进制表格(XLSX/XLS):由后端 /api/table/text 提取为 TSV 后按列着色预览 */
function isPreviewableTable(name) {
const ext = (name || '').toLowerCase();
return ext.endsWith('.xlsx') || ext.endsWith('.xls');
}
const BINARY_EXTS = new Set([ const BINARY_EXTS = new Set([
'.exe','.dll','.so','.dylib','.bin','.dat','.zip','.rar','.7z','.tar','.gz','.bz2','.xz', '.exe','.dll','.so','.dylib','.bin','.dat','.zip','.rar','.7z','.tar','.gz','.bz2','.xz',
'.png','.jpg','.jpeg','.gif','.bmp','.ico','.webp','.tiff','.tif','.psd','.ai','.eps', '.png','.jpg','.jpeg','.gif','.bmp','.ico','.webp','.tiff','.tif','.psd','.ai','.eps',
@@ -78,7 +84,19 @@ function isBinaryFile(name) {
} }
// ===== 模式切换 ===== // ===== 模式切换 =====
let _modeSwitchCooldown = false;
export function switchToMode(mode) { export function switchToMode(mode) {
// 防连点:短时间内禁止重复切换
if (_modeSwitchCooldown) return;
_modeSwitchCooldown = true;
setTimeout(() => { _modeSwitchCooldown = false; }, 300);
// Agent 运行中禁止切换到 cron/edit 模式,避免 chat 区域被替换导致工具调用和思考历史消失
if (state.sending && mode !== "chat") {
showToast("Agent 正在运行中,请等待完成后再切换模式");
return;
}
state.mode = mode; state.mode = mode;
if (mode === "edit") { if (mode === "edit") {
state.editMode = true; state.editMode = true;
@@ -155,6 +173,15 @@ export async function openFolder(folderPath) {
} }
if (!rootPath) return; if (!rootPath) return;
// 先校验目录存在并列出文件;文件夹被删除/不可访问时给出明确提示,避免把失效路径写入状态
let listRes;
try {
listRes = await listDir(rootPath, 10);
} catch (e) {
showToast("打开文件夹失败: " + (e.message || e));
return;
}
state._docRootPath = rootPath; state._docRootPath = rootPath;
state._docRootName = rootPath.split(/[/\\]/).pop() || rootPath; state._docRootName = rootPath.split(/[/\\]/).pop() || rootPath;
@@ -166,7 +193,6 @@ export async function openFolder(folderPath) {
if (!state._docTreeFolders) state._docTreeFolders = {}; if (!state._docTreeFolders) state._docTreeFolders = {};
state._docTreeFolders[state._docRootName] = true; state._docTreeFolders[state._docRootName] = true;
const listRes = await listDir(rootPath, 10);
const files = listRes.files || []; const files = listRes.files || [];
const supportedFiles = files.filter((f) => !f.is_dir && isSupportedFile(f.name)); const supportedFiles = files.filter((f) => !f.is_dir && isSupportedFile(f.name));
// 去重:按 path 去重 // 去重:按 path 去重
@@ -257,6 +283,19 @@ export async function loadDocContent(filePath) {
openFilePreview(filePath, fileName, res.content || '', openFilePreview(filePath, fileName, res.content || '',
{ status, onDownload: () => downloadFile(filePath, fileName) }); { status, onDownload: () => downloadFile(filePath, fileName) });
} }
} else if (isPreviewableTable(fileName)) {
// XLSX/XLS:后端提取为 TSV 文本,走与 CSV/TSV 一致的按列着色预览
const absPath = (/^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith('/') || filePath.startsWith('\\'))
? filePath
: state._docRootPath.replace(/[\\/]+$/, '') + '/' + filePath.replace(/^[\\/]+/, '');
try {
const tsv = await api('/api/table/text?path=' + encodeURIComponent(absPath));
openFilePreview(absPath, fileName, (tsv && tsv.content) || '',
{ status, onDownload: () => downloadFile(absPath, fileName) });
} catch (e) {
openFilePreview(absPath, fileName, '(加载失败: ' + (e.message || e) + ')',
{ status, onDownload: () => downloadFile(absPath, fileName) });
}
} else if (isBinaryFile(fileName)) { } else if (isBinaryFile(fileName)) {
showToast('二进制文件不支持在线查看', 'warning'); showToast('二进制文件不支持在线查看', 'warning');
} else { } else {
@@ -309,6 +348,20 @@ async function downloadFile(filePath, fileName) {
a.href = url; a.download = fileName; a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); document.body.removeChild(a); document.body.appendChild(a); a.click(); document.body.removeChild(a);
} }
} else if (isBinaryFile(fileName)) {
// 二进制文件(xlsx/docx/pdf 等):按 base64 还原字节后下载
const res = await readBinaryFile(filePath, state._docRootPath);
if (res && res.status === 'ok' && res.data) {
const bin = atob(res.data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const blob = new Blob([bytes], { type: res.mime || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} else { } else {
const res = await readTextFile(filePath, state._docRootPath); const res = await readTextFile(filePath, state._docRootPath);
if (res && res.status === 'ok') { if (res && res.status === 'ok') {
@@ -737,17 +790,37 @@ function setupInputDropHandler() {
const target = composerInput || $('textInput'); const target = composerInput || $('textInput');
if (!target) return; if (!target) return;
// 子元素间移动会成对触发 dragenter/dragleave,用计数器避免状态误清除
let dragDepth = 0;
const setDragState = (on) => target.classList.toggle('is-drag-attach', on);
const isAcceptedDrag = (types) => types.includes('application/doc-path') || types.includes('Files');
target.addEventListener('dragenter', (e) => {
if (isAcceptedDrag(Array.from(e.dataTransfer.types))) {
dragDepth++;
setDragState(true);
}
});
target.addEventListener('dragover', (e) => { target.addEventListener('dragover', (e) => {
const types = Array.from(e.dataTransfer.types); const types = Array.from(e.dataTransfer.types);
// 接受应用内文件/文件夹引用拖拽 或 外部文件拖拽 // 接受应用内文件/文件夹引用拖拽 或 外部文件拖拽
if (types.includes('application/doc-path') || types.includes('Files')) { if (types.includes('application/doc-path') || types.includes('Files')) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
setDragState(true);
} }
}); });
target.addEventListener('dragleave', () => {
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) setDragState(false);
});
target.addEventListener('drop', async (e) => { target.addEventListener('drop', async (e) => {
e.preventDefault(); e.preventDefault();
dragDepth = 0;
setDragState(false);
// 1. 内部拖拽(doc-tree 文件/文件夹引用) // 1. 内部拖拽(doc-tree 文件/文件夹引用)
// app.js 检测到 application/doc-path 也会 return,无需 stopPropagation // app.js 检测到 application/doc-path 也会 return,无需 stopPropagation
+101 -1
View File
@@ -6,8 +6,9 @@ import { $, escapeHtml } from './utils.js';
const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico']); const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico']);
const HTML_EXTS = new Set(['.html', '.htm']); const HTML_EXTS = new Set(['.html', '.htm']);
const TEXT_EXTS = new Set(['.txt', '.json', '.md', '.py', '.js', '.ts', '.css', '.xml', '.yaml', '.yml', '.toml', 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', '.csv', '.tsv', '.log', '.env', '.ini', '.cfg', '.sh', '.bat', '.ps1', '.sql', '.r', '.go', '.rs', '.rb',
'.php', '.swift', '.kt', '.scala', '.cpp', '.c', '.h', '.java', '.m', '.svg']); '.php', '.swift', '.kt', '.scala', '.cpp', '.c', '.h', '.java', '.m', '.svg']);
const TABLE_EXTS = new Set(['.csv', '.tsv', '.xlsx', '.xls']);
let _currentFilePath = null; let _currentFilePath = null;
let _currentContent = null; let _currentContent = null;
@@ -33,6 +34,13 @@ function isTextFile(fileName) {
return dot >= 0 && TEXT_EXTS.has(ext.substring(dot)); return dot >= 0 && TEXT_EXTS.has(ext.substring(dot));
} }
/** 判断表格文件(CSV/TSV 文本按列着色;XLSX/XLS 由后端提取为 TSV 后同样按列着色) */
function isTableFile(fileName) {
const ext = (fileName || '').toLowerCase();
const dot = ext.lastIndexOf('.');
return dot >= 0 && TABLE_EXTS.has(ext.substring(dot));
}
/** /**
* 打开文件预览浮窗 * 打开文件预览浮窗
* @param {string} filePath - 文件路径 * @param {string} filePath - 文件路径
@@ -84,6 +92,8 @@ export function openFilePreview(filePath, fileName, content, opts = {}) {
_renderImage(body, content, fileName); _renderImage(body, content, fileName);
} else if (isHtml(fileName) && !opts.asText) { } else if (isHtml(fileName) && !opts.asText) {
_renderPlainText(body, content); _renderPlainText(body, content);
} else if (isTableFile(fileName)) {
_renderTable(body, content, fileName);
} else if (isTextFile(fileName)) { } else if (isTextFile(fileName)) {
_renderPlainText(body, content); _renderPlainText(body, content);
} else if (content && content.startsWith && content.startsWith('data:')) { } else if (content && content.startsWith && content.startsWith('data:')) {
@@ -162,6 +172,96 @@ function _renderPlainText(body, content) {
codeLines.addEventListener('contextmenu', (e) => _onCodeContextMenu(e)); codeLines.addEventListener('contextmenu', (e) => _onCodeContextMenu(e));
} }
// ===== 表格预览(复用 plaintext 预览 UI,不同列不同颜色) =====
/** 按主题区分的列色板(暗色 / 亮色各一套,保证可读性) */
const TABLE_COLUMN_COLORS = {
dark: ['#e06c75', '#61afef', '#98c379', '#e5c07b', '#c678dd', '#56b6c2', '#d19a66', '#f472b6', '#7f9cf5', '#4ade80'],
light: ['#c0392b', '#2471a3', '#1e8449', '#b7950b', '#7d3c98', '#117a65', '#ca6f1e', '#a93226', '#2e86c1', '#229954'],
};
/** 判断分隔符:TSV 用 tab,CSV 用逗号,XLSX/XLS(后端已提取为 TSV)用 tab,其他按首行内容猜测 */
function _getDelimiter(fileName, content) {
const ext = (fileName || '').toLowerCase();
if (ext.endsWith('.tsv') || ext.endsWith('.xlsx') || ext.endsWith('.xls')) return '\t';
if (ext.endsWith('.csv')) return ',';
const firstLine = (content || '').split('\n')[0] || '';
return firstLine.includes('\t') ? '\t' : ',';
}
/** 解析单行分隔符文本(支持引号包裹的字段,如 "a,b") */
function _splitDelimited(line, delim) {
const cells = [];
let cur = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') { cur += '"'; i++; }
else inQuotes = false;
} else cur += ch;
} else if (ch === '"') {
inQuotes = true;
} else if (ch === delim) {
cells.push(cur);
cur = '';
} else {
cur += ch;
}
}
cells.push(cur);
return cells;
}
/** 表格预览:保留行号/选中引用交互,单元格按列着色 */
function _renderTable(body, content, fileName) {
const text = content || '';
const delim = _getDelimiter(fileName, text);
const lines = text.split('\n');
const colors = document.body.classList.contains('theme-dark') ? TABLE_COLUMN_COLORS.dark : TABLE_COLUMN_COLORS.light;
const wrap = document.createElement('div');
wrap.className = 'fp-code-wrap fp-table-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 row = document.createElement('span');
row.setAttribute('data-line', i + 1);
const cells = _splitDelimited(line, delim);
if (cells.length === 1 && !line.trim()) {
row.textContent = '\u00A0';
} else {
cells.forEach((cell, ci) => {
const cellEl = document.createElement('span');
cellEl.className = 'fp-tbl-cell' + (i === 0 ? ' fp-tbl-cell--header' : '');
cellEl.textContent = cell;
cellEl.style.color = colors[ci % colors.length];
row.appendChild(cellEl);
});
}
codeLines.appendChild(row);
});
wrap.appendChild(lineNos);
wrap.appendChild(codeLines);
body.appendChild(wrap);
// 右键菜单:引用选中行
codeLines.addEventListener('contextmenu', (e) => _onCodeContextMenu(e));
}
function _onCodeContextMenu(e) { function _onCodeContextMenu(e) {
const sel = window.getSelection(); const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) return; if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
-12
View File
@@ -211,12 +211,6 @@ function buildPendingCard(pending, isSubAgent) {
inputRow.appendChild(sendBtn); inputRow.appendChild(sendBtn);
cardBody.appendChild(inputRow); 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") { } else if (interactionType === "selection") {
if (pending.questions && Array.isArray(pending.questions) && pending.questions.length > 0) { if (pending.questions && Array.isArray(pending.questions) && pending.questions.length > 0) {
pending.questions.forEach((q) => { pending.questions.forEach((q) => {
@@ -289,12 +283,6 @@ function buildPendingCard(pending, isSubAgent) {
}); });
cardBody.appendChild(pillsRow); 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); card.appendChild(cardBody);
+45 -9
View File
@@ -139,16 +139,19 @@ function buildAgentCard(agent, idx) {
header.className = "agent-card-header"; header.className = "agent-card-header";
const h4 = document.createElement("h4"); const h4 = document.createElement("h4");
h4.textContent = `Agent: ${agent.name || "(\u672A\u547D\u540D)"}`; h4.textContent = `Agent: ${agent.name || "(\u672A\u547D\u540D)"}`;
const removeBtn = document.createElement("button");
removeBtn.className = "agent-card-remove";
removeBtn.textContent = "\u2715";
removeBtn.title = "\u5220\u9664\u6B64 Agent";
removeBtn.addEventListener("click", () => {
state.cachedAgentConfigs.splice(idx, 1);
renderAgentConfigs();
});
header.appendChild(h4); header.appendChild(h4);
header.appendChild(removeBtn); // 主 Agent 不可删除
if (agent.role !== "main") {
const removeBtn = document.createElement("button");
removeBtn.className = "agent-card-remove";
removeBtn.textContent = "\u2715";
removeBtn.title = "\u5220\u9664\u6B64 Agent";
removeBtn.addEventListener("click", () => {
state.cachedAgentConfigs.splice(idx, 1);
renderAgentConfigs();
});
header.appendChild(removeBtn);
}
const nameField = makeField("\u540D\u79F0", "text", agent.name || "", (v) => { agent.name = v; }); const nameField = makeField("\u540D\u79F0", "text", agent.name || "", (v) => { agent.name = v; });
const descField = makeField("\u80FD\u529B\u63CF\u8FF0 (description)", "text", agent.description || "", (v) => { agent.description = v; }); const descField = makeField("\u80FD\u529B\u63CF\u8FF0 (description)", "text", agent.description || "", (v) => { agent.description = v; });
@@ -1135,6 +1138,8 @@ function renderThemePanel() {
const bgApplyBtn = $("bgImageApplyBtn"); const bgApplyBtn = $("bgImageApplyBtn");
const bgResetBtn = $("bgImageResetBtn"); const bgResetBtn = $("bgImageResetBtn");
const bgStatus = $("bgPreviewStatus"); const bgStatus = $("bgPreviewStatus");
const bgPickerBtn = $("bgImagePickerBtn");
const bgFileInput = $("bgImageFileInput");
if (bgInput) { if (bgInput) {
bgInput.value = getBgImage(); bgInput.value = getBgImage();
const applyBg = () => { const applyBg = () => {
@@ -1154,6 +1159,37 @@ function renderThemePanel() {
} }
// 回车也应用 // 回车也应用
bgInput.onkeydown = (e) => { if (e.key === 'Enter') applyBg(); }; bgInput.onkeydown = (e) => { if (e.key === 'Enter') applyBg(); };
// 图片选择器按钮点击事件
if (bgPickerBtn && bgFileInput) {
bgPickerBtn.onclick = () => {
bgFileInput.click();
};
// 文件选择事件
bgFileInput.onchange = (e) => {
const file = e.target.files?.[0];
if (file) {
// 使用文件路径(Electron 环境)或创建临时 URL(Web 环境)
if (window.electronAPI && window.electronAPI.getFilePath) {
// Electron 环境:获取文件路径
const filePath = window.electronAPI.getFilePath(file);
bgInput.value = filePath;
applyBg();
} else {
// Web 环境:创建临时 URL
const reader = new FileReader();
reader.onload = (event) => {
bgInput.value = event.target?.result || "";
applyBg();
};
reader.readAsDataURL(file);
}
}
// 清空 input 以便重复选择同一文件
bgFileInput.value = "";
};
}
} }
// 背景模糊度 // 背景模糊度
+95 -45
View File
@@ -583,12 +583,40 @@ export const THEMES = {
}; };
const DEFAULT_THEME = "vscode-dark"; const DEFAULT_THEME = "vscode-dark";
const STORAGE_KEY = "minor_theme";
const STORAGE_BG_KEY = "minor_bg_image"; // 主题设置统一内存态:全部落盘到服务端 theme_config.json(不使用 localStorage)
const STORAGE_FONT_KEY = "minor_font"; let _settings = {
const STORAGE_FONT_SIZE_KEY = "minor_font_size"; theme: DEFAULT_THEME,
const STORAGE_BLUR_KEY = "minor_bg_blur"; bg_image: "",
const STORAGE_VIGNETTE_KEY = "minor_bg_vignette"; font_family: "",
font_size: "",
bg_blur: "",
bg_vignette: "",
};
let _loadingTheme = false; // 从服务端加载期间不写回,避免覆盖已有配置
let _persistTimer = null;
/** 防抖写回服务端 theme_config.json(仅保存用户主动修改,加载期间跳过) */
function _persistThemeConfig() {
if (_loadingTheme) return;
if (_persistTimer) clearTimeout(_persistTimer);
_persistTimer = setTimeout(() => {
_persistTimer = null;
try {
api("/api/config/theme", {
method: "POST",
body: JSON.stringify({
theme: _settings.theme,
bg_image: _settings.bg_image,
font_family: _settings.font_family,
font_size: _settings.font_size,
bg_blur: _settings.bg_blur,
bg_vignette: _settings.bg_vignette,
}),
});
} catch { /* ignore */ }
}, 300);
}
// 字体预设 // 字体预设
export const FONT_PRESETS = { export const FONT_PRESETS = {
@@ -611,14 +639,14 @@ export const FONT_SIZE_PRESETS = {
"20px": "20px", "20px": "20px",
}; };
const getFontKey = (k) => ({ family: STORAGE_FONT_KEY, size: STORAGE_FONT_SIZE_KEY }[k]);
export function getFontSetting(key) { export function getFontSetting(key) {
try { return localStorage.getItem(getFontKey(key)) || ""; } catch { return ""; } return key === "family" ? _settings.font_family : key === "size" ? _settings.font_size : "";
} }
export function setFontSetting(key, val) { export function setFontSetting(key, val) {
try { localStorage.setItem(getFontKey(key), val || ""); } catch {} if (key === "family") _settings.font_family = val || "";
else if (key === "size") _settings.font_size = val || "";
applyFontSetting(key, val); applyFontSetting(key, val);
_persistThemeConfig();
} }
export function applyFontSetting(key, val) { export function applyFontSetting(key, val) {
const r = document.documentElement; const r = document.documentElement;
@@ -638,15 +666,12 @@ function localPathToUrl(localPath) {
/** 获取/设置自定义背景图 */ /** 获取/设置自定义背景图 */
export function getBgImage() { export function getBgImage() {
try { return localStorage.getItem(STORAGE_BG_KEY) || ""; } return _settings.bg_image;
catch { return ""; }
} }
export function setBgImage(url) { export function setBgImage(url) {
try { localStorage.setItem(STORAGE_BG_KEY, url || ""); } _settings.bg_image = url || "";
catch {} applyBgImage(_settings.bg_image);
applyBgImage(url); _persistThemeConfig();
// 异步同步到服务端
try { api("/api/config/theme", { method: "POST", body: JSON.stringify({ bg_image: url || "" }) }); } catch {}
} }
export function applyBgImage(url) { export function applyBgImage(url) {
const r = document.documentElement; const r = document.documentElement;
@@ -666,8 +691,7 @@ export function applyBgImage(url) {
/** 获取当前保存的主题 */ /** 获取当前保存的主题 */
export function getCurrentTheme() { export function getCurrentTheme() {
try { return localStorage.getItem(STORAGE_KEY) || DEFAULT_THEME; } return _settings.theme;
catch { return DEFAULT_THEME; }
} }
/** 应用主题到页面 */ /** 应用主题到页面 */
@@ -685,8 +709,9 @@ export function applyTheme(themeId) {
Object.entries(theme.edit).forEach(([k, v]) => r.style.setProperty(k, v)); Object.entries(theme.edit).forEach(([k, v]) => r.style.setProperty(k, v));
} }
// 持久化 // 更新内存态并落盘(加载期间跳过,避免覆盖服务端已有配置)
try { localStorage.setItem(STORAGE_KEY, themeId); } catch {} _settings.theme = THEMES[themeId] ? themeId : DEFAULT_THEME;
_persistThemeConfig();
// 触发 body 类名切换(用于全局暗/亮模式判断) // 触发 body 类名切换(用于全局暗/亮模式判断)
// 优先使用主题对象的 dark 字段;缺失时回退到 id 字符串推断 // 优先使用主题对象的 dark 字段;缺失时回退到 id 字符串推断
@@ -695,28 +720,27 @@ export function applyTheme(themeId) {
document.body.classList.toggle("theme-light", !isDark); document.body.classList.toggle("theme-light", !isDark);
} }
/** 初始化:从 localStorage 恢复主题,并从服务端同步背景图 */ /** 初始化:先用默认主题渲染,再从服务端 theme_config.json 恢复全部设置 */
export function initTheme() { export function initTheme() {
const id = getCurrentTheme(); _loadingTheme = true;
applyTheme(id); applyTheme(DEFAULT_THEME);
// 先从 localStorage 快读,再从服务端同步(异步) applyBgImage("");
applyBgImage(getBgImage()); applyFontSetting("family", "");
applyFontSetting('family', getFontSetting('family')); applyFontSetting("size", "");
applyFontSetting('size', getFontSetting('size')); applyBgBlur("");
applyBgBlur(getBgBlur()); applyBgVignette("");
applyBgVignette(getBgVignette()); loadThemeConfig(); // 完成后在 finally 中解除 _loadingTheme
// 异步从服务端同步主题配置 return DEFAULT_THEME;
loadThemeConfig();
return id;
} }
// ===== 背景模糊度 ===== // ===== 背景模糊度 =====
export function getBgBlur() { export function getBgBlur() {
try { return localStorage.getItem(STORAGE_BLUR_KEY) || ""; } catch { return ""; } return _settings.bg_blur;
} }
export function setBgBlur(val) { export function setBgBlur(val) {
try { localStorage.setItem(STORAGE_BLUR_KEY, val || ""); } catch {} _settings.bg_blur = val || "";
applyBgBlur(val); applyBgBlur(_settings.bg_blur);
_persistThemeConfig();
} }
export function applyBgBlur(val) { export function applyBgBlur(val) {
// val 为空字符串时使用主题默认值(由 applyTheme 设置),否则覆盖 // val 为空字符串时使用主题默认值(由 applyTheme 设置),否则覆盖
@@ -730,11 +754,12 @@ export function applyBgBlur(val) {
// ===== 背景暗角 ===== // ===== 背景暗角 =====
export function getBgVignette() { export function getBgVignette() {
try { return localStorage.getItem(STORAGE_VIGNETTE_KEY) || ""; } catch { return ""; } return _settings.bg_vignette;
} }
export function setBgVignette(val) { export function setBgVignette(val) {
try { localStorage.setItem(STORAGE_VIGNETTE_KEY, val || ""); } catch {} _settings.bg_vignette = val || "";
applyBgVignette(val); applyBgVignette(_settings.bg_vignette);
_persistThemeConfig();
} }
export function applyBgVignette(val) { export function applyBgVignette(val) {
const r = document.documentElement; const r = document.documentElement;
@@ -745,14 +770,39 @@ export function applyBgVignette(val) {
} }
} }
/** 从服务端加载主题配置(覆盖 localStorage 缓存) */ /** 从服务端 theme_config.json 加载主题配置并应用(不使用 localStorage) */
export async function loadThemeConfig() { export async function loadThemeConfig() {
try { try {
const res = await api("/api/config/theme"); const res = await api("/api/config/theme");
if (res.bg_image !== undefined) { _loadingTheme = true;
const img = res.bg_image || ""; if (res.theme && THEMES[res.theme]) {
try { localStorage.setItem(STORAGE_BG_KEY, img); } catch {} applyTheme(res.theme);
applyBgImage(img);
} }
} catch {} if (res.bg_image !== undefined) {
_settings.bg_image = res.bg_image || "";
applyBgImage(_settings.bg_image);
}
if (res.font_family !== undefined) {
// 过滤旧版默认占位值(msjh 等非真实字体)
const fam = (res.font_family || "").trim();
_settings.font_family = (fam && fam !== "msjh" && fam !== "msjhbd") ? fam : "";
applyFontSetting("family", _settings.font_family);
}
if (res.font_size !== undefined) {
_settings.font_size = res.font_size || "";
applyFontSetting("size", _settings.font_size);
}
if (res.bg_blur !== undefined) {
_settings.bg_blur = res.bg_blur || "";
applyBgBlur(_settings.bg_blur);
}
if (res.bg_vignette !== undefined) {
_settings.bg_vignette = res.bg_vignette || "";
applyBgVignette(_settings.bg_vignette);
}
} catch {
// 服务端不可达:保持默认设置
} finally {
_loadingTheme = false;
}
} }
+3 -1
View File
@@ -626,7 +626,9 @@ function renderToolCallRow(call, options = {}) {
icon.title = isRunning ? "点击折叠" : "点击展开工具返回值"; icon.title = isRunning ? "点击折叠" : "点击展开工具返回值";
icon.addEventListener("click", (e) => { icon.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
row.classList.toggle("tool-call-row--expanded"); const nowExpanded = row.classList.toggle("tool-call-row--expanded");
// 展开后若详情在可视区外,滚动到可见位置(block:nearest 已可见时不滚动)
if (nowExpanded) detailEl.scrollIntoView({ block: "nearest" });
}); });
} }
} }
+66 -304
View File
@@ -24,7 +24,7 @@ from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
_SRC = Path(__file__).resolve().parent.parent.parent _SRC = Path(__file__).resolve().parent.parent.parent
if str(_SRC) not in sys.path: if str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC)) sys.path.insert(0, str(_SRC))
@@ -82,6 +82,23 @@ def _init_workspace():
"""从 workspace_config.json 恢复上次选择的工作空间。""" """从 workspace_config.json 恢复上次选择的工作空间。"""
cfg = _read_ws_config() cfg = _read_ws_config()
current = cfg.get("current", "") current = cfg.get("current", "")
ws_list = cfg.get("list", [])
changed = False
# 清理列表中已不存在的文件夹
valid = [p for p in ws_list if os.path.isdir(p)]
if len(valid) != len(ws_list):
cfg["list"] = valid
changed = True
# 上次选择的工作空间已不存在时,回退到默认工作空间
if current and not os.path.isdir(current):
current = ""
cfg["current"] = ""
changed = True
if changed:
_write_ws_config(cfg)
if current: if current:
from agent.memory.system_prompt import set_current_workspace from agent.memory.system_prompt import set_current_workspace
set_current_workspace(current) set_current_workspace(current)
@@ -1145,9 +1162,17 @@ def api_get_workspace() -> dict[str, Any]:
import os import os
cfg = _read_ws_config() cfg = _read_ws_config()
current = _current_workspace_dir or cfg.get("current", "") current = _current_workspace_dir or cfg.get("current", "")
ws_list = cfg.get("list", [])
# 清理列表中已不存在的文件夹
valid = [p for p in ws_list if os.path.isdir(p)]
if len(valid) != len(ws_list):
cfg["list"] = valid
_write_ws_config(cfg)
return { return {
"current": current, "current": current,
"list": cfg.get("list", []), "list": valid,
"default": get_workspace_dir(), "default": get_workspace_dir(),
"snapshots_base": os.path.join(SESSIONS_ROOT, ".minor_snapshots").replace("\\", "/"), "snapshots_base": os.path.join(SESSIONS_ROOT, ".minor_snapshots").replace("\\", "/"),
} }
@@ -1340,87 +1365,6 @@ if ($folder) { Write-Output $folder.Self.Path }
return {"path": None} return {"path": None}
@app.get("/api/fs/list")
def api_fs_list(path: str = "", depth: int = 10) -> dict[str, Any]:
"""列出目录下的所有文件(递归)。
输入: path=str (必填,绝对路径), depth=int (最大递归深度,默认 10)
输出: {"files": [{path, name, is_dir, size}], "root": str}
"""
p = Path(path).resolve()
if not p.exists():
raise HTTPException(404, f"路径不存在: {path}")
if not p.is_dir():
raise HTTPException(400, f"不是目录: {path}")
files = []
_walk_dir(p, files, p, depth)
return {"files": files, "root": str(p)}
def _walk_dir(base: Path, out: list, root: Path, max_depth: int, current_depth: int = 0):
if current_depth > max_depth:
return
try:
for entry in sorted(base.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if entry.name.startswith(".") or entry.name == "node_modules" or entry.name == "__pycache__":
continue
rel = str(entry.relative_to(root)).replace("\\", "/")
if entry.is_dir():
out.append({"path": rel, "name": entry.name, "is_dir": True, "size": 0})
_walk_dir(entry, out, root, max_depth, current_depth + 1)
else:
out.append({"path": rel, "name": entry.name, "is_dir": False, "size": entry.stat().st_size})
except PermissionError:
pass
@app.get("/api/fs/read")
def api_fs_read(path: str = "", raw: str = "") -> Any:
"""读取文件内容。
输入: path=str (必填,绝对路径), raw=str (可选,'1'=返回原始字节流)
"""
p = Path(path).resolve()
if not p.exists():
raise HTTPException(404, f"文件不存在: {path}")
if not p.is_file():
raise HTTPException(400, f"不是文件: {path}")
# 原始流模式:返回文件字节(用于图片/HTML 等预览)
if raw == "1":
try:
content_type = mimetypes.guess_type(str(p))[0] or "application/octet-stream"
return FileResponse(str(p), media_type=content_type)
except Exception:
raise HTTPException(500, "读取文件失败")
# 限制大小 5MB
if p.stat().st_size > 5 * 1024 * 1024:
raise HTTPException(413, "文件过大(>5MB)")
try:
content = p.read_text(encoding="utf-8", errors="replace")
except Exception:
content = p.read_text(encoding="latin-1", errors="replace")
return {"content": content, "path": str(p), "size": len(content)}
@app.post("/api/fs/write")
def api_fs_write(path: str = Form(""), content: str = Form("")) -> dict[str, Any]:
"""写入文件内容。
输入: path=str, content=str
输出: {"success": bool, "path": str}
"""
p = Path(path).resolve()
try:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return {"success": True, "path": str(p)}
except Exception as e:
raise HTTPException(500, f"写入失败: {e}")
@app.post("/api/config/reload") @app.post("/api/config/reload")
def api_reload_configs() -> dict[str, Any]: def api_reload_configs() -> dict[str, Any]:
"""热重载所有配置并重建 Agent 运行时,无需重启服务。 """热重载所有配置并重建 Agent 运行时,无需重启服务。
@@ -1886,198 +1830,65 @@ def serve_image_asset(asset_name: str) -> FileResponse:
return FileResponse(path, media_type=mime or "application/octet-stream") return FileResponse(path, media_type=mime or "application/octet-stream")
@app.get("/api/pptx/preview") # ---------- 表格文本提取(XLSX/XLS → TSV,供前端按列着色的纯文本预览) ----------
def pptx_preview(path: str = "") -> Response:
"""将 PPTX 文件转为带翻页导航的 HTML 预览页面。 @app.get("/api/table/text")
def table_text(path: str = "", sheet: str = "") -> dict[str, Any]:
"""将表格文件(XLSX / XLS)内容提取为 TSV 文本。
输入: 输入:
path: PPTX 文件的绝对路径。 path: 表格文件绝对路径。
sheet: 可选的工作表名(不传时取第一个工作表)。
输出: 输出:
包含所有幻灯片 + 翻页控件的 HTML 页面。 {"content": str, "sheet": str, "sheets": list[str]}
content: TSV 分隔的表格文本(供前端按列着色渲染)。
""" """
if not path or not os.path.isfile(path): if not path or not os.path.isfile(path):
return HTMLResponse("<html><body><p style='color:#888;padding:2rem'>PPTX 文件不存在</p></body></html>", status_code=404) raise HTTPException(status_code=404, detail=f"文件不存在: {path}")
from agent.utils.ppt_utils import convert_pptx_to_html
try:
html_content = convert_pptx_to_html(Path(path))
except Exception as e:
return HTMLResponse(f"<html><body><p style='color:#888;padding:2rem'>PPT 解析失败: {e}</p></body></html>", status_code=500)
if not html_content:
return HTMLResponse("<html><body><p style='color:#888;padding:2rem'>PPTX 无幻灯片内容</p></body></html>")
return HTMLResponse(html_content)
@app.get("/api/table/preview")
def table_preview(path: str = "", sheet: str = "") -> Response:
"""将表格文件(CSV / XLSX)转为 HTML 表格预览页面。
输入:
path: 表格文件的绝对路径。
sheet: 可选的 sheet 名称(仅 XLSX)。
输出:
包含表格数据的 HTML 页面。
"""
if not path or not os.path.isfile(path):
return HTMLResponse("<html><body><p style='color:#888;padding:2rem'>文件不存在</p></body></html>", status_code=404)
ext = os.path.splitext(path)[1].lower() ext = os.path.splitext(path)[1].lower()
try: if ext == ".xlsx":
if ext == ".csv": return _xlsx_to_tsv(path, sheet)
html_content = _csv_to_html(path) if ext == ".xls":
elif ext == ".xlsx": return _xls_to_tsv(path, sheet)
html_content = _xlsx_to_html(path, sheet) raise HTTPException(status_code=400, detail="不支持的表格格式")
elif ext == ".xls":
html_content = _xls_to_html(path, sheet)
else:
return HTMLResponse("<html><body><p style='color:#888;padding:2rem'>不支持的表格格式</p></body></html>", status_code=400)
except Exception as e:
return HTMLResponse(f"<html><body><p style='color:#888;padding:2rem'>表格解析失败: {e}</p></body></html>", status_code=500)
return HTMLResponse(html_content)
def _csv_to_html(filepath: str) -> str: def _xlsx_to_tsv(filepath: str, sheet_name: str = "") -> dict[str, Any]:
"""将 CSV 文件转为 HTML 表格。""" """将 XLSX 工作表内容提取为 TSV 文本。"""
import csv
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
sample = f.read(4096)
f.seek(0)
dialect = csv.Sniffer().sniff(sample)
reader = csv.reader(f, dialect)
rows = list(reader)
if not rows:
return "<html><body><p style='color:#888;padding:2rem'>CSV 文件为空</p></body></html>"
name = os.path.basename(filepath)
cols = max(len(r) for r in rows)
rows_html = []
for i, row in enumerate(rows):
cells = []
for j in range(cols):
val = row[j] if j < len(row) else ""
val = val.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
tag = "th" if i == 0 else "td"
cells.append(f"<{tag}>{val or '&nbsp;'}</{tag}>")
rows_html.append(f"<tr>{''.join(cells)}</tr>")
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{name}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--bg,#1e1e1e);color:var(--text,#ddd);padding:12px;overflow:auto}}
table{{border-collapse:collapse;width:max-content;min-width:100%;font-size:13px}}
th{{background:var(--th-bg,#2d2d2d);color:var(--th-text,#e0e0e0);font-weight:600;padding:6px 12px;border:1px solid var(--border,#444);text-align:left;white-space:nowrap;position:sticky;top:0;z-index:1}}
td{{padding:5px 12px;border:1px solid var(--border,#444);white-space:nowrap;max-width:400px;overflow:hidden;text-overflow:ellipsis}}
tr:hover td{{background:var(--hover-bg,rgba(255,255,255,.04))}}
</style></head>
<body><table>{"".join(rows_html)}</table></body></html>"""
def _xlsx_to_html(filepath: str, sheet_name: str = "") -> str:
"""将 XLSX 文件转为 HTML 表格。"""
try: try:
import openpyxl import openpyxl
except ImportError: except ImportError:
return "<html><body><p style='color:#888;padding:2rem'>需要安装 openpyxl: pip install openpyxl</p></body></html>" return {"content": "(需要安装 openpyxl 才能预览 XLSX: pip install openpyxl)", "sheet": "", "sheets": []}
wb = openpyxl.load_workbook(filepath, data_only=True, read_only=True) wb = openpyxl.load_workbook(filepath, data_only=True, read_only=True)
name = os.path.basename(filepath) try:
sheets = wb.sheetnames
sheets = wb.sheetnames if not sheets:
if not sheets: return {"content": "(无工作表)", "sheet": "", "sheets": []}
return "<html><body><p style='color:#888;padding:2rem'>XLSX 无工作表</p></body></html>" selected = sheet_name if sheet_name and sheet_name in sheets else sheets[0]
ws = wb[selected]
selected = sheet_name if sheet_name and sheet_name in sheets else sheets[0] lines = []
ws = wb[selected] for row in ws.iter_rows(values_only=True):
vals = ["" if c is None else str(c) for c in row]
rows_data = [] lines.append("\t".join(vals))
max_col = 0 finally:
for row in ws.iter_rows(values_only=True): wb.close()
vals = [str(c) if c is not None else "" for c in row] return {"content": "\n".join(lines), "sheet": selected, "sheets": sheets}
max_col = max(max_col, len(vals))
rows_data.append(vals)
wb.close()
if not rows_data:
return "<html><body><p style='color:#888;padding:2rem'>工作表为空</p></body></html>"
sheet_tabs = "".join(
f'<button class="st{" active" if s == selected else ""}" onclick="switchSheet(\'{s}\')">{s}</button>'
for s in sheets
) if len(sheets) > 1 else ""
rows_html = []
for i, row in enumerate(rows_data):
cells = []
for j in range(max_col):
val = row[j] if j < len(row) else ""
val = val.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
tag = "th" if i == 0 else "td"
cells.append(f"<{tag}>{val or '&nbsp;'}</{tag}>")
rows_html.append(f"<tr>{''.join(cells)}</tr>")
import json as _json
sheets_json = _json.dumps(sheets)
enc_path = _json.dumps(filepath)
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{name}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#1e1e1e;color:#ddd;padding:12px;overflow:auto}}
#stabs{{display:flex;gap:4px;margin-bottom:10px;flex-wrap:wrap}}
.st{{padding:5px 14px;border:1px solid #444;border-radius:6px;background:#2a2a2a;color:#aaa;cursor:pointer;font-size:12px;transition:all .15s}}
.st:hover{{background:#333;color:#ddd}}
.st.active{{background:#3a6;border-color:#3a6;color:#fff}}
table{{border-collapse:collapse;width:max-content;min-width:100%;font-size:13px}}
th{{background:#2d2d2d;color:#e0e0e0;font-weight:600;padding:6px 12px;border:1px solid #444;text-align:left;white-space:nowrap;position:sticky;top:0;z-index:1}}
td{{padding:5px 12px;border:1px solid #444;white-space:nowrap;max-width:400px;overflow:hidden;text-overflow:ellipsis}}
tr:hover td{{background:rgba(255,255,255,.04)}}
</style></head>
<body>
{sheet_tabs}
<table>{"".join(rows_html)}</table>
<script>
function switchSheet(s){{
var p={enc_path};
var url="/api/table/preview?path="+encodeURIComponent(p)+"&sheet="+encodeURIComponent(s);
window.location.href=url;
}}
</script>
</body></html>"""
def _xls_to_html(filepath: str, sheet_name: str = "") -> str: def _xls_to_tsv(filepath: str, sheet_name: str = "") -> dict[str, Any]:
"""将 XLS 文件转为 HTML 表格(使用 xlrd)。""" """将 XLS 工作表内容提取为 TSV 文本。"""
try: try:
import xlrd import xlrd
except ImportError: except ImportError:
return "<html><body><p style='color:#888;padding:2rem'>需要安装 xlrd: pip install xlrd</p></body></html>" return {"content": "(需要安装 xlrd 才能预览 XLS: pip install xlrd)", "sheet": "", "sheets": []}
wb = xlrd.open_workbook(filepath) wb = xlrd.open_workbook(filepath)
name = os.path.basename(filepath)
sheets = wb.sheet_names() sheets = wb.sheet_names()
if not sheets: if not sheets:
return "<html><body><p style='color:#888;padding:2rem'>XLS 无工作表</p></body></html>" return {"content": "(无工作表)", "sheet": "", "sheets": []}
selected = sheet_name if sheet_name and sheet_name in sheets else sheets[0] selected = sheet_name if sheet_name and sheet_name in sheets else sheets[0]
ws = wb.sheet_by_name(selected) ws = wb.sheet_by_name(selected)
lines = []
rows_data = []
max_col = ws.ncols
for r in range(ws.nrows): for r in range(ws.nrows):
vals = [] vals = []
for c in range(ws.ncols): for c in range(ws.ncols):
@@ -2087,58 +1898,9 @@ def _xls_to_html(filepath: str, sheet_name: str = "") -> str:
v = xlrd.xldate_as_datetime(v, wb.datemode) v = xlrd.xldate_as_datetime(v, wb.datemode)
except Exception: except Exception:
pass pass
vals.append(str(v) if v is not None else "") vals.append("" if v is None else str(v))
rows_data.append(vals) lines.append("\t".join(vals))
return {"content": "\n".join(lines), "sheet": selected, "sheets": sheets}
if not rows_data:
return "<html><body><p style='color:#888;padding:2rem'>工作表为空</p></body></html>"
import json as _json
enc_path = _json.dumps(filepath)
sheet_tabs = "".join(
f'<button class="st{" active" if s == selected else ""}" '
f'onclick="switchSheet(\'{s}\')">{s}</button>'
for s in sheets
) if len(sheets) > 1 else ""
rows_html = []
for i, row in enumerate(rows_data):
cells = []
for j in range(max_col):
val = row[j] if j < len(row) else ""
val = val.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
tag = "th" if i == 0 else "td"
cells.append(f"<{tag}>{val}</{tag}>")
rows_html.append(f"<tr>{''.join(cells)}</tr>")
return f"""<!DOCTYPE html>
<html lang="zh">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{name}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#1e1e1e;color:#ddd;padding:12px;overflow:auto}}
#stabs{{display:flex;gap:4px;margin-bottom:10px;flex-wrap:wrap}}
.st{{padding:5px 14px;border:1px solid #444;border-radius:6px;background:#2a2a2a;color:#aaa;cursor:pointer;font-size:12px;transition:all .15s}}
.st:hover{{background:#333;color:#ddd}}
.st.active{{background:#3a6;border-color:#3a6;color:#fff}}
table{{border-collapse:collapse;width:max-content;min-width:100%;font-size:13px}}
th{{background:#2d2d2d;color:#e0e0e0;font-weight:600;padding:6px 12px;border:1px solid #444;text-align:left;white-space:nowrap;position:sticky;top:0;z-index:1}}
td{{padding:5px 12px;border:1px solid #444;white-space:nowrap;max-width:400px;overflow:hidden;text-overflow:ellipsis}}
tr:hover td{{background:rgba(255,255,255,.04)}}
</style></head>
<body>
{sheet_tabs}
<table>{"".join(rows_html)}</table>
<script>
function switchSheet(s){{
var p={enc_path};
var url="/api/table/preview?path="+encodeURIComponent(p)+"&sheet="+encodeURIComponent(s);
window.location.href=url;
}}
</script>
</body></html>"""
@app.get("/") @app.get("/")