kboss/b/cntoai/chat.html
2026-05-23 09:27:03 +08:00

728 lines
23 KiB
HTML
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.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模型对话测试 · cntoai</title>
<style>
:root {
--bg: #f0f4f8;
--panel: #fff;
--border: #e2e8f0;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--text: #1e293b;
--muted: #64748b;
--user-bg: #2563eb;
--assistant-bg: #f1f5f9;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
.layout { display: flex; height: 100vh; }
.sidebar {
width: 300px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h1 { font-size: 15px; margin-bottom: 4px; }
.sidebar-header p { font-size: 11px; color: var(--muted); word-break: break-all; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 14px;
font-size: 13px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary { background: var(--primary); color: #fff; width: 100%; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
margin-top: 8px;
width: 100%;
font-size: 12px;
}
.btn-ghost:hover { background: #f8fafc; color: var(--text); }
.btn-danger { color: #dc2626; border-color: #fecaca; }
.history-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.history-item {
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-item:hover { background: #f1f5f9; }
.history-item.active { background: #eff6ff; color: var(--primary); }
.history-empty { padding: 16px; font-size: 12px; color: var(--muted); text-align: center; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.toolbar {
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.toolbar label { font-size: 12px; color: var(--muted); margin-right: 4px; }
.toolbar select, .toolbar input[type="text"] {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
min-width: 160px;
}
.toolbar .chk { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.notice {
padding: 10px 20px;
background: #fffbeb;
border-bottom: 1px solid #fde68a;
font-size: 12px;
color: #92400e;
line-height: 1.5;
}
.notice strong { display: block; margin-bottom: 4px; }
.messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.msg {
max-width: 85%;
padding: 12px 16px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.msg.user {
align-self: flex-end;
background: var(--user-bg);
color: #fff;
}
.msg.assistant {
align-self: flex-start;
background: var(--assistant-bg);
border: 1px solid var(--border);
}
.msg .role-tag {
font-size: 11px;
opacity: 0.75;
margin-bottom: 6px;
}
.msg img.preview { max-width: 200px; border-radius: 8px; margin-top: 8px; display: block; }
.composer {
padding: 16px 20px;
background: var(--panel);
border-top: 1px solid var(--border);
}
.composer textarea {
width: 100%;
min-height: 80px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 14px;
resize: vertical;
font-family: inherit;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
flex-wrap: wrap;
gap: 8px;
}
.composer-actions .left { display: flex; gap: 8px; align-items: center; }
.pending-img {
position: relative;
display: inline-block;
margin-bottom: 8px;
}
.pending-img img { max-height: 60px; border-radius: 6px; }
.pending-img button {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: #64748b;
color: #fff;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.settings {
padding: 12px 16px;
border-top: 1px solid var(--border);
font-size: 12px;
}
.settings summary { cursor: pointer; color: var(--muted); }
.settings-grid {
display: grid;
gap: 8px;
margin-top: 10px;
}
.settings-grid input { width: 100%; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; }
.auth-panel {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: #f8fafc;
}
.auth-panel h2 { font-size: 13px; margin-bottom: 10px; color: var(--text); }
.auth-panel label {
display: block;
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
margin-top: 8px;
}
.auth-panel label:first-of-type { margin-top: 0; }
.auth-panel input {
width: 100%;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
}
.auth-panel .hint { font-size: 10px; color: var(--muted); margin-top: 10px; line-height: 1.4; }
.status-bar {
font-size: 11px;
color: var(--muted);
padding: 4px 20px 8px;
}
.loading { color: var(--primary); }
.error { color: #dc2626; }
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="auth-panel">
<h2>接口凭证(手动测试)</h2>
<label for="cfgApiBase">Dspy 网关(本系统接口域名)</label>
<input type="text" id="cfgApiBase" value="https://dev.opencomputing.cn" placeholder="https://dev.opencomputing.cn">
<label for="cfgApiUrl">api_url模型 chat/completions 完整地址)</label>
<input type="text" id="cfgApiUrl" value="https://ai.atvoe.com/llmage/v1/chat/completions" placeholder="https://.../v1/chat/completions">
<label for="cfgApiKey">api_keyBearer 令牌,不含 Bearer 前缀)</label>
<input type="text" id="cfgApiKey" placeholder="xGvvta0hnXPDDHIp7knfB" autocomplete="off">
<label for="cfgUserid">userid会话归属用户持久化接口必填</label>
<input type="text" id="cfgUserid" placeholder="users 表 id">
<p class="hint">填写后所有请求会携带 api_url、api_key、userid可不登录 Cookie 测试。凭证仅保存在本机 localStorage。</p>
<button type="button" class="btn btn-ghost" id="btnSaveAuth" style="margin-top:10px">保存凭证到本地</button>
</div>
<div class="sidebar-header">
<h1>对话历史</h1>
<button type="button" class="btn btn-primary" id="btnNewChat">开启新对话</button>
<button type="button" class="btn btn-ghost btn-danger" id="btnDeleteSession" disabled>删除当前会话</button>
</div>
<div class="history-list" id="historyList">
<div class="history-empty">填写 userid 后刷新</div>
</div>
<details class="settings">
<summary>其它选项</summary>
<div class="settings-grid">
<label>model_id可选从文档表读 api_url<input type="text" id="cfgModelId" placeholder="model_management 表 id"></label>
<label class="chk"><input type="checkbox" id="cfgPersist" checked> 使用 chat_send持久化多轮</label>
</div>
</details>
</aside>
<main class="main">
<div class="notice" id="noticeBox">
<strong>测试说明</strong>
左侧可手动填写 <code>api_url</code><code>api_key</code><code>userid</code> 直接联调;未填 userid 时持久化接口会失败。Dspy 网关默认 <code>https://dev.opencomputing.cn</code>,路径为 <code>/cntoai/*.dspy</code>。须已执行 <code>chat_tables.sql</code>
</div>
<div class="toolbar">
<div>
<label for="modelSelect">模型</label>
<select id="modelSelect">
<option value="">加载模型列表…</option>
</select>
</div>
<div>
<label for="modelManual">或手动输入 model</label>
<input type="text" id="modelManual" placeholder="qwen3.6-plus" value="qwen3.6-plus">
</div>
<label class="chk"><input type="checkbox" id="chkStream" checked> 流式(后端汇总后返回全文)</label>
<button type="button" class="btn btn-ghost" id="btnRefreshHistory" style="width:auto;margin:0">刷新历史</button>
</div>
<div class="messages" id="messages"></div>
<div class="status-bar" id="statusBar"></div>
<div class="composer">
<div class="pending-img" id="pendingImgWrap" style="display:none">
<img id="pendingImg" alt="preview">
<button type="button" id="btnClearImg" title="移除图片">×</button>
</div>
<textarea id="inputText" placeholder="输入消息Ctrl+Enter 发送;可上传图片测试图文问答"></textarea>
<div class="composer-actions">
<div class="left">
<label class="btn btn-ghost" style="width:auto;margin:0;cursor:pointer">
图片<input type="file" id="fileImage" accept="image/*" hidden>
</label>
<span style="font-size:12px;color:var(--muted)">Ctrl+Enter 发送</span>
</div>
<button type="button" class="btn btn-primary" id="btnSend" style="width:auto;min-width:100px">发送</button>
</div>
</div>
</main>
</div>
<script>
(function () {
const $ = (id) => document.getElementById(id);
const state = {
apiBase: 'https://dev.opencomputing.cn',
sessionId: '',
sending: false,
imageBase64: '',
imageMime: 'image/jpeg',
models: [],
};
function getApiBase() {
return ($('cfgApiBase').value || 'https://dev.opencomputing.cn').replace(/\/$/, '');
}
function getModel() {
const sel = $('modelSelect').value;
const manual = $('modelManual').value.trim();
return manual || sel || '';
}
function getModelId() {
const opt = $('modelSelect').selectedOptions[0];
return $('cfgModelId').value.trim() || (opt && opt.dataset.modelId) || '';
}
/** 手动凭证,随每个 cntoai 接口请求传递 */
function getAuthExtras() {
const extras = {};
const apiUrl = $('cfgApiUrl').value.trim();
const apiKey = $('cfgApiKey').value.trim();
const userid = $('cfgUserid').value.trim();
if (apiUrl) extras.api_url = apiUrl;
if (apiKey) extras.api_key = apiKey;
if (userid) extras.userid = userid;
return extras;
}
const LS_AUTH = 'cntoai_chat_auth_v1';
function saveAuthLocal() {
localStorage.setItem(LS_AUTH, JSON.stringify({
apiBase: $('cfgApiBase').value,
apiUrl: $('cfgApiUrl').value,
apiKey: $('cfgApiKey').value,
userid: $('cfgUserid').value,
}));
setStatus('凭证已保存到浏览器本地');
}
function loadAuthLocal() {
try {
const raw = localStorage.getItem(LS_AUTH);
if (!raw) return;
const o = JSON.parse(raw);
if (o.apiBase) $('cfgApiBase').value = o.apiBase;
if (o.apiUrl) $('cfgApiUrl').value = o.apiUrl;
if (o.apiKey) $('cfgApiKey').value = o.apiKey;
if (o.userid) $('cfgUserid').value = o.userid;
} catch (e) { /* ignore */ }
}
function buildUrl(path) {
const base = getApiBase();
const p = path.startsWith('/') ? path : '/' + path;
return base + p;
}
/** 调用 dspy优先 POST JSON失败时尝试 GET */
async function callDspy(path, params, method) {
const url = buildUrl(path);
const body = { ...params, ...getAuthExtras() };
const usePost = method === 'POST' || (method !== 'GET' && JSON.stringify(body).length > 1800);
const opts = {
credentials: 'include',
headers: { 'Accept': 'application/json' },
};
let res;
if (usePost) {
opts.method = 'POST';
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
res = await fetch(url, opts);
} else {
const q = new URLSearchParams();
Object.keys(body).forEach((k) => {
const v = body[k];
if (v !== undefined && v !== null && v !== '') {
q.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
}
});
res = await fetch(url + (q.toString() ? '?' + q.toString() : ''), {
...opts,
method: 'GET',
});
}
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
throw new Error('非 JSON 响应 HTTP ' + res.status + ': ' + text.slice(0, 200));
}
return data;
}
function setStatus(msg, isError) {
const el = $('statusBar');
el.textContent = msg;
el.className = 'status-bar' + (isError ? ' error' : msg ? ' loading' : '');
}
function renderMessages(list) {
const box = $('messages');
if (!list.length) {
box.innerHTML = '<div class="msg assistant" style="align-self:center;max-width:100%"><div class="role-tag">提示</div>选择或新建对话后开始聊天</div>';
return;
}
box.innerHTML = list.map((m) => {
const role = m.role === 'user' ? 'user' : 'assistant';
const label = role === 'user' ? '我' : '助手';
const img = m.imagePreview ? '<img class="preview" src="' + m.imagePreview + '" alt="">' : '';
return '<div class="msg ' + role + '"><div class="role-tag">' + label + '</div>' + escapeHtml(m.content) + img + '</div>';
}).join('');
box.scrollTop = box.scrollHeight;
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function loadModels() {
const sel = $('modelSelect');
try {
const res = await callDspy('/cntoai/model_management_customer_search.dspy', {
page_size: 200,
current_page: 1,
}, 'GET');
if (!res.status) {
sel.innerHTML = '<option value="">加载失败: ' + (res.msg || '') + '</option>';
return;
}
state.models = res.data.model_list || [];
if (!state.models.length) {
sel.innerHTML = '<option value="">无已上架模型,请手动输入</option>';
return;
}
sel.innerHTML = state.models.map((m) => {
const name = m.model_name || m.display_name || m.id;
return '<option value="' + escapeAttr(m.model_name || name) + '" data-model-id="' + escapeAttr(m.id) + '">' +
escapeHtml((m.display_name || m.model_name) + ' (' + (m.provider || '') + ')') + '</option>';
}).join('');
if (state.models[0]) {
$('modelManual').value = state.models[0].model_name || '';
}
} catch (e) {
sel.innerHTML = '<option value="">请求异常</option>';
setStatus('模型列表: ' + e.message, true);
}
}
function escapeAttr(s) {
return String(s).replace(/"/g, '&quot;');
}
async function loadHistory() {
const box = $('historyList');
try {
const res = await callDspy('/cntoai/chat_session_list.dspy', { page_size: 50 }, 'GET');
if (!res.status) {
box.innerHTML = '<div class="history-empty">' + escapeHtml(res.msg || '加载失败') + '</div>';
return;
}
const sessions = res.data.sessions || [];
if (!sessions.length) {
box.innerHTML = '<div class="history-empty">暂无历史(发送消息后会出现在此)</div>';
return;
}
box.innerHTML = sessions.map((s) => {
const active = s.id === state.sessionId ? ' active' : '';
const title = escapeHtml(s.title || '未命名');
return '<div class="history-item' + active + '" data-id="' + escapeAttr(s.id) + '" title="' + title + '">' + title + '</div>';
}).join('');
box.querySelectorAll('.history-item').forEach((el) => {
el.addEventListener('click', () => loadSession(el.dataset.id));
});
} catch (e) {
box.innerHTML = '<div class="history-empty">' + escapeHtml(e.message) + '</div>';
}
}
async function loadSession(sessionId) {
state.sessionId = sessionId;
$('btnDeleteSession').disabled = !sessionId;
setStatus('加载会话…');
try {
const res = await callDspy('/cntoai/chat_session_messages.dspy', { session_id: sessionId }, 'GET');
if (!res.status) {
setStatus(res.msg || '加载失败', true);
return;
}
const session = res.data.session || {};
if (session.model) {
$('modelManual').value = session.model;
const sel = $('modelSelect');
for (let i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === session.model) {
sel.selectedIndex = i;
break;
}
}
}
const msgs = (res.data.messages || []).map((m) => ({
role: m.role,
content: m.content || '',
}));
syncUiMessages(msgs);
renderMessages(uiMessages);
await loadHistory();
setStatus('');
} catch (e) {
setStatus(e.message, true);
}
}
async function deleteSession() {
if (!state.sessionId || !confirm('确定删除当前会话?')) return;
try {
const res = await callDspy('/cntoai/chat_session_delete.dspy', { session_id: state.sessionId }, 'GET');
if (res.status) {
state.sessionId = '';
$('btnDeleteSession').disabled = true;
syncUiMessages([]);
renderMessages([]);
await loadHistory();
setStatus('已删除');
} else {
setStatus(res.msg || '删除失败', true);
}
} catch (e) {
setStatus(e.message, true);
}
}
function newChat() {
state.sessionId = '';
$('btnDeleteSession').disabled = true;
syncUiMessages([]);
renderMessages([]);
setStatus('新对话');
}
let uiMessages = [];
function syncUiMessages(list) {
uiMessages = list.map((m) => ({ role: m.role, content: m.content || '' }));
}
async function sendMessage() {
const text = $('inputText').value.trim();
const model = getModel();
if (!model) {
alert('请选择或输入模型名称');
return;
}
if (!text && !state.imageBase64) {
alert('请输入文本或上传图片');
return;
}
if (state.sending) return;
const auth = getAuthExtras();
const persist = $('cfgPersist').checked;
if (persist && !auth.userid) {
alert('使用 chat_send 持久化时,请在左侧填写 userid');
return;
}
if (!auth.api_key) {
if (!confirm('未填写 api_key将依赖服务端配置或登录用户 Key是否继续')) return;
}
const userContent = text || '[图片消息]';
uiMessages.push({
role: 'user',
content: userContent,
imagePreview: state.imageBase64 ? ('data:' + state.imageMime + ';base64,' + state.imageBase64) : '',
});
renderMessages(uiMessages);
$('inputText').value = '';
const imgB64 = state.imageBase64;
const imgMime = state.imageMime;
clearImage();
state.sending = true;
$('btnSend').disabled = true;
setStatus('请求中…');
const payload = {
model,
message: text,
stream: $('chkStream').checked,
model_id: getModelId() || undefined,
...getAuthExtras(),
};
if (state.sessionId) payload.session_id = state.sessionId;
if (imgB64) {
payload.image_base64 = imgB64;
payload.image_mime = imgMime;
}
const path = persist ? '/cntoai/chat_send.dspy' : '/cntoai/llm_chat_completions.dspy';
if (!persist) {
payload.messages = uiMessages.slice(0, -1).map((m) => ({ role: m.role, content: m.content }));
payload.message = text || '请描述这张图片';
if (imgB64) {
payload.image_base64 = imgB64;
payload.image_mime = imgMime;
}
delete payload.session_id;
}
try {
const res = await callDspy(path, payload, 'POST');
if (!res.status) {
setStatus(res.msg || '请求失败', true);
uiMessages.pop();
renderMessages(uiMessages);
return;
}
const reply = persist ? (res.data && res.data.reply) : (res.data && res.data.reply);
if (persist && res.data && res.data.session_id) {
state.sessionId = res.data.session_id;
$('btnDeleteSession').disabled = false;
}
uiMessages.push({ role: 'assistant', content: reply || '(空回复)' });
renderMessages(uiMessages);
await loadHistory();
setStatus('完成');
} catch (e) {
setStatus(e.message, true);
uiMessages.pop();
renderMessages(uiMessages);
} finally {
state.sending = false;
$('btnSend').disabled = false;
}
}
function clearImage() {
state.imageBase64 = '';
$('pendingImgWrap').style.display = 'none';
$('fileImage').value = '';
}
$('fileImage').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file || !file.type.startsWith('image/')) return;
state.imageMime = file.type || 'image/jpeg';
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result;
state.imageBase64 = String(dataUrl).split(',')[1] || '';
$('pendingImg').src = dataUrl;
$('pendingImgWrap').style.display = 'inline-block';
};
reader.readAsDataURL(file);
});
$('btnClearImg').addEventListener('click', clearImage);
$('btnSend').addEventListener('click', sendMessage);
$('btnNewChat').addEventListener('click', newChat);
$('btnDeleteSession').addEventListener('click', deleteSession);
$('btnRefreshHistory').addEventListener('click', () => {
if (!$('cfgUserid').value.trim() && $('cfgPersist').checked) {
alert('持久化接口需要填写 userid');
return;
}
loadHistory();
});
$('inputText').addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') sendMessage();
});
$('modelSelect').addEventListener('change', () => {
const v = $('modelSelect').value;
if (v) $('modelManual').value = v;
const opt = $('modelSelect').selectedOptions[0];
if (opt && opt.dataset.modelId) $('cfgModelId').value = opt.dataset.modelId;
});
$('btnSaveAuth').addEventListener('click', saveAuthLocal);
$('cfgUserid').addEventListener('change', loadHistory);
$('cfgApiBase').addEventListener('change', () => {
loadModels();
loadHistory();
});
function init() {
loadAuthLocal();
renderMessages([]);
loadModels();
if ($('cfgUserid').value.trim()) loadHistory();
else {
$('historyList').innerHTML = '<div class="history-empty">请填写 userid 后点「刷新历史」</div>';
}
}
init();
})();
</script>
</body>
</html>