This commit is contained in:
ping 2026-05-23 09:27:03 +08:00
parent 2e34cc4d82
commit a0ff6ba2c5

727
b/cntoai/chat.html Normal file
View File

@ -0,0 +1,727 @@
<!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>