/* ========================================
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 = '
';
return;
}
container.innerHTML = '';
sessions.forEach(function(s) {
var item = document.createElement('div');
item.className = 'history-item' + (s.id === activeId ? ' history-item-active' : '');
item.innerHTML = '' + escapeHtml(s.title) + '' +
'' + new Date(s.created_at).toLocaleDateString('zh-CN') + '';
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 =
'' +
'' + icon + ' ' + escapeHtml(pendingFile.name) + ' (' + sizeStr + ')' +
'' +
'
';
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 = '🌀铸渊思考中…
';
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 = '🌀';
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 = '🌀';
else if (role === 'user') avatar = '👤';
else avatar = '⚙️';
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 + '' + renderedContent + '
';
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 = '' + escapeHtml(text) + '';
}
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 = '引擎: ' + escapeHtml(info.engine) + '';
}
if (info.model) {
var modelEl = document.getElementById('routeModel');
if (modelEl) modelEl.innerHTML = '模型: ' + escapeHtml(info.model) + '';
}
if (info.line) {
var lineEl = document.getElementById('routeLine');
if (lineEl) lineEl.innerHTML = '线路: ' + escapeHtml(info.line) + '';
}
if (info.persona) {
var personaEl = document.getElementById('routePersona');
if (personaEl) personaEl.innerHTML = '人格: ' + escapeHtml(info.persona) + '';
}
}
// 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
});
}
})();