update
This commit is contained in:
parent
2e34cc4d82
commit
a0ff6ba2c5
727
b/cntoai/chat.html
Normal file
727
b/cntoai/chat.html
Normal 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_key(Bearer 令牌,不含 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, '"');
|
||||
}
|
||||
|
||||
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>
|
||||
Loading…
x
Reference in New Issue
Block a user