Files
2026-08-07 13:36:56 +08:00

205 lines
36 KiB
HTML
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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#f7f4ee">
<title>LineUp</title>
<script defer src="/vendor/marked/marked.umd.js"></script>
<script defer src="/vendor/dompurify/purify.min.js"></script>
<script defer src="/chat/interaction-kernel.js"></script>
<style>
:root { --paper:#f7f4ee; --surface:#fffdf9; --ink:#25231f; --muted:#77736b; --line:#e8e2d8; --accent:#47736b; --accent-soft:#e1eee9; --agent:#f0eee8; --danger:#b64c48; }
* { box-sizing:border-box; } body { margin:0; min-height:100vh; font-family:ui-rounded,"SF Pro Rounded",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:radial-gradient(circle at 20% 0,#fff 0,transparent 32%),var(--paper); }
button,input { font:inherit; } button { cursor:pointer; }
#login { width:min(360px,calc(100% - 32px)); margin:14vh auto; padding:32px; border:1px solid var(--line); border-radius:22px; background:rgba(255,253,249,.9); box-shadow:0 20px 55px rgba(58,48,35,.11); text-align:center; }
#login h1 { font-family:ui-serif,Georgia,serif; font-size:32px; letter-spacing:-1px; margin:0 0 7px; } #login p { margin:0 0 24px; color:var(--muted); }
#login input { width:100%; margin:0 0 10px; padding:12px 14px; border:1px solid var(--line); border-radius:10px; outline:none; background:#fff; } #login input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(71,115,107,.12); }
.primary { border:0; border-radius:10px; padding:12px 18px; color:#fff; background:var(--accent); font-weight:700; } .primary:hover { background:#345b54; } .error { min-height:19px; margin-top:9px; color:var(--danger); font-size:13px; }
#chat { display:none; width:min(900px,100%); min-height:100vh; margin:auto; background:rgba(255,253,249,.93); border-inline:1px solid var(--line); flex-direction:column; }
#chat-header { display:flex; align-items:center; justify-content:space-between; gap:16px; min-height:72px; padding:14px 24px; border-bottom:1px solid var(--line); background:rgba(255,253,249,.86); backdrop-filter:blur(12px); position:sticky; top:0; z-index:2; }
.agent-title { display:flex; align-items:center; gap:11px; }.avatar { display:grid; place-items:center; width:38px; height:38px; border-radius:13px; color:#fff; background:linear-gradient(145deg,#5f9085,#315a54); box-shadow:inset 0 1px rgba(255,255,255,.3); }.agent-title h1 { margin:0; font-size:15px; letter-spacing:.1px; }.presence { display:flex; align-items:center; gap:5px; margin-top:3px; color:var(--muted); font-size:12px; }.presence-dot { width:7px; height:7px; border-radius:50%; background:#aaa; }.presence.online .presence-dot { background:#43a976; box-shadow:0 0 0 3px rgba(67,169,118,.13); }.presence.thinking .presence-dot { background:#d68d36; animation:pulse 1.2s ease-in-out infinite; }.presence.offline .presence-dot { background:#aaa; }.text-button { padding:7px 9px; border:0; background:transparent; color:var(--muted); font-size:13px; }.text-button:hover { color:var(--danger); }
#messages { display:flex; flex:1; flex-direction:column; gap:16px; overflow-y:auto; padding:28px clamp(16px,5vw,56px); scroll-behavior:smooth; }.system-message { align-self:center; max-width:100%; color:var(--muted); font-size:12px; text-align:center; }.message-row { display:flex; gap:9px; max-width:100%; }.message-row.user { justify-content:flex-end; }.message-row.agent { align-items:flex-start; }.message-avatar { flex:0 0 25px; display:grid; place-items:center; width:25px; height:25px; margin-top:4px; border-radius:9px; background:var(--accent-soft); color:var(--accent); font-size:13px; }.message { max-width:min(690px,calc(100% - 34px)); padding:12px 15px; border:1px solid transparent; border-radius:16px; line-height:1.65; font-size:15px; overflow-wrap:anywhere; }.user .message { color:#fff; background:var(--accent); border-bottom-right-radius:5px; }.agent .message { background:var(--agent); border-bottom-left-radius:5px; }.message-meta { display:flex; align-items:center; justify-content:flex-end; gap:5px; margin-top:7px; color:inherit; font-size:11px; opacity:.65; }.delivery.failed { color:#ffe3e1; opacity:1; }.message.thinking { display:flex; align-items:center; gap:7px; min-height:43px; color:#5e5b55; font-size:13px; }.thinking-dots { display:flex; gap:3px; }.thinking-dots i { width:5px; height:5px; border-radius:50%; background:var(--accent); animation:bounce 1s infinite; }.thinking-dots i:nth-child(2) { animation-delay:.16s; }.thinking-dots i:nth-child(3) { animation-delay:.32s; }
.markdown { color:var(--ink); }.markdown>:first-child { margin-top:0; }.markdown>:last-child { margin-bottom:0; }.markdown h1,.markdown h2,.markdown h3,.markdown h4 { margin:1.05em 0 .42em; line-height:1.28; font-family:ui-serif,Georgia,serif; letter-spacing:-.2px; }.markdown h1 { font-size:1.48em; }.markdown h2 { font-size:1.28em; }.markdown h3 { font-size:1.1em; }.markdown p,.markdown ul,.markdown ol,.markdown blockquote { margin:.65em 0; }.markdown ul,.markdown ol { padding-left:1.35em; }.markdown li+li { margin-top:.2em; }.markdown blockquote { padding:.1em 0 .1em .9em; color:#615c54; border-left:3px solid #b9cfc8; }.markdown a { color:#236a60; text-decoration-color:#8bb8ad; text-underline-offset:2px; }.markdown code { padding:.13em .35em; border-radius:5px; background:rgba(43,57,52,.09); font-family:"SFMono-Regular",Consolas,monospace; font-size:.87em; }.markdown pre { margin:.8em 0; padding:13px; overflow:auto; border:1px solid #dedbd3; border-radius:10px; background:#292b29; color:#f5f1e9; }.markdown pre code { padding:0; background:none; color:inherit; }.markdown table { display:block; max-width:100%; overflow:auto; border-collapse:collapse; font-size:.92em; }.markdown th,.markdown td { padding:7px 9px; border:1px solid #dcd7ce; text-align:left; }.markdown th { background:rgba(255,255,255,.55); }.markdown img { display:block; max-width:100%; height:auto; border-radius:9px; }.markdown hr { border:0; border-top:1px solid #d8d2c8; margin:1em 0; }
.long-reply { padding:0; overflow:hidden; }.long-reply>summary { padding:12px 15px; color:#37675e; cursor:pointer; font-weight:650; list-style:none; }.long-reply>summary::-webkit-details-marker { display:none; }.long-reply>summary::after { content:"⌄"; float:right; transition:transform .18s; }.long-reply[open]>summary::after { transform:rotate(180deg); }.long-reply .markdown { padding:0 15px 13px; border-top:1px solid rgba(116,104,88,.14); }.code-block { margin:.8em 0; border-radius:10px; overflow:hidden; background:#292b29; }.code-block>summary { padding:7px 10px; color:#c9d4cf; font:12px "SFMono-Regular",Consolas,monospace; cursor:pointer; list-style:none; border-bottom:1px solid rgba(255,255,255,.1); }.code-block>summary::-webkit-details-marker { display:none; }.code-block>summary::before { content:"⌘"; margin-right:6px; color:#89b6aa; }.code-block pre { margin:0; border:0; border-radius:0; }.copy-code { float:right; border:0; padding:0; color:#b5c7c0; background:transparent; font-size:12px; }.copy-code:hover { color:#fff; }
.card { margin-top:8px; padding:13px; border:1px solid #dfe5de; border-radius:12px; background:#f8faf7; }.card-title { margin-bottom:7px; font-weight:700; }.card-body { color:#5e5b55; font-size:13px; }.card-actions { display:flex; flex-wrap:wrap; gap:7px; margin-top:11px; }.card-actions button { padding:7px 12px; border:1px solid #7aa499; border-radius:8px; color:#315f57; background:#fff; font-size:13px; }.card-actions button:hover { color:#fff; background:var(--accent); }.progress-bar { height:6px; overflow:hidden; border-radius:20px; background:#d9e2dd; }.progress-fill { height:100%; border-radius:inherit; background:var(--accent); transition:width .35s; }
.surface { width:min(690px,calc(100vw - 82px)); overflow:hidden; border:1px solid #d6dfd9; border-radius:14px; background:#fff; box-shadow:0 8px 25px rgba(49,66,57,.08); }.surface-header { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:38px; padding:8px 11px; border-bottom:1px solid #e4ebe6; color:#50645d; background:#f6faf7; font-size:12px; }.surface-header strong { color:#365e55; }.surface-frame { display:block; width:100%; min-height:120px; border:0; background:#fff; }.surface-error { padding:14px; color:var(--danger); font-size:13px; }.surface-close { border:0; color:#64726d; background:transparent; font-size:16px; }.surface-close:hover { color:var(--danger); }
#input-area { display:flex; align-items:flex-end; gap:9px; padding:14px clamp(16px,5vw,56px) max(16px,env(safe-area-inset-bottom)); border-top:1px solid var(--line); background:rgba(255,253,249,.9); }.input-shell { display:flex; flex:1; align-items:center; min-height:43px; padding:0 4px 0 13px; border:1px solid #dcd6cb; border-radius:13px; background:#fff; transition:.15s; }.input-shell:focus-within { border-color:#78a99d; box-shadow:0 0 0 3px rgba(71,115,107,.12); }.input-shell input { flex:1; min-width:0; border:0; outline:0; background:transparent; color:var(--ink); }.send { width:43px; height:35px; border:0; border-radius:10px; color:#fff; background:var(--accent); font-size:16px; }.send:disabled { cursor:not-allowed; opacity:.45; }@keyframes pulse { 50% { transform:scale(.68); opacity:.55; } }@keyframes bounce { 50% { transform:translateY(-4px); opacity:.45; } }
@media (max-width:620px) { #chat { border:0; }.message { max-width:calc(100% - 30px); }.message-row { width:100%; } #chat-header { padding-inline:16px; } #messages { padding-top:20px; } }
</style>
</head>
<body>
<main id="login">
<h1>LineUp</h1><p>你的 AI 搭档</p>
<input id="phone" type="text" autocomplete="tel" placeholder="手机号" value="13800138000">
<input id="code" type="text" inputmode="numeric" placeholder="验证码" value="123456">
<button class="primary" type="button" onclick="login()">进入对话</button>
<div id="loginError" class="error" aria-live="polite"></div>
</main>
<main id="chat">
<header id="chat-header">
<div class="agent-title"><div class="avatar" aria-hidden="true"></div><div><h1>AI Agent</h1><div id="presence" class="presence offline"><span class="presence-dot"></span><span id="status">正在连接…</span></div></div></div>
<button class="text-button" type="button" onclick="logout()">退出</button>
</header>
<section id="messages" aria-live="polite" aria-label="对话消息"></section>
<form id="input-area" onsubmit="sendMsg(); return false;"><div class="input-shell"><input id="msgInput" autocomplete="off" placeholder="问问你的 AI 搭档…"></div><button id="sendBtn" class="send" type="submit" title="发送"></button></form>
</main>
<script>
const API = '';
let state = { uid:'', token:'', agentUID:'agent_hermes_main', channelID:'agent_default_channel', channelType:2, lastSeq:0, active:true };
let polling = false, healthTimer = null, thinkingEl = null;
let itemFactory = null, rendererRegistry = null;
const surfaces = new Map();
const MAX_SURFACE_BYTES = 160 * 1024;
function timeLabel(date = new Date()) { return `${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`; }
function scrollToLatest() { const el = document.getElementById('messages'); el.scrollTop = el.scrollHeight; }
function setPresence(kind, label) { const el = document.getElementById('presence'); el.className = `presence ${kind}`; document.getElementById('status').textContent = label; }
function showLoginError(message) { document.getElementById('loginError').textContent = message; }
async function login() {
const phone = document.getElementById('phone').value.trim(), code = document.getElementById('code').value.trim();
if (!phone || !code) return showLoginError('请填写手机号和验证码');
try {
const response = await fetch(`${API}/login`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({phone, code}) });
const data = await response.json(); if (!response.ok) return showLoginError(data.error || '登录失败');
state = { ...state, uid:data.uid, token:data.token, agentUID:data.agent_uid || state.agentUID, channelID:data.channel_id || state.channelID, channelType:data.channel_type || state.channelType, lastSeq:0, active:true };
document.getElementById('login').style.display = 'none'; document.getElementById('chat').style.display = 'flex';
addSystemMsg('已进入与 AI Agent 的私密对话'); setPresence('offline','正在确认 Agent 状态…'); document.getElementById('msgInput').focus();
pollMessages(); pollAgentHealth(); healthTimer = window.setInterval(pollAgentHealth, 10000);
} catch (error) { showLoginError(`网络错误:${error.message}`); }
}
function logout() { state.active = false; window.clearInterval(healthTimer); healthTimer = null; surfaces.forEach(surface => surface.row.remove()); surfaces.clear(); document.getElementById('chat').style.display = 'none'; document.getElementById('login').style.display = 'block'; document.getElementById('messages').replaceChildren(); thinkingEl = null; state = { uid:'', token:'', agentUID:'agent_hermes_main', channelID:'agent_default_channel', channelType:2, lastSeq:0, active:true }; }
async function pollAgentHealth() {
if (!state.uid || !state.active) return;
try { const response = await fetch(`${API}/agents/${encodeURIComponent(state.agentUID)}/health`); const health = await response.json(); if (!state.active) return;
if (thinkingEl) setPresence('thinking','正在思考…'); else if (health.online) setPresence('online','在线'); else setPresence('offline', health.known ? '暂时离线' : '等待 Agent 上线');
} catch { if (state.active) setPresence('offline','状态不可用'); }
}
async function sendMsg() {
const input = document.getElementById('msgInput'), button = document.getElementById('sendBtn'), text = input.value.trim(); if (!text || !state.uid) return;
input.value = ''; button.disabled = true; const message = addMessage(text, 'user'); setDelivery(message, '发送中');
try { const response = await fetch(`${API}/messages/send`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({from_uid:state.uid, channel_id:state.channelID, channel_type:state.channelType, payload:text})}); const data = await response.json();
if (!response.ok) throw new Error(data.error || '发送失败'); if (data.message_seq) state.lastSeq = Math.max(state.lastSeq, data.message_seq); setDelivery(message, '已发送');
} catch (error) { setDelivery(message, '发送失败', true); addSystemMsg(`消息未送达:${error.message}`); } finally { button.disabled = false; input.focus(); }
}
async function pollMessages() {
if (polling) return; polling = true;
while (state.active && document.getElementById('chat').style.display !== 'none') {
// WuKongIM returns start_message_seq inclusively. Continue from the
// next unconsumed sequence after the local cursor.
try { const startSeq = state.lastSeq === 0 ? 0 : state.lastSeq + 1; const response = await fetch(`${API}/messages/sync`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({channel_id:state.channelID, channel_type:state.channelType, login_uid:state.uid, start_message_seq:startSeq, limit:50})}); const data = await response.json();
if (!response.ok) throw new Error(data.error || '同步失败'); for (const message of (data.messages || [])) { if (message.message_seq > state.lastSeq) state.lastSeq = message.message_seq; if (message.from_uid !== state.uid) renderAgentMessage(base64Decode(message.payload)); }
} catch { if (state.active) setPresence('offline','消息同步中断,正在重试'); }
await sleep(1500);
} polling = false;
}
function addMessage(text, role) {
const row = document.createElement('article'); row.className = `message-row ${role}`;
if (role === 'agent') { const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; row.append(avatar); }
const bubble = document.createElement('div'); bubble.className = 'message';
if (role === 'agent') appendMarkdown(bubble, text); else bubble.textContent = text;
const meta = document.createElement('div'); meta.className = 'message-meta'; meta.textContent = timeLabel(); bubble.append(meta); row.append(bubble); document.getElementById('messages').append(row); scrollToLatest(); return row;
}
function setDelivery(row, text, failed = false) { const meta = row.querySelector('.message-meta'); meta.textContent = `${timeLabel()} · ${text}`; meta.classList.toggle('delivery', true); meta.classList.toggle('failed', failed); }
function addSystemMsg(text) { const item = document.createElement('div'); item.className = 'system-message'; item.textContent = text; document.getElementById('messages').append(item); scrollToLatest(); }
function showThinking(detail = '') { if (thinkingEl) thinkingEl.remove(); const row = document.createElement('article'); row.className = 'message-row agent'; row.id = 'thinkingIndicator'; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; const bubble = document.createElement('div'); bubble.className = 'message thinking'; bubble.innerHTML = '<span class="thinking-dots"><i></i><i></i><i></i></span><span></span>'; bubble.querySelector('span:last-child').textContent = detail || '正在思考'; row.append(avatar,bubble); document.getElementById('messages').append(row); thinkingEl = row; setPresence('thinking','正在思考…'); scrollToLatest(); }
function hideThinking() { if (thinkingEl) thinkingEl.remove(); thinkingEl = null; }
function appendMarkdown(container, source) {
const markdown = document.createElement('div'); markdown.className = 'markdown';
const parsed = window.marked ? marked.parse(String(source), {gfm:true, breaks:true}) : escapeHtml(source).replace(/\n/g,'<br>');
markdown.innerHTML = window.DOMPurify ? DOMPurify.sanitize(parsed, {USE_PROFILES:{html:true}, FORBID_TAGS:['style','form','input','button']}) : parsed;
markdown.querySelectorAll('a').forEach(link => { link.target = '_blank'; link.rel = 'noopener noreferrer'; });
markdown.querySelectorAll('pre').forEach(pre => {
const code = pre.querySelector('code'); const language = [...(code?.classList || [])].find(name => name.startsWith('language-'))?.slice(9) || '代码'; const disclosure = document.createElement('details'); disclosure.className = 'code-block'; disclosure.open = true; const summary = document.createElement('summary'); summary.textContent = language; const copy = document.createElement('button'); copy.className = 'copy-code'; copy.type = 'button'; copy.textContent = '复制'; copy.onclick = async event => { event.preventDefault(); try { await navigator.clipboard.writeText(code?.textContent || ''); copy.textContent = '已复制'; setTimeout(() => copy.textContent = '复制', 1400); } catch { copy.textContent = '请手动复制'; } }; summary.append(copy); pre.replaceWith(disclosure); disclosure.append(summary,pre);
});
if (String(source).length > 1200 || markdown.querySelectorAll('pre,table,blockquote').length > 2) { const details = document.createElement('details'); details.className = 'long-reply'; const summary = document.createElement('summary'); summary.textContent = '展开完整回复'; details.append(summary,markdown); container.append(details); } else container.append(markdown);
}
// UI Surfaces are intentionally not normal DOM content. Agent-supplied
// HTML/CSS/JS runs only in a unique-origin sandbox without network access;
// it can emit validated user events but cannot read this page, its tokens,
// or invoke a client capability by itself.
function builtInSurface(requested) {
if (!requested || requested.id !== 'com.lineup.task.dashboard') return null;
return {
id: 'com.lineup.task.dashboard', name: '任务执行面板', version: '1.0.0',
html: '<main class="task"><header><div><p class="eyebrow">LINEUP TASK</p><h1 id="title">任务处理中</h1></div><span id="phase" class="phase">运行中</span></header><p id="summary" class="summary"></p><div class="progress"><i id="progress"></i></div><p id="percent" class="percent"></p><ol id="steps" class="steps"></ol><footer><button id="cancel" class="cancel" type="button">取消任务</button></footer></main>',
css: '.task{padding:18px;font-family:ui-sans-serif,system-ui,sans-serif;color:#20312d}.task header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.eyebrow{margin:0 0 3px;color:#668078;font-size:10px;font-weight:700;letter-spacing:.12em}.task h1{margin:0;font-size:19px;line-height:1.25}.phase{padding:4px 8px;border-radius:99px;background:#e2eee9;color:#35675a;font-size:12px;white-space:nowrap}.summary{margin:12px 0;color:#5e6965;font-size:14px;line-height:1.55}.progress{height:7px;overflow:hidden;border-radius:8px;background:#e4ece8}.progress i{display:block;height:100%;border-radius:inherit;background:#4b8474;transition:width .25s}.percent{margin:6px 0 13px;color:#60716b;font-size:12px;text-align:right}.steps{display:grid;gap:8px;margin:0;padding:0;list-style:none}.steps li{display:flex;gap:9px;align-items:flex-start;padding:9px;border-radius:9px;background:#f4f7f5;font-size:13px}.steps b{display:block;color:#30423d}.steps small{display:block;margin-top:2px;color:#718078}.mark{width:18px;height:18px;border-radius:50%;text-align:center;line-height:18px;background:#d9e8e1;color:#397363;font-size:11px}.mark.running{background:#f8e9ca;color:#9a6824}.mark.failed{background:#f6d8d6;color:#a64743}.task footer{display:flex;justify-content:flex-end;margin-top:15px}.cancel{padding:7px 11px;border:1px solid #cf827e;border-radius:8px;background:#fff;color:#a74743;font-size:13px}',
js: "const symbols={completed:'✓',running:'…',failed:'!',pending:'○',cancelled:''};function renderTask(state){document.querySelector('#title').textContent=state.title||'任务处理中';document.querySelector('#summary').textContent=state.summary||'';const phase=state.phase||'running';document.querySelector('#phase').textContent={running:'运行中',waiting_input:'等待确认',completed:'已完成',failed:'失败',cancelled:'已取消'}[phase]||phase;const percent=Math.max(0,Math.min(100,Number(state.percent)||0));document.querySelector('#progress').style.width=percent+'%';document.querySelector('#percent').textContent=percent+'%';const steps=document.querySelector('#steps');steps.replaceChildren(...(Array.isArray(state.steps)?state.steps:[]).map(step=>{const li=document.createElement('li'),mark=document.createElement('span'),body=document.createElement('div'),label=document.createElement('b'),detail=document.createElement('small'),status=step.status||'pending';mark.className='mark '+status;mark.textContent=symbols[status]||'○';label.textContent=step.title||'未命名步骤';detail.textContent=step.detail||'';body.append(label,detail);li.append(mark,body);return li;}));document.querySelector('#cancel').hidden=state.cancellable===false||['completed','failed','cancelled'].includes(phase);}LineUpSurface.onState(renderTask);document.querySelector('#cancel').onclick=()=>LineUpSurface.event('cancel',{});"
};
}
function surfaceDocument(app) {
const title = escapeHtml(String(app.name || app.id || 'LineUp Surface'));
const html = typeof app.html === 'string' ? app.html : '<main>此界面没有内容。</main>';
const css = typeof app.css === 'string' ? app.css : '';
const javascript = typeof app.js === 'string' ? app.js : '';
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; script-src 'unsafe-inline'"><title>${title}</title><style>html,body{margin:0;min-height:100%;font-family:system-ui,-apple-system,sans-serif;color:#25231f;background:#fff}button,input,select,textarea{font:inherit}${css}</style></head><body>${html}<script>const LineUpSurface={event:(event,data={})=>parent.postMessage({namespace:'lineup.surface.v1',type:'event',event,data},'*'),ready:()=>parent.postMessage({namespace:'lineup.surface.v1',type:'ready'},'*'),resize:(height)=>parent.postMessage({namespace:'lineup.surface.v1',type:'resize',height},'*'),onState:(handler)=>addEventListener('message',e=>{const d=e.data||{};if(d.namespace==='lineup.surface.v1'&&d.type==='state')handler(d.state||{})})};${javascript};LineUpSurface.ready();<\/script></body></html>`;
}
function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
function jsonSize(value) { try { return new TextEncoder().encode(JSON.stringify(value)).length; } catch { return Number.POSITIVE_INFINITY; } }
async function sendProtocol(type, payload, conversationID) {
const envelope = { v:1, id:`msg_${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`, type, conversation_id:conversationID || `lineup:${state.channelID}:${state.uid}`, sender:{kind:'human',id:state.uid}, target:{kind:'agent',id:state.agentUID}, timestamp:new Date().toISOString(), payload };
const response = await fetch(`${API}/messages/send`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({from_uid:state.uid, channel_id:state.channelID, channel_type:state.channelType, payload:JSON.stringify(envelope)})});
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || '协议消息发送失败'); }
}
function renderSurface(envelope) {
const payload = envelope.payload || {}, requestedApp = payload.app, app = builtInSurface(requestedApp) || requestedApp, instanceID = String(payload.instance_id || '');
if (!instanceID || !/^[A-Za-z0-9._:-]{1,128}$/.test(instanceID) || !isPlainObject(app) || typeof app.id !== 'string' || typeof app.version !== 'string' || jsonSize(app) > MAX_SURFACE_BYTES) { addSystemMsg('Agent 请求的扩展界面无效或超过安全大小限制。'); return; }
closeSurface(instanceID, false);
const row = document.createElement('article'); row.className = 'message-row agent'; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; const shell = document.createElement('section'); shell.className = 'surface'; shell.setAttribute('aria-label', `${app.name || app.id} 扩展界面`);
const header = document.createElement('div'); header.className = 'surface-header'; const name = document.createElement('strong'); name.textContent = String(app.name || app.id); const close = document.createElement('button'); close.type = 'button'; close.className = 'surface-close'; close.title = '关闭界面'; close.textContent = '×'; close.onclick = () => closeSurface(instanceID, true); header.append(name,close);
const initialState = isPlainObject(payload.state) ? payload.state : {}; const frame = document.createElement('iframe'); frame.className = 'surface-frame'; frame.title = String(app.name || app.id); frame.sandbox = 'allow-scripts'; frame.referrerPolicy = 'no-referrer'; frame.srcdoc = surfaceDocument(app); shell.append(header,frame); row.append(avatar,shell); document.getElementById('messages').append(row); surfaces.set(instanceID, {row,frame,conversationID:envelope.conversation_id,state:initialState});
frame.addEventListener('load', () => frame.contentWindow?.postMessage({namespace:'lineup.surface.v1',type:'state',state:initialState}, '*')); scrollToLatest();
}
function patchSurface(envelope) { const payload = envelope.payload || {}, surface = surfaces.get(String(payload.instance_id || '')); if (!surface) return addSystemMsg('收到一个尚未打开的扩展界面更新。'); if (!isPlainObject(payload.state) || jsonSize(payload.state) > 32 * 1024) return; surface.state = payload.state; surface.frame.contentWindow?.postMessage({namespace:'lineup.surface.v1',type:'state',state:surface.state}, '*'); }
function closeSurface(instanceID, notifyAgent) { const surface = surfaces.get(instanceID); if (!surface) return; surface.row.remove(); surfaces.delete(instanceID); if (notifyAgent) sendProtocol('lineup.v1.ui.event', {instance_id:instanceID,event:'close',data:{}}, surface.conversationID).catch(error => addSystemMsg(`无法通知 Agent 关闭界面:${error.message}`)); }
window.addEventListener('message', event => { const data = event.data; if (!isPlainObject(data) || data.namespace !== 'lineup.surface.v1') return; const entry = [...surfaces.entries()].find(([, surface]) => surface.frame.contentWindow === event.source); if (!entry) return; const [instanceID,surface] = entry;
if (data.type === 'ready') { surface.frame.contentWindow?.postMessage({namespace:'lineup.surface.v1',type:'state',state:surface.state}, '*'); return; }
if (data.type === 'resize') { const height = Number(data.height); if (Number.isFinite(height)) surface.frame.style.height = `${Math.max(120,Math.min(680,height))}px`; return; }
if (data.type !== 'event' || typeof data.event !== 'string' || !/^[a-z][a-z0-9._:-]{0,63}$/i.test(data.event) || (data.data !== undefined && !isPlainObject(data.data)) || jsonSize(data.data || {}) > 16 * 1024) return;
sendProtocol('lineup.v1.ui.event', {instance_id:instanceID,event:data.event,data:data.data || {}}, surface.conversationID).catch(error => addSystemMsg(`扩展界面事件未送达:${error.message}`));
});
function renderAppCall(envelope) { const payload = envelope.payload || {}, callID = String(payload.call_id || ''), capability = String(payload.capability || ''); if (!callID || !capability) return addSystemMsg('收到无效的 App 能力调用。'); const known = new Set(['app.open_url','clipboard.write']); const respond = (status, result = {}, error = '') => sendProtocol('lineup.v1.app.result',{call_id:callID,status,result,error},envelope.conversation_id).catch(err => addSystemMsg(`能力调用结果未送达:${err.message}`)); if (!known.has(capability)) { addSystemMsg(`此客户端不支持 Agent 请求的能力:${capability}`); respond('unsupported',{},'capability_not_registered'); return; }
const row = document.createElement('article'); row.className='message-row agent'; const avatar=document.createElement('div'); avatar.className='message-avatar'; avatar.textContent='✦'; const bubble=document.createElement('div'); bubble.className='message'; const card=document.createElement('div'); card.className='card'; const title=document.createElement('div'); title.className='card-title'; title.textContent=`应用请求:${capability}`; const body=document.createElement('div'); body.className='card-body'; body.textContent=String(payload.reason || 'Agent 请求使用一项本机能力。请确认后再执行。'); const actions=document.createElement('div'); actions.className='card-actions'; const allow=document.createElement('button'); allow.textContent='允许'; const reject=document.createElement('button'); reject.textContent='拒绝'; reject.onclick=()=>{row.remove();respond('rejected',{},'user_rejected');}; allow.onclick=async()=>{ try { const args=isPlainObject(payload.arguments)?payload.arguments:{}; if (capability==='app.open_url') { const url=new URL(String(args.url || '')); if (!['https:','http:'].includes(url.protocol)) throw new Error('仅允许 HTTP(S) 链接'); window.open(url.href,'_blank','noopener,noreferrer'); respond('completed',{opened:true}); } else { await navigator.clipboard.writeText(String(args.text || '')); respond('completed',{written:true}); } row.remove(); } catch(error) { respond('failed',{},String(error.message || error)); } }; actions.append(allow,reject); card.append(title,body,actions); bubble.append(card); row.append(avatar,bubble); document.getElementById('messages').append(row); scrollToLatest(); }
function renderAgentMessage(payload) {
if (!itemFactory || !rendererRegistry) { addSystemMsg('交互渲染层尚未就绪,请稍候。'); return; }
rendererRegistry.render(itemFactory.decode(payload));
}
function renderCard(obj) {
const row = document.createElement('article'); row.className = 'message-row agent'; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; const bubble = document.createElement('div'); bubble.className = 'message'; if (obj.text) appendMarkdown(bubble,String(obj.text)); const card = document.createElement('div'); card.className = 'card';
const labels = {choice:['🔀 ','请选择'],confirm:['❓ ','请确认'],progress:['⏳ ','处理中'],result:['✓ ','结果'],error:['⚠ ','错误']}; const label = labels[obj.type] || ['','Agent 消息']; const title = document.createElement('div'); title.className = 'card-title'; title.textContent = label[0] + String(obj.title || label[1]); card.append(title);
if (obj.type === 'progress') { const bar = document.createElement('div'); bar.className = 'progress-bar'; const fill = document.createElement('div'); fill.className = 'progress-fill'; fill.style.width = `${Math.max(0,Math.min(100,Number(obj.percent) || 0))}%`; bar.append(fill); card.append(bar); const label = document.createElement('div'); label.className = 'card-body'; label.style.marginTop='7px'; label.textContent = String(obj.status || `${obj.percent || 0}%`); card.append(label); }
else if (obj.type === 'choice' || obj.type === 'confirm') { if (obj.description) { const body = document.createElement('div'); body.className='card-body'; body.textContent=String(obj.description); card.append(body); } const actions = document.createElement('div'); actions.className='card-actions'; const options = obj.type === 'confirm' ? ['确认','取消'] : (obj.options || []); options.forEach(option => { const button = document.createElement('button'); button.type='button'; button.textContent=String(option); button.onclick=() => sendChoiceResponse(String(option)); actions.append(button); }); card.append(actions); }
else if (obj.content) { const body = document.createElement('div'); body.className='card-body'; obj.type === 'result' ? appendMarkdown(body,String(obj.content)) : body.textContent=String(obj.content); card.append(body); }
bubble.append(card); const meta = document.createElement('div'); meta.className='message-meta'; meta.textContent=timeLabel(); bubble.append(meta); row.append(avatar,bubble); document.getElementById('messages').append(row); scrollToLatest();
}
async function sendChoiceResponse(option) { const input = document.getElementById('msgInput'); input.value = option; await sendMsg(); }
function installRendererRegistry() {
if (!window.LineUp) throw new Error('LineUp Interaction Kernel 未加载');
itemFactory = new LineUp.ConversationItemFactory(); rendererRegistry = new LineUp.RendererRegistry();
rendererRegistry
.register('markdown', item => { hideThinking(); addMessage(item.data.markdown, 'agent'); })
.register('agent-status', item => { const {status,detail} = item.data; if (status === 'thinking') showThinking(detail); else if (status === 'idle') { hideThinking(); setPresence('online','在线'); } else { hideThinking(); setPresence(status === 'failed' ? 'offline' : 'online', detail || status || '在线'); } })
.register('progress', item => renderCard({type:'progress', ...item.data}))
.register('agent-error', item => { hideThinking(); addSystemMsg(`Agent 错误:${item.data.message}`); })
.register('legacy-card', item => renderCard(item.data))
.register('ui.open', item => renderSurface(item.envelope))
.register('ui.patch', item => patchSurface(item.envelope))
.register('ui.close', item => closeSurface(String(item.data.instance_id || ''), false))
.register('app-call', item => renderAppCall(item.envelope))
.register('protocol-fallback', item => addSystemMsg(item.data.reason === 'unsupported_type' ? `当前客户端暂不支持 Agent 内容:${item.data.type}` : '收到无法安全展示的 Agent 内容。'));
}
document.addEventListener('DOMContentLoaded', () => { try { installRendererRegistry(); } catch (error) { showLoginError(`初始化交互层失败:${error.message}`); } });
function base64Decode(value) { try { return decodeURIComponent(Array.from(atob(value), c => `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`).join('')); } catch { try { return atob(value); } catch { return value; } } }
function escapeHtml(value) { return String(value).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve,ms)); }
</script>
</body>
</html>