修复一些已知问题
This commit is contained in:
1 parent
4a11e448cf
commit
b59db6879c
31 files changed
+1316
-772
No files matched your search
+6
-6
@@ -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' });
|
||||
|
||||
@@ -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()
|
||||
@@ -6,6 +6,8 @@
|
||||
"max_iterations": 200,
|
||||
"llm_model_id": "affiaa7r",
|
||||
"tools": [
|
||||
"doc_tool",
|
||||
"call_subagent",
|
||||
"date_query"
|
||||
],
|
||||
"enabled": true,
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -105,7 +105,7 @@
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
"timeout": 900
|
||||
},
|
||||
{
|
||||
"name": "doc_tool",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,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()
|
||||
@@ -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`)
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
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 " "
|
||||
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 " "
|
||||
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>"""
|
||||
@@ -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); }
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
tag = "th" if i == 0 else "td"
|
||||
cells.append(f"<{tag}>{val or ' '}</{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("&", "&").replace("<", "<").replace(">", ">")
|
||||
tag = "th" if i == 0 else "td"
|
||||
cells.append(f"<{tag}>{val or ' '}</{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("&", "&").replace("<", "<").replace(">", ">")
|
||||
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("/")
|
||||
|
||||
Reference in new issue
Block a user