mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:13:19 +08:00
完善 MCP/会话上下文治理并补齐搜索与诊断能力。
补充历史压缩与会话诊断链路,强化 tool pairing 与空响应兜底观测;完善 Admin MCP 导入/绑定与相关前端展示;新增 web_search_fast/web_fetch_clean 及多项测试与文档更新,并同步技能安装与角色绑定策略改进。 Made-with: Cursor
This commit is contained in:
parent
df91434936
commit
ae44cbcad5
43 changed files with 3549 additions and 57 deletions
|
|
@ -34,6 +34,7 @@ from oclaw.platform.files.session_export import export_session_json, export_sess
|
|||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.gateway import OclawGateway
|
||||
from oclaw.runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist
|
||||
from oclaw.runtime.chat.history_tool_result_compact import compact_tool_results_in_session_history
|
||||
|
||||
|
||||
def _oclaw_config_path() -> Path:
|
||||
|
|
@ -988,6 +989,50 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
"messages": [_serialize_message(m) for m in msgs],
|
||||
}
|
||||
|
||||
@chat.post("/sessions/{session_id}/compress-history")
|
||||
def api_chat_compress_history(
|
||||
session_id: str,
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
"""Rewrite overlarge tool_result history using the same replay-guard strategy.
|
||||
|
||||
This is intended for operators to clean up historical polluted sessions so UI/export
|
||||
performance remains stable. It only touches `role=tool` chat_message rows.
|
||||
"""
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
sess = _resolve_chat_session(store, ctx, session_id)
|
||||
if not sess:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
limit_messages = int(payload.get("limit_messages") or 5000)
|
||||
raw_cap = payload.get("cap_chars")
|
||||
cap_chars: int | None
|
||||
try:
|
||||
cap_chars = int(raw_cap) if raw_cap is not None else None
|
||||
except Exception:
|
||||
cap_chars = None
|
||||
out = compact_tool_results_in_session_history(
|
||||
store=store,
|
||||
session_id=str(session_id),
|
||||
cap_chars=cap_chars,
|
||||
limit_messages=limit_messages,
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"session_id": out.session_id,
|
||||
"scanned_tool_messages": int(out.scanned_tool_messages),
|
||||
"compacted_tool_messages": int(out.compacted_tool_messages),
|
||||
"rewritten_all_tool_messages": int(out.rewritten_all_tool_messages),
|
||||
"skipped_already_guarded": int(out.skipped_already_guarded),
|
||||
"max_original_chars_seen": int(out.max_original_chars_seen),
|
||||
"cap_chars": int(out.cap_chars),
|
||||
"detail": str(out.detail or ""),
|
||||
},
|
||||
}
|
||||
|
||||
@chat.delete("/sessions/{session_id}/messages/{message_id}")
|
||||
def api_chat_delete_message(
|
||||
session_id: str,
|
||||
|
|
|
|||
|
|
@ -4796,7 +4796,7 @@ async function renderPlugins() {
|
|||
const jsonInstallInput = el("textarea", {
|
||||
class: "input",
|
||||
placeholder:
|
||||
"Paste install JSON: one object, array of objects, or { servers: [...] } / { payload: {...} } (seed file shape)",
|
||||
"Paste install JSON: one object, array of objects, { servers:[...] }, { payload:{...} }, or Cursor-style { mcpServers:{...} }",
|
||||
rows: "6",
|
||||
});
|
||||
const cliInstallInput = el("input", {
|
||||
|
|
@ -4962,10 +4962,89 @@ async function renderPlugins() {
|
|||
return;
|
||||
}
|
||||
let list;
|
||||
let detectedMcpServersShape = false;
|
||||
if (Array.isArray(parsed)) {
|
||||
list = parsed;
|
||||
} else if (parsed && typeof parsed === "object" && Array.isArray(parsed.servers)) {
|
||||
list = parsed.servers;
|
||||
} else if (parsed && typeof parsed === "object" && parsed.mcpServers && typeof parsed.mcpServers === "object") {
|
||||
detectedMcpServersShape = true;
|
||||
installStatus.textContent =
|
||||
"[json] detected mcpServers shape; streamableHttp/sse entries will be installed via mcp-remote bridge";
|
||||
const envVarRe = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
||||
list = Object.entries(parsed.mcpServers).map(([rawKey, rawServer]) => {
|
||||
const key = String(rawKey || "").trim();
|
||||
const s = rawServer && typeof rawServer === "object" ? rawServer : {};
|
||||
const transport = String(s.type || "").trim().toLowerCase();
|
||||
const baseUrl = String(s.baseUrl || s.url || "").trim();
|
||||
const headers = s.headers && typeof s.headers === "object" ? s.headers : {};
|
||||
|
||||
// Build env schema from ${ENV_VAR} placeholders in headers.
|
||||
const envSchema = {};
|
||||
Object.entries(headers).forEach(([hk, hv]) => {
|
||||
const v = String(hv || "");
|
||||
let m;
|
||||
while ((m = envVarRe.exec(v)) !== null) {
|
||||
const envName = String(m[1] || "").trim();
|
||||
if (!envName) continue;
|
||||
envSchema[envName] = {
|
||||
required: true,
|
||||
description: `Auto-detected from header ${String(hk || "").trim()}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Convert remote MCP (streamable HTTP / SSE) to stdio bridge via mcp-remote.
|
||||
// Equivalent runtime command: npx -y mcp-remote <baseUrl> --header "K: V" ...
|
||||
//
|
||||
// Note: mcp-remote uses transport strategy "http-first" and may fall back to "sse-only"
|
||||
// automatically, so both shapes can share the same bridge command.
|
||||
if (
|
||||
transport === "streamablehttp" ||
|
||||
transport === "streamable_http" ||
|
||||
transport === "streamable-http" ||
|
||||
transport === "sse"
|
||||
) {
|
||||
const entryArgs = ["-y", "mcp-remote"];
|
||||
if (baseUrl) entryArgs.push(baseUrl);
|
||||
Object.entries(headers).forEach(([hk, hv]) => {
|
||||
const hn = String(hk || "").trim();
|
||||
const hvs = String(hv || "").trim();
|
||||
if (!hn || !hvs) return;
|
||||
entryArgs.push("--header", `${hn}: ${hvs}`);
|
||||
});
|
||||
return {
|
||||
source_type: "npm",
|
||||
source_ref: "mcp-remote",
|
||||
server_id: key || String(s.name || "mcp-remote").trim() || "mcp-remote",
|
||||
entry_command: "npx",
|
||||
entry_args: entryArgs,
|
||||
env_schema: envSchema,
|
||||
required_permissions: Array.isArray(s.required_permissions) ? s.required_permissions.map((x) => String(x)) : [],
|
||||
risk_level: String(s.risk_level || "high"),
|
||||
enabled: Object.prototype.hasOwnProperty.call(s, "isActive") ? !!s.isActive : true,
|
||||
timeout_s: Number(s.timeout_s || 30),
|
||||
dry_run: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Best-effort passthrough for stdio-like definitions.
|
||||
const cmd = String(s.command || s.entry_command || "").trim();
|
||||
const cmdArgs = Array.isArray(s.args) ? s.args.map((x) => String(x)) : [];
|
||||
return {
|
||||
source_type: String(s.source_type || "npm").trim() || "npm",
|
||||
source_ref: String(s.source_ref || "mcp-remote").trim() || "mcp-remote",
|
||||
server_id: key || String(s.name || s.server_id || "mcp-server").trim() || "mcp-server",
|
||||
entry_command: cmd,
|
||||
entry_args: cmdArgs,
|
||||
env_schema: Object.keys(envSchema).length ? envSchema : (s.env_schema && typeof s.env_schema === "object" ? s.env_schema : {}),
|
||||
required_permissions: Array.isArray(s.required_permissions) ? s.required_permissions.map((x) => String(x)) : [],
|
||||
risk_level: String(s.risk_level || "high"),
|
||||
enabled: Object.prototype.hasOwnProperty.call(s, "isActive") ? !!s.isActive : true,
|
||||
timeout_s: Number(s.timeout_s || 30),
|
||||
dry_run: false,
|
||||
};
|
||||
});
|
||||
} else if (parsed && typeof parsed === "object" && parsed.payload && typeof parsed.payload === "object") {
|
||||
list = [parsed.payload];
|
||||
} else {
|
||||
|
|
@ -5014,6 +5093,10 @@ async function renderPlugins() {
|
|||
});
|
||||
}
|
||||
installStatus.textContent = JSON.stringify(results);
|
||||
if (detectedMcpServersShape) {
|
||||
installStatus.textContent =
|
||||
"[json] mcpServers import finished (using mcp-remote bridge where needed)\n" + installStatus.textContent;
|
||||
}
|
||||
markPrewarmReminder("mcp_batch_installed");
|
||||
router();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ const I18N = {
|
|||
"chat.tools": "推理",
|
||||
"chat.tools.hidden": "推理已隐藏",
|
||||
"chat.tools.visible": "推理已显示",
|
||||
"chat.compressHistory": "压缩历史",
|
||||
"chat.compressHistoryPrompt": "将本会话历史工具输出按回放策略写回压缩(不可逆)?建议仅在发现超大 tool_result/导出卡顿时使用。",
|
||||
"chat.compressHistoryOk": "压缩完成:扫描 {scanned} 条 tool 消息,重写 {rewritten} 条,超限压缩 {compacted} 条(已跳过 {skipped} 条已压缩)。",
|
||||
"chat.compressHistoryFail": "压缩失败:{error}",
|
||||
"chat.tools.off": "关",
|
||||
"chat.tools.on": "开",
|
||||
"chat.status.oclaw": "主控",
|
||||
|
|
@ -187,6 +191,10 @@ const I18N = {
|
|||
"chat.tools": "Reasoning",
|
||||
"chat.tools.hidden": "Reasoning hidden",
|
||||
"chat.tools.visible": "Reasoning visible",
|
||||
"chat.compressHistory": "Compress history",
|
||||
"chat.compressHistoryPrompt": "Rewrite this session's historical tool outputs using replay-guard compaction (irreversible). Use only when a session is polluted by huge tool_result.",
|
||||
"chat.compressHistoryOk": "Compaction done: scanned {scanned} tool messages, rewritten {rewritten}, oversized compacted {compacted} (skipped {skipped} already compacted).",
|
||||
"chat.compressHistoryFail": "Compaction failed: {error}",
|
||||
"chat.tools.off": "Off",
|
||||
"chat.tools.on": "On",
|
||||
"chat.status.oclaw": "Controller",
|
||||
|
|
@ -2556,6 +2564,39 @@ async function renderChatUi() {
|
|||
el("span", { class: "muted", text: t("chat.modeLabel") }),
|
||||
modeSelect,
|
||||
toolToggleWrap,
|
||||
el("button", {
|
||||
type: "button",
|
||||
class: "btn",
|
||||
style: "padding:4px 8px;min-height:auto;font-size:12px;",
|
||||
text: t("chat.compressHistory"),
|
||||
onclick: async () => {
|
||||
const sid = String(activeId || "");
|
||||
if (!sid) return;
|
||||
if (!(await confirmChatAction(t("chat.compressHistoryPrompt")))) return;
|
||||
try {
|
||||
const resp = await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(sid)}/compress-history`, {});
|
||||
const r = resp && resp.result ? resp.result : {};
|
||||
if (!resp || !resp.ok) {
|
||||
const err = String((resp && (resp.error || resp.detail)) || "failed");
|
||||
showToast(t("chat.compressHistoryFail", { error: err.slice(0, 180) }), { kind: "error", ttlMs: 6500 });
|
||||
return;
|
||||
}
|
||||
showToast(
|
||||
t("chat.compressHistoryOk", {
|
||||
scanned: String(Number(r.scanned_tool_messages || 0)),
|
||||
rewritten: String(Number(r.rewritten_all_tool_messages || 0)),
|
||||
compacted: String(Number(r.compacted_tool_messages || 0)),
|
||||
skipped: String(Number(r.skipped_already_guarded || 0)),
|
||||
}),
|
||||
{ kind: "info", ttlMs: 6500 },
|
||||
);
|
||||
// Reload messages so UI reflects compacted history.
|
||||
loadMessagesForActive().catch(() => {});
|
||||
} catch (e) {
|
||||
showToast(t("chat.compressHistoryFail", { error: String(e || "failed").slice(0, 180) }), { kind: "error", ttlMs: 6500 });
|
||||
}
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const fitComposerTextarea = () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue