chore: initial import
这个提交包含在:
5
.env.example
普通文件
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
普通文件
1
.gitignore
vendored
普通文件
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
50
LLM_PROXY.md
普通文件
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
普通文件
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: 启动 Nginx(HTTP 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
普通文件
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
html/.gitkeep
普通文件
821
html/__manus__/debug-collector.js
普通文件
821
html/__manus__/debug-collector.js
普通文件
@@ -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)");
|
||||||
|
})();
|
||||||
文件差异因一行或多行过长而隐藏
976
html/assets/index-Xbs2xuHt.js
普通文件
976
html/assets/index-Xbs2xuHt.js
普通文件
文件差异因一行或多行过长而隐藏
380
html/chat.html
普通文件
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
普通文件
135
html/index.html
普通文件
文件差异因一行或多行过长而隐藏
10
llm-proxy/Dockerfile
普通文件
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
普通文件
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
普通文件
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
nginx/conf.d/default-ssl.conf
普通文件
96
nginx/conf.d/default-ssl.conf
普通文件
@@ -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
普通文件
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
在新工单中引用
屏蔽一个用户