pi2web 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js ADDED
@@ -0,0 +1,544 @@
1
+ (() => {
2
+ 'use strict';
3
+ const $ = id => document.getElementById(id);
4
+ const state = {
5
+ base: localStorage.rpBase || location.origin, token: '', workspace: null, workspaces: [], treePath: '.',
6
+ filePath: null, fileOffset: 0, fileSize: 0, fileLimit: 64 * 1024, agent: null, agents: [], terminals: [], terminal: null, selectedKind: 'agent', ws: null, terminalWs: null,
7
+ terminalEmulator: null, fitAddon: null, resizeObserver: null, reconnectTimer: null, reconnectAttempt: 0, manuallyClosed: false, streams: new Map(), contextTarget: null,
8
+ mentionPath: '.', mentionStart: null, mentionEnd: null, mentionPrefix: '', mentionOptions: [], mentionFiltered: [], mentionIndex: 0, mentionRequest: 0, extensionStatus: new Map(), widgets: new Map(), contexts: new Map(), connected: false, mobileView: 'home',
9
+ agentPageSize: Number(localStorage.rpAgentPageSize || 10), agentVisibleCount: Number(localStorage.rpAgentPageSize || 10), messagePageStart: 0, messageTotal: 0, messagePageSize: 25,
10
+ voiceEnabled: false, voiceSttEnabled: false, voicePlaybackEnabled: localStorage.rpVoicePlayback === 'true', voiceAudio: {context:null,nextTime:0,playbackId:null,sources:new Set(),decodeChain:Promise.resolve(),generation:0}, mediaRecorder:null, mediaChunks:[], mediaStream:null, mediaTimer:null, mediaAgentId:null,
11
+ mailNotificationsAvailable:false,mailSettings:{enabled:false,aggregationDelaySeconds:0,includeResponse:true,includeSessionDetails:true},
12
+ };
13
+ $('api').value = state.base;
14
+ $('pairBase').value = state.base;
15
+
16
+ const clean = value => String(value || '').split('\\').join('/').replace(/\/+/g, '/').replace(/\/$/, '') || '/';
17
+ const joinPath = (base, name) => base === '.' ? name : `${base}/${name}`;
18
+ const parentPath = value => { const p = value.split('/'); p.pop(); return p.join('/') || '.'; };
19
+ const absolutePath = relative => clean(`${state.workspace.rootPath}/${relative === '.' ? '' : relative}`);
20
+ const isInside = (file, dir) => file === dir || file.startsWith(`${dir}/`);
21
+ const relativeTo = (file, dir) => file === dir ? '.' : file.slice(dir.length + 1);
22
+ const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
23
+ const toast = text => { $('toast').textContent = text; $('toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('toast').hidden = true, 3000); };
24
+ const requireConnection = () => { if (!state.connected) { openPair(); return false; } return true; };
25
+ const mobileMedia = matchMedia('(max-width: 700px)');
26
+ const isMobile = () => mobileMedia.matches;
27
+ function setMobileView(view, {push = false, replace = false} = {}) {
28
+ if (!['home','workspace','file','agent'].includes(view)) view = 'home';
29
+ const changed = state.mobileView !== view;
30
+ state.mobileView = view; document.body.dataset.mobileView = view;
31
+ $('workspaceHeading').textContent = view === 'home' ? 'Workspace' : state.workspace?.label || 'Workspace';
32
+ $('mobileAgentActions').closest('.toolbar')?.classList.remove('actions-open');
33
+ if (!isMobile()) return;
34
+ const historyState = {...(history.state || {}), rpView:view};
35
+ if (replace) history.replaceState(historyState, '');
36
+ else if (push && (changed || history.state?.rpView !== view)) history.pushState(historyState, '');
37
+ }
38
+ const navigateMobile = view => { if (isMobile()) setMobileView(view, {push:true}); };
39
+ const mobileBack = fallback => { if (isMobile() && history.state?.rpView === state.mobileView && state.mobileView !== 'home') history.back(); else setMobileView(fallback, {replace:true}); };
40
+
41
+ async function request(base, token, url, options = {}, disconnectOnUnauthorized = false) {
42
+ if (!token) throw Error('请先输入配对码');
43
+ const headers = {Authorization: `Bearer ${token}`, ...(options.body ? {'Content-Type':'application/json'} : {}), ...options.headers};
44
+ const response = await fetch(base + url, {...options, headers});
45
+ const body = await response.json().catch(() => ({}));
46
+ if (!response.ok) {
47
+ if (disconnectOnUnauthorized && response.status === 401) disconnect('配对码无效或已失效');
48
+ throw Error(body.error?.message || `HTTP ${response.status}`);
49
+ }
50
+ return body.data;
51
+ }
52
+ async function api(url, options = {}) { return request(state.base, state.token, url, options, true); }
53
+ const post = (url, body = {}) => api(url, {method:'POST', body:JSON.stringify(body)});
54
+
55
+ function openPair() {
56
+ if($('configDialog').open)$('configDialog').close();
57
+ $('pairBase').value = state.base;
58
+ $('pairToken').value = localStorage.rpToken || '';
59
+ if (!$('pairDialog').open) $('pairDialog').showModal();
60
+ }
61
+ async function connect(base, token) {
62
+ const candidateBase=base.trim().replace(/\/$/, '')||location.origin,candidateToken=token.trim();
63
+ const status=await request(candidateBase,candidateToken,'/api/v1/system/status');
64
+ if(status.protocolVersion!==1)throw Error(`不支持的协议版本 ${status.protocolVersion}(需要 1)`);
65
+ disconnect();state.base=candidateBase;state.token=candidateToken;state.connected=true;localStorage.rpBase=state.base;$('api').value=state.base;
66
+ $('status').className='ok';$('status').textContent='已配对';$('serverInfo').textContent=`v${status.version} · Pi ${status.piVersion}`;
67
+ state.voiceEnabled=!!(status.voiceCapabilities?.tts??status.voiceEnabled);state.voiceSttEnabled=!!(status.voiceCapabilities?.stt??status.voiceEnabled);$('voicePlayback').hidden=!state.voiceEnabled;$('voiceInput').hidden=!state.voiceSttEnabled;updateVoiceButton();
68
+ await loadMailNotificationSettings(status);updateConfigUi();await refreshWs();await refreshAgents(true);setMobileView('home',{replace:true});
69
+ if(localStorage.rpToken!==state.token){if(confirm('是否将配对码保存到浏览器本地存储?\n\n请仅在可信设备上保存。'))localStorage.rpToken=state.token;else localStorage.removeItem('rpToken');}
70
+ $('pairDialog').close();
71
+ }
72
+ function disconnect(reason = '未配对') {
73
+ state.connected = false; state.token = ''; state.manuallyClosed = true;state.voiceEnabled=false;state.voiceSttEnabled=false;state.mailNotificationsAvailable=false;$('voicePlayback').hidden=true;$('voiceInput').hidden=true;
74
+ clearTimeout(state.reconnectTimer); state.ws?.close(); state.ws = null; state.terminalWs?.close(); state.terminalWs = null; if(state.mediaRecorder?.state==='recording')state.mediaRecorder.stop();stopVoiceAudio();
75
+ $('status').className = 'bad'; $('status').textContent = reason; $('serverInfo').textContent = '';updateConfigUi();
76
+ }
77
+
78
+ async function refreshWs() {
79
+ if (!requireConnection()) return;
80
+ state.workspaces = await api('/api/v1/workspaces');
81
+ $('workspaces').replaceChildren(...state.workspaces.map(workspace => {
82
+ const option = document.createElement('option'); option.value = workspace.id;
83
+ option.textContent = `${workspace.label} (${workspace.rootPath})`; return option;
84
+ }));
85
+ renderMobileWorkspaces();
86
+ const wanted = state.workspace?.id || localStorage.rpWorkspaceId;
87
+ if (state.workspaces.length) {
88
+ $('workspaces').value = state.workspaces.some(x => x.id === wanted) ? wanted : state.workspaces[0].id;
89
+ await selectWorkspace();
90
+ } else {
91
+ state.workspace = null; $('tree').innerHTML = '<div class="muted">请添加 Workspace</div>';
92
+ }
93
+ }
94
+ function renderMobileWorkspaces() {
95
+ $('mobileWorkspaces').replaceChildren(...state.workspaces.map(workspace => {
96
+ const button=document.createElement('button');button.type='button';button.className='workspace-card';
97
+ const name=document.createElement('b'),path=document.createElement('small');name.textContent=workspace.label;path.textContent=workspace.rootPath;button.append(name,path);
98
+ button.onclick=async()=>{try{$('workspaces').value=workspace.id;await selectWorkspace();navigateMobile('workspace');}catch(error){toast(error.message);}};return button;
99
+ }));
100
+ }
101
+ async function selectWorkspace() {
102
+ state.workspace = state.workspaces.find(x => x.id === $('workspaces').value);
103
+ if (!state.workspace) return;
104
+ localStorage.rpWorkspaceId = state.workspace.id; state.treePath = '.'; $('agentCwd').value = '.'; $('filePanel').hidden = true;
105
+ $('workspaceHeading').textContent = isMobile() && state.mobileView !== 'home' ? state.workspace.label : 'Workspace';
106
+ await loadTree();
107
+ }
108
+ async function loadTree() {
109
+ if (!state.workspace) return;
110
+ $('treePath').textContent = absolutePath(state.treePath);
111
+ try {
112
+ const items = await api(`/api/v1/workspaces/${state.workspace.id}/tree?path=${encodeURIComponent(state.treePath)}`);
113
+ $('tree').replaceChildren(...items.map(item => {
114
+ const el = document.createElement('div'); el.className = `entry ${item.type === 'directory' ? 'dir' : 'file-entry'}`;
115
+ el.textContent = `${item.type === 'directory' ? '📁' : '📄'} ${item.name}`;
116
+ const relativePath = joinPath(state.treePath, item.name);
117
+ el.onclick = () => item.type === 'directory' ? openDirectory(relativePath) : openFile(relativePath, 0);
118
+ const contextTarget={relativePath,type:item.type};el.oncontextmenu=event=>showContextMenu(event,contextTarget);enableLongPressMenu(el,event=>showContextMenu(event,contextTarget));return el;
119
+ }));
120
+ } catch (error) { $('tree').textContent = error.message; }
121
+ }
122
+ async function openDirectory(relativePath) { state.treePath = relativePath; $('filePanel').hidden = true; await loadTree(); }
123
+ async function openFile(relativePath, offset, showView = true) {
124
+ try {
125
+ const file = await api(`/api/v1/workspaces/${state.workspace.id}/file?path=${encodeURIComponent(relativePath)}&offset=${offset}&limit=${state.fileLimit}`);
126
+ state.filePath = relativePath; state.fileOffset = file.offset; state.fileSize = file.size;
127
+ $('filePanel').hidden = false; $('fileName').textContent = relativePath;
128
+ $('file').textContent = file.binary ? `[二进制文件,${file.size} bytes,无法预览]` : file.content;
129
+ $('filePage').textContent = `${file.offset.toLocaleString()}–${Math.min(file.offset + file.limit, file.size).toLocaleString()} / ${file.size.toLocaleString()} bytes`;
130
+ $('filePrev').disabled = file.offset <= 0; $('fileNext').disabled = file.binary || file.offset + file.limit >= file.size;
131
+ if (showView) navigateMobile('file');
132
+ } catch (error) { toast(error.message); }
133
+ }
134
+ function showContextMenu(event, target) {
135
+ event.preventDefault(); state.contextTarget = target; const isDirectory = target.type === 'directory'; $('menuStartAgent').hidden = !isDirectory; $('menuSetCwd').hidden = !isDirectory; $('menuOpenTerminal').hidden = !isDirectory;
136
+ const menu = $('contextMenu'); menu.hidden = false; const margin=8;menu.style.left=`${Math.max(margin,Math.min(event.clientX,innerWidth-menu.offsetWidth-margin))}px`;menu.style.top=`${Math.max(margin,Math.min(event.clientY,innerHeight-menu.offsetHeight-margin))}px`;
137
+ }
138
+ function enableLongPressMenu(element, openMenu) {
139
+ let timer=null,pointerId=null,startX=0,startY=0,suppressClickUntil=0;
140
+ const cancel=()=>{if(timer!==null)clearTimeout(timer);timer=null;pointerId=null;element.classList.remove('long-press-pending');};
141
+ element.classList.add('long-press-menu');
142
+ element.addEventListener('pointerdown',event=>{
143
+ if(!isMobile()||event.pointerType==='mouse'||event.button!==0||!event.isPrimary)return;
144
+ cancel();pointerId=event.pointerId;startX=event.clientX;startY=event.clientY;element.classList.add('long-press-pending');
145
+ timer=setTimeout(()=>{timer=null;suppressClickUntil=Date.now()+700;element.classList.remove('long-press-pending');navigator.vibrate?.(15);openMenu({preventDefault(){},clientX:startX,clientY:startY});},550);
146
+ });
147
+ element.addEventListener('pointermove',event=>{if(event.pointerId===pointerId&&Math.hypot(event.clientX-startX,event.clientY-startY)>10)cancel();});
148
+ element.addEventListener('pointerup',event=>{if(event.pointerId===pointerId)cancel();});
149
+ element.addEventListener('pointercancel',cancel);element.addEventListener('pointerleave',event=>{if(event.pointerId===pointerId)cancel();});
150
+ element.addEventListener('click',event=>{if(Date.now()<suppressClickUntil){event.preventDefault();event.stopImmediatePropagation();}},true);
151
+ }
152
+ function mentionContext() {
153
+ const input=$('input'),end=input.selectionStart,before=input.value.slice(0,end),match=before.match(/(^|\s)@(?:"([^"]*)"?|([^\s]*))$/);
154
+ if(!match)return null;const token=match[0].slice(match[1]?.length||0),start=end-token.length,replacementEnd=end+(token.startsWith('@"')&&input.value[end]==='"'?1:0);return {start,end,replacementEnd,query:match[2]??match[3]??''};
155
+ }
156
+ function mentionValue(relativePath, directory=false) {
157
+ const target=absolutePath(relativePath),cwd=clean(state.agent?.cwd||absolutePath($('agentCwd').value.trim()||'.'));
158
+ let value=isInside(target,cwd)?relativeTo(target,cwd):target;if(directory&&value!=='.')value+='/';return value;
159
+ }
160
+ function writeMention(relativePath,{directory=false,close=true}={}) {
161
+ if(!state.workspace)return;const input=$('input'),context=mentionContext(),from=state.mentionStart??context?.start??input.selectionStart,to=state.mentionEnd??context?.replacementEnd??input.selectionStart;
162
+ const value=mentionValue(relativePath,directory),quoted=value.includes(' '),mention=`@${quoted?`"${value}"`:value}`;input.setRangeText(`${mention}${close?' ':''}`,from,to,'end');state.mentionStart=from;state.mentionEnd=from+mention.length;if(directory&&!close&&quoted)input.setSelectionRange(state.mentionEnd-1,state.mentionEnd-1);input.focus();if(close)closeMention();
163
+ }
164
+ function insertMention(relativePath) { state.mentionStart=null;state.mentionEnd=null;writeMention(relativePath); }
165
+ function setMentionSelection(index) {
166
+ const count=state.mentionFiltered.length;if(!count){state.mentionIndex=0;return;}state.mentionIndex=(index+count)%count;
167
+ $('mentionItems').querySelectorAll('.picker-item').forEach((element,i)=>element.classList.toggle('selected',i===state.mentionIndex));
168
+ $('mentionItems').querySelector('.picker-item.selected')?.scrollIntoView({block:'nearest'});
169
+ }
170
+ function browseMention(path) { writeMention(path,{directory:true,close:false});state.mentionPrefix=mentionValue(path,true);void openMention(path,''); }
171
+ function chooseMention(option,close) {
172
+ if(!option)return;if(option.type==='directory'&&!close&&!option.current){browseMention(option.path);return;}
173
+ writeMention(option.path,{close});if(!close)state.mentionPrefix='';
174
+ }
175
+ function renderMentionItems(filter='') {
176
+ const query=filter.toLocaleLowerCase(),options=state.mentionOptions.filter(option=>!query||(!option.current&&(option.name.toLocaleLowerCase().includes(query)||option.path.toLocaleLowerCase().includes(query))));
177
+ state.mentionFiltered=options;state.mentionIndex=0;
178
+ if(!options.length){const empty=document.createElement('div');empty.className='picker-empty muted';empty.textContent='没有匹配的文件或目录';$('mentionItems').replaceChildren(empty);return;}
179
+ $('mentionItems').replaceChildren(...options.map((option,index)=>{const el=document.createElement('div');el.className=`picker-item ${option.type==='directory'?'dir':'file-entry'}${index===0?' selected':''}`;el.textContent=option.label;el.onmouseenter=()=>setMentionSelection(index);el.onclick=()=>chooseMention(option,option.type!=='directory'||option.current);return el;}));
180
+ }
181
+ async function openMention(path=state.treePath,filter='') {
182
+ if(!state.workspace)return;const request=++state.mentionRequest;state.mentionPath=path;$('mentionPicker').hidden=false;$('mentionPath').textContent=absolutePath(path);
183
+ try {
184
+ const items=await api(`/api/v1/workspaces/${state.workspace.id}/tree?path=${encodeURIComponent(path)}`);if(request!==state.mentionRequest)return;
185
+ state.mentionOptions=[{name:'引用当前目录',path,type:'directory',current:true,label:'📁 引用当前目录'},...items.map(item=>({name:item.name,path:joinPath(path,item.name),type:item.type,current:false,label:`${item.type==='directory'?'📁':'📄'} ${item.name}`}))];renderMentionItems(filter);
186
+ } catch(error){if(request===state.mentionRequest)$('mentionItems').textContent=error.message;}
187
+ }
188
+ function closeMention(){state.mentionRequest++;state.mentionStart=null;state.mentionEnd=null;state.mentionPrefix='';$('mentionPicker').hidden=true;}
189
+
190
+ function agentName(agent) { return agent.sessionName || agent.name; }
191
+ function agentLabel(agent) { return agentName(agent) || agent.cwd || agent.agentId; }
192
+ function agentSubtitle(agent) { return agentName(agent) ? agent.cwd : agent.agentId; }
193
+ const formatTokens = value => { const n=Number(value); if(!Number.isFinite(n)||n<=0)return '0'; if(n>=1e6)return `${(n/1e6).toFixed(1)}M`;if(n>=1e3)return `${(n/1e3).toFixed(n>=1e4?0:1)}k`;return String(Math.round(n)); };
194
+ const usageLevel = usage => Number(usage?.percent)>90?'usage-danger':Number(usage?.percent)>70?'usage-warning':'';
195
+ const usageText = usage => { if(!usage||!Number(usage.contextWindow))return 'Context ?';const percent=usage.percent==null?'?':`${Number(usage.percent).toFixed(1)}%`;return `${percent} · ${formatTokens(usage.tokens)}/${formatTokens(usage.contextWindow)}`; };
196
+ const costText = usage => { const cost=Number(usage?.cost);if(!Number.isFinite(cost))return 'Session cost ?';return `Session cost ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`; };
197
+ const sessionUsage = session => ({...(session.contextUsage||{}),cost:session.stats?.cost});
198
+ function renderAgentList() {
199
+ const recent=(value,fallback)=>{const time=Date.parse(value||fallback||'');return Number.isFinite(time)?time:0;};
200
+ const allItems=[...state.agents.map(item=>({kind:'agent',item})),...state.terminals.map(item=>({kind:'terminal',item}))].sort((a,b)=>recent(b.item.lastActiveAt,b.item.createdAt)-recent(a.item.lastActiveAt,a.item.createdAt));
201
+ const items=allItems.slice(0,state.agentVisibleCount);
202
+ $('agents').replaceChildren(...items.map(({kind,item}) => {
203
+ const isAgent=kind==='agent',id=isAgent?item.agentId:item.terminalId,selected=state.selectedKind===kind&&(isAgent?state.agent?.agentId:state.terminal?.terminalId)===id;
204
+ const el=document.createElement('div');el.dataset.itemId=id;el.className=`agent ${item.status}${selected?' selected':''}`;
205
+ if(isAgent){const usage=state.contexts.get(item.agentId);el.innerHTML=`<div class="agent-top"><b>${esc(agentLabel(item))}</b><span class="state-badge state-${esc(item.status)}">${esc(item.status)}</span></div><small>${esc(agentSubtitle(item))}</small><div class="agent-context ${usageLevel(usage)}"><span>Context</span><div class="mini-track"><i style="width:${Math.min(100,Math.max(0,Number(usage?.percent)||0))}%"></i></div><span>${esc(usageText(usage))} · ${esc(costText(usage))}</span></div>`;}
206
+ else el.innerHTML=`<div class="agent-top"><span class="terminal-kind">&gt;_</span><b>${esc(item.title||'Terminal')}</b><span class="state-badge state-${esc(item.status)}">${esc(item.status)}</span></div><small>${esc(item.cwd)}</small>`;
207
+ el.onclick=()=>isAgent?selectAgent(item):selectTerminal(item);el.oncontextmenu=event=>showItemContextMenu(event,kind,item);return el;
208
+ }));
209
+ $('agentListCount').textContent=`已显示 ${items.length} / ${allItems.length}`;$('loadMoreAgents').hidden=items.length>=allItems.length;$('loadMoreAgents').disabled=items.length>=allItems.length;
210
+ }
211
+ async function loadAgentContexts(agents=state.agents) {
212
+ await Promise.allSettled(agents.map(async agent=>{const session=await api(`/api/v1/agents/${agent.agentId}/session?summary=true`);agent.sessionName=session.sessionName||undefined;let lastMessageAt=0;for(const entry of session.entries||[]){if(entry.type!=='message')continue;const time=Date.parse(entry.timestamp||'');if(Number.isFinite(time))lastMessageAt=Math.max(lastMessageAt,time);}if(lastMessageAt)agent.lastActiveAt=new Date(lastMessageAt).toISOString();state.contexts.set(agent.agentId,sessionUsage(session));}));
213
+ renderAgentList();if(state.agent)updateAgentHeader();
214
+ }
215
+ async function refreshAgents(selectPrevious = false) {
216
+ if (!state.connected) return;
217
+ [state.agents,state.terminals] = await Promise.all([api('/api/v1/agents'),api('/api/v1/terminals')]); const previous = state.agent?.agentId || localStorage.rpAgentId,previousTerminal=state.terminal?.terminalId||localStorage.rpTerminalId;
218
+ if(state.agent){const fresh=state.agents.find(x=>x.agentId===state.agent.agentId);if(fresh)state.agent=Object.assign(state.agent,fresh);}
219
+ renderAgentList();
220
+ if (selectPrevious) { const foundTerminal=state.terminals.find(x=>x.terminalId===previousTerminal),found = state.agents.find(x => x.agentId === previous); if(localStorage.rpSelectedKind==='terminal'&&foundTerminal)await selectTerminal(foundTerminal,false);else if (found) await selectAgent(found, false); else connectSocket(); }
221
+ else if (!state.ws) connectSocket();
222
+ }
223
+ function setAgentStatus(agentId,status) {
224
+ const agent=state.agents.find(x=>x.agentId===agentId);if(!agent){void refreshAgents();return null;}agent.status=status;if(state.agent?.agentId===agentId)state.agent.status=status;renderAgentList();updateAgentHeader();return agent;
225
+ }
226
+ function updateAgentHeader(agent = state.agent) {
227
+ if (!agent) return;
228
+ $('agentTitle').textContent = agentLabel(agent); $('agentStatus').textContent = agentSubtitle(agent);
229
+ const badge=$('agentStateBadge');badge.textContent=agent.status;badge.className=`state-badge state-${agent.status}`;
230
+ const usage=state.contexts.get(agent.agentId),panel=$('contextUsage');panel.hidden=false;$('contextText').textContent=`Context ${usageText(usage)} · ${costText(usage)}`;const percent=Math.min(100,Math.max(0,Number(usage?.percent)||0));$('contextBar').style.width=`${percent}%`;panel.className=`context-usage ${usageLevel(usage)}`;
231
+ }
232
+ function showAgentView(){
233
+ $('agentToolbar').hidden=false;$('terminalToolbar').hidden=true;$('terminalView').hidden=true;$('widgets').hidden=false;$('messages').hidden=false;$('prompt').hidden=false;
234
+ }
235
+ function ensureTerminalEmulator(){
236
+ if(state.terminalEmulator)return;
237
+ const term=new Terminal({cursorBlink:true,convertEol:false,fontFamily:'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',fontSize:14,scrollback:5000,theme:{background:'#111111',foreground:'#d8dee9',cursor:'#88c0d0'}}),fit=new FitAddon.FitAddon();
238
+ term.loadAddon(fit);term.open($('terminal'));term.onData(data=>{if(state.terminalWs?.readyState===WebSocket.OPEN)state.terminalWs.send(JSON.stringify({type:'input',data}));});
239
+ term.onResize(size=>{if(state.terminalWs?.readyState===WebSocket.OPEN)state.terminalWs.send(JSON.stringify({type:'resize',cols:size.cols,rows:size.rows}));});
240
+ state.resizeObserver=new ResizeObserver(()=>{if(!$('terminalView').hidden)try{fit.fit();}catch{}});state.resizeObserver.observe($('terminalView'));state.terminalEmulator=term;state.fitAddon=fit;
241
+ }
242
+ function connectTerminalSocket(terminal){
243
+ state.terminalWs?.close();ensureTerminalEmulator();const term=state.terminalEmulator;term.reset();term.clear();
244
+ const ws=new WebSocket(state.base.replace(/^http/,'ws')+`/api/v1/terminals/${encodeURIComponent(terminal.terminalId)}/ws`,['access-token.'+state.token]);state.terminalWs=ws;
245
+ ws.onopen=()=>{if(ws!==state.terminalWs)return;requestAnimationFrame(()=>{try{state.fitAddon.fit();ws.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}));term.focus();}catch{}});};
246
+ ws.onmessage=event=>{if(ws!==state.terminalWs)return;try{const message=JSON.parse(event.data);if(message.type==='snapshot'){term.reset();if(message.data)term.write(message.data);terminal.status=message.record.status;updateTerminalHeader();renderAgentList();}else if(message.type==='output')term.write(message.data);else if(message.type==='exit'){terminal.status='exited';terminal.exitCode=message.exitCode;updateTerminalHeader();renderAgentList();void refreshAgents();}else if(message.type==='error')toast(message.message);}catch(error){console.error(error);}};
247
+ ws.onerror=()=>{};ws.onclose=()=>{if(ws===state.terminalWs&&terminal.status==='running')$('terminalStatus').textContent=`${terminal.cwd} · 连接已断开`;};
248
+ }
249
+ function updateTerminalHeader(){const terminal=state.terminal;if(!terminal)return;$('terminalTitle').textContent=terminal.title||'Terminal';$('terminalStatus').textContent=`${terminal.cwd}${terminal.exitCode===undefined?'':` · exit ${terminal.exitCode}`}`;const badge=$('terminalStateBadge');badge.textContent=terminal.status;badge.className=`state-badge state-${terminal.status}`;}
250
+ async function selectTerminal(terminal,openView=true){
251
+ state.selectedKind='terminal';state.terminal=terminal;localStorage.rpTerminalId=terminal.terminalId;localStorage.rpSelectedKind='terminal';$('agentToolbar').hidden=true;$('terminalToolbar').hidden=false;$('terminalView').hidden=false;$('widgets').hidden=true;$('messages').hidden=true;$('prompt').hidden=true;updateTerminalHeader();renderAgentList();if(openView)navigateMobile('agent');connectTerminalSocket(terminal);
252
+ }
253
+ async function startAgent(relativePath){if(!state.workspace)return toast('请先选择 Workspace');$('contextMenu').hidden=true;try{const agent=await post('/api/v1/agents',{workspaceId:state.workspace.id,relativeCwd:relativePath});$('agentCwd').value=relativePath;await refreshAgents();await selectAgent(agent);}catch(error){toast(error.message);}}
254
+ async function openTerminal(relativePath){if(!state.workspace)return toast('请先选择 Workspace');try{const terminal=await post('/api/v1/terminals',{workspaceId:state.workspace.id,relativeCwd:relativePath});state.terminals.unshift(terminal);$('contextMenu').hidden=true;renderAgentList();await selectTerminal(terminal);}catch(error){toast(error.message);}}
255
+ async function closeTerminal(terminal=state.terminal){if(!terminal)return;try{await api(`/api/v1/terminals/${terminal.terminalId}`,{method:'DELETE'});state.terminalWs?.close();state.terminalWs=null;state.terminals=state.terminals.filter(x=>x.terminalId!==terminal.terminalId);if(state.terminal?.terminalId===terminal.terminalId){state.terminal=null;localStorage.removeItem('rpTerminalId');localStorage.rpSelectedKind='agent';showAgentView();}renderAgentList();toast('Terminal 已关闭');}catch(error){toast(error.message);}}
256
+ async function selectAgent(agent, openView = true) {
257
+ state.selectedKind='agent';state.terminalWs?.close();state.terminalWs=null;state.agent = agent; localStorage.rpAgentId = agent.agentId;localStorage.rpSelectedKind='agent';showAgentView();updateAgentHeader();
258
+ if (openView) navigateMobile('agent');
259
+ if (state.workspace && isInside(clean(agent.cwd), clean(state.workspace.rootPath))) $('agentCwd').value = relativeTo(clean(agent.cwd), clean(state.workspace.rootPath));
260
+ $('messages').replaceChildren(); state.streams.clear();state.messagePageStart=0;state.messageTotal=0;
261
+ try { await loadMessagePage(agent.agentId,undefined,true); await loadSessionIdentity(); } catch (error) { addCard('加载消息失败', error.message, 'error', true); }
262
+ renderAgentList();
263
+ connectSocket();
264
+ }
265
+ async function loadSessionIdentity() {
266
+ if (!state.agent) return;
267
+ const session = await api(`/api/v1/agents/${state.agent.agentId}/session?summary=true`);
268
+ state.agent.sessionName=session.sessionName||undefined;state.contexts.set(state.agent.agentId,sessionUsage(session));renderAgentList();updateAgentHeader();
269
+ }
270
+
271
+ function textContent(content) {
272
+ if (typeof content === 'string') return content;
273
+ if (!Array.isArray(content)) return '';
274
+ return content.map(part => part?.type === 'text' ? part.text : part?.type === 'thinking' ? part.thinking || part.text : part?.type === 'toolCall' ? `${part.name || 'Tool'}\n${JSON.stringify(part.arguments || {}, null, 2)}` : '').filter(Boolean).join('\n');
275
+ }
276
+ function renderCardBody(body, content, markdown = false) {
277
+ const value=String(content??'');body.classList.toggle('markdown',markdown);
278
+ if(markdown&&window.marked&&window.DOMPurify){body.innerHTML=DOMPurify.sanitize(marked.parse(value,{gfm:true,breaks:true}));for(const link of body.querySelectorAll('a')){link.target='_blank';link.rel='noopener noreferrer';}}
279
+ else body.textContent=value;
280
+ }
281
+ function addCard(title, content, kind = 'system', open = false, timestamp, target = $('messages'), autoScroll = true) {
282
+ const derivedTitle=!title,details=document.createElement('details'); details.className = `msg ${kind}${derivedTitle?' derived-title':''}`; details.open = open;
283
+ const summary=document.createElement('summary'),summaryText=document.createElement('span'),summaryLabel=document.createElement('span');summaryText.className='summary-text';summaryText.textContent=title||String(content).trim()||kind;summaryLabel.className='summary-label';summaryLabel.textContent=kind==='user'?'You':kind==='assistant'?'Assistant':kind;summary.append(summaryText,summaryLabel);
284
+ if (timestamp) { const time = document.createElement('span'); time.className = 'event-time'; time.textContent = new Date(timestamp * 1000).toLocaleTimeString(); summary.append(time); }
285
+ const body = document.createElement('div'); body.className = 'msg-content';renderCardBody(body,content,kind==='assistant');
286
+ details.append(summary, body); target.append(details); if(autoScroll&&target===$('messages'))$('messages').scrollTop = $('messages').scrollHeight; return {details, summary, summaryText, body, content:String(content??'')};
287
+ }
288
+ function renderMessages(messages, target = $('messages'), showEmpty = true) {
289
+ if (!messages?.length) { if(showEmpty&&target===$('messages'))$('messages').innerHTML = '<div class="empty">尚无消息</div>'; return; }
290
+ for (const message of messages) {
291
+ const role = message.role || 'event';
292
+ if (Array.isArray(message.content) && role === 'assistant') {
293
+ for (const part of message.content) {
294
+ if (part.type === 'text' && part.text) addCard(null,part.text,'assistant',false,undefined,target,false);
295
+ else if (part.type === 'thinking' && (part.thinking||part.text)) addCard('Thinking',part.thinking||part.text,'thinking',false,undefined,target,false);
296
+ else if (part.type === 'toolCall') addCard(`🔧 ${part.name||'Tool'}`,JSON.stringify(part.arguments||{},null,2),'tool',false,undefined,target,false);
297
+ }
298
+ } else { const content = textContent(message.content); if (content) addCard(['user','assistant'].includes(role)?null:role, content, role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role === 'toolResult' ? 'tool' : 'system',false,undefined,target,false); }
299
+ }
300
+ if(target===$('messages'))$('messages').scrollTop=$('messages').scrollHeight;
301
+ }
302
+ function updateMessageHistoryControl() {
303
+ $('messageHistoryControls')?.remove();if(state.messagePageStart<=0)return;
304
+ const controls=document.createElement('div');controls.id='messageHistoryControls';controls.className='message-history-controls';
305
+ const older=document.createElement('button');older.id='loadOlderMessages';older.type='button';older.textContent=`加载更早的对话(还有 ${state.messagePageStart} 条)`;older.onclick=()=>state.agent&&loadMessagePage(state.agent.agentId,state.messagePageStart,false).catch(error=>toast(error.message));
306
+ const all=document.createElement('button');all.id='loadAllMessages';all.type='button';all.textContent='加载全部对话';all.onclick=()=>state.agent&&loadAllMessages(state.agent.agentId,all).catch(error=>toast(error.message));controls.append(older,all);$('messages').prepend(controls);
307
+ }
308
+ async function loadAllMessages(agentId,button) {
309
+ button.disabled=true;button.textContent='正在加载全部对话…';
310
+ try { const messages=await api(`/api/v1/agents/${agentId}/messages`);if(state.agent?.agentId!==agentId)return;const box=$('messages');box.replaceChildren();renderMessages(messages);state.messagePageStart=0;state.messageTotal=messages.length;updateMessageHistoryControl(); }
311
+ finally { button.disabled=false;button.textContent='加载全部对话'; }
312
+ }
313
+ async function loadMessagePage(agentId,before,reset) {
314
+ const query=new URLSearchParams({limit:String(state.messagePageSize)});if(before!==undefined)query.set('before',String(before));
315
+ const page=await api(`/api/v1/agents/${agentId}/messages?${query}`);if(state.agent?.agentId!==agentId)return;
316
+ const box=$('messages'),oldHeight=box.scrollHeight,oldTop=box.scrollTop,fragment=document.createDocumentFragment();renderMessages(page.items,fragment,false);
317
+ if(reset){box.replaceChildren(fragment);state.messageTotal=page.total;}else{box.querySelector('#messageHistoryControls')?.remove();box.prepend(fragment);}
318
+ state.messagePageStart=page.start;state.messageTotal=page.total;updateMessageHistoryControl();
319
+ if(reset)box.scrollTop=box.scrollHeight;else box.scrollTop=oldTop+(box.scrollHeight-oldHeight);
320
+ if(!page.total)box.innerHTML='<div class="empty">尚无消息</div>';
321
+ }
322
+ function ensureStream(key, title, kind) {
323
+ if (!state.streams.has(key)) state.streams.set(key, addCard(title, '', kind, true));
324
+ return state.streams.get(key);
325
+ }
326
+ function extensionRequest(agentId, ev, timestamp) {
327
+ const card = addCard(ev.title || `Extension ${ev.kind}`, ev.message || ev.placeholder || ev.prefill || '', 'system', true, timestamp);
328
+ const controls = document.createElement('div'); controls.className = 'dialog-actions';
329
+ const send = async value => { try { await post(`/api/v1/agents/${agentId}/extension-response`, {requestId:ev.requestId, value}); controls.replaceChildren(document.createTextNode('已响应')); } catch (e) { toast(e.message); } };
330
+ if (ev.kind === 'select') for (const option of ev.options || []) { const b = document.createElement('button'); b.textContent = option; b.onclick = () => send(option); controls.append(b); }
331
+ else if (ev.kind === 'confirm') { for (const [label, value] of [['否',false],['是',true]]) { const b=document.createElement('button');b.textContent=label;b.onclick=()=>send(value);controls.append(b); } }
332
+ else { const input = ev.kind === 'editor' ? document.createElement('textarea') : document.createElement('input'); input.value = ev.prefill || ''; input.placeholder = ev.placeholder || ''; const b = document.createElement('button'); b.textContent = '提交'; b.onclick = () => send(input.value); controls.append(input,b); }
333
+ card.body.append(controls);
334
+ }
335
+ function stopVoiceAudio() {
336
+ state.voiceAudio.generation++;for(const source of state.voiceAudio.sources){try{source.stop();}catch{}}state.voiceAudio.sources.clear();state.voiceAudio.nextTime=0;state.voiceAudio.playbackId=null;
337
+ }
338
+ async function ensureAudioContext() {
339
+ const AudioContext=window.AudioContext||window.webkitAudioContext;if(!AudioContext)throw Error('当前浏览器不支持音频播放');
340
+ state.voiceAudio.context??=new AudioContext();if(state.voiceAudio.context.state==='suspended')await state.voiceAudio.context.resume();return state.voiceAudio.context;
341
+ }
342
+ function playVoiceChunk(event) {
343
+ if(!state.voicePlaybackEnabled||!event.audio)return;const generation=state.voiceAudio.generation;
344
+ state.voiceAudio.decodeChain=state.voiceAudio.decodeChain.then(async()=>{
345
+ const context=await ensureAudioContext();if(generation!==state.voiceAudio.generation)return;state.voiceAudio.playbackId=event.playbackId;
346
+ const raw=atob(event.audio),bytes=Uint8Array.from(raw,c=>c.charCodeAt(0));let buffer;
347
+ if(event.encoding==='mp3')buffer=await context.decodeAudioData(bytes.buffer.slice(0));
348
+ else{const samples=new Float32Array(Math.floor(bytes.length/2));for(let i=0;i<samples.length;i++){let value=bytes[i*2]|(bytes[i*2+1]<<8);if(value&0x8000)value-=0x10000;samples[i]=value/32768;}const rate=Number(event.sampleRate||24000);buffer=context.createBuffer(1,samples.length,rate);buffer.copyToChannel(samples,0);}
349
+ if(generation!==state.voiceAudio.generation||state.voiceAudio.playbackId!==event.playbackId)return;const source=context.createBufferSource();source.buffer=buffer;source.connect(context.destination);const start=Math.max(context.currentTime+0.03,state.voiceAudio.nextTime||0);source.start(start);state.voiceAudio.nextTime=start+buffer.duration;state.voiceAudio.sources.add(source);source.onended=()=>state.voiceAudio.sources.delete(source);
350
+ }).catch(error=>toast(`语音播放失败:${error.message}`));
351
+ }
352
+ function handleVoiceEvent(message) {
353
+ const ev=message.event||{},selected=state.selectedKind==='agent'&&state.agent?.agentId===message.agentId,key=`voice-${ev.playbackId||message.agentId}`;
354
+ if(ev.type==='voice_start'){if(state.voicePlaybackEnabled)void ensureAudioContext().catch(()=>{});if(selected){const c=ensureStream(key,'语音摘要','system');c.content='';renderCardBody(c.body,'正在生成语音摘要…',false);}}
355
+ else if(ev.type==='voice_summary_delta'){if(selected){const c=ensureStream(key,'语音摘要','system');c.content+=ev.text||'';renderCardBody(c.body,c.content,false);c.summaryText.textContent='语音摘要';}}
356
+ else if(ev.type==='voice_audio_chunk')playVoiceChunk(ev);
357
+ else if(ev.type==='voice_cancelled'){stopVoiceAudio();}
358
+ else if(ev.type==='voice_error'){if(selected)addCard('语音服务',ev.message||'语音生成失败','error',true);}
359
+ else if(ev.type==='voice_end'){if(selected)state.streams.delete(key);}
360
+ }
361
+ function updateVoiceButton(){const button=$('voicePlayback');button.textContent=state.voicePlaybackEnabled?'语音已启用':'启用语音';button.title=state.voicePlaybackEnabled?'点击关闭 Agent 语音摘要':'点击启用 Agent 语音摘要播放';}
362
+ async function toggleVoicePlayback(){state.voicePlaybackEnabled=!state.voicePlaybackEnabled;localStorage.rpVoicePlayback=String(state.voicePlaybackEnabled);updateVoiceButton();if(state.voicePlaybackEnabled){try{await ensureAudioContext();toast('Agent 完成后将播放语音摘要');}catch(error){state.voicePlaybackEnabled=false;localStorage.rpVoicePlayback='false';updateVoiceButton();toast(error.message);}}else stopVoiceAudio();}
363
+ async function toggleVoiceInput(){
364
+ if(!state.voiceSttEnabled)return toast('服务端未启用语音识别');if(!state.agent)return toast('请先选择 Agent');
365
+ if(state.mediaRecorder&&state.mediaRecorder.state==='recording'){state.mediaRecorder.stop();return;}
366
+ if(!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder)return toast('当前浏览器不支持麦克风录音');
367
+ try{
368
+ const stream=await navigator.mediaDevices.getUserMedia({audio:true}),preferred=['audio/webm;codecs=opus','audio/webm','audio/mp4'].find(type=>MediaRecorder.isTypeSupported(type));state.mediaStream=stream;state.mediaChunks=[];state.mediaRecorder=new MediaRecorder(stream,preferred?{mimeType:preferred}:undefined);
369
+ state.mediaRecorder.ondataavailable=event=>{if(event.data.size)state.mediaChunks.push(event.data)};
370
+ state.mediaAgentId=state.agent.agentId;state.mediaRecorder.onstop=()=>void transcribeVoice();state.mediaRecorder.start(250);$('voiceInput').textContent='■';$('voiceInput').title='停止并识别';$('voiceInput').classList.add('recording');state.mediaTimer=setTimeout(()=>state.mediaRecorder?.state==='recording'&&state.mediaRecorder.stop(),60000);
371
+ }catch(error){toast(`无法录音:${error.message}`);}
372
+ }
373
+ async function transcribeVoice(){
374
+ clearTimeout(state.mediaTimer);const recorder=state.mediaRecorder,mime=recorder?.mimeType||'audio/webm',blob=new Blob(state.mediaChunks,{type:mime}),agentId=state.mediaAgentId;state.mediaStream?.getTracks().forEach(track=>track.stop());state.mediaRecorder=null;state.mediaStream=null;state.mediaChunks=[];state.mediaAgentId=null;$('voiceInput').textContent='…';$('voiceInput').classList.remove('recording');
375
+ if(!agentId){$('voiceInput').textContent='🎙';return;}try{const response=await fetch(state.base+`/api/v1/agents/${agentId}/transcribe`,{method:'POST',headers:{Authorization:`Bearer ${state.token}`,'Content-Type':mime,'X-Audio-Filename':mime.includes('mp4')?'recording.m4a':'recording.webm'},body:blob});const body=await response.json().catch(()=>({}));if(!response.ok)throw Error(body.error?.message||`HTTP ${response.status}`);const text=body.data?.text?.trim();if(text){const input=$('input'),prefix=input.value&& !/\s$/.test(input.value)?' ':'';input.value+=prefix+text;input.focus();toast('语音已转换为文字');}else toast('没有识别到语音');}catch(error){toast(`语音识别失败:${error.message}`);}finally{$('voiceInput').textContent='🎙';$('voiceInput').title='开始语音输入';}
376
+ }
377
+ function handleAgentEvent(message) {
378
+ if(message.type==='voice_event'){handleVoiceEvent(message);return;}
379
+ if (message.type === 'subscribed') {
380
+ const key = `rpSeq:${message.agentId}`, saved = Number(localStorage[key] || 0), current = Number(message.currentSequence || 0);
381
+ // Agent event sequences restart when the server restores an Agent. A cursor
382
+ // from the previous process must not cause every new event to be discarded.
383
+ if (saved > current) localStorage[key] = current;
384
+ return;
385
+ }
386
+ if (message.type === 'agent_snapshot') {
387
+ localStorage[`rpSeq:${message.agentId}`] = message.lastSequence; if (state.agent?.agentId === message.agentId) { $('messages').replaceChildren(); renderMessages(message.messages);state.messagePageStart=message.messagePage?.start||0;state.messageTotal=message.messagePage?.total??message.messages?.length??0;updateMessageHistoryControl(); } return;
388
+ }
389
+ if (message.type !== 'agent_event') return;
390
+ const key = `rpSeq:${message.agentId}`, last = Number(localStorage[key] || 0); if (message.sequence <= last) return; localStorage[key] = message.sequence;
391
+ const ev = message.event || {}, selected = state.selectedKind==='agent'&&state.agent?.agentId === message.agentId;
392
+ let eventAgent=state.agents.find(x=>x.agentId===message.agentId);
393
+ if(eventAgent&&Number(message.timestamp)>0)eventAgent.lastActiveAt=new Date(Number(message.timestamp)*1000).toISOString();
394
+ if (ev.type === 'agent_start' || ev.type === 'auto_retry_start') eventAgent=setAgentStatus(message.agentId,'streaming')||eventAgent;
395
+ else if (ev.type === 'agent_end') {
396
+ const final=!ev.willRetry;eventAgent=setAgentStatus(message.agentId,final?'idle':'streaming')||eventAgent;
397
+ if(final){if(selected)state.streams.clear();notifyComplete(eventAgent||{agentId:message.agentId},message.timestamp);if(eventAgent)void loadAgentContexts([eventAgent]);}
398
+ } else if (ev.type === 'agent_settled') { eventAgent=setAgentStatus(message.agentId,'idle')||eventAgent;if(eventAgent)void loadAgentContexts([eventAgent]); }
399
+ else if (ev.type === 'session_info_changed') { if(eventAgent)eventAgent.sessionName=ev.name||undefined;if(selected)state.agent.sessionName=ev.name||undefined;renderAgentList();if(selected)updateAgentHeader(); }
400
+ if (!selected) return;
401
+ if (ev.type === 'agent_start') { $('messages').querySelector('.empty')?.remove(); }
402
+ else if (ev.type === 'message_update') {
403
+ const update = ev.assistantMessageEvent || {};
404
+ if (update.type === 'text_delta') { const c=ensureStream('assistant',null,'assistant');c.content+=update.delta||'';renderCardBody(c.body,c.content,true);c.summaryText.textContent=c.content||'Assistant'; }
405
+ else if (update.type === 'thinking_delta') {const c=ensureStream('thinking','Thinking','thinking');c.content+=update.delta||'';c.body.textContent=c.content;}
406
+ } else if (ev.type === 'tool_execution_start') addCard(`🔧 ${ev.toolName || 'Tool'}`, JSON.stringify(ev.args || {}, null, 2), 'tool', false, message.timestamp);
407
+ else if (ev.type === 'tool_execution_end') addCard(`${ev.isError ? '✗' : '✓'} ${ev.toolName || 'Tool'}`, textContent(ev.result) || (ev.isError ? '执行失败' : '执行完成'), ev.isError ? 'error' : 'tool', false, message.timestamp);
408
+ else if (ev.type === 'auto_retry_start' || ev.type === 'auto_retry_end') addCard('Retry', ev.errorMessage || ev.type, 'system', false, message.timestamp);
409
+ else if (ev.type === 'extension_ui_request') extensionRequest(message.agentId, ev, message.timestamp);
410
+ else if (ev.type === 'extension_ui_notify') addCard(ev.notificationType || '通知', ev.message, 'system', false, message.timestamp);
411
+ else if (ev.type === 'extension_ui_status') { ev.text ? state.extensionStatus.set(ev.key, ev.text) : state.extensionStatus.delete(ev.key); renderExtensionStatus(); }
412
+ else if (ev.type === 'extension_ui_widget') { ev.content ? state.widgets.set(ev.key, ev.content) : state.widgets.delete(ev.key); renderWidgets(); }
413
+ else if (ev.type === 'extension_ui_title') { state.agent.sessionName = ev.title; updateAgentHeader(); }
414
+ else if (ev.type === 'extension_ui_working_message') { ev.message ? state.extensionStatus.set('working',ev.message) : state.extensionStatus.delete('working'); renderExtensionStatus(); }
415
+ else if (!['message_start','message_end','agent_end','agent_settled','session_info_changed'].includes(ev.type)) addCard(ev.title || ev.type || 'Event', JSON.stringify(ev, null, 2), 'system', false, message.timestamp);
416
+ $('messages').scrollTop = $('messages').scrollHeight;
417
+ }
418
+ function renderExtensionStatus() { let row=$('extensionStatus'); if (!state.extensionStatus.size) { row?.remove(); return; } if(!row){row=document.createElement('div');row.id='extensionStatus';row.className='status-row';$('widgets').after(row);} row.textContent=[...state.extensionStatus.values()].join(' · '); }
419
+ function renderWidgets() { $('widgets').replaceChildren(...[...state.widgets.entries()].map(([key,value])=>{const el=document.createElement('div');el.className='widget';el.dataset.key=key;el.textContent=Array.isArray(value)?value.map(x=>typeof x==='string'?x:x.text||'').join('\n'):String(value);return el;})); }
420
+
421
+ function connectSocket() {
422
+ if (!state.connected) return;
423
+ clearTimeout(state.reconnectTimer); state.manuallyClosed = true; const previous=state.ws; state.ws=null; previous?.close(); state.manuallyClosed = false;
424
+ const ws = new WebSocket(state.base.replace(/^http/, 'ws') + '/api/v1/ws', ['access-token.' + state.token]); state.ws = ws;
425
+ ws.onopen = () => { state.reconnectAttempt = 0; $('status').textContent = '已配对 · 实时'; for (const agent of state.agents) { const key=`rpSeq:${agent.agentId}`, cursor=localStorage[key]; ws.send(JSON.stringify(cursor===undefined?{type:'subscribe',agentId:agent.agentId,fromNow:true,messageLimit:state.messagePageSize}:{type:'subscribe',agentId:agent.agentId,lastSequence:Number(cursor),messageLimit:state.messagePageSize})); } ws.send(JSON.stringify({type:'subscribe_all',fromNow:true})); };
426
+ ws.onmessage = event => { try { handleAgentEvent(JSON.parse(event.data)); } catch (error) { console.error(error); } };
427
+ ws.onclose = () => { if (!state.connected || state.manuallyClosed || ws !== state.ws) return; $('status').textContent = '正在重连…'; const delays=[1000,2000,4000,8000,15000,30000], delay=delays[Math.min(state.reconnectAttempt++,delays.length-1)]; state.reconnectTimer=setTimeout(connectSocket,delay); };
428
+ ws.onerror = () => {};
429
+ }
430
+ function notificationUnavailableReason() {
431
+ if (!window.isSecureContext) return '浏览器通知需要 HTTPS;仅 localhost 可使用 HTTP';
432
+ if (!('Notification' in window) || typeof Notification.requestPermission !== 'function') return '当前浏览器不支持系统通知';
433
+ return '';
434
+ }
435
+ function updateNotificationButton() {
436
+ const button=$('notifications'),status=$('browserNotificationStatus'),unavailable=notificationUnavailableReason();
437
+ // Keep the button clickable so unsupported/denied states can explain how to
438
+ // fix the problem instead of silently ignoring the user's click.
439
+ button.disabled=false;
440
+ if (unavailable) { button.textContent='通知不可用'; button.title=unavailable;status.textContent=unavailable; return; }
441
+ button.textContent=Notification.permission==='granted'?'通知已启用':Notification.permission==='denied'?'通知已禁用':'启用通知';
442
+ button.title=Notification.permission==='denied'?'通知权限已被浏览器阻止,点击查看处理方式':'启用所有 Agent 的完成通知';status.textContent=Notification.permission==='granted'?'Agent 完成后会显示浏览器系统通知。':Notification.permission==='denied'?'通知权限已被浏览器阻止。':'尚未授予浏览器通知权限。';
443
+ }
444
+ async function enableNotifications() {
445
+ const unavailable=notificationUnavailableReason();
446
+ if (unavailable) return toast(unavailable);
447
+ if (Notification.permission==='denied') return toast('通知权限已被阻止,请在地址栏的网站权限中允许通知后刷新页面');
448
+ try {
449
+ const permission=await Notification.requestPermission(); updateNotificationButton();
450
+ toast(permission==='granted'?'已启用所有 Agent 的完成通知':'未获得通知权限,请在浏览器的网站权限中开启');
451
+ } catch (error) {
452
+ console.error('Unable to request notification permission',error);
453
+ toast(`无法请求通知权限:${error?.message||'请检查浏览器的网站权限'}`);
454
+ }
455
+ }
456
+ function notifyComplete(agent, timestamp) {
457
+ if (!('Notification' in window) || Notification.permission !== 'granted') return;
458
+ const occurredAt=Number(timestamp)||Math.floor(Date.now()/1000);
459
+ try { new Notification('Agent 完成', {body:`${agentLabel(agent)} 已转为空闲\n本地时间:${new Date(occurredAt*1000).toLocaleString()}\nUnix 时间戳:${occurredAt}`, timestamp:occurredAt*1000, tag:`remote-pi-${agent.agentId}-${occurredAt}`}); } catch (error) { console.error('Unable to show notification',error); }
460
+ }
461
+ async function loadMailNotificationSettings(status) {
462
+ if(status?.mailNotificationsConfigured===false){state.mailNotificationsAvailable=false;return}
463
+ try{const result=await api('/api/v1/mail-notifications');state.mailNotificationsAvailable=!!result.available;if(result.settings)state.mailSettings={...state.mailSettings,...result.settings};}
464
+ catch(error){state.mailNotificationsAvailable=false;console.error('Unable to load mail notification settings',error);}
465
+ }
466
+ function updateMailControls(){
467
+ const available=state.connected&&state.mailNotificationsAvailable,settings=state.mailSettings;
468
+ $('mailNotificationEnabled').checked=!!settings.enabled;$('mailAggregationDelay').value=String(settings.aggregationDelaySeconds??0);$('mailIncludeResponse').checked=!!settings.includeResponse;$('mailIncludeSessionDetails').checked=!!settings.includeSessionDetails;
469
+ for(const id of ['mailNotificationEnabled','mailAggregationDelay','mailIncludeResponse','mailIncludeSessionDetails','configSave'])$(id).disabled=!available;
470
+ $('mailNotificationAvailability').textContent=!state.connected?'连接服务后可以配置邮件通知。':available?'MailDispatch 已在服务端配置。设置对所有 Agent 和客户端生效。':'服务端启动时未配置 MailDispatch endpoint、API key 和通知邮箱。';
471
+ }
472
+ function updateConfigUi(){
473
+ $('configConnectionStatus').textContent=state.connected?`已连接 ${state.base}`:'当前未配对';updateNotificationButton();updateVoiceButton();updateMailControls();
474
+ }
475
+ function openConfig(){updateConfigUi();if(!$('configDialog').open)$('configDialog').showModal();}
476
+ async function saveMailSettings(){
477
+ if(!requireConnection())return;if(!state.mailNotificationsAvailable)return toast('服务端未配置 MailDispatch');
478
+ const delay=Number($('mailAggregationDelay').value);if(!Number.isInteger(delay)||delay<0||delay>86400)return toast('聚合时间必须是 0 到 86400 之间的整数秒');
479
+ $('configSave').disabled=true;
480
+ try{const result=await post('/api/v1/mail-notifications',{enabled:$('mailNotificationEnabled').checked,aggregationDelaySeconds:delay,includeResponse:$('mailIncludeResponse').checked,includeSessionDetails:$('mailIncludeSessionDetails').checked});state.mailSettings=result.settings;updateMailControls();toast('邮件通知设置已保存');}
481
+ catch(error){toast(error.message)}finally{$('configSave').disabled=!state.mailNotificationsAvailable;}
482
+ }
483
+
484
+ function modal(title, build, okText = '确定') {
485
+ const dialog=$('modal'), body=$('modalBody'); $('modalTitle').textContent=title; $('modalOk').textContent=okText; body.replaceChildren(); const value=build(body);
486
+ return new Promise(resolve => { const done=()=>{dialog.removeEventListener('close',done);resolve(dialog.returnValue==='default'?(typeof value==='function'?value():value):null);};dialog.addEventListener('close',done);dialog.showModal(); });
487
+ }
488
+ const field = (body, label, value='', type='text') => { const l=document.createElement('label');l.textContent=label;const input=document.createElement('input');input.type=type;input.value=value;l.append(input);body.append(l);return input; };
489
+ let agentContextTarget=null;
490
+ function showItemContextMenu(event,kind,item){event.preventDefault();event.stopPropagation();agentContextTarget={kind,item};const archive=$('menuArchiveAgent'),active=kind==='agent'&&['starting','streaming','waiting_for_user','stopping'].includes(item.status);archive.hidden=kind!=='agent';archive.disabled=active;archive.title=active?'Agent 执行期间不能 Archive':'';$('menuCloseTerminal').hidden=kind!=='terminal';const menu=$('agentContextMenu');menu.hidden=false;menu.style.left=`${Math.min(event.clientX,innerWidth-210)}px`;menu.style.top=`${Math.min(event.clientY,innerHeight-80)}px`;}
491
+ async function createAgent() {
492
+ if (!state.workspace) return toast('请先选择 Workspace');
493
+ const cwd=$('agentCwd').value.trim()||'.'; let sessions=[]; try { sessions=await api(`/api/v1/sessions?workspaceId=${state.workspace.id}&path=${encodeURIComponent(cwd)}`); } catch(e){return toast(e.message);}
494
+ const result=await modal('新建 Agent',body=>{const c=field(body,'工作路径',cwd);const l=document.createElement('label');l.textContent='Session';const s=document.createElement('select');s.innerHTML='<option value="">新 Session</option>'+sessions.map(x=>`<option value="${esc(x.path)}">${esc(x.name||x.sessionName||x.id||x.path)} · ${esc(x.modified||'')}</option>`).join('');l.append(s);body.append(l);return()=>({cwd:c.value.trim()||'.',sessionFile:s.value||undefined});},'创建');
495
+ if (!result) return; try { const agent=await post('/api/v1/agents',{workspaceId:state.workspace.id,relativeCwd:result.cwd,sessionFile:result.sessionFile});await refreshAgents();await selectAgent(agent);}catch(e){toast(e.message);}
496
+ }
497
+ async function navigateAgent(entryId) {
498
+ const agent=state.agent;if(!agent)return;
499
+ const result=await post(`/api/v1/agents/${agent.agentId}/navigate`,{entryId});
500
+ await selectAgent(agent);
501
+ if(result.editorText!==undefined)$('input').value=result.editorText;
502
+ toast(result.editorText!==undefined?'已回退到该用户输入,可编辑后重新发送':'已回退到所选输出');
503
+ }
504
+ async function showSessionTree() {
505
+ if (!state.agent) return toast('请选择 Agent'); const session=await api(`/api/v1/agents/${state.agent.agentId}/session`); const entries=session.entries||[];
506
+ const choice=await modal('Session Tree',body=>{const actionLabel=document.createElement('label');actionLabel.textContent='操作';const action=document.createElement('select');action.innerHTML='<option value="navigate">Navigate 到条目</option><option value="fork">从用户消息 Fork 新 Agent</option>';actionLabel.append(action);body.append(actionLabel);const list=document.createElement('div');list.className='modal-list';let selected='';for(const e of entries){const b=document.createElement('button');b.type='button';b.className='choice';const text=textContent(e.message?.content||e.content)||e.type;b.innerHTML=`${e.id===session.leafId?'● ':'○ '}${esc(text.slice(0,100))}<small>${esc(e.id)}</small>`;b.onclick=()=>{selected=e.id;list.querySelectorAll('button').forEach(x=>x.classList.remove('selected'));b.classList.add('selected');};list.append(b);}body.append(list);return()=>selected?{entryId:selected,action:action.value}:null;},'执行');
507
+ if(choice){if(choice.action==='fork'){const result=await post(`/api/v1/agents/${state.agent.agentId}/fork`,{entryId:choice.entryId});await refreshAgents();await selectAgent(result.agent);}else await navigateAgent(choice.entryId);}
508
+ }
509
+ async function revert() {
510
+ if(!state.agent)return toast('请选择 Agent');const session=await api(`/api/v1/agents/${state.agent.agentId}/session`);
511
+ const messages=(session.entries||[]).filter(e=>e.type==='message'&&['user','assistant'].includes(e.message?.role));
512
+ if(!messages.length)return toast('当前 Session 没有可回退的消息');
513
+ const selected=await modal('Undo(Session Tree)',body=>{const note=document.createElement('p');note.className='muted';note.textContent='选择用户输入会回到输入前并恢复文字;选择 Agent 输出会保留该输出、移除后续上下文。文件改动不会撤销。';body.append(note);const list=document.createElement('div');list.className='modal-list';let value=null;for(const e of [...messages].reverse()){const role=e.message.role==='user'?'You':'Agent',text=textContent(e.message.content)||'(空消息)';const b=document.createElement('button');b.type='button';b.className='choice';b.innerHTML=`<b>${role}</b> ${esc(text.slice(0,160))}<small>${esc(e.id)}${e.id===session.leafId?' · 当前':''}</small>`;b.onclick=()=>{value=e.id;list.querySelectorAll('button').forEach(x=>x.classList.remove('selected'));b.classList.add('selected');};list.append(b);}body.append(list);return()=>value;},'回退');
514
+ if(selected)await navigateAgent(selected);
515
+ }
516
+ async function modelControls() {
517
+ if(!state.agent)return toast('请选择 Agent');const cap=await api(`/api/v1/agents/${state.agent.agentId}/capabilities`);
518
+ const result=await modal('模型与 Thinking',body=>{const ml=document.createElement('label');ml.textContent='模型';const m=document.createElement('select');for(const x of cap.models||[]){const o=document.createElement('option');o.value=JSON.stringify([x.provider,x.id]);o.textContent=`${x.provider} / ${x.name||x.id}`;o.selected=x.provider===cap.model?.provider&&x.id===cap.model?.id;m.append(o);}ml.append(m);body.append(ml);const tl=document.createElement('label');tl.textContent='Thinking level';const t=document.createElement('select');for(const x of cap.thinkingLevels||[]){const o=document.createElement('option');o.value=x;o.textContent=x;o.selected=x===cap.thinkingLevel;t.append(o);}tl.append(t);body.append(tl);return()=>({model:m.value?JSON.parse(m.value):null,thinking:t.value});});
519
+ if(result){if(result.model)await post(`/api/v1/agents/${state.agent.agentId}/model`,{provider:result.model[0],modelId:result.model[1]});if(result.thinking)await post(`/api/v1/agents/${state.agent.agentId}/thinking`,{level:result.thinking});toast('设置已更新');}
520
+ }
521
+
522
+ $('pairCancel').onclick=()=>$('pairDialog').close();
523
+ $('modalCancel').onclick=()=>$('modal').close('cancel');
524
+ $('pairForm').onsubmit=async event=>{event.preventDefault();$('pairSubmit').disabled=true;try{await connect($('pairBase').value,$('pairToken').value);}catch(e){$('status').className='bad';$('status').textContent='连接失败';toast(e.message);}finally{$('pairSubmit').disabled=false;}};
525
+ $('openConfig').onclick=openConfig;$('configClose').onclick=()=>$('configDialog').close();$('configSave').onclick=saveMailSettings;$('connect').onclick=openPair;$('notifications').onclick=enableNotifications;$('voicePlayback').onclick=toggleVoicePlayback;$('voiceInput').onclick=toggleVoiceInput;$('api').onchange=()=>{state.base=$('api').value.replace(/\/$/,'');localStorage.rpBase=state.base;openPair();};
526
+ $('refreshWs').onclick=()=>refreshWs().catch(e=>toast(e.message));$('addWs').onclick=async()=>{if(!requireConnection())return;const result=await modal('添加 Workspace',body=>{const n=field(body,'名称');const p=field(body,'主机绝对路径');return()=>({label:n.value.trim(),rootPath:p.value.trim()});},'添加');if(result?.label&&result.rootPath){await post('/api/v1/workspaces',result);await refreshWs();}};
527
+ $('workspaces').onchange=selectWorkspace;$('agentPageSize').value=String(state.agentPageSize);$('agentPageSize').onchange=()=>{state.agentPageSize=Number($('agentPageSize').value)||10;state.agentVisibleCount=state.agentPageSize;localStorage.rpAgentPageSize=String(state.agentPageSize);renderAgentList();};$('loadMoreAgents').onclick=()=>{state.agentVisibleCount+=state.agentPageSize;renderAgentList();};$('treeRoot').onclick=()=>openDirectory('.');$('treeUp').onclick=()=>openDirectory(parentPath(state.treePath));$('mentionCurrent').onclick=()=>insertMention(state.treePath);$('terminalCurrent').onclick=()=>openTerminal(state.treePath);
528
+ $('treePath').oncontextmenu=e=>showContextMenu(e,{relativePath:state.treePath,type:'directory'});enableLongPressMenu($('treePath'),e=>showContextMenu(e,{relativePath:state.treePath,type:'directory'}));$('filePrev').onclick=()=>openFile(state.filePath,Math.max(0,state.fileOffset-state.fileLimit),false);$('fileNext').onclick=()=>openFile(state.filePath,state.fileOffset+state.fileLimit,false);
529
+ $('menuStartAgent').onclick=()=>startAgent(state.contextTarget.relativePath);$('menuOpenTerminal').onclick=()=>openTerminal(state.contextTarget.relativePath);$('menuSetCwd').onclick=()=>{$('agentCwd').value=state.contextTarget.relativePath;$('contextMenu').hidden=true;};$('menuMention').onclick=()=>{insertMention(state.contextTarget.relativePath);$('contextMenu').hidden=true;};$('menuCloseTerminal').onclick=()=>{const terminal=agentContextTarget?.kind==='terminal'?agentContextTarget.item:null;$('agentContextMenu').hidden=true;if(terminal&&confirm('关闭这个 Terminal?正在运行的进程会被终止。'))void closeTerminal(terminal);};$('menuArchiveAgent').onclick=async()=>{const agent=agentContextTarget?.kind==='agent'?agentContextTarget.item:null;$('agentContextMenu').hidden=true;if(!agent||!confirm('Archive 这个 Agent?Pi Session 文件不会被修改。'))return;try{await post(`/api/v1/agents/${agent.agentId}/archive`);if(state.agent?.agentId===agent.agentId){state.agent=null;localStorage.removeItem('rpAgentId');$('agentTitle').textContent='请选择 Agent';$('agentStatus').textContent='';$('agentStateBadge').textContent='未选择';$('agentStateBadge').className='state-badge state-none';$('contextUsage').hidden=true;$('messages').innerHTML='<div class="empty">选择或创建 Agent 开始对话</div>'}await refreshAgents();toast('Agent 已 Archive')}catch(e){toast(e.message)}};document.addEventListener('click',e=>{if(!$('contextMenu').contains(e.target))$('contextMenu').hidden=true;if(!$('agentContextMenu').contains(e.target))$('agentContextMenu').hidden=true;});
530
+ $('newAgent').onclick=createAgent;$('newTerminal').onclick=()=>openTerminal($('agentCwd').value.trim()||'.');$('abort').onclick=()=>state.agent&&post(`/api/v1/agents/${state.agent.agentId}/abort`).catch(e=>toast(e.message));$('stop').onclick=async()=>{if(state.agent&&confirm('停止这个 Agent?之后再次使用时会按需启动。')){await api(`/api/v1/agents/${state.agent.agentId}`,{method:'DELETE'});setAgentStatus(state.agent.agentId,'unloaded');}};
531
+ $('sessionName').onclick=async()=>{if(!state.agent)return;const result=await modal('Session 名称',body=>{const n=field(body,'名称',state.agent.sessionName||'');return()=>n.value.trim();});if(result){const s=await post(`/api/v1/agents/${state.agent.agentId}/session-name`,{name:result});state.agent.sessionName=s.sessionName||result;updateAgentHeader();await refreshAgents();}};
532
+ $('sessionTree').onclick=()=>showSessionTree().catch(e=>toast(e.message));$('undo').onclick=()=>revert().catch(e=>toast(e.message));$('controls').onclick=()=>modelControls().catch(e=>toast(e.message));$('compact').onclick=async()=>{if(!state.agent)return;const instructions=await modal('Compact',body=>{const n=field(body,'可选指令');return()=>n.value;},'开始');if(instructions!==null){await post(`/api/v1/agents/${state.agent.agentId}/compact`,{instructions:instructions||undefined});toast('Compact 完成');}};
533
+ $('prompt').onsubmit=async event=>{event.preventDefault();if(!state.agent)return toast('请先创建或选择 Agent');const text=$('input').value.trim();if(!text)return;const mode=$('sendMode').value,agentId=state.agent.agentId,sequenceBefore=localStorage[`rpSeq:${agentId}`];$('messages').querySelector('.empty')?.remove();addCard(null,text,'user');$('input').value='';try{await post(`/api/v1/agents/${agentId}/${mode}`,{message:text});if(mode==='prompt'&&state.agent?.agentId===agentId&&localStorage[`rpSeq:${agentId}`]===sequenceBefore){await loadMessagePage(agentId,undefined,true);state.streams.clear();}}catch(e){addCard('发送失败',e.message,'error',true);}};
534
+ $('input').onkeydown=event=>{if(!$('mentionPicker').hidden){if(event.key==='ArrowDown'||event.key==='ArrowUp'){event.preventDefault();setMentionSelection(state.mentionIndex+(event.key==='ArrowDown'?1:-1));return;}if(event.key==='Tab'||event.key==='Enter'){event.preventDefault();chooseMention(state.mentionFiltered[state.mentionIndex],event.key==='Enter');return;}if(event.key==='Escape'){event.preventDefault();closeMention();return;}}if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){$('prompt').requestSubmit();}};$('input').oninput=()=>{const context=mentionContext();if(!context){if(!$('mentionPicker').hidden)closeMention();return;}const fresh=$('mentionPicker').hidden||state.mentionStart!==context.start;state.mentionStart=context.start;state.mentionEnd=context.replacementEnd;if(fresh){state.mentionPrefix='';void openMention(state.treePath,context.query);return;}const filter=state.mentionPrefix&&context.query.startsWith(state.mentionPrefix)?context.query.slice(state.mentionPrefix.length):context.query;renderMentionItems(filter);};
535
+ $('mentionUp').onclick=()=>browseMention(parentPath(state.mentionPath));$('mentionClose').onclick=closeMention;
536
+ $('workspaceBack').onclick=()=>mobileBack('home');$('fileBack').onclick=()=>mobileBack('workspace');$('agentBack').onclick=()=>mobileBack('home');$('terminalBack').onclick=()=>mobileBack('home');$('terminalClear').onclick=()=>state.terminalEmulator?.clear();$('terminalClose').onclick=()=>{if(state.terminal&&confirm('关闭这个 Terminal?正在运行的进程会被终止。'))void closeTerminal();};
537
+ $('mobileAgentActions').onclick=()=>$('mobileAgentActions').closest('.toolbar').classList.toggle('actions-open');
538
+ document.querySelectorAll('.agent-action').forEach(button=>button.addEventListener('click',()=>button.closest('.toolbar').classList.remove('actions-open')));
539
+ window.addEventListener('popstate',event=>setMobileView(event.state?.rpView||'home'));
540
+ mobileMedia.addEventListener?.('change',event=>{if(event.matches)setMobileView('home',{replace:true});});
541
+ window.addEventListener('beforeunload',()=>{state.manuallyClosed=true;state.ws?.close();state.terminalWs?.close();state.mediaRecorder?.state==='recording'&&state.mediaRecorder.stop();stopVoiceAudio();});
542
+ window.addEventListener('focus',updateNotificationButton);
543
+ setMobileView('home', {replace:true}); updateConfigUi(); openPair();
544
+ })();