252 lines
10 KiB
JavaScript
252 lines
10 KiB
JavaScript
"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;
|
||
}
|
||
})();
|