修复一些已知问题

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.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
console.log('[Electron] 配置已写入:', CONFIG_PATH);
console.log('[Electron] CONFIG_PATH:', CONFIG_PATH);
}
// ==================== Python 检测 ====================
@@ -217,15 +217,15 @@ function startFastAPI() {
});
pythonProcess.on('error', (err) => {
console.error('[Electron] FastAPI 启动失败:', err.message);
console.error('[Electron] FastAPI Failed:', err.message);
reject(err);
});
pythonProcess.on('exit', (code) => {
console.log(`[Electron] FastAPI 退出,码: ${code}`);
console.log(`[Electron] FastAPI Exit Code: ${code}`);
pythonProcess = null;
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) => {
if (res.statusCode === 200) {
console.log('[Electron] FastAPI 就绪');
console.log('[Electron] FastAPI Ready');
resolve();
} else {
retry();
@@ -260,7 +260,7 @@ function stopFastAPI() {
killPortProcess(PORT);
// 再通过 PID 杀一次(确保主进程被杀)
if (pythonProcess) {
console.log('[Electron] 关闭 FastAPI...');
console.log('[Electron] Killing FastAPI Process...');
try {
if (process.platform === 'win32') {
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:
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
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]:
"""从 agent_config.json 读取所有启用 Agent 并构建运行时字典。
@@ -140,6 +171,16 @@ def build_all_agent_runtimes() -> dict[str, AgentRuntime]:
_main_agent_name = first_name
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
@@ -248,25 +289,3 @@ agent_graph = main_agent_runtime.graph
tools = main_agent_runtime.tools
TOOLS_BY_NAME = main_agent_runtime.tools_by_name
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,
"llm_model_id": "affiaa7r",
"tools": [
"doc_tool",
"call_subagent",
"date_query"
],
"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",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
"timeout": 900
},
{
"name": "doc_tool",
+2 -3
View File
@@ -1,7 +1,6 @@
{
"current": "C:\\Users\\86166\\Desktop\\新建文件夹",
"current": "",
"list": [
"C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace",
"C:\\Users\\86166\\Desktop\\新建文件夹"
"C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace"
]
}
+2 -11
View File
@@ -175,17 +175,8 @@ class ChatQwen(ChatOpenAI):
if ext in IMAGE_FILE_EXTENSIONS:
new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}}))
else:
try:
file_text, file_images = Documents_process.process(file_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}]"})
# 非图片附件只注入文件路径提示,内容由 Agent 通过 doc_tool 读取
new_content.append({"type": "text", "text": make_attachment_text("附件文件", normalized_path)})
else:
text = assistant_text(part)
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
- 仅 process_tool_artifact 生成的合成图片 HumanMessage 按 trajectory_rounds 裁剪
- 每轮由一个 AIMessage(含 tool_calls)定义
- 只对实际包含图片截图的轮次计数,无截图的轮次跳过不计数
"""
if trajectory_rounds <= 0:
return list(messages)
# 从后向前遍历,统计已遇到的含 tool_calls 的 AIMessage 轮数
rounds_seen = 0
# 从后向前遍历,先标记哪些轮次包含图片截图
# 一"轮"由一个 AIMessage(含 tool_calls)之后的消息(ToolMessage + synthetic HumanMessage)组成
img_rounds_seen = 0
kept_indices = set()
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None):
rounds_seen += 1
# 合成图片消息:仅保留最近 trajectory_rounds 轮
# 合成图片消息:仅保留最近 trajectory_rounds 轮(有图片的轮才计数)
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)
else:
kept_indices.add(i)
continue
# 遇到 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)]
@@ -209,12 +222,12 @@ def _compress_messages_in_loop(state: dict, response: Any) -> None:
if total <= 0:
return
# 存储最新 token 用量供前端轮询
# 存储最新 token 用量供前端轮询(仅统计主 Agent,子 Agent 上下文不计入)
_sid = ""
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 ""
if _sid:
if _sid and not is_sub_agent_context():
set_session_tokens(_sid, total)
except Exception:
pass
+26 -19
View File
@@ -71,24 +71,31 @@ _OOM_MESSAGE = "OOM: 已达到最大迭代次数限制,任务未能完成。
def _inject_previous_turn_context(session_id: str, messages: list) -> None:
"""从上一轮 tool_calling 记录中读取工具调用上下文,注入为合成消息。"""
"""从最近5轮(不含当前轮)tool_calling 记录中读取工具调用上下文,注入为合成消息。"""
try:
from agent.history.tool_call_recorder import get_latest_turn
latest = get_latest_turn(session_id)
if not latest:
from agent.history.tool_call_recorder import list_turns
# 取6轮(当前轮 + 前5轮历史)
turns = list_turns(session_id, limit=6)
if not turns:
return
tool_calls = latest.get("tool_calls", [])
if not tool_calls:
return
for tc in tool_calls:
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},
))
# 跳过最近一轮(当前正在执行或刚结束的 turn),取前面最多5轮历史
recent_turns = turns[1:6] if len(turns) > 1 else []
if not recent_turns:
# 首次对话场景:只有1轮,用唯一那一轮
recent_turns = [turns[0]]
for turn in recent_turns:
tool_calls = turn.get("tool_calls", [])
if not tool_calls:
continue
for tc in tool_calls:
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:
pass
@@ -703,7 +710,7 @@ def _resume_sub_agent(
sub_call_id = sub_tool_call.get("id") or f"sub_{approval_id}"
# 1) 构造子图续跑的 ToolMessage
if decision == "skip":
if decision == "skip" and sub_type != "human_interaction":
sub_messages.append(ToolMessage(
content=f"用户已跳过此工具调用({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, "")
return chat_history
# ===== 跳过:不执行工具,写跳过 ToolMessage 后续跑图(tool_call / human_interaction 通用)=====
if decision == "skip":
# ===== 跳过:仅 tool_call 类型支持,human_interaction 不支持跳过 =====
if decision == "skip" and pending.get("type") != "human_interaction":
tool_call = dict(pending.get("tool_call") or {})
tool_name = tool_call.get("name") or "unknown_tool"
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
val = float(minutes)
if val <= 0:
raise ValueError("时间段长度必须为正数")
raise ValueError("Time period must be a positive number")
_CRON_TIME_PERIOD_MINUTES = val
try:
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
save_env_config(cfg)
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
@@ -228,7 +228,7 @@ class CronScheduler:
_load_time_period_minutes()
self._thread = threading.Thread(target=self._run_loop, name="cron-scheduler", daemon=True)
self._thread.start()
print("[CronScheduler] 调度线程已启动")
print("[CronScheduler] Scheduler thread started")
def shutdown(self) -> None:
if not self._started:
@@ -245,7 +245,7 @@ class CronScheduler:
if self._thread:
self._thread.join(timeout=3.0)
self._started = False
print("[CronScheduler] 调度线程已停止")
print("[CronScheduler] Scheduler thread stopped")
# ---------- 主循环 ----------
def _run_loop(self) -> None:
@@ -253,13 +253,13 @@ class CronScheduler:
try:
self._on_startup()
except Exception as e:
print(f"[CronScheduler] 启动扫描异常: {e}")
print(f"[CronScheduler] Startup scan error: {e}")
while not self._stop_event.is_set():
try:
self._scan_once()
except Exception as e:
print(f"[CronScheduler] 扫描异常: {e}")
print(f"[CronScheduler] Scan error: {e}")
self._stop_event.wait(_SCAN_INTERVAL)
def _on_startup(self) -> None:
@@ -288,12 +288,12 @@ class CronScheduler:
"started_at": trigger.run_at,
"ended_at": now_iso(),
"status": "expired",
"error": "应用未运行,已错过执行时间",
"error": "App was not running, missed execution time",
"trigger": "schedule",
"turn_id": "",
})
repo.update_status(task.task_id, "expired",
error="应用未运行,已错过执行时间",
error="App was not running, missed execution time",
next_run_at="")
task.enabled = False
task.last_status = "expired"
@@ -318,7 +318,7 @@ class CronScheduler:
"started_at": task.next_run_at,
"ended_at": now_iso(),
"status": "expired",
"error": "应用未运行错过该次触发",
"error": "App was not running, missed this trigger",
"trigger": "schedule",
"turn_id": "",
})
@@ -390,10 +390,10 @@ class CronScheduler:
# 2) 上一个任务启动后仍在 CRON_TIME_PERIOD_MINUTES 时段窗口内 → 跳过。
now_ts = time.time()
if self._running_procs:
print(f"[CronScheduler] 时段内已有任务运行,跳过 {task.task_id}")
print(f"[CronScheduler] Task already running in period, skipping {task.task_id}")
return False
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
# 占用新时段
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,
)
except Exception as e:
print(f"[CronScheduler] 拉起子进程失败 task={task.task_id}: {e}")
get_task_repository().update_status(task.task_id, "failed", error=f"拉起子进程失败: {e}")
print(f"[CronScheduler] Failed to spawn subprocess task={task.task_id}: {e}")
get_task_repository().update_status(task.task_id, "failed", error=f"Failed to spawn subprocess: {e}")
# 子进程未启动成功,释放占用的时段(避免空占时段阻塞后续任务)
with self._procs_lock:
if not self._running_procs:
@@ -478,7 +478,7 @@ class CronScheduler:
error = ""
else:
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.append_execution(task_id, {
"started_at": started_iso,
+100
View File
@@ -82,6 +82,9 @@ class SessionRecordRepository(Protocol):
def list_session_ids(self) -> list[str]: ...
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 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 delete_turns_after(self, session_id: str, keep_until_turn_id: str) -> None: ...
def delete_session(self, session_id: str) -> None: ...
@@ -97,6 +100,23 @@ class MysqlSessionRecordRepository:
def __init__(self) -> None:
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:
if not session_id or not turn_id:
@@ -210,6 +230,54 @@ class MysqlSessionRecordRepository:
finally:
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:
conn = mysql_conn()
try:
@@ -381,6 +449,38 @@ def get_latest_turn_record(session_id: str) -> dict[str, Any] | 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:
"""mysql 后端返回最后 turn_id;json 后端返回 None。"""
if not is_mysql():
+61 -6
View File
@@ -57,9 +57,18 @@ def sub_agent_context():
子 Agent 的工具调用记录写入独立的 _sub_live_records[session_id],
get_live_tool_calls 将其注入到 call_subagent 条目的 sub_tool_calls 中,
前端在 call_subagent 展开时实时渲染嵌套的子工具调用列表。
进入时清空上一子 Agent 遗留的实时记录与待消费思考,
避免同一轮内多个子 Agent 顺序执行时发生记忆串扰。
"""
token = _sub_agent_context.set(True)
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
finally:
_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:
"""将 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.parent.mkdir(parents=True, exist_ok=True)
tf.write_text(json.dumps({"session_id": session_id, "tokens": tokens}), encoding="utf-8")
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)
if not tf.exists():
return 0
@@ -142,9 +171,20 @@ def get_session_tokens(session_id: str) -> int:
def clear_session_tokens(session_id: str) -> None:
"""清除指定会话的 token 记录(内存 + 磁盘)。"""
"""清除指定会话的 token 记录(内存 + 存储)。"""
with _session_tokens_lock:
_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)
if tf.exists():
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
def list_turns(session_id: str) -> list[dict[str, Any]]:
"""列出会话下所有 turn(按时间倒序,最近5轮)。"""
def list_turns(session_id: str, limit: int = 5) -> list[dict[str, Any]]:
"""列出会话下所有 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)
if not session_dir.is_dir():
return []
@@ -989,7 +1044,7 @@ def list_turns(session_id: str) -> list[dict[str, Any]]:
turns.append(json.load(fh))
except (json.JSONDecodeError, OSError):
pass
return turns[:5]
return turns[:limit]
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 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用,注意单次调用最多包含5个动作。
- 如果不确定是否需要工具,优先选择不调用,用已有信息给出最佳答案。
- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。
- 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。
- 当任务完成时,向用户总结执行过程和结果。
""".strip()
@@ -161,7 +161,12 @@
**交付**:
- 使用 `doc_tool create` 逐页写出 `{N}.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` 即可逐页浏览全部幻灯片
---
@@ -179,6 +184,7 @@
| 动效引擎(Executor) | `08-animation-system.md` |
| QA & 交付 | `09-quality-checklist.md` |
| 图片内联固化 | `attachments/complete_htmls_to_base64.py`(运行即可) |
| 单文件合并 | `attachments/merge_to_deck.py`(可选,交付前最后一步) |
---
@@ -212,3 +218,4 @@
| `09-quality-checklist.md` | 质检清单 — HTML 质量检查项与交付规范 | QA 阶段 |
| `templates/` | 模板库 — 预置 HTML 模板(封面、过渡页、内容页、结束页) | Executor 参考复用 |
| `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 阶段 |
| `templates/` | 模板库 | 预置 HTML 模板(封面、过渡页、内容页、结束页) | Executor 可参考复用 |
| `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 逐项检查
├── 确认每页 HTML 可独立在浏览器中打开
├── 运行 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:
"""加载整个 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)
if not os.path.isfile(mp):
return {}
@@ -170,7 +179,18 @@ def _load_session_meta(session_id: str) -> dict:
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)
os.makedirs(os.path.dirname(mp), exist_ok=True)
with open(mp, "w", encoding="utf-8") as f:
-281
View File
@@ -1,7 +1,6 @@
"""PPT 读取与预览工具:自主实现 PPTX 解析,不依赖外部 ppt-master 项目。
支持:
- pptx → SVG: 将 PPTX 每页转为自包含 SVG(扁平渲染,用于前端预览)
- pptx → HTML: 将 PPTX 转为带翻页导航的 HTML 预览页
渲染范围(聚焦核心功能):
- 形状: rect, roundRect, ellipse, line, text, image, path, polygon, group
@@ -17,7 +16,6 @@
from __future__ import annotations
import base64
import json
import os
import xml.etree.ElementTree as ET
import zipfile
@@ -739,282 +737,3 @@ def _convert_group_to_svg(node: ShapeNode, slide: _SlideRef, pkg: OoxmlPackage,
parts.append(child_svg)
parts.append("</g>")
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;
}
/* 拖拽附件到输入区时:显示"添加附件"提示 */
.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 {
width: 100%;
resize: vertical;
@@ -1086,6 +1110,18 @@
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 {
max-width: 100%;
max-height: 75vh;
@@ -1319,18 +1355,18 @@
pointer-events: none;
}
.token-bar-seg--green {
background: var(--accent, #0e7490);
background: var(--success, #22c55e);
opacity: 0.45;
z-index: 1;
border-radius: 3px 0 0 3px;
}
.token-bar-seg--yellow {
background: var(--accent, #0e7490);
background: var(--warning, #f59e0b);
opacity: 0.7;
z-index: 1;
}
.token-bar-seg--red {
background: var(--accent, #0e7490);
background: var(--danger, #ef4444);
opacity: 0.85;
z-index: 1;
border-radius: 0 3px 3px 0;
@@ -1374,16 +1410,16 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
}
.token-handle--hard .token-handle-dot {
background: #d4756b;
box-shadow: 0 0 0 2px rgba(212, 117, 107, 0.2);
background: var(--danger, #d4756b);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 25%, transparent);
}
.token-handle.overshot .token-handle-dot {
animation: handlePulse 0.6s ease-in-out infinite alternate;
}
@keyframes handlePulse {
from { box-shadow: 0 0 0 2px rgba(212, 117, 107, 0.15); }
to { box-shadow: 0 0 0 5px rgba(212, 117, 107, 0.1); }
from { box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 15%, transparent); }
to { box-shadow: 0 0 0 5px color-mix(in srgb, var(--danger) 10%, transparent); }
}
.token-label {
@@ -1397,4 +1433,6 @@
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;
}
/* ===== 背景图片选择器 ===== */
.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 {
display: inline-flex;
+13 -2
View File
@@ -43,7 +43,13 @@
overflow: hidden;
}
.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;
}
.tool-call-row--expanded + .tc-detail {
max-height: 600px;
max-height: 45vh;
overflow-y: auto;
padding: 0.3rem 0.45rem;
}
/* .tc-detail 同为 flex column,内部子项保持自然高度,避免被压缩导致结果不可见 */
.tc-detail > * {
flex-shrink: 0;
}
.tc-detail-text {
max-height: 200px;
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>
<p class="settings-hint">输入本地图片路径或 URL(支持 jpg/png/webp),留空恢复默认。</p>
<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 style="display:flex;gap:0.5rem;">
<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');
}
/** 二进制表格(XLSX/XLS):由后端 /api/table/text 提取为 TSV 后按列着色预览 */
function isPreviewableTable(name) {
const ext = (name || '').toLowerCase();
return ext.endsWith('.xlsx') || ext.endsWith('.xls');
}
const BINARY_EXTS = new Set([
'.exe','.dll','.so','.dylib','.bin','.dat','.zip','.rar','.7z','.tar','.gz','.bz2','.xz',
'.png','.jpg','.jpeg','.gif','.bmp','.ico','.webp','.tiff','.tif','.psd','.ai','.eps',
@@ -78,7 +84,19 @@ function isBinaryFile(name) {
}
// ===== 模式切换 =====
let _modeSwitchCooldown = false;
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;
if (mode === "edit") {
state.editMode = true;
@@ -155,6 +173,15 @@ export async function openFolder(folderPath) {
}
if (!rootPath) return;
// 先校验目录存在并列出文件;文件夹被删除/不可访问时给出明确提示,避免把失效路径写入状态
let listRes;
try {
listRes = await listDir(rootPath, 10);
} catch (e) {
showToast("打开文件夹失败: " + (e.message || e));
return;
}
state._docRootPath = rootPath;
state._docRootName = rootPath.split(/[/\\]/).pop() || rootPath;
@@ -166,7 +193,6 @@ export async function openFolder(folderPath) {
if (!state._docTreeFolders) state._docTreeFolders = {};
state._docTreeFolders[state._docRootName] = true;
const listRes = await listDir(rootPath, 10);
const files = listRes.files || [];
const supportedFiles = files.filter((f) => !f.is_dir && isSupportedFile(f.name));
// 去重:按 path 去重
@@ -257,6 +283,19 @@ export async function loadDocContent(filePath) {
openFilePreview(filePath, fileName, res.content || '',
{ 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)) {
showToast('二进制文件不支持在线查看', 'warning');
} else {
@@ -309,6 +348,20 @@ async function downloadFile(filePath, fileName) {
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
} else if (isBinaryFile(fileName)) {
// 二进制文件(xlsx/docx/pdf 等):按 base64 还原字节后下载
const res = await readBinaryFile(filePath, state._docRootPath);
if (res && res.status === 'ok' && res.data) {
const bin = atob(res.data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const blob = new Blob([bytes], { type: res.mime || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} else {
const res = await readTextFile(filePath, state._docRootPath);
if (res && res.status === 'ok') {
@@ -737,17 +790,37 @@ function setupInputDropHandler() {
const target = composerInput || $('textInput');
if (!target) return;
// 子元素间移动会成对触发 dragenter/dragleave,用计数器避免状态误清除
let dragDepth = 0;
const setDragState = (on) => target.classList.toggle('is-drag-attach', on);
const isAcceptedDrag = (types) => types.includes('application/doc-path') || types.includes('Files');
target.addEventListener('dragenter', (e) => {
if (isAcceptedDrag(Array.from(e.dataTransfer.types))) {
dragDepth++;
setDragState(true);
}
});
target.addEventListener('dragover', (e) => {
const types = Array.from(e.dataTransfer.types);
// 接受应用内文件/文件夹引用拖拽 或 外部文件拖拽
if (types.includes('application/doc-path') || types.includes('Files')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setDragState(true);
}
});
target.addEventListener('dragleave', () => {
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) setDragState(false);
});
target.addEventListener('drop', async (e) => {
e.preventDefault();
dragDepth = 0;
setDragState(false);
// 1. 内部拖拽(doc-tree 文件/文件夹引用)
// app.js 检测到 application/doc-path 也会 return,无需 stopPropagation
+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 HTML_EXTS = new Set(['.html', '.htm']);
const TEXT_EXTS = new Set(['.txt', '.json', '.md', '.py', '.js', '.ts', '.css', '.xml', '.yaml', '.yml', '.toml',
'.csv', '.log', '.env', '.ini', '.cfg', '.sh', '.bat', '.ps1', '.sql', '.r', '.go', '.rs', '.rb',
'.csv', '.tsv', '.log', '.env', '.ini', '.cfg', '.sh', '.bat', '.ps1', '.sql', '.r', '.go', '.rs', '.rb',
'.php', '.swift', '.kt', '.scala', '.cpp', '.c', '.h', '.java', '.m', '.svg']);
const TABLE_EXTS = new Set(['.csv', '.tsv', '.xlsx', '.xls']);
let _currentFilePath = null;
let _currentContent = null;
@@ -33,6 +34,13 @@ function isTextFile(fileName) {
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 - 文件路径
@@ -84,6 +92,8 @@ export function openFilePreview(filePath, fileName, content, opts = {}) {
_renderImage(body, content, fileName);
} else if (isHtml(fileName) && !opts.asText) {
_renderPlainText(body, content);
} else if (isTableFile(fileName)) {
_renderTable(body, content, fileName);
} else if (isTextFile(fileName)) {
_renderPlainText(body, content);
} else if (content && content.startsWith && content.startsWith('data:')) {
@@ -162,6 +172,96 @@ function _renderPlainText(body, content) {
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) {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
-12
View File
@@ -211,12 +211,6 @@ function buildPendingCard(pending, isSubAgent) {
inputRow.appendChild(sendBtn);
cardBody.appendChild(inputRow);
// 跳过按钮
const btnRow = document.createElement("div");
btnRow.className = "pending-overlay-btn-row";
btnRow.appendChild(makeDecisionBtn("跳过", "skip", "", () => submitSingle(pending.id, "skip", "", card)));
cardBody.appendChild(btnRow);
} else if (interactionType === "selection") {
if (pending.questions && Array.isArray(pending.questions) && pending.questions.length > 0) {
pending.questions.forEach((q) => {
@@ -289,12 +283,6 @@ function buildPendingCard(pending, isSubAgent) {
});
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);
+45 -9
View File
@@ -139,16 +139,19 @@ function buildAgentCard(agent, idx) {
header.className = "agent-card-header";
const h4 = document.createElement("h4");
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(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 descField = makeField("\u80FD\u529B\u63CF\u8FF0 (description)", "text", agent.description || "", (v) => { agent.description = v; });
@@ -1135,6 +1138,8 @@ function renderThemePanel() {
const bgApplyBtn = $("bgImageApplyBtn");
const bgResetBtn = $("bgImageResetBtn");
const bgStatus = $("bgPreviewStatus");
const bgPickerBtn = $("bgImagePickerBtn");
const bgFileInput = $("bgImageFileInput");
if (bgInput) {
bgInput.value = getBgImage();
const applyBg = () => {
@@ -1154,6 +1159,37 @@ function renderThemePanel() {
}
// 回车也应用
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 STORAGE_KEY = "minor_theme";
const STORAGE_BG_KEY = "minor_bg_image";
const STORAGE_FONT_KEY = "minor_font";
const STORAGE_FONT_SIZE_KEY = "minor_font_size";
const STORAGE_BLUR_KEY = "minor_bg_blur";
const STORAGE_VIGNETTE_KEY = "minor_bg_vignette";
// 主题设置统一内存态:全部落盘到服务端 theme_config.json(不使用 localStorage)
let _settings = {
theme: DEFAULT_THEME,
bg_image: "",
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 = {
@@ -611,14 +639,14 @@ export const FONT_SIZE_PRESETS = {
"20px": "20px",
};
const getFontKey = (k) => ({ family: STORAGE_FONT_KEY, size: STORAGE_FONT_SIZE_KEY }[k]);
export function getFontSetting(key) {
try { return localStorage.getItem(getFontKey(key)) || ""; } catch { return ""; }
return key === "family" ? _settings.font_family : key === "size" ? _settings.font_size : "";
}
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);
_persistThemeConfig();
}
export function applyFontSetting(key, val) {
const r = document.documentElement;
@@ -638,15 +666,12 @@ function localPathToUrl(localPath) {
/** 获取/设置自定义背景图 */
export function getBgImage() {
try { return localStorage.getItem(STORAGE_BG_KEY) || ""; }
catch { return ""; }
return _settings.bg_image;
}
export function setBgImage(url) {
try { localStorage.setItem(STORAGE_BG_KEY, url || ""); }
catch {}
applyBgImage(url);
// 异步同步到服务端
try { api("/api/config/theme", { method: "POST", body: JSON.stringify({ bg_image: url || "" }) }); } catch {}
_settings.bg_image = url || "";
applyBgImage(_settings.bg_image);
_persistThemeConfig();
}
export function applyBgImage(url) {
const r = document.documentElement;
@@ -666,8 +691,7 @@ export function applyBgImage(url) {
/** 获取当前保存的主题 */
export function getCurrentTheme() {
try { return localStorage.getItem(STORAGE_KEY) || DEFAULT_THEME; }
catch { return DEFAULT_THEME; }
return _settings.theme;
}
/** 应用主题到页面 */
@@ -685,8 +709,9 @@ export function applyTheme(themeId) {
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 类名切换(用于全局暗/亮模式判断)
// 优先使用主题对象的 dark 字段;缺失时回退到 id 字符串推断
@@ -695,28 +720,27 @@ export function applyTheme(themeId) {
document.body.classList.toggle("theme-light", !isDark);
}
/** 初始化:从 localStorage 恢复主题,并从服务端同步背景图 */
/** 初始化:先用默认主题渲染,再从服务端 theme_config.json 恢复全部设置 */
export function initTheme() {
const id = getCurrentTheme();
applyTheme(id);
// 先从 localStorage 快读,再从服务端同步(异步)
applyBgImage(getBgImage());
applyFontSetting('family', getFontSetting('family'));
applyFontSetting('size', getFontSetting('size'));
applyBgBlur(getBgBlur());
applyBgVignette(getBgVignette());
// 异步从服务端同步主题配置
loadThemeConfig();
return id;
_loadingTheme = true;
applyTheme(DEFAULT_THEME);
applyBgImage("");
applyFontSetting("family", "");
applyFontSetting("size", "");
applyBgBlur("");
applyBgVignette("");
loadThemeConfig(); // 完成后在 finally 中解除 _loadingTheme
return DEFAULT_THEME;
}
// ===== 背景模糊度 =====
export function getBgBlur() {
try { return localStorage.getItem(STORAGE_BLUR_KEY) || ""; } catch { return ""; }
return _settings.bg_blur;
}
export function setBgBlur(val) {
try { localStorage.setItem(STORAGE_BLUR_KEY, val || ""); } catch {}
applyBgBlur(val);
_settings.bg_blur = val || "";
applyBgBlur(_settings.bg_blur);
_persistThemeConfig();
}
export function applyBgBlur(val) {
// val 为空字符串时使用主题默认值(由 applyTheme 设置),否则覆盖
@@ -730,11 +754,12 @@ export function applyBgBlur(val) {
// ===== 背景暗角 =====
export function getBgVignette() {
try { return localStorage.getItem(STORAGE_VIGNETTE_KEY) || ""; } catch { return ""; }
return _settings.bg_vignette;
}
export function setBgVignette(val) {
try { localStorage.setItem(STORAGE_VIGNETTE_KEY, val || ""); } catch {}
applyBgVignette(val);
_settings.bg_vignette = val || "";
applyBgVignette(_settings.bg_vignette);
_persistThemeConfig();
}
export function applyBgVignette(val) {
const r = document.documentElement;
@@ -745,14 +770,39 @@ export function applyBgVignette(val) {
}
}
/** 从服务端加载主题配置(覆盖 localStorage 缓存) */
/** 从服务端 theme_config.json 加载主题配置并应用(不使用 localStorage) */
export async function loadThemeConfig() {
try {
const res = await api("/api/config/theme");
if (res.bg_image !== undefined) {
const img = res.bg_image || "";
try { localStorage.setItem(STORAGE_BG_KEY, img); } catch {}
applyBgImage(img);
_loadingTheme = true;
if (res.theme && THEMES[res.theme]) {
applyTheme(res.theme);
}
} 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.addEventListener("click", (e) => {
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.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
if str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
@@ -82,6 +82,23 @@ def _init_workspace():
"""从 workspace_config.json 恢复上次选择的工作空间。"""
cfg = _read_ws_config()
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:
from agent.memory.system_prompt import set_current_workspace
set_current_workspace(current)
@@ -1145,9 +1162,17 @@ def api_get_workspace() -> dict[str, Any]:
import os
cfg = _read_ws_config()
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 {
"current": current,
"list": cfg.get("list", []),
"list": valid,
"default": get_workspace_dir(),
"snapshots_base": os.path.join(SESSIONS_ROOT, ".minor_snapshots").replace("\\", "/"),
}
@@ -1340,87 +1365,6 @@ if ($folder) { Write-Output $folder.Self.Path }
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")
def api_reload_configs() -> dict[str, Any]:
"""热重载所有配置并重建 Agent 运行时,无需重启服务。
@@ -1886,198 +1830,65 @@ def serve_image_asset(asset_name: str) -> FileResponse:
return FileResponse(path, media_type=mime or "application/octet-stream")
@app.get("/api/pptx/preview")
def pptx_preview(path: str = "") -> Response:
"""将 PPTX 文件转为带翻页导航的 HTML 预览页面。
# ---------- 表格文本提取(XLSX/XLS → TSV,供前端按列着色的纯文本预览) ----------
@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):
return HTMLResponse("<html><body><p style='color:#888;padding:2rem'>PPTX 文件不存在</p></body></html>", status_code=404)
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)
raise HTTPException(status_code=404, detail=f"文件不存在: {path}")
ext = os.path.splitext(path)[1].lower()
try:
if ext == ".csv":
html_content = _csv_to_html(path)
elif ext == ".xlsx":
html_content = _xlsx_to_html(path, sheet)
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)
if ext == ".xlsx":
return _xlsx_to_tsv(path, sheet)
if ext == ".xls":
return _xls_to_tsv(path, sheet)
raise HTTPException(status_code=400, detail="不支持的表格格式")
def _csv_to_html(filepath: str) -> str:
"""将 CSV 文件转为 HTML 表格。"""
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 表格。"""
def _xlsx_to_tsv(filepath: str, sheet_name: str = "") -> dict[str, Any]:
"""将 XLSX 工作表内容提取为 TSV 文本。"""
try:
import openpyxl
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)
name = os.path.basename(filepath)
sheets = wb.sheetnames
if not 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]
rows_data = []
max_col = 0
for row in ws.iter_rows(values_only=True):
vals = [str(c) if c is not None else "" for c in row]
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>"""
try:
sheets = wb.sheetnames
if not sheets:
return {"content": "(无工作表)", "sheet": "", "sheets": []}
selected = sheet_name if sheet_name and sheet_name in sheets else sheets[0]
ws = wb[selected]
lines = []
for row in ws.iter_rows(values_only=True):
vals = ["" if c is None else str(c) for c in row]
lines.append("\t".join(vals))
finally:
wb.close()
return {"content": "\n".join(lines), "sheet": selected, "sheets": sheets}
def _xls_to_html(filepath: str, sheet_name: str = "") -> str:
"""将 XLS 文件转为 HTML 表格(使用 xlrd)。"""
def _xls_to_tsv(filepath: str, sheet_name: str = "") -> dict[str, Any]:
"""将 XLS 工作表内容提取为 TSV 文本。"""
try:
import xlrd
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)
name = os.path.basename(filepath)
sheets = wb.sheet_names()
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]
ws = wb.sheet_by_name(selected)
rows_data = []
max_col = ws.ncols
lines = []
for r in range(ws.nrows):
vals = []
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)
except Exception:
pass
vals.append(str(v) if v is not None else "")
rows_data.append(vals)
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>"""
vals.append("" if v is None else str(v))
lines.append("\t".join(vals))
return {"content": "\n".join(lines), "sheet": selected, "sheets": sheets}
@app.get("/")