增加调试前端页面
This commit is contained in:
1 parent
3080b17d02
commit
ee9d2d7cf1
22 files changed
+1442
-59
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user