chore: initial import

这个提交包含在:
Codex
2026-03-06 08:55:47 +08:00
当前提交 acb8a43c5c
修改 16 个文件,包含 2820 行新增0 行删除

5
.env.example 普通文件
查看文件

@@ -0,0 +1,5 @@
LLM_BASE_URL=http://8.211.173.24:9404/v1
LLM_API_KEY=your_api_key_here
LLM_MODEL=qwen3.5-plus
LLM_TIMEOUT_MS=60000
LLM_FORCE_CHINESE=true

1
.gitignore vendored 普通文件
查看文件

@@ -0,0 +1 @@
.env

50
LLM_PROXY.md 普通文件
查看文件

@@ -0,0 +1,50 @@
# LLM Proxy 使用说明
本项目已包含本地反向代理服务 `llm-proxy`
## 1) 环境配置
先复制配置模板:
```bash
cp .env.example .env
```
必填环境变量:
- `LLM_BASE_URL`(示例:`http://8.211.173.24:9404/v1`
- `LLM_API_KEY`
- `LLM_MODEL`(默认:`qwen3.5-plus`
- `LLM_TIMEOUT_MS`(默认:`60000`
- `LLM_FORCE_CHINESE`(默认:`true`,会在缺少 system 消息时自动注入中文回复约束)
## 2) 启动服务
```bash
docker compose up -d llm-proxy nginx
```
## 3) 前端/后端调用地址
统一调用同域路径(浏览器不暴露 API Key
```bash
curl -X POST https://reserve.xn--15t503c5up.com/api/llm/chat \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role":"user","content":"你好,做个自我介绍"}],
"stream": false
}'
```
代理会转发到 `${LLM_BASE_URL}/chat/completions`,并在服务端注入:
- `Authorization: Bearer ${LLM_API_KEY}`
## 4) 中文前端页面
新增页面:
- `/chat.html`
页面内所有 UI 文案均为中文,默认中文对话,支持流式返回开关。

58
deploy.sh 普通文件
查看文件

@@ -0,0 +1,58 @@
#!/bin/bash
set -e
DOMAIN="reserve.xn--15t503c5up.com"
EMAIL="admin@${DOMAIN}"
DEPLOY_DIR="/root/android-resever"
echo "=========================================="
echo " Android RE Wiki 部署脚本"
echo " 域名: ${DOMAIN}"
echo "=========================================="
cd "${DEPLOY_DIR}"
# Ensure LLM proxy environment file exists
if [ ! -f ".env" ]; then
echo "❌ 缺少 .env 文件,请先配置 LLM_BASE_URL / LLM_API_KEY"
exit 1
fi
# Step 1: 启动 NginxHTTP only,用于 ACME 验证)
echo "[1/5] 启动 Nginx (HTTP) + LLM Proxy..."
cp nginx/conf.d/default.conf nginx/conf.d/active.conf
docker compose up -d nginx llm-proxy
sleep 3
# Step 2: 申请 Let's Encrypt 证书
echo "[2/5] 申请 SSL 证书..."
docker compose run --rm certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
--email "${EMAIL}" \
--agree-tos \
--no-eff-email \
--force-renewal \
-d "${DOMAIN}"
# Step 3: 切换到 HTTPS 配置
echo "[3/5] 切换到 HTTPS 配置..."
cp nginx/conf.d/default-ssl.conf nginx/conf.d/active.conf
# Step 4: 重载 Nginx
echo "[4/5] 重载 Nginx 使用 HTTPS..."
docker compose restart nginx
sleep 3
# Step 5: 验证
echo "[5/5] 验证服务状态..."
docker compose ps
echo ""
echo "✅ 部署完成!"
echo " 访问: https://${DOMAIN}"
echo ""
# 设置证书自动续期 cron
echo "[+] 设置证书自动续期..."
(crontab -l 2>/dev/null; echo "0 3 * * * cd ${DEPLOY_DIR} && docker compose run --rm certbot renew --quiet && docker compose exec nginx nginx -s reload") | sort -u | crontab -
echo "✅ 自动续期已配置 (每天 03:00 检查)"

43
docker-compose.yml 普通文件
查看文件

@@ -0,0 +1,43 @@
services:
nginx:
image: nginx:1.25-alpine
container_name: android_re_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./html:/usr/share/nginx/html:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on: []
networks:
- web
llm-proxy:
build:
context: ./llm-proxy
container_name: android_re_llm_proxy
restart: unless-stopped
env_file:
- ./.env
networks:
- web
- comingsoon_default
certbot:
image: certbot/certbot:latest
container_name: android_re_certbot
restart: "no"
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
networks:
- web
networks:
web:
driver: bridge
comingsoon_default:
external: true

0
html/.gitkeep 普通文件
查看文件

查看文件

@@ -0,0 +1,821 @@
/**
* Manus Debug Collector (agent-friendly)
*
* Captures:
* 1) Console logs
* 2) Network requests (fetch + XHR)
* 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.)
*
* Data is periodically sent to /__manus__/logs
* Note: uiEvents are mirrored to sessionEvents for sessionReplay.log
*/
(function () {
"use strict";
// Prevent double initialization
if (window.__MANUS_DEBUG_COLLECTOR__) return;
// ==========================================================================
// Configuration
// ==========================================================================
const CONFIG = {
reportEndpoint: "/__manus__/logs",
bufferSize: {
console: 500,
network: 200,
// semantic, agent-friendly UI events
ui: 500,
},
reportInterval: 2000,
sensitiveFields: [
"password",
"token",
"secret",
"key",
"authorization",
"cookie",
"session",
],
maxBodyLength: 10240,
// UI event logging privacy policy:
// - inputs matching sensitiveFields or type=password are masked by default
// - non-sensitive inputs log up to 200 chars
uiInputMaxLen: 200,
uiTextMaxLen: 80,
// Scroll throttling: minimum ms between scroll events
scrollThrottleMs: 500,
};
// ==========================================================================
// Storage
// ==========================================================================
const store = {
consoleLogs: [],
networkRequests: [],
uiEvents: [],
lastReportTime: Date.now(),
lastScrollTime: 0,
};
// ==========================================================================
// Utility Functions
// ==========================================================================
function sanitizeValue(value, depth) {
if (depth === void 0) depth = 0;
if (depth > 5) return "[Max Depth]";
if (value === null) return null;
if (value === undefined) return undefined;
if (typeof value === "string") {
return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value;
}
if (typeof value !== "object") return value;
if (Array.isArray(value)) {
return value.slice(0, 100).map(function (v) {
return sanitizeValue(v, depth + 1);
});
}
var sanitized = {};
for (var k in value) {
if (Object.prototype.hasOwnProperty.call(value, k)) {
var isSensitive = CONFIG.sensitiveFields.some(function (f) {
return k.toLowerCase().indexOf(f) !== -1;
});
if (isSensitive) {
sanitized[k] = "[REDACTED]";
} else {
sanitized[k] = sanitizeValue(value[k], depth + 1);
}
}
}
return sanitized;
}
function formatArg(arg) {
try {
if (arg instanceof Error) {
return { type: "Error", message: arg.message, stack: arg.stack };
}
if (typeof arg === "object") return sanitizeValue(arg);
return String(arg);
} catch (e) {
return "[Unserializable]";
}
}
function formatArgs(args) {
var result = [];
for (var i = 0; i < args.length; i++) result.push(formatArg(args[i]));
return result;
}
function pruneBuffer(buffer, maxSize) {
if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize);
}
function tryParseJson(str) {
if (typeof str !== "string") return str;
try {
return JSON.parse(str);
} catch (e) {
return str;
}
}
// ==========================================================================
// Semantic UI Event Logging (agent-friendly)
// ==========================================================================
function shouldIgnoreTarget(target) {
try {
if (!target || !(target instanceof Element)) return false;
return !!target.closest(".manus-no-record");
} catch (e) {
return false;
}
}
function compactText(s, maxLen) {
try {
var t = (s || "").trim().replace(/\s+/g, " ");
if (!t) return "";
return t.length > maxLen ? t.slice(0, maxLen) + "…" : t;
} catch (e) {
return "";
}
}
function elText(el) {
try {
var t = el.innerText || el.textContent || "";
return compactText(t, CONFIG.uiTextMaxLen);
} catch (e) {
return "";
}
}
function describeElement(el) {
if (!el || !(el instanceof Element)) return null;
var getAttr = function (name) {
return el.getAttribute(name);
};
var tag = el.tagName ? el.tagName.toLowerCase() : null;
var id = el.id || null;
var name = getAttr("name") || null;
var role = getAttr("role") || null;
var ariaLabel = getAttr("aria-label") || null;
var dataLoc = getAttr("data-loc") || null;
var testId =
getAttr("data-testid") ||
getAttr("data-test-id") ||
getAttr("data-test") ||
null;
var type = tag === "input" ? (getAttr("type") || "text") : null;
var href = tag === "a" ? getAttr("href") || null : null;
// a small, stable hint for agents (avoid building full CSS paths)
var selectorHint = null;
if (testId) selectorHint = '[data-testid="' + testId + '"]';
else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]';
else if (id) selectorHint = "#" + id;
else selectorHint = tag || "unknown";
return {
tag: tag,
id: id,
name: name,
type: type,
role: role,
ariaLabel: ariaLabel,
testId: testId,
dataLoc: dataLoc,
href: href,
text: elText(el),
selectorHint: selectorHint,
};
}
function isSensitiveField(el) {
if (!el || !(el instanceof Element)) return false;
var tag = el.tagName ? el.tagName.toLowerCase() : "";
if (tag !== "input" && tag !== "textarea") return false;
var type = (el.getAttribute("type") || "").toLowerCase();
if (type === "password") return true;
var name = (el.getAttribute("name") || "").toLowerCase();
var id = (el.id || "").toLowerCase();
return CONFIG.sensitiveFields.some(function (f) {
return name.indexOf(f) !== -1 || id.indexOf(f) !== -1;
});
}
function getInputValueSafe(el) {
if (!el || !(el instanceof Element)) return null;
var tag = el.tagName ? el.tagName.toLowerCase() : "";
if (tag !== "input" && tag !== "textarea" && tag !== "select") return null;
var v = "";
try {
v = el.value != null ? String(el.value) : "";
} catch (e) {
v = "";
}
if (isSensitiveField(el)) return { masked: true, length: v.length };
if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…";
return v;
}
function logUiEvent(kind, payload) {
var entry = {
timestamp: Date.now(),
kind: kind,
url: location.href,
viewport: { width: window.innerWidth, height: window.innerHeight },
payload: sanitizeValue(payload),
};
store.uiEvents.push(entry);
pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui);
}
function installUiEventListeners() {
// Clicks
document.addEventListener(
"click",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("click", {
target: describeElement(t),
x: e.clientX,
y: e.clientY,
});
},
true
);
// Typing "commit" events
document.addEventListener(
"change",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("change", {
target: describeElement(t),
value: getInputValueSafe(t),
});
},
true
);
document.addEventListener(
"focusin",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("focusin", { target: describeElement(t) });
},
true
);
document.addEventListener(
"focusout",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("focusout", {
target: describeElement(t),
value: getInputValueSafe(t),
});
},
true
);
// Enter/Escape are useful for form flows & modals
document.addEventListener(
"keydown",
function (e) {
if (e.key !== "Enter" && e.key !== "Escape") return;
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("keydown", { key: e.key, target: describeElement(t) });
},
true
);
// Form submissions
document.addEventListener(
"submit",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("submit", { target: describeElement(t) });
},
true
);
// Throttled scroll events
window.addEventListener(
"scroll",
function () {
var now = Date.now();
if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return;
store.lastScrollTime = now;
logUiEvent("scroll", {
scrollX: window.scrollX,
scrollY: window.scrollY,
documentHeight: document.documentElement.scrollHeight,
viewportHeight: window.innerHeight,
});
},
{ passive: true }
);
// Navigation tracking for SPAs
function nav(reason) {
logUiEvent("navigate", { reason: reason });
}
var origPush = history.pushState;
history.pushState = function () {
origPush.apply(this, arguments);
nav("pushState");
};
var origReplace = history.replaceState;
history.replaceState = function () {
origReplace.apply(this, arguments);
nav("replaceState");
};
window.addEventListener("popstate", function () {
nav("popstate");
});
window.addEventListener("hashchange", function () {
nav("hashchange");
});
}
// ==========================================================================
// Console Interception
// ==========================================================================
var originalConsole = {
log: console.log.bind(console),
debug: console.debug.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
["log", "debug", "info", "warn", "error"].forEach(function (method) {
console[method] = function () {
var args = Array.prototype.slice.call(arguments);
var entry = {
timestamp: Date.now(),
level: method.toUpperCase(),
args: formatArgs(args),
stack: method === "error" ? new Error().stack : null,
};
store.consoleLogs.push(entry);
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
originalConsole[method].apply(console, args);
};
});
window.addEventListener("error", function (event) {
store.consoleLogs.push({
timestamp: Date.now(),
level: "ERROR",
args: [
{
type: "UncaughtError",
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error ? event.error.stack : null,
},
],
stack: event.error ? event.error.stack : null,
});
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
// Mark an error moment in UI event stream for agents
logUiEvent("error", {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
});
});
window.addEventListener("unhandledrejection", function (event) {
var reason = event.reason;
store.consoleLogs.push({
timestamp: Date.now(),
level: "ERROR",
args: [
{
type: "UnhandledRejection",
reason: reason && reason.message ? reason.message : String(reason),
stack: reason && reason.stack ? reason.stack : null,
},
],
stack: reason && reason.stack ? reason.stack : null,
});
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
logUiEvent("unhandledrejection", {
reason: reason && reason.message ? reason.message : String(reason),
});
});
// ==========================================================================
// Fetch Interception
// ==========================================================================
var originalFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
init = init || {};
var startTime = Date.now();
// Handle string, Request object, or URL object
var url = typeof input === "string"
? input
: (input && (input.url || input.href || String(input))) || "";
var method = init.method || (input && input.method) || "GET";
// Don't intercept internal requests
if (url.indexOf("/__manus__/") === 0) {
return originalFetch(input, init);
}
// Safely parse headers (avoid breaking if headers format is invalid)
var requestHeaders = {};
try {
if (init.headers) {
requestHeaders = Object.fromEntries(new Headers(init.headers).entries());
}
} catch (e) {
requestHeaders = { _parseError: true };
}
var entry = {
timestamp: startTime,
type: "fetch",
method: method.toUpperCase(),
url: url,
request: {
headers: requestHeaders,
body: init.body ? sanitizeValue(tryParseJson(init.body)) : null,
},
response: null,
duration: null,
error: null,
};
return originalFetch(input, init)
.then(function (response) {
entry.duration = Date.now() - startTime;
var contentType = (response.headers.get("content-type") || "").toLowerCase();
var contentLength = response.headers.get("content-length");
entry.response = {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: null,
};
// Semantic network hint for agents on failures (sync, no need to wait for body)
if (response.status >= 400) {
logUiEvent("network_error", {
kind: "fetch",
method: entry.method,
url: entry.url,
status: response.status,
statusText: response.statusText,
});
}
// Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks
var isStreaming = contentType.indexOf("text/event-stream") !== -1 ||
contentType.indexOf("application/stream") !== -1 ||
contentType.indexOf("application/x-ndjson") !== -1;
if (isStreaming) {
entry.response.body = "[Streaming response - not captured]";
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
return response;
}
// Skip body capture for large responses to avoid memory issues
if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) {
entry.response.body = "[Response too large: " + contentLength + " bytes]";
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
return response;
}
// Skip body capture for binary content types
var isBinary = contentType.indexOf("image/") !== -1 ||
contentType.indexOf("video/") !== -1 ||
contentType.indexOf("audio/") !== -1 ||
contentType.indexOf("application/octet-stream") !== -1 ||
contentType.indexOf("application/pdf") !== -1 ||
contentType.indexOf("application/zip") !== -1;
if (isBinary) {
entry.response.body = "[Binary content: " + contentType + "]";
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
return response;
}
// For text responses, clone and read body in background
var clonedResponse = response.clone();
// Async: read body in background, don't block the response
clonedResponse
.text()
.then(function (text) {
if (text.length <= CONFIG.maxBodyLength) {
entry.response.body = sanitizeValue(tryParseJson(text));
} else {
entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]";
}
})
.catch(function () {
entry.response.body = "[Unable to read body]";
})
.finally(function () {
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
});
// Return response immediately, don't wait for body reading
return response;
})
.catch(function (error) {
entry.duration = Date.now() - startTime;
entry.error = { message: error.message, stack: error.stack };
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
logUiEvent("network_error", {
kind: "fetch",
method: entry.method,
url: entry.url,
message: error.message,
});
throw error;
});
};
// ==========================================================================
// XHR Interception
// ==========================================================================
var originalXHROpen = XMLHttpRequest.prototype.open;
var originalXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._manusData = {
method: (method || "GET").toUpperCase(),
url: url,
startTime: null,
};
return originalXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
var xhr = this;
if (
xhr._manusData &&
xhr._manusData.url &&
xhr._manusData.url.indexOf("/__manus__/") !== 0
) {
xhr._manusData.startTime = Date.now();
xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null;
xhr.addEventListener("load", function () {
var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase();
var responseBody = null;
// Skip body capture for streaming responses
var isStreaming = contentType.indexOf("text/event-stream") !== -1 ||
contentType.indexOf("application/stream") !== -1 ||
contentType.indexOf("application/x-ndjson") !== -1;
// Skip body capture for binary content types
var isBinary = contentType.indexOf("image/") !== -1 ||
contentType.indexOf("video/") !== -1 ||
contentType.indexOf("audio/") !== -1 ||
contentType.indexOf("application/octet-stream") !== -1 ||
contentType.indexOf("application/pdf") !== -1 ||
contentType.indexOf("application/zip") !== -1;
if (isStreaming) {
responseBody = "[Streaming response - not captured]";
} else if (isBinary) {
responseBody = "[Binary content: " + contentType + "]";
} else {
// Safe to read responseText for text responses
try {
var text = xhr.responseText || "";
if (text.length > CONFIG.maxBodyLength) {
responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]";
} else {
responseBody = sanitizeValue(tryParseJson(text));
}
} catch (e) {
// responseText may throw for non-text responses
responseBody = "[Unable to read response: " + e.message + "]";
}
}
var entry = {
timestamp: xhr._manusData.startTime,
type: "xhr",
method: xhr._manusData.method,
url: xhr._manusData.url,
request: { body: xhr._manusData.requestBody },
response: {
status: xhr.status,
statusText: xhr.statusText,
body: responseBody,
},
duration: Date.now() - xhr._manusData.startTime,
error: null,
};
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
if (entry.response && entry.response.status >= 400) {
logUiEvent("network_error", {
kind: "xhr",
method: entry.method,
url: entry.url,
status: entry.response.status,
statusText: entry.response.statusText,
});
}
});
xhr.addEventListener("error", function () {
var entry = {
timestamp: xhr._manusData.startTime,
type: "xhr",
method: xhr._manusData.method,
url: xhr._manusData.url,
request: { body: xhr._manusData.requestBody },
response: null,
duration: Date.now() - xhr._manusData.startTime,
error: { message: "Network error" },
};
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
logUiEvent("network_error", {
kind: "xhr",
method: entry.method,
url: entry.url,
message: "Network error",
});
});
}
return originalXHRSend.apply(this, arguments);
};
// ==========================================================================
// Data Reporting
// ==========================================================================
function reportLogs() {
var consoleLogs = store.consoleLogs.splice(0);
var networkRequests = store.networkRequests.splice(0);
var uiEvents = store.uiEvents.splice(0);
// Skip if no new data
if (
consoleLogs.length === 0 &&
networkRequests.length === 0 &&
uiEvents.length === 0
) {
return Promise.resolve();
}
var payload = {
timestamp: Date.now(),
consoleLogs: consoleLogs,
networkRequests: networkRequests,
// Mirror uiEvents to sessionEvents for sessionReplay.log
sessionEvents: uiEvents,
// agent-friendly semantic events
uiEvents: uiEvents,
};
return originalFetch(CONFIG.reportEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(function () {
// Put data back on failure (but respect limits)
store.consoleLogs = consoleLogs.concat(store.consoleLogs);
store.networkRequests = networkRequests.concat(store.networkRequests);
store.uiEvents = uiEvents.concat(store.uiEvents);
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui);
});
}
// Periodic reporting
setInterval(reportLogs, CONFIG.reportInterval);
// Report on page unload
window.addEventListener("beforeunload", function () {
var consoleLogs = store.consoleLogs;
var networkRequests = store.networkRequests;
var uiEvents = store.uiEvents;
if (
consoleLogs.length === 0 &&
networkRequests.length === 0 &&
uiEvents.length === 0
) {
return;
}
var payload = {
timestamp: Date.now(),
consoleLogs: consoleLogs,
networkRequests: networkRequests,
// Mirror uiEvents to sessionEvents for sessionReplay.log
sessionEvents: uiEvents,
uiEvents: uiEvents,
};
if (navigator.sendBeacon) {
var payloadStr = JSON.stringify(payload);
// sendBeacon has ~64KB limit, truncate if too large
var MAX_BEACON_SIZE = 60000; // Leave some margin
if (payloadStr.length > MAX_BEACON_SIZE) {
// Prioritize: keep recent events, drop older logs
var truncatedPayload = {
timestamp: Date.now(),
consoleLogs: consoleLogs.slice(-50),
networkRequests: networkRequests.slice(-20),
sessionEvents: uiEvents.slice(-100),
uiEvents: uiEvents.slice(-100),
_truncated: true,
};
payloadStr = JSON.stringify(truncatedPayload);
}
navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr);
}
});
// ==========================================================================
// Initialization
// ==========================================================================
// Install semantic UI listeners ASAP
try {
installUiEventListeners();
} catch (e) {
console.warn("[Manus] Failed to install UI listeners:", e);
}
// Mark as initialized
window.__MANUS_DEBUG_COLLECTOR__ = {
version: "2.0-no-rrweb",
store: store,
forceReport: reportLogs,
};
console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)");
})();

文件差异因一行或多行过长而隐藏

文件差异因一行或多行过长而隐藏

380
html/chat.html 普通文件
查看文件

@@ -0,0 +1,380 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>中文 AI 对话工作台</title>
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--panel-2: #1f2937;
--text: #e5e7eb;
--muted: #9ca3af;
--primary: #22c55e;
--primary-hover: #16a34a;
--danger: #ef4444;
--border: #374151;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif;
background: radial-gradient(circle at top right, #1e293b 0%, var(--bg) 55%);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.app {
width: min(980px, 100%);
height: min(92vh, 860px);
border: 1px solid var(--border);
border-radius: 16px;
background: linear-gradient(180deg, #0b1220 0%, #0d1424 100%);
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
}
.header {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.2px;
}
.subtitle {
margin-top: 2px;
color: var(--muted);
font-size: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.switch {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
.btn {
border: none;
border-radius: 10px;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-clear {
color: #fff;
background: #374151;
}
.btn-clear:hover {
background: #4b5563;
}
.chat {
padding: 18px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.msg {
max-width: 82%;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.msg.user {
align-self: flex-end;
background: #0f2a1f;
border-color: #1d4f3c;
}
.msg.assistant {
align-self: flex-start;
background: var(--panel);
}
.msg.system {
align-self: center;
max-width: 92%;
background: #172033;
border-style: dashed;
color: #cbd5e1;
font-size: 13px;
}
.composer {
border-top: 1px solid var(--border);
padding: 14px;
display: grid;
gap: 10px;
}
textarea {
width: 100%;
min-height: 96px;
resize: vertical;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
padding: 12px;
font-size: 14px;
line-height: 1.5;
}
textarea::placeholder {
color: var(--muted);
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.hint {
color: var(--muted);
font-size: 12px;
}
.btn-send {
color: #08120a;
background: var(--primary);
}
.btn-send:hover {
background: var(--primary-hover);
}
.btn-send:disabled {
cursor: not-allowed;
background: #14532d;
color: #86efac;
}
.status {
min-height: 18px;
color: #93c5fd;
font-size: 12px;
}
@media (max-width: 720px) {
.app {
height: 100vh;
border-radius: 0;
}
.msg {
max-width: 92%;
}
}
</style>
</head>
<body>
<main class="app">
<header class="header">
<div>
<div class="title">中文 AI 对话工作台</div>
<div class="subtitle">默认通过 /api/llm/chat 调用,回答语言优先简体中文</div>
</div>
<div class="header-actions">
<label class="switch">
<input id="streamSwitch" type="checkbox" checked />
流式返回
</label>
<button id="clearBtn" class="btn btn-clear" type="button">清空对话</button>
</div>
</header>
<section id="chatBox" class="chat" aria-live="polite"></section>
<section class="composer">
<textarea id="inputBox" placeholder="请输入问题(示例:请总结 APK 动态调试的关键步骤)"></textarea>
<div class="actions">
<div>
<div id="status" class="status"></div>
<div class="hint">回车发送,Shift+回车换行</div>
</div>
<button id="sendBtn" class="btn btn-send" type="button">发送</button>
</div>
</section>
</main>
<script>
const chatBox = document.getElementById("chatBox");
const inputBox = document.getElementById("inputBox");
const sendBtn = document.getElementById("sendBtn");
const clearBtn = document.getElementById("clearBtn");
const statusEl = document.getElementById("status");
const streamSwitch = document.getElementById("streamSwitch");
const apiPath = "/api/llm/chat";
const conversation = [];
let sending = false;
function setStatus(text) {
statusEl.textContent = text || "";
}
function addMessage(role, content) {
const el = document.createElement("div");
el.className = `msg ${role}`;
el.textContent = content;
chatBox.appendChild(el);
chatBox.scrollTop = chatBox.scrollHeight;
return el;
}
function resetConversation() {
conversation.length = 0;
chatBox.innerHTML = "";
addMessage(
"system",
"系统提示:本页面用于中文对话。若你明确要求其他语言,模型会按你的要求切换。"
);
setStatus("");
}
function extractTextFromChunk(json) {
const choice = json && Array.isArray(json.choices) ? json.choices[0] : null;
if (!choice) return "";
if (choice.delta && typeof choice.delta.content === "string") return choice.delta.content;
if (choice.message && typeof choice.message.content === "string") return choice.message.content;
return "";
}
async function sendMessage() {
if (sending) return;
const text = inputBox.value.trim();
if (!text) return;
sending = true;
sendBtn.disabled = true;
setStatus("正在请求模型...");
addMessage("user", text);
conversation.push({ role: "user", content: text });
inputBox.value = "";
const stream = !!streamSwitch.checked;
try {
const res = await fetch(apiPath, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: conversation,
stream,
}),
});
if (!res.ok) {
const errText = await res.text();
addMessage("system", `请求失败(${res.status}${errText || "未知错误"}`);
return;
}
if (!stream) {
const data = await res.json();
const content =
data && data.choices && data.choices[0] && data.choices[0].message
? data.choices[0].message.content || ""
: "";
addMessage("assistant", content || "(空响应)");
conversation.push({ role: "assistant", content: content || "" });
setStatus("完成");
return;
}
const aiEl = addMessage("assistant", "");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let full = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const json = JSON.parse(payload);
const piece = extractTextFromChunk(json);
if (!piece) continue;
full += piece;
aiEl.textContent = full;
chatBox.scrollTop = chatBox.scrollHeight;
} catch (_) {
}
}
}
conversation.push({ role: "assistant", content: full });
setStatus("完成(流式)");
} catch (error) {
addMessage("system", `网络异常:${error && error.message ? error.message : "未知错误"}`);
} finally {
sending = false;
sendBtn.disabled = false;
}
}
sendBtn.addEventListener("click", sendMessage);
inputBox.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
});
clearBtn.addEventListener("click", resetConversation);
resetConversation();
</script>
</body>
</html>

135
html/index.html 普通文件

文件差异因一行或多行过长而隐藏

10
llm-proxy/Dockerfile 普通文件
查看文件

@@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY server.js /app/server.js
ENV NODE_ENV=production
EXPOSE 9405
CMD ["node", "server.js"]

190
llm-proxy/server.js 普通文件
查看文件

@@ -0,0 +1,190 @@
const http = require("node:http");
const { Readable } = require("node:stream");
const PORT = Number(process.env.PORT || 9405);
const LLM_BASE_URL = (process.env.LLM_BASE_URL || "").replace(/\/+$/, "");
const LLM_API_KEY = process.env.LLM_API_KEY || "";
const LLM_MODEL = process.env.LLM_MODEL || "qwen3.5-plus";
const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 60000);
const LLM_FORCE_CHINESE =
String(process.env.LLM_FORCE_CHINESE || "true").toLowerCase() !== "false";
const CHINESE_SYSTEM_PROMPT =
"你是专业助手。除非用户明确要求使用其他语言,否则始终使用简体中文回答。";
const MAX_BODY_BYTES = 2 * 1024 * 1024;
function failFast() {
const missing = [];
if (!LLM_BASE_URL) missing.push("LLM_BASE_URL");
if (!LLM_API_KEY) missing.push("LLM_API_KEY");
if (missing.length) {
console.error(`Missing required env vars: ${missing.join(", ")}`);
process.exit(1);
}
}
function sendJson(res, statusCode, data) {
const body = JSON.stringify(data);
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
}
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let total = 0;
const chunks = [];
req.on("data", (chunk) => {
total += chunk.length;
if (total > MAX_BODY_BYTES) {
reject(new Error("Request body too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const text = Buffer.concat(chunks).toString("utf8");
const parsed = text ? JSON.parse(text) : {};
resolve(parsed);
} catch (error) {
reject(new Error("Invalid JSON body"));
}
});
req.on("error", reject);
});
}
function buildPayload(body) {
const payload = { ...body };
if (!payload.model || typeof payload.model !== "string") {
payload.model = LLM_MODEL;
}
if (LLM_FORCE_CHINESE && Array.isArray(payload.messages)) {
const hasSystem = payload.messages.some((m) => m && m.role === "system");
if (!hasSystem) {
payload.messages = [{ role: "system", content: CHINESE_SYSTEM_PROMPT }, ...payload.messages];
}
}
return payload;
}
function isChatPath(pathname) {
return pathname === "/api/llm/chat" || pathname === "/v1/chat/completions";
}
function parseTimeoutMs(value) {
if (!Number.isFinite(value) || value <= 0) {
return 60000;
}
return value;
}
async function proxyChatCompletion(req, res) {
let body;
try {
body = await readJsonBody(req);
} catch (error) {
const status = error.message === "Request body too large" ? 413 : 400;
sendJson(res, status, { error: error.message });
return;
}
if (!Array.isArray(body.messages)) {
sendJson(res, 400, { error: "Field `messages` must be an array." });
return;
}
const payload = buildPayload(body);
const timeoutMs = parseTimeoutMs(LLM_TIMEOUT_MS);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
let upstream;
try {
upstream = await fetch(`${LLM_BASE_URL}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${LLM_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: controller.signal,
});
} catch (error) {
clearTimeout(timeout);
const status = error.name === "AbortError" ? 504 : 502;
sendJson(res, status, { error: "LLM upstream request failed" });
return;
} finally {
clearTimeout(timeout);
}
const contentType = upstream.headers.get("content-type") || "";
const isSSE = contentType.includes("text/event-stream") || payload.stream === true;
if (isSSE) {
res.writeHead(upstream.status, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
if (!upstream.body) {
res.end();
return;
}
const stream = Readable.fromWeb(upstream.body);
stream.on("error", () => {
if (!res.writableEnded) {
res.end();
}
});
stream.pipe(res);
return;
}
const text = await upstream.text();
res.writeHead(upstream.status, {
"Content-Type": contentType || "application/json; charset=utf-8",
});
res.end(text);
}
function requestHandler(req, res) {
const host = req.headers.host || "localhost";
const url = new URL(req.url, `http://${host}`);
if (req.method === "GET" && url.pathname === "/health") {
sendJson(res, 200, { ok: true });
return;
}
if (req.method === "POST" && isChatPath(url.pathname)) {
proxyChatCompletion(req, res).catch((error) => {
console.error("proxyChatCompletion failed:", error);
sendJson(res, 500, { error: "Internal server error" });
});
return;
}
sendJson(res, 404, { error: "Not found" });
}
failFast();
const server = http.createServer(requestHandler);
server.listen(PORT, "0.0.0.0", () => {
console.log(`llm-proxy listening on :${PORT}`);
});

27
nginx/conf.d/active.conf 普通文件
查看文件

@@ -0,0 +1,27 @@
server {
listen 80;
server_name reserve.xn--15t503c5up.com;
# Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# LLM API reverse proxy
location /api/llm/ {
proxy_pass http://llm-proxy:9405;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# Redirect all HTTP to HTTPS
location / {
return 301 https://$host$request_uri;
}
}

查看文件

@@ -0,0 +1,96 @@
server {
listen 80;
server_name reserve.xn--15t503c5up.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# LLM API reverse proxy
location /api/llm/ {
proxy_pass http://llm-proxy:9405;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name reserve.xn--15t503c5up.com;
# SSL certificates
ssl_certificate /etc/letsencrypt/live/reserve.xn--15t503c5up.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/reserve.xn--15t503c5up.com/privkey.pem;
# Modern SSL settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
root /usr/share/nginx/html;
index index.html;
# LLM API reverse proxy
location /api/llm/ {
proxy_pass http://llm-proxy:9405;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# Chinese chat frontend entry
location = /chat {
try_files /chat.html =404;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# No cache for index.html
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma no-cache;
add_header Expires 0;
}
}

27
nginx/conf.d/default.conf 普通文件
查看文件

@@ -0,0 +1,27 @@
server {
listen 80;
server_name reserve.xn--15t503c5up.com;
# Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# LLM API reverse proxy
location /api/llm/ {
proxy_pass http://llm-proxy:9405;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# Redirect all HTTP to HTTPS
location / {
return 301 https://$host$request_uri;
}
}