Files
fire-safety-ymd/internal/handler/chatpage/app.js
2026-09-06 00:25:25 +08:00

252 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
}
})();