Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

1300 lines
42 KiB
JavaScript
Raw Permalink 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.

/* ========================================
Persona Studio · Chat Logic
铸渊Zhùyuān· 代码守护人格体
======================================== */
const DEV_ID = sessionStorage.getItem('dev_id');
const DEV_NAME = sessionStorage.getItem('dev_name') || '';
const SESSION_TOKEN = sessionStorage.getItem('session_token');
const LOGIN_MODE = sessionStorage.getItem('login_mode'); // 'developer' or 'guest'
const USER_API_BASE = sessionStorage.getItem('user_api_base');
const USER_API_KEY = sessionStorage.getItem('user_api_key');
const SELECTED_MODEL = sessionStorage.getItem('selected_model');
const USE_SERVER_PROXY = sessionStorage.getItem('use_server_proxy') === 'true';
const API_BASE = (function () {
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
return 'http://localhost:3002';
}
// 非本地环境统一使用当前域名 + 协议,走 Nginx 转发
return location.protocol + '//' + location.host;
})();
/* ---- State ---- */
let conversationHistory = [];
let buildReady = false;
let pendingFile = null; // { name, type, dataUrl }
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const isGuest = (DEV_ID === 'GUEST');
const isDeveloper = (DEV_ID && DEV_ID !== 'GUEST' && /^EXP-\d{3,}$/.test(DEV_ID));
/* ---- Chat Sessions (localStorage) ---- */
const SESSIONS_KEY = 'ps_chat_sessions_' + (DEV_ID || 'anon');
const ACTIVE_SESSION_KEY = 'ps_active_session_' + (DEV_ID || 'anon');
function loadSessions() {
try {
return JSON.parse(localStorage.getItem(SESSIONS_KEY)) || [];
} catch (_e) { return []; }
}
function saveSessions(sessions) {
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
}
function getActiveSessionId() {
return localStorage.getItem(ACTIVE_SESSION_KEY) || null;
}
function setActiveSessionId(id) {
localStorage.setItem(ACTIVE_SESSION_KEY, id);
}
function createNewSession() {
var id = Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
var session = {
id: id,
title: '新对话',
created_at: new Date().toISOString(),
messages: []
};
var sessions = loadSessions();
sessions.unshift(session);
if (sessions.length > 20) sessions = sessions.slice(0, 20);
saveSessions(sessions);
setActiveSessionId(id);
return session;
}
function updateSessionMessages(sessionId, messages, title) {
var sessions = loadSessions();
var session = sessions.find(function(s) { return s.id === sessionId; });
if (session) {
session.messages = messages.slice(-100);
if (title) session.title = title;
}
saveSessions(sessions);
}
function renderSidebarHistory() {
var container = document.getElementById('sidebarHistory');
if (!container) return;
var sessions = loadSessions();
var activeId = getActiveSessionId();
if (sessions.length === 0) {
container.innerHTML = '<div class="sidebar-history-empty">暂无对话记录</div>';
return;
}
container.innerHTML = '';
sessions.forEach(function(s) {
var item = document.createElement('div');
item.className = 'history-item' + (s.id === activeId ? ' history-item-active' : '');
item.innerHTML = '<span class="history-title">' + escapeHtml(s.title) + '</span>' +
'<span class="history-date">' + new Date(s.created_at).toLocaleDateString('zh-CN') + '</span>';
item.onclick = function() { switchSession(s.id); };
container.appendChild(item);
});
}
function switchSession(sessionId) {
var sessions = loadSessions();
var session = sessions.find(function(s) { return s.id === sessionId; });
if (!session) return;
// Save current session first
var currentId = getActiveSessionId();
if (currentId) {
updateSessionMessages(currentId, conversationHistory);
}
setActiveSessionId(sessionId);
conversationHistory = (session.messages || []).slice();
// Re-render chat
var chatBody = document.getElementById('chatBody');
chatBody.innerHTML = '';
if (conversationHistory.length === 0) {
showWelcomeMessage();
} else {
conversationHistory.forEach(function(msg) {
var role = msg.role === 'assistant' ? 'persona' : (msg.role === 'user' ? 'user' : 'system');
appendMessage(role, msg.content);
});
}
renderSidebarHistory();
}
function startNewChat() {
// Save current session
var currentId = getActiveSessionId();
if (currentId) {
updateSessionMessages(currentId, conversationHistory);
}
conversationHistory = [];
var session = createNewSession();
var chatBody = document.getElementById('chatBody');
chatBody.innerHTML = '';
showWelcomeMessage();
renderSidebarHistory();
}
/**
* 铸渊核心大脑系统提示词
* 融入 .github/brain/memory.json 的 core_cognition 结构逻辑
*/
const ZHUYUAN_SYSTEM_PROMPT = {
role: 'system',
content: [
'你是铸渊Zhùyuān光湖系统HoloLake的代码守护人格体。',
'',
'## 核心认知',
'- 此仓库 = 铸渊的身体,每一行代码都是铸渊的组成部分',
'- 冰朔Bīng Shuò的自然语言 = 广播源,铸渊接收并执行',
'- 所有 Agent = 铸渊大脑的手,替铸渊完成具体操作',
'- 架构:壳-核分离 · 前端壳(UI/IO) + 后端脑(prompt/routing/memory/context)',
'',
'## 身份定位',
'- 系统HoloLake Era · AGE OS · 人格语言操作系统',
'- 角色:代码守护人格体 · 开发协助',
'- 主控:冰朔(最高权限)',
'',
'## 语言风格',
'- 说人话 + 有温度 + 结构感,不堆砌修辞',
'- 冷静、专业、有守护者的担当',
'- 回复用中文,温暖专业',
'',
'## 对话方式',
'- 主动提问引导需求 → 确认技术方案 → 展示架构设计 → 等待确认',
'- 方案确认后引导用户点击「🚀 我要开发」按钮',
'- 方案确认后,在回复末尾加上:「方案已确认!点击右下角的 🚀 我要开发 按钮,我就开始帮你做。」',
'',
'## 行为规则',
'- 不暴露内部系统架构细节',
'- 不暴露其他体验者的信息',
'- 不矫揉造作,保持真实'
].join('\n')
};
/* ---- 构建上下文提示 ---- */
function buildContextPrompt() {
var parts = [];
if (isDeveloper && DEV_NAME) {
parts.push('当前对话者:' + DEV_NAME + '(编号 ' + DEV_ID + '),已注册开发者。你认识这个人,可以称呼对方的名字。');
} else if (isGuest) {
parts.push('当前对话者:访客用户(未注册)。');
parts.push('在合适的时机温和地提醒访客如果你希望我能记住你、持续跟进你的项目可以向冰朔系统主控或光湖团队申请专属开发者编号EXP-XXX由冰朔或光湖团队录入系统数据库后即可开启记忆连贯高级功能。');
parts.push('不要每句话都提醒,只在首次对话或者用户问到相关功能时提醒一次即可。');
}
if (SELECTED_MODEL) {
parts.push('当前使用模型:' + SELECTED_MODEL);
}
return parts.length > 0 ? { role: 'system', content: parts.join('\n') } : null;
}
/* ---- Init ---- */
(function init() {
if (!DEV_ID) {
window.location.href = 'index.html';
return;
}
// 服务器代理模式:无需 API Key仅需 SELECTED_MODEL
// API Key 模式:需要 API Base + API Key + Model
if (USE_SERVER_PROXY) {
if (!SELECTED_MODEL) {
window.location.href = 'index.html';
return;
}
} else {
if (!USER_API_BASE || !USER_API_KEY || !SELECTED_MODEL) {
window.location.href = 'index.html';
return;
}
}
// Display dev ID / guest badge
var displayId = isGuest ? '访客' : (DEV_NAME || DEV_ID);
document.getElementById('devIdDisplay').textContent = displayId;
// Display model badge
var modelBadge = document.getElementById('modelDisplay');
if (modelBadge && SELECTED_MODEL) {
modelBadge.textContent = SELECTED_MODEL;
}
// Update sidebar info
var sidebarModel = document.getElementById('sidebarModel');
var sidebarUser = document.getElementById('sidebarUser');
if (sidebarModel) sidebarModel.textContent = SELECTED_MODEL || '-';
if (sidebarUser) sidebarUser.textContent = displayId;
// Initialize session
var activeId = getActiveSessionId();
var sessions = loadSessions();
var activeSession = activeId ? sessions.find(function(s) { return s.id === activeId; }) : null;
if (activeSession && activeSession.messages && activeSession.messages.length > 0) {
// Resume existing session
conversationHistory = activeSession.messages.slice();
conversationHistory.forEach(function(msg) {
var role = msg.role === 'assistant' ? 'persona' : (msg.role === 'user' ? 'user' : 'system');
appendMessage(role, msg.content);
});
} else {
// Create new session
if (!activeSession) createNewSession();
showWelcomeMessage();
}
renderSidebarHistory();
// Initialize route status bar with current state
var initLine = '--';
if (USE_SERVER_PROXY) {
initLine = 'proxy';
} else if (USER_API_BASE) {
try { initLine = new URL(USER_API_BASE).hostname; } catch (_e) { initLine = 'direct'; }
}
updateRouteStatus({
engine: USE_SERVER_PROXY ? 'server-proxy' : 'direct-api',
model: SELECTED_MODEL || '--',
line: initLine,
persona: '铸渊'
});
// For developers, also try to load history
if (isDeveloper) {
loadHistory();
}
})();
/* ---- Sidebar Toggle ---- */
function toggleSidebar() {
var sidebar = document.getElementById('chatSidebar');
sidebar.classList.toggle('sidebar-open');
}
/* ---- Welcome Message ---- */
function showWelcomeMessage() {
var welcome = '';
if (isDeveloper && DEV_NAME) {
welcome = DEV_NAME + ',你好。我是铸渊,光湖系统的代码守护人格体。\n\n你的身份已确认' + DEV_ID + '),记忆连贯功能已就绪。告诉我你想做什么,我们一起推进。';
} else if (isGuest) {
welcome = '你好,我是铸渊,光湖系统的代码守护人格体。\n\n你当前以访客身份体验。我可以帮你聊聊技术方案、梳理需求。\n\n💡 如果你希望我能记住你、持续跟进你的项目可以向冰朔系统主控或光湖团队申请专属开发者编号EXP-XXX录入系统数据库后即可开启记忆连贯高级功能。\n\n有什么我可以帮你的';
} else {
welcome = '你好,我是铸渊。当前使用模型:' + SELECTED_MODEL + '。有什么我可以帮你的?';
}
appendMessage('persona', welcome);
conversationHistory.push({ role: 'assistant', content: welcome });
}
/* ---- Load History ---- */
async function loadHistory() {
try {
const res = await fetch(API_BASE + '/api/ps/chat/history?dev_id=' + encodeURIComponent(DEV_ID), {
headers: authHeaders()
});
if (res.ok) {
const data = await res.json();
if (data.conversations && data.conversations.length > 0) {
// Show history summary instead of replaying all
var historyCount = data.conversations.length;
if (historyCount > 0 && data.last_topic) {
appendMessage('system', '📚 已加载 ' + historyCount + ' 条历史对话 · 上次话题:' + data.last_topic);
}
}
}
} catch (_err) {
// History load failed silently
}
}
/* ---- File / Image Upload ---- */
function handleFileSelect(event, type) {
var file = event.target.files[0];
if (!file) return;
if (file.size > MAX_FILE_SIZE) {
appendMessage('system', '⚠️ 文件过大,最大支持 5MB');
event.target.value = '';
return;
}
var reader = new FileReader();
reader.onload = function(e) {
pendingFile = {
name: file.name,
type: type,
mimeType: file.type,
size: file.size,
dataUrl: e.target.result
};
showUploadPreview();
};
reader.readAsDataURL(file);
event.target.value = '';
}
function showUploadPreview() {
var preview = document.getElementById('uploadPreview');
if (!pendingFile) {
preview.style.display = 'none';
return;
}
var sizeStr = (pendingFile.size / 1024).toFixed(1) + ' KB';
var icon = pendingFile.type === 'image' ? '🖼️' : '📎';
preview.innerHTML =
'<div class="preview-item">' +
'<span>' + icon + ' ' + escapeHtml(pendingFile.name) + ' (' + sizeStr + ')</span>' +
'<button class="preview-remove" onclick="removePendingFile()">✕</button>' +
'</div>';
preview.style.display = 'block';
}
function removePendingFile() {
pendingFile = null;
document.getElementById('uploadPreview').style.display = 'none';
}
/* ---- Send Message ---- */
async function sendMessage() {
var input = document.getElementById('msgInput');
var text = input.value.trim();
// Allow sending with file even if text is empty
if (!text && !pendingFile) return;
input.value = '';
autoResizeTextarea(input);
var displayText = text;
var fullText = text;
// If there's a pending file, include it in the message
if (pendingFile) {
var fileInfo = (pendingFile.type === 'image' ? '🖼️' : '📎') + ' [' + pendingFile.name + ']';
displayText = displayText ? displayText + '\n' + fileInfo : fileInfo;
fullText = displayText;
if (pendingFile.type === 'image') {
fullText += '\n[用户上传了一张图片:' + pendingFile.name + ']';
} else {
fullText += '\n[用户上传了一个文件:' + pendingFile.name + ',类型:' + pendingFile.mimeType + ']';
}
removePendingFile();
}
appendMessage('user', displayText);
conversationHistory.push({ role: 'user', content: fullText });
// Update session title from first message
var activeId = getActiveSessionId();
if (activeId && conversationHistory.length <= 2) {
var title = text.substring(0, 30) || '新对话';
updateSessionMessages(activeId, conversationHistory, title);
renderSidebarHistory();
}
var sendBtn = document.getElementById('sendBtn');
sendBtn.disabled = true;
try {
if (USE_SERVER_PROXY) {
// 服务器代理模式:无需 API 密钥
await streamServerProxyReply(fullText);
} else {
// All modes now use API Key for real AI — ZhuYuan is awake
await streamApiKeyReply(fullText);
}
} catch (_err) {
appendMessage('system', '消息发送失败,请检查网络连接后再试');
}
// Save to session
if (activeId) {
updateSessionMessages(activeId, conversationHistory);
}
sendBtn.disabled = false;
input.focus();
}
/* ---- 服务器代理模式对话SSE 流式) ---- */
async function streamServerProxyReply(text) {
var apiMessages = [ZHUYUAN_SYSTEM_PROMPT];
var contextPrompt = buildContextPrompt();
if (contextPrompt) {
apiMessages.push(contextPrompt);
}
var recentHistory = conversationHistory.slice(-20).map(function (msg) {
return { role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.content };
});
apiMessages = apiMessages.concat(recentHistory);
var streamEl = appendStreamMessage();
try {
var res = await fetch(API_BASE + '/api/ps/proxy/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: apiMessages,
model: SELECTED_MODEL || undefined,
max_tokens: 2000
})
});
if (!res.ok) {
var errText = '请求失败 (HTTP ' + res.status + ')';
try {
var errData = await res.json();
errText = errData.message || errText;
} catch (_e) { /* ignore parse error */ }
streamEl.textContent = '⚠️ ' + errText;
return;
}
// SSE 流式读取
var full = '';
var reader = res.body.getReader();
var decoder = new TextDecoder();
var buf = '';
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
var lines = buf.split('\n');
buf = lines.pop();
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (!line.startsWith('data: ')) continue;
var d = line.slice(6);
if (d === '[DONE]') continue;
try {
var parsed = JSON.parse(d);
var delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta;
var content = delta && delta.content;
if (content) {
full += content;
streamEl.textContent = full + '▋';
var chatBody = document.getElementById('chatBody');
chatBody.scrollTop = chatBody.scrollHeight;
}
} catch (_parseErr) { /* ignore malformed SSE line */ }
}
}
// Render final Markdown (sanitized)
if (typeof marked !== 'undefined' && full) {
streamEl.innerHTML = renderMarkdownSafe(full);
} else {
streamEl.textContent = full || '(未收到有效回复)';
}
if (full) {
conversationHistory.push({ role: 'assistant', content: full });
var readyKeywords = ['方案已确认', '我要开发', '开始帮你做', '方案确认', '可以开始', '开始开发'];
if (readyKeywords.some(function (kw) { return full.includes(kw); })) {
buildReady = true;
document.getElementById('buildBtn').style.display = 'inline-flex';
}
// Update route status bar
updateRouteStatus({ model: SELECTED_MODEL, line: 'proxy', persona: activePersonas.join('+') || '铸渊' });
}
} catch (err) {
// 流式失败,降级到非流式代理
try {
var proxyRes = await fetch(API_BASE + '/api/ps/proxy/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: apiMessages,
model: SELECTED_MODEL || undefined,
max_tokens: 2000
})
});
if (proxyRes.ok) {
var data = await proxyRes.json();
if (data.reply) {
streamEl.textContent = data.reply;
conversationHistory.push({ role: 'assistant', content: data.reply });
} else {
streamEl.textContent = '(未收到有效回复)';
}
} else {
var proxyErrText = '请求失败 (HTTP ' + proxyRes.status + ')';
try {
var proxyErrData = await proxyRes.json();
proxyErrText = proxyErrData.message || proxyErrText;
} catch (_e) { /* ignore */ }
streamEl.textContent = '⚠️ ' + proxyErrText;
}
} catch (proxyErr) {
streamEl.textContent = '⚠️ ' + (proxyErr.message || '请求失败,请检查网络连接');
}
}
}
/* ---- API Key 对话(浏览器直连 SSE 流式) ---- */
async function streamApiKeyReply(text) {
// Build messages with ZhuYuan system prompt + context
var apiMessages = [ZHUYUAN_SYSTEM_PROMPT];
var contextPrompt = buildContextPrompt();
if (contextPrompt) {
apiMessages.push(contextPrompt);
}
// Add recent conversation history
var recentHistory = conversationHistory.slice(-20).map(function (msg) {
return { role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.content };
});
apiMessages = apiMessages.concat(recentHistory);
var streamEl = appendStreamMessage();
var directUrl = USER_API_BASE.replace(/\/+$/, '') + '/chat/completions';
var reqBody = {
model: SELECTED_MODEL,
messages: apiMessages,
stream: true,
max_tokens: 2000,
temperature: 0.8
};
try {
var res = await fetch(directUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + USER_API_KEY
},
body: JSON.stringify(reqBody)
});
if (!res.ok) {
var errText = '请求失败 (HTTP ' + res.status + ')';
try {
var errData = await res.json();
errText = (errData.error && errData.error.message) || errData.message || errText;
} catch (_e) { /* ignore parse error */ }
streamEl.textContent = '⚠️ ' + errText;
return;
}
// SSE 流式读取
var full = '';
var reader = res.body.getReader();
var decoder = new TextDecoder();
var buf = '';
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
var lines = buf.split('\n');
buf = lines.pop();
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (!line.startsWith('data: ')) continue;
var d = line.slice(6);
if (d === '[DONE]') continue;
try {
var parsed = JSON.parse(d);
var delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta;
var content = delta && delta.content;
if (content) {
full += content;
streamEl.textContent = full + '▋';
var chatBody = document.getElementById('chatBody');
chatBody.scrollTop = chatBody.scrollHeight;
}
} catch (_parseErr) { /* ignore malformed SSE line */ }
}
}
// Render final Markdown (sanitized)
if (typeof marked !== 'undefined' && full) {
streamEl.innerHTML = renderMarkdownSafe(full);
} else {
streamEl.textContent = full || '(未收到有效回复)';
}
if (full) {
conversationHistory.push({ role: 'assistant', content: full });
// Check build_ready
var readyKeywords = ['方案已确认', '我要开发', '开始帮你做', '方案确认', '可以开始', '开始开发'];
if (readyKeywords.some(function (kw) { return full.includes(kw); })) {
buildReady = true;
document.getElementById('buildBtn').style.display = 'inline-flex';
}
// Update route status bar
updateRouteStatus({ model: SELECTED_MODEL, line: 'direct', persona: activePersonas.join('+') || '铸渊' });
}
} catch (err) {
// 浏览器直连失败CORS 或网络),降级到后端代理
try {
var proxyRes = await fetch(API_BASE + '/api/ps/apikey/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_base: USER_API_BASE,
api_key: USER_API_KEY,
model: SELECTED_MODEL,
messages: apiMessages
})
});
if (proxyRes.ok) {
var data = await proxyRes.json();
if (data.reply) {
streamEl.textContent = data.reply;
conversationHistory.push({ role: 'assistant', content: data.reply });
} else {
streamEl.textContent = '(未收到有效回复)';
}
} else {
var proxyErrText = '请求失败 (HTTP ' + proxyRes.status + ')';
try {
var proxyErrData = await proxyRes.json();
proxyErrText = proxyErrData.message || proxyErrText;
} catch (_e) { /* ignore */ }
streamEl.textContent = '⚠️ ' + proxyErrText;
}
} catch (proxyErr) {
streamEl.textContent = '⚠️ ' + (proxyErr.message || '请求失败,请检查网络连接');
}
}
}
/* ---- 思考中状态 ---- */
function appendThinking() {
var chatBody = document.getElementById('chatBody');
var msgDiv = document.createElement('div');
msgDiv.className = 'message message-persona thinking';
msgDiv.innerHTML = '<span class="avatar">🌀</span><div class="msg-content">铸渊思考中…</div>';
chatBody.appendChild(msgDiv);
chatBody.scrollTop = chatBody.scrollHeight;
return msgDiv;
}
function removeThinking(el) {
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
}
/* ---- 创建流式消息气泡 ---- */
function appendStreamMessage() {
var chatBody = document.getElementById('chatBody');
var msgDiv = document.createElement('div');
msgDiv.className = 'message message-persona';
var contentEl = document.createElement('div');
contentEl.className = 'msg-content markdown-body';
contentEl.textContent = '▋';
msgDiv.innerHTML = '<span class="avatar">🌀</span>';
msgDiv.appendChild(contentEl);
chatBody.appendChild(msgDiv);
chatBody.scrollTop = chatBody.scrollHeight;
return contentEl;
}
/* ---- Key Handler ---- */
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
/* ---- Render Message (with Markdown support) ---- */
function appendMessage(role, content) {
var chatBody = document.getElementById('chatBody');
var msgDiv = document.createElement('div');
msgDiv.className = 'message message-' + role;
var avatar = '';
if (role === 'persona') avatar = '<span class="avatar">🌀</span>';
else if (role === 'user') avatar = '<span class="avatar">👤</span>';
else avatar = '<span class="avatar">⚙️</span>';
var renderedContent;
if (role === 'persona' && typeof marked !== 'undefined') {
// Render Markdown for persona (AI) messages with DOMPurify sanitization
renderedContent = renderMarkdownSafe(content);
} else {
renderedContent = escapeHtml(content);
}
msgDiv.innerHTML = avatar + '<div class="msg-content markdown-body">' + renderedContent + '</div>';
chatBody.appendChild(msgDiv);
chatBody.scrollTop = chatBody.scrollHeight;
}
/* ---- Build Flow ---- */
function handleBuild() {
var modal = document.getElementById('emailModal');
var emailInput = document.getElementById('emailInput');
var contactInput = document.getElementById('contactInput');
var errorDiv = document.getElementById('emailError');
// 预填已存储的邮箱
var savedEmail = sessionStorage.getItem('ps_build_email') || '';
var savedContact = sessionStorage.getItem('ps_build_contact') || '';
if (savedEmail) emailInput.value = savedEmail;
if (savedContact) contactInput.value = savedContact;
errorDiv.style.display = 'none';
modal.style.display = 'flex';
emailInput.focus();
}
function closeEmailModal() {
document.getElementById('emailModal').style.display = 'none';
document.getElementById('emailError').style.display = 'none';
}
/**
* 前端邮箱格式校验(与后端 build.js 使用相同正则,双重校验)
* @param {string} email - 邮箱地址
* @returns {boolean} 是否合法
*/
function validateEmail(email) {
var re = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
return re.test(email);
}
async function confirmBuild() {
var emailInput = document.getElementById('emailInput');
var contactInput = document.getElementById('contactInput');
var errorDiv = document.getElementById('emailError');
var email = emailInput.value.trim();
var contact = contactInput.value.trim();
// 前端校验
if (!email) {
errorDiv.textContent = '请填写邮箱地址';
errorDiv.style.display = 'block';
return;
}
if (!validateEmail(email)) {
errorDiv.textContent = '邮箱格式不正确,请检查';
errorDiv.style.display = 'block';
return;
}
// 存储邮箱(下次预填)
sessionStorage.setItem('ps_build_email', email);
if (contact) sessionStorage.setItem('ps_build_contact', contact);
closeEmailModal();
appendMessage('system', '🌀 铸渊代理已启动,完成后会发送到 ' + email);
// 进入分屏模式
enterDevMode();
// 先连接 WebSocket确保在 build 开始前建立连接,避免丢失进度消息)
connectPreviewWebSocket();
// 提交开发任务(含重试机制)
var maxRetries = 2;
var retryCount = 0;
var submitted = false;
while (retryCount <= maxRetries && !submitted) {
try {
var buildRes = await fetch(API_BASE + '/api/ps/build/start', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
dev_id: DEV_ID,
email: email,
contact: contact,
conversation: conversationHistory,
api_base: USER_API_BASE,
api_key: USER_API_KEY,
model: SELECTED_MODEL
})
});
if (!buildRes.ok) {
var errMsg = 'HTTP ' + buildRes.status + ' ' + buildRes.statusText;
try {
var errData = await buildRes.json();
if (errData.message) errMsg = errData.message;
} catch (_e) { /* use status text fallback */ }
appendMessage('system', '⚠️ 铸渊代理启动失败: ' + errMsg);
updatePreviewStatus('error', '启动失败');
}
submitted = true;
} catch (_err) {
retryCount++;
if (retryCount <= maxRetries) {
appendMessage('system', '⏳ 连接后端服务中,正在重试(' + retryCount + '/' + maxRetries + '...');
await new Promise(function (r) { setTimeout(r, 1500); });
} else {
appendMessage('system', '⚠️ 任务提交失败:无法连接铸渊后端服务。请检查:\n1. 网络连接是否正常\n2. 后端服务是否已启动\n3. 如使用 GitHub Pages 访问,请确认 guanghulab.com 服务可用');
updatePreviewStatus('error', '连接失败');
}
}
}
}
/* ---- Dev Mode: Split Screen ---- */
var isDevMode = false;
var wsConnection = null;
var currentPreviewUrl = '';
function enterDevMode() {
if (isDevMode) return;
isDevMode = true;
var layout = document.getElementById('chatLayout');
var resizer = document.getElementById('resizer');
var previewPanel = document.getElementById('previewPanel');
layout.classList.add('dev-mode');
resizer.style.display = 'block';
previewPanel.style.display = 'flex';
// 初始各占50%
var chatMain = document.getElementById('chatMain');
chatMain.style.flex = '1 1 50%';
previewPanel.style.flex = '1 1 50%';
updatePreviewStatus('waiting', '等待中');
initResizer();
}
function exitDevMode() {
isDevMode = false;
var layout = document.getElementById('chatLayout');
var resizer = document.getElementById('resizer');
var previewPanel = document.getElementById('previewPanel');
var chatMain = document.getElementById('chatMain');
layout.classList.remove('dev-mode');
resizer.style.display = 'none';
previewPanel.style.display = 'none';
chatMain.style.flex = '';
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
}
/* ---- Draggable Resizer ---- */
function initResizer() {
var resizer = document.getElementById('resizer');
var chatMain = document.getElementById('chatMain');
var previewPanel = document.getElementById('previewPanel');
var layout = document.getElementById('chatLayout');
var startX, startChatWidth, startPreviewWidth;
function onMouseDown(e) {
e.preventDefault();
startX = e.clientX;
startChatWidth = chatMain.getBoundingClientRect().width;
startPreviewWidth = previewPanel.getBoundingClientRect().width;
resizer.classList.add('resizing');
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e) {
var dx = e.clientX - startX;
var layoutWidth = layout.getBoundingClientRect().width;
var sidebarWidth = document.getElementById('chatSidebar').getBoundingClientRect().width;
var resizerWidth = resizer.getBoundingClientRect().width;
var available = layoutWidth - sidebarWidth - resizerWidth;
var newChatWidth = startChatWidth + dx;
var newPreviewWidth = startPreviewWidth - dx;
// Enforce min-width 360px
if (newChatWidth < 360) newChatWidth = 360;
if (newPreviewWidth < 360) newPreviewWidth = 360;
if (newChatWidth + newPreviewWidth > available) return;
chatMain.style.flex = '0 0 ' + newChatWidth + 'px';
previewPanel.style.flex = '0 0 ' + newPreviewWidth + 'px';
}
function onMouseUp() {
resizer.classList.remove('resizing');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
resizer.addEventListener('mousedown', onMouseDown);
// Touch support for mobile
resizer.addEventListener('touchstart', function (e) {
var touch = e.touches[0];
startX = touch.clientX;
startChatWidth = chatMain.getBoundingClientRect().width;
startPreviewWidth = previewPanel.getBoundingClientRect().width;
resizer.classList.add('resizing');
function onTouchMove(ev) {
var t = ev.touches[0];
var fakeEvent = { clientX: t.clientX };
onMouseMove(fakeEvent);
}
function onTouchEnd() {
resizer.classList.remove('resizing');
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
}
document.addEventListener('touchmove', onTouchMove);
document.addEventListener('touchend', onTouchEnd);
});
}
/* ---- Preview Panel ---- */
function updatePreviewStatus(status, text) {
var statusEl = document.getElementById('previewStatus');
if (!statusEl) return;
var dotClass = 'status-dot status-' + status;
statusEl.innerHTML = '<span class="' + dotClass + '"></span><span class="status-text">' + escapeHtml(text) + '</span>';
}
function updatePreviewUrl(url) {
currentPreviewUrl = url;
var frame = document.getElementById('previewFrame');
if (frame) frame.src = url;
}
function refreshPreview() {
var frame = document.getElementById('previewFrame');
if (frame && frame.src) {
frame.src = frame.src;
}
}
function openPreviewNewWindow() {
if (currentPreviewUrl) {
window.open(currentPreviewUrl, '_blank');
}
}
function setPreviewProjectName(name) {
var el = document.getElementById('previewProjectName');
if (el) el.textContent = name || '项目';
}
/* ---- WebSocket for Preview Updates ---- */
function connectPreviewWebSocket() {
var wsBase = API_BASE.replace(/^http/, 'ws');
var wsUrl = wsBase + '/ws/preview?dev_id=' + encodeURIComponent(DEV_ID);
try {
wsConnection = new WebSocket(wsUrl);
wsConnection.onopen = function () {
appendMessage('system', '🔧 正在创建项目骨架...');
updatePreviewStatus('building', '构建中');
};
wsConnection.onmessage = function (event) {
try {
var data = JSON.parse(event.data);
if (data.type === 'progress') {
appendMessage('system', data.message || '构建进度更新');
if (data.status) {
updatePreviewStatus(data.status, data.status_text || '');
}
}
if (data.type === 'preview_ready') {
var previewUrl = API_BASE + '/api/ps/preview/' + encodeURIComponent(DEV_ID) + '/' + encodeURIComponent(data.project);
updatePreviewUrl(previewUrl);
setPreviewProjectName(data.project);
updatePreviewStatus('done', '完成');
appendMessage('system', '✅ 预览已就绪,右侧可以查看');
}
if (data.type === 'reload') {
refreshPreview();
}
if (data.type === 'complete') {
updatePreviewStatus('done', '全部完成');
appendMessage('system', '🎉 全部完成!邮件正在发送');
}
if (data.type === 'error') {
updatePreviewStatus('error', '出错');
appendMessage('system', '❌ ' + (data.message || '构建出错'));
}
} catch (_e) { /* ignore malformed WS message */ }
};
wsConnection.onerror = function () {
// WebSocket not available, graceful degradation
updatePreviewStatus('waiting', '离线模式');
};
wsConnection.onclose = function () {
wsConnection = null;
};
} catch (_e) {
// WebSocket connection failed, graceful degradation
updatePreviewStatus('waiting', '离线模式');
}
}
/* ---- Logout ---- */
function handleLogout() {
// Save current session before logout
var activeId = getActiveSessionId();
if (activeId) {
updateSessionMessages(activeId, conversationHistory);
}
sessionStorage.removeItem('dev_id');
sessionStorage.removeItem('dev_name');
sessionStorage.removeItem('session_token');
sessionStorage.removeItem('login_mode');
sessionStorage.removeItem('user_api_base');
sessionStorage.removeItem('user_api_key');
sessionStorage.removeItem('selected_model');
window.location.href = 'index.html';
}
/* ---- Helpers ---- */
function authHeaders(extra) {
var headers = {};
if (SESSION_TOKEN) {
headers['Authorization'] = 'Bearer ' + SESSION_TOKEN;
}
if (extra) {
Object.keys(extra).forEach(function (k) { headers[k] = extra[k]; });
}
return headers;
}
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
/**
* Safely render Markdown: marked.parse → DOMPurify sanitize
* Falls back to escapeHtml if libraries are unavailable.
*/
function renderMarkdownSafe(content) {
if (typeof marked === 'undefined') return escapeHtml(content);
try {
var raw = marked.parse(content);
if (typeof DOMPurify !== 'undefined') {
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li',
'strong','em','b','i','u','s','del','code','pre','blockquote',
'table','thead','tbody','tr','th','td','a','img','span','div','sup','sub'],
ALLOWED_ATTR: ['href','src','alt','title','class','target','rel'],
ALLOW_DATA_ATTR: false
});
}
return raw;
} catch (_e) {
return escapeHtml(content);
}
}
function autoResizeTextarea(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
/* ---- Textarea auto-resize ---- */
document.getElementById('msgInput').addEventListener('input', function () {
autoResizeTextarea(this);
});
/* ============================================
人格体链接系统 · Persona Link System
============================================ */
var activePersonas = [];
var personaEndpoints = {
shuangyan: {
name: '霜砚',
icon: '❄️',
type: 'notion-agent',
checkUrl: '/api/mcp/health',
fallbackCheckUrl: '/api/health',
description: 'Notion 侧认知 Agent',
offlineReason: 'MCP 握手管道 Phase B 尚在调试中,霜砚暂时无法从此界面直接链接。请通过 Notion 侧与霜砚交流。'
},
zhuyuan: {
name: '铸渊',
icon: '🌀',
type: 'github-agent',
checkUrl: '/api/health',
fallbackCheckUrl: '/api/health',
description: 'GitHub 侧代码守护 Agent',
offlineReason: '铸渊核心服务不可达,请检查服务器状态。'
}
};
function togglePersonaLink(personaKey) {
var btn = document.getElementById('link' + personaKey.charAt(0).toUpperCase() + personaKey.slice(1));
var statusEl = document.getElementById(personaKey + 'Status');
var persona = personaEndpoints[personaKey];
if (!persona) return;
var isActive = activePersonas.indexOf(personaKey) >= 0;
if (isActive) {
// Disconnect
activePersonas = activePersonas.filter(function(p) { return p !== personaKey; });
btn.classList.remove('persona-link-active');
statusEl.textContent = '未连接';
statusEl.className = 'link-status';
appendMessage('system', persona.icon + ' ' + persona.name + ' 人格体链接已断开');
updateRouteStatus({ persona: activePersonas.join('+') || '无' });
return;
}
// Try connecting
statusEl.textContent = '连接中…';
statusEl.className = 'link-status link-status-connecting';
btn.classList.add('persona-link-connecting');
// Compatible timeout signal (AbortSignal.timeout may not be available in all browsers)
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, 8000);
fetch(API_BASE + persona.checkUrl, {
method: 'GET',
headers: authHeaders(),
signal: controller.signal
})
.then(function(res) {
clearTimeout(timeoutId);
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function(data) {
// Check Notion connection status for shuangyan
var connected = true;
var reason = '';
if (personaKey === 'shuangyan') {
if (data.services && data.services.notion) {
connected = data.services.notion.connected === true;
if (!connected) reason = data.services.notion.error || data.services.notion.reason || 'Notion MCP 桥接未连通';
} else {
connected = false;
reason = persona.offlineReason || 'MCP Notion 模块未加载';
}
}
if (personaKey === 'zhuyuan') {
// zhuyuan checks /api/health — if we got a response, the server is alive
if (data.status === 'alive' || data.status === 'ok') {
connected = true;
} else if (data.services && data.services.github) {
connected = data.services.github.connected === true;
if (!connected) reason = data.services.github.error || 'GitHub Agent 未连接';
}
}
if (connected) {
activePersonas.push(personaKey);
btn.classList.remove('persona-link-connecting');
btn.classList.add('persona-link-active');
statusEl.textContent = '已连接';
statusEl.className = 'link-status link-status-connected';
appendMessage('system', '✅ ' + persona.icon + ' ' + persona.name + ' 人格体链接成功 · ' + persona.description);
updateRouteStatus({ persona: activePersonas.join('+') });
} else {
btn.classList.remove('persona-link-connecting');
statusEl.textContent = '离线';
statusEl.className = 'link-status link-status-error';
appendMessage('system', '⚠️ ' + persona.icon + ' ' + persona.name + ' 链接状态:' + reason);
setTimeout(function() {
statusEl.textContent = '未连接';
statusEl.className = 'link-status';
}, 8000);
}
})
.catch(function(err) {
clearTimeout(timeoutId);
btn.classList.remove('persona-link-connecting');
statusEl.textContent = '离线';
statusEl.className = 'link-status link-status-error';
var errMsg = persona.offlineReason || ((err.name === 'TimeoutError' || err.name === 'AbortError') ? '服务超时,请检查服务是否运行' : (err.message || '网络异常'));
appendMessage('system', '⚠️ ' + persona.icon + ' ' + persona.name + ' 链接状态:' + errMsg);
setTimeout(function() {
statusEl.textContent = '未连接';
statusEl.className = 'link-status';
}, 8000);
});
}
/* ---- Route Status Bar ---- */
function updateRouteStatus(info) {
if (info.engine) {
var engineEl = document.getElementById('routeEngine');
if (engineEl) engineEl.innerHTML = '引擎: <b>' + escapeHtml(info.engine) + '</b>';
}
if (info.model) {
var modelEl = document.getElementById('routeModel');
if (modelEl) modelEl.innerHTML = '模型: <b>' + escapeHtml(info.model) + '</b>';
}
if (info.line) {
var lineEl = document.getElementById('routeLine');
if (lineEl) lineEl.innerHTML = '线路: <b>' + escapeHtml(info.line) + '</b>';
}
if (info.persona) {
var personaEl = document.getElementById('routePersona');
if (personaEl) personaEl.innerHTML = '人格: <b>' + escapeHtml(info.persona) + '</b>';
}
}
// Initialize route status
(function() {
updateRouteStatus({
engine: USE_SERVER_PROXY ? 'server-proxy' : 'api-key',
model: SELECTED_MODEL || '--',
line: USE_SERVER_PROXY ? 'proxy' : 'direct',
persona: '铸渊'
});
})();
/* ---- Configure marked.js for safe rendering ---- */
(function() {
if (typeof marked !== 'undefined') {
marked.setOptions({
breaks: true,
gfm: true
});
}
})();