增加调试前端页面

This commit is contained in:
andy committed 2026-09-06 00:25:25 +08:00
1 parent 3080b17d02
commit ee9d2d7cf1
22 files changed
+1442 -59

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
:root {
color: #17251e;
background: #eef2ed;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-synthesis: none;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at 85% 5%, rgba(177, 204, 181, .55), transparent 28rem),
#eef2ed;
}
button, input, textarea { font: inherit; }
button {
min-height: 44px;
border: 0;
border-radius: 12px;
padding: 0 18px;
color: #fff;
background: #175d3b;
cursor: pointer;
}
button:hover { background: #0e472c; }
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 3px solid #e1a73d; outline-offset: 2px; }
button:disabled { cursor: wait; opacity: .55; }
.shell {
width: min(940px, calc(100% - 32px));
margin: 32px auto;
}
.masthead {
display: flex;
align-items: start;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
color: #f8fbf8;
border-radius: 22px 22px 0 0;
background: #123d2b;
}
.eyebrow { margin: 0 0 8px; color: #b9d4c2; font-size: .75rem; font-weight: 700; letter-spacing: .16em; }
h1 { margin: 0; font-size: clamp(1.5rem, 4vw, 2.25rem); line-height: 1.2; }
.intro { max-width: 680px; margin: 12px 0 0; color: #dbe8df; line-height: 1.7; }
h2 { margin: 0; font-size: 1rem; }
.quiet-button { color: #194f35; background: #e6eee8; white-space: nowrap; }
.quiet-button:hover { background: #d2e2d7; }
.credential-card {
display: grid;
grid-template-columns: 1fr minmax(300px, 1.2fr);
gap: 10px 24px;
align-items: center;
padding: 20px 32px;
border-bottom: 1px solid #dce4de;
background: #fff;
}
.credential-card p { margin: 5px 0 0; color: #5d6c63; font-size: .86rem; }
.credential-form { display: flex; gap: 8px; }
.credential-form input { min-width: 0; flex: 1; }
.credential-state { grid-column: 2; }
input, textarea {
width: 100%;
border: 1px solid #bdc9c0;
border-radius: 12px;
padding: 11px 13px;
color: #17251e;
background: #fbfcfb;
}
.conversation {
overflow: hidden;
border-radius: 0 0 22px 22px;
box-shadow: 0 16px 48px rgba(28, 55, 39, .12);
background: #fff;
}
.messages {
min-height: 360px;
max-height: min(58vh, 620px);
overflow-y: auto;
padding: 26px 32px;
}
.message {
width: fit-content;
max-width: min(78%, 720px);
margin: 0 0 18px;
border-radius: 5px 17px 17px;
padding: 13px 16px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.message p { margin: 0; white-space: pre-wrap; }
.assistant-message { border: 1px solid #dce6df; background: #f5f8f6; }
.user-message { margin-left: auto; color: #fff; border-radius: 17px 5px 17px 17px; background: #1d6945; }
.error-message { color: #7b211c; border: 1px solid #e9b9b5; background: #fff2f0; }
.noscript-warning {
margin: 0;
padding: 14px 18px;
color: #7b211c;
border: 1px solid #e9b9b5;
background: #fff2f0;
}
.composer { padding: 18px 22px 20px; border-top: 1px solid #e1e7e2; background: #f8faf8; }
.composer textarea { display: block; min-height: 82px; resize: vertical; background: #fff; }
.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 10px; }
.composer-footer p { margin: 0; color: #68766d; font-size: .82rem; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 680px) {
.shell { width: 100%; margin: 0; }
.masthead { padding: 22px 18px; border-radius: 0; }
.intro { font-size: .9rem; }
.credential-card { grid-template-columns: 1fr; padding: 18px; }
.credential-form { flex-wrap: wrap; }
.credential-form input { flex-basis: 100%; }
.credential-state { grid-column: 1; }
.conversation { border-radius: 0; box-shadow: none; }
.messages { min-height: calc(100vh - 480px); padding: 20px 16px; }
.message { max-width: 92%; }
.composer { padding: 14px 12px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; }
}
+251
View File
@@ -0,0 +1,251 @@
"use strict";
(() => {
const app = document.getElementById("chat-app");
const appID = app.dataset.appId;
const credentialForm = document.getElementById("credential-form");
const credentialInput = document.getElementById("token");
const credentialState = document.getElementById("credential-state");
const clearTokenButton = document.getElementById("clear-token");
const newChatButton = document.getElementById("new-chat");
const composer = document.getElementById("composer");
const promptInput = document.getElementById("prompt");
const sendButton = document.getElementById("send");
const requestState = document.getElementById("request-state");
const messages = document.getElementById("messages");
const errorMessages = {
CHAT_APP_NOT_FOUND: "对话应用未找到,请联系管理员检查 App ID。",
CHAT_AUTH_INVALID: "访问凭证无效,请重新输入。",
CHAT_ORIGIN_FORBIDDEN: "当前页面来源未获授权,请联系管理员检查允许来源。",
CHAT_REQUEST_INVALID: "请求内容无效,请修改后重试。",
CHAT_REQUEST_BODY_TOO_LARGE: "问题内容过长,请缩短后重试。",
CHAT_CONVERSATION_NOT_FOUND: "会话已失效,请重新开始。",
CHAT_CONVERSATION_BUSY: "当前会话仍在处理上一轮请求,请稍后重试。",
CHAT_CAPACITY_REACHED: "当前会话容量已满,请稍后重试。",
CHAT_UPSTREAM_TIMEOUT: "助手响应超时,请重新开始对话后重试。",
CHAT_RUN_FAILED: "助手未能完成本轮请求,请重新开始对话后重试。",
CHAT_UPSTREAM_PROTOCOL_ERROR: "助手返回了不完整的响应,请重新开始对话后重试。",
CHAT_UPSTREAM_UNAVAILABLE: "助手服务暂时不可用,请稍后重试。",
CHAT_INTERNAL_ERROR: "对话服务暂时不可用,请稍后重试。",
RATE_LIMITED: "请求过于频繁,请稍后重试。"
};
let authToken = "";
let committedSessionID = "";
let activeController = null;
credentialForm.addEventListener("submit", (event) => {
event.preventDefault();
const candidate = credentialInput.value.trim();
if (!candidate) {
credentialState.textContent = "请输入有效凭证";
credentialInput.focus();
return;
}
if (candidate !== authToken) {
if (activeController) activeController.abort();
committedSessionID = "";
}
authToken = candidate;
credentialInput.value = "";
credentialState.textContent = "凭证已应用,仅保存在页面内存中";
promptInput.focus();
});
clearTokenButton.addEventListener("click", () => {
if (activeController) activeController.abort();
authToken = "";
committedSessionID = "";
credentialInput.value = "";
credentialState.textContent = "凭证已清除";
credentialInput.focus();
});
newChatButton.addEventListener("click", () => {
if (activeController) activeController.abort();
committedSessionID = "";
messages.replaceChildren();
appendMessage("assistant", "已开始新对话。之前的会话标识已从页面内存中清除。");
requestState.textContent = "新对话已就绪";
promptInput.focus();
});
promptInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
composer.requestSubmit();
}
});
composer.addEventListener("submit", async (event) => {
event.preventDefault();
const prompt = promptInput.value.trim();
if (!prompt || activeController) return;
if (!authToken) {
credentialState.textContent = "发送前请先应用访问凭证";
credentialInput.focus();
return;
}
appendMessage("user", prompt);
promptInput.value = "";
setBusy(true, "正在等待助手完成查询…");
const controller = new AbortController();
activeController = controller;
try {
const input = {prompt};
if (committedSessionID) input.session_id = committedSessionID;
const response = await fetch(`/api/v1/apps/${encodeURIComponent(appID)}/completion`, {
method: "POST",
headers: {
"Accept": "text/event-stream",
"Content-Type": "application/json",
"xtoken": authToken
},
body: JSON.stringify({input, parameters: {}}),
credentials: "omit",
cache: "no-store",
signal: controller.signal
});
if (!response.ok) {
throw new Error(await readHTTPError(response));
}
if (!response.body) throw new Error("浏览器无法读取流式响应");
const completed = await consumeSSE(response.body);
committedSessionID = completed.sessionID;
appendMessage("assistant", completed.text || "助手已完成,但没有返回正文。");
requestState.textContent = "回答完成,可以继续提问";
} catch (error) {
committedSessionID = "";
if (error && error.name === "AbortError") {
requestState.textContent = "请求已取消";
} else {
appendMessage("error", error && error.message ? error.message : "请求失败,请稍后重试。");
requestState.textContent = "本轮未成功";
}
} finally {
if (activeController === controller) activeController = null;
setBusy(false);
promptInput.focus();
}
});
async function consumeSSE(stream) {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
let latest = {sessionID: "", text: null, sawInitial: false, stopped: false};
while (true) {
const {value, done} = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), {stream: !done});
buffer = buffer.replaceAll("\r\n", "\n");
let boundary;
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
latest = processSSEBlock(block, latest);
}
if (done) break;
}
if (buffer.trim()) latest = processSSEBlock(buffer, latest);
if (!latest.sawInitial || !latest.stopped || !latest.sessionID || typeof latest.text !== "string") {
throw new Error("响应在完成前中断;为避免会话状态不一致,请开始新对话后重试。");
}
return {sessionID: latest.sessionID, text: latest.text};
}
function processSSEBlock(block, latest) {
let eventName = "message";
const dataLines = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) eventName = line.slice(6).trim();
if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (!dataLines.length) return latest;
let payload;
try {
payload = JSON.parse(dataLines.join("\n"));
} catch {
throw new Error("服务返回了无法解析的数据");
}
if (eventName === "error") throw new Error(publicErrorMessage(payload, "对话处理失败,请重新开始后重试。"));
if (eventName !== "result") return latest;
if (!payload.output || typeof payload.output !== "object" || Array.isArray(payload.output)) {
throw new Error("服务返回了无效的对话结果");
}
const sessionID = payload.output.session_id;
const finishReason = payload.output.finish_reason;
if (typeof sessionID !== "string" || !sessionID) throw new Error("服务返回了无效的会话标识");
if (typeof finishReason !== "string") throw new Error("服务返回了无效的完成状态");
if (Object.prototype.hasOwnProperty.call(payload.output, "text") && typeof payload.output.text !== "string") {
throw new Error("服务返回了无效的回答内容");
}
if (latest.sessionID && latest.sessionID !== sessionID) throw new Error("服务在同一响应中返回了不一致的会话标识");
latest.sessionID = sessionID;
if (finishReason === "null") {
if (latest.stopped) throw new Error("服务在完成后继续返回了中间结果");
latest.sawInitial = true;
return latest;
}
if (finishReason !== "stop") throw new Error("服务返回了未知的完成状态");
if (!latest.sawInitial || latest.stopped || typeof payload.output.text !== "string") {
throw new Error("服务返回了不完整的最终结果");
}
latest.stopped = true;
latest.text = payload.output.text;
return latest;
}
async function readHTTPError(response) {
try {
const payload = await response.json();
return publicErrorMessage(payload, statusErrorMessage(response.status));
} catch {
return statusErrorMessage(response.status);
}
}
function publicErrorMessage(payload, fallback) {
const nested = payload && payload.error && typeof payload.error === "object" ? payload.error : null;
const code = payload && typeof payload.code === "string"
? payload.code
: nested && typeof nested.code === "string" ? nested.code : "";
return errorMessages[code] || fallback;
}
function statusErrorMessage(status) {
if (status === 401) return errorMessages.CHAT_AUTH_INVALID;
if (status === 403) return errorMessages.CHAT_ORIGIN_FORBIDDEN;
if (status === 404) return errorMessages.CHAT_CONVERSATION_NOT_FOUND;
if (status === 409) return errorMessages.CHAT_CONVERSATION_BUSY;
if (status === 429) return errorMessages.RATE_LIMITED;
if (status === 502 || status === 503) return errorMessages.CHAT_UPSTREAM_UNAVAILABLE;
if (status === 504) return errorMessages.CHAT_UPSTREAM_TIMEOUT;
return `请求失败(HTTP ${status})`;
}
function appendMessage(kind, text) {
const article = document.createElement("article");
const paragraph = document.createElement("p");
article.className = `message ${kind}-message`;
paragraph.textContent = text;
article.appendChild(paragraph);
messages.appendChild(article);
messages.scrollTop = messages.scrollHeight;
}
function setBusy(busy, message) {
sendButton.disabled = busy;
promptInput.disabled = busy;
newChatButton.disabled = false;
if (message) requestState.textContent = message;
}
})();
+57
View File
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>养马岛森林防火助手</title>
<link rel="stylesheet" href="/chat/app.css">
<script src="/chat/app.js" defer></script>
</head>
<body>
<main class="shell" id="chat-app" data-app-id="{{.AppID}}">
<noscript>
<p class="noscript-warning" role="alert">此测试页面需要启用 JavaScript。请勿在脚本不可用时输入访问凭证或问题。</p>
</noscript>
<header class="masthead">
<div>
<p class="eyebrow">FIRE SAFETY · YMD</p>
<h1>森林防火演练助手</h1>
<p class="intro">基于现有消防空间资料提供候选信息。结果需经现场核验,不能替代报警、撤离和现场指挥。</p>
</div>
<button class="quiet-button" id="new-chat" type="button">新对话</button>
</header>
<section class="credential-card" aria-labelledby="credential-title">
<div>
<h2 id="credential-title">访问凭证</h2>
<p>凭证只保存在本页面内存中,刷新或关闭页面后即清除。</p>
</div>
<form id="credential-form" class="credential-form" method="post" action="/chat/">
<label class="sr-only" for="token">xtoken</label>
<input id="token" name="token" type="password" autocomplete="off" autocapitalize="none" spellcheck="false" maxlength="4096" required placeholder="输入 xtoken">
<button type="submit">应用凭证</button>
<button class="quiet-button" id="clear-token" type="button">清除</button>
</form>
<p class="credential-state" id="credential-state" role="status">尚未设置凭证</p>
</section>
<section class="conversation" aria-label="对话">
<div class="messages" id="messages" role="log" aria-live="polite" aria-relevant="additions text">
<article class="message assistant-message">
<p>请先设置访问凭证,然后输入你的森林防火演练问题。</p>
</article>
</div>
<form class="composer" id="composer" method="post" action="/chat/">
<label class="sr-only" for="prompt">问题</label>
<textarea id="prompt" name="prompt" rows="3" maxlength="8000" required placeholder="例如:我要在观水镇附近进行演练,请先搜索地点候选。"></textarea>
<div class="composer-footer">
<p id="request-state" role="status">Enter 发送,Shift + Enter 换行</p>
<button id="send" type="submit">发送</button>
</div>
</form>
</section>
</main>
</body>
</html>