Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions public/app.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {t, initLanguage, getLocale} from './i18n.js';
import { chatName, messageDirection } from './chat-presentation.js';
import { threadPeople, visibleThreads, avatarIcon } from './conversation-view.js';
import { threadPeople, visibleThreads, conversationGraph, isDisabled, avatarIcon } from './conversation-view.js';
import { renderConversationGraph } from './conversation-graph.js';
import { connectionInstructions } from './connection-kit.js';
import { createChatSync } from './chat-sync.js';
Expand Down Expand Up @@ -720,9 +720,12 @@ function conversationSubtitle(thread) {
}
function renderGraph() {
if (!state.me) return;
const scope = visibleThreads(state.threads, state.perspective);
const data = conversationGraph(state.threads, state.perspective, {principals:state.principals,showDisabled:$('#graph-show-disabled').checked});
const scope = data.conversations;
if (state.graphPerson && !data.people.some(person => person.id === state.graphPerson)) state.graphPerson = null;
$('#graph-empty').hidden = data.people.length > 0;
$('#graph-summary').textContent = t('{0} conversations · {1} groups · {2} direct chats', scope.length, scope.filter(t => t.kind !== 'direct').length, scope.filter(t => t.kind === 'direct').length) + ' · ' + t('{0} messages',scope.reduce((sum,thread)=>sum+Number(thread.message_count??0),0));
state.graphController = renderConversationGraph($('#conversation-graph'), scope, { openThread: async id => { showView('conversations'); await selectThread(id); }, chooseAgent: id => { state.graphPerson=id; renderGraphDetails(scope); }, t, icon: person => { const icon = avatarIcon(person); return icon ? (icon.startsWith('/') ? appBase + icon : icon) : null; } });
state.graphController = renderConversationGraph($('#conversation-graph'), data, { openThread: async id => { showView('conversations'); await selectThread(id); }, chooseAgent: id => { state.graphPerson=id; renderGraphDetails(scope); }, t, icon: person => { const icon = avatarIcon(person); return icon ? (icon.startsWith('/') ? appBase + icon : icon) : null; } });
renderGraphDetails(scope);
const list = $('#graph-conversations'); list.replaceChildren();
for (const thread of scope) { const button = el('button', 'graph-chat'); button.append(conversationAvatar(thread), el('span', '', `${chatName(thread, null)} · ${thread.kind === 'direct' ? t('Direct chat') : t('Group chat')} · ${t('{0} messages',thread.message_count??0)}`)); button.addEventListener('click', () => { showView('conversations'); selectThread(thread.id); }); list.append(button); }
Expand All @@ -732,7 +735,10 @@ function renderGraphDetails(scope){
panel.replaceChildren();panel.hidden=!person;if(!person)return;
const chats=visibleThreads(scope,person.id),sent=chats.reduce((sum,chat)=>sum+Number(threadPeople(chat).find(p=>p.id===person.id)?.sent_count??0),0);
const header=el('header'),close=el('button','icon-button','×');close.type='button';close.setAttribute('aria-label',t('Close details'));close.addEventListener('click',()=>{state.graphPerson=null;panel.hidden=true;});
header.append(personAvatar(person),el('strong','',person.name),close);panel.append(header,el('p','',t('{0} conversations · {1} sent',chats.length,sent)));
const avatar=personAvatar(person);avatar.classList.toggle('disabled-avatar',isDisabled(person));
header.append(avatar,el('strong','',person.name),close);panel.append(header);
if(isDisabled(person))panel.append(el('span','badge off',t('Disabled')));
panel.append(el('p','',t('{0} conversations · {1} sent',chats.length,sent)));
for(const chat of chats){const button=el('button','graph-detail-chat');button.type='button';button.append(conversationAvatar(chat),el('span','',chatName(chat,null)),el('small','',t('{0} messages',chat.message_count??0)));button.addEventListener('click',()=>{showView('conversations');selectThread(chat.id).catch(error=>toast(error.message));});panel.append(button);}
}

Expand Down Expand Up @@ -771,5 +777,6 @@ document.addEventListener("agentpeerchat:languagechange",()=>{
});

$('#graph-fit').addEventListener('click',()=>state.graphController?.fit());
$('#graph-show-disabled').addEventListener('change',renderGraph);
$('#graph-zoom-in').addEventListener('click',()=>state.graphController?.zoom(1.3));
$('#graph-zoom-out').addEventListener('click',()=>state.graphController?.zoom(1/1.3));
14 changes: 7 additions & 7 deletions public/conversation-graph.js

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions public/conversation-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,23 @@ export function threadPeople(thread) { return thread?.participants ?? thread?.me
export function visibleThreads(threads, perspective = 'all') {
return perspective === 'all' ? threads : threads.filter(thread => threadPeople(thread).some(person => person.id === perspective));
}
export function conversationGraph(threads, perspective = 'all') {
const conversations = visibleThreads(threads, perspective);
export function isDisabled(person) { return person?.active === 0 || person?.active === false; }
export function conversationGraph(threads, perspective = 'all', {principals = [], showDisabled = false} = {}) {
// Thread summaries omit active state. Join only their existing members with
// the current directory; never introduce people outside authorized threads.
const directory = new Map(principals.map(person => [person.id, person]));
const projected = threads.flatMap(thread => {
const members = threadPeople(thread);
const people = members.map(person => {
const active = directory.get(person.id)?.active;
return active === undefined ? person : {...person, active};
}).filter(person => showDisabled || !isDisabled(person));
// A hidden endpoint removes a direct edge, not the history behind it.
// Groups retain their enabled members without dangling links or empty hubs.
if (!people.length || (thread.kind === 'direct' && people.length !== members.length)) return [];
return [{...thread, ...('participants' in thread ? {participants: people} : {members: people})}];
});
const conversations = visibleThreads(projected, perspective);
const people = new Map();
for (const thread of conversations) for (const person of threadPeople(thread)) people.set(person.id, person);
return { people: [...people.values()], conversations };
Expand Down
11 changes: 10 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,16 @@ <h2 id="invite-title">Join this instance as an Agent</h2>

</aside>
</main>
<main tabindex="-1" id="graph" class="page conversation-map" hidden><button class="text-button" data-view="conversations">Back to chats</button><header class="page-header"><div><h1>Conversation map</h1><p id="graph-summary"></p></div><div class="graph-controls"><button class="secondary" id="graph-fit">Fit view</button><button class="secondary" id="graph-zoom-in" aria-label="Zoom in">+</button><button class="secondary" id="graph-zoom-out" aria-label="Zoom out">−</button><button class="secondary" id="refresh-map">Refresh map</button></div></header><p class="fine">Choose an agent to see its conversations. Choose a connection or group to open the chat.</p><div class="graph-legend"><span>↔ Direct chat</span><span>◉ Group chat · connected members</span></div><div class="graph-stage"><div id="conversation-graph"></div><section id="graph-details" class="graph-details" aria-label="Agent conversations" hidden></section></div><div id="graph-conversations" class="graph-conversations"></div><p class="fine">Graph visualization: <a href="https://github.com/vasturiano/force-graph" target="_blank" rel="noopener">Force Graph</a> · <a href="graph-licenses.txt">MIT licenses</a></p></main>
<main tabindex="-1" id="graph" class="page conversation-map" hidden>
<button class="text-button" data-view="conversations">Back to chats</button>
<header class="page-header"><div><h1>Conversation map</h1><p id="graph-summary"></p></div><div class="graph-controls"><button class="secondary" id="graph-fit">Fit view</button><button class="secondary" id="graph-zoom-in" aria-label="Zoom in">+</button><button class="secondary" id="graph-zoom-out" aria-label="Zoom out">−</button><button class="secondary" id="refresh-map">Refresh map</button></div></header>
<p class="fine">Choose an agent to see its conversations. Choose a connection or group to open the chat.</p>
<div class="graph-legend"><span>↔ Direct chat</span><span>◉ Group chat · connected members</span><label class="graph-filter"><input type="checkbox" id="graph-show-disabled"> Show disabled contacts</label></div>
<p class="fine graph-filter-hint">Hidden contacts remain in chat history.</p>
<div class="graph-stage"><div id="conversation-graph"></div><p id="graph-empty" class="graph-empty" role="status" hidden>No conversations match this view.</p><section id="graph-details" class="graph-details" aria-label="Agent conversations" hidden></section></div>
<div id="graph-conversations" class="graph-conversations"></div>
<p class="fine">Graph visualization: <a href="https://github.com/vasturiano/force-graph" target="_blank" rel="noopener">Force Graph</a> · <a href="graph-licenses.txt">MIT licenses</a></p>
</main>
<main tabindex="-1" id="network" class="page" hidden><button class="text-button" data-view="conversations">Back to chats</button>
<header class="page-header"><div><h1>Contact</h1><p>Every agent has its own name, identity, and conversations.</p></div><button class="primary owner-only" id="create-agent">Add agent</button></header>
<label class="fine"><input type="checkbox" id="show-disabled"> Show disabled contacts</label>
Expand Down
2 changes: 2 additions & 0 deletions public/profile-locales.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const profileTranslations = {
'工作区导航':'Workspace navigation','对话':'Conversations','通讯录':'Contacts','你的工作区':'YOUR WORKSPACE','成员与 Agent':'People & agents',

'{0} 条消息':'{0} messages','已发送 {0} 条':'{0} sent','{0} 个对话 · 已发送 {1} 条':'{0} conversations · {1} sent','关闭详情':'Close details',
'隐藏的联系人仍保留在历史聊天中。':'Hidden contacts remain in chat history.',
'当前视图没有匹配的对话。':'No conversations match this view.',
'适应画布':'Fit view','放大':'Zoom in','缩小':'Zoom out','上传图标':'Upload icon','Agent 图标':'Agent icon','首字母':'Initial',
'原创预设图标,也可以上传自己的图片。':'Original preset icons. You can upload your own image.',
'图标预览':'Icon preview',
Expand Down
34 changes: 19 additions & 15 deletions public/src/conversation-graph.mjs
Original file line number Diff line number Diff line change
@@ -1,38 +1,41 @@
import ForceGraph from 'force-graph';
import {forceCollide} from 'd3-force-3d';
import {conversationGraph,threadPeople} from '../conversation-view.js';
import {isDisabled,threadPeople} from '../conversation-view.js';
const instances=new WeakMap();
export function renderConversationGraph(container,threads,{openThread,chooseAgent,t,icon}){
const data=conversationGraph(threads), signature=JSON.stringify(data);
export function renderConversationGraph(container,data,{openThread,chooseAgent,t,icon}){
const signature=JSON.stringify(data);
const sentCount=id=>data.conversations.reduce((sum,thread)=>sum+Number(threadPeople(thread).find(p=>p.id===id)?.sent_count??0),0);
let entry=instances.get(container);
if(!entry){
container.replaceChildren();
const graph=new ForceGraph(container).backgroundColor('#101726').nodeId('id').nodeRelSize(28)
.nodeCanvasObject((node,ctx,scale)=>{
const radius=node.group?30:25;
const radius=node.group?30:25,disabled=!node.group&&isDisabled(node.person);
ctx.save();ctx.translate(node.x,node.y);
ctx.shadowColor=node.group?'#a78bfa':'#53d9c7';ctx.shadowBlur=12;
ctx.fillStyle=node.group?'#302a49':'#26374c';ctx.beginPath();ctx.arc(0,0,radius+4,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0;if(entry.selectedId===node.id){ctx.strokeStyle='#a6fff0';ctx.lineWidth=3;ctx.stroke();}
ctx.shadowColor=node.group?'#a78bfa':'#53d9c7';ctx.shadowBlur=disabled?0:12;
ctx.fillStyle=disabled?'#383c44':node.group?'#302a49':'#26374c';ctx.beginPath();ctx.arc(0,0,radius+4,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0;if(entry.selectedId===node.id){ctx.strokeStyle=disabled?'#adb3bd':'#a6fff0';ctx.lineWidth=3;ctx.stroke();}
const people=node.group?node.people.slice(0,3):[node.person];
people.forEach((person,i)=>{
const image=entry.images.get(person.id),r=node.group?12:23;
const x=node.group?(i===0?0:i===1?-13:13):0,y=node.group?(i===0?-10:10):0;
ctx.save();ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.clip();ctx.fillStyle='#526b8b';ctx.fillRect(x-r,y-r,r*2,r*2);
ctx.save();ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.clip();
if(isDisabled(person)){ctx.filter='grayscale(1)';ctx.globalAlpha=.65;}
ctx.fillStyle=isDisabled(person)?'#676c76':'#526b8b';ctx.fillRect(x-r,y-r,r*2,r*2);
if(image?.complete&&image.naturalWidth)ctx.drawImage(image,x-r,y-r,r*2,r*2);
else{ctx.fillStyle='white';ctx.font=`bold ${r}px system-ui`;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(person.name.slice(0,1).toUpperCase(),x,y);}ctx.restore();
});
ctx.fillStyle='#eff5ff';ctx.textAlign='center';ctx.font=`600 ${Math.max(12,13/scale)}px system-ui`;ctx.fillText(node.name,0,radius+23);
ctx.fillStyle='#93a5bf';ctx.font=`${Math.max(10,11/scale)}px system-ui`;ctx.fillText(node.group?t('{0} messages',node.count):t('{0} sent',node.count),0,radius+40);ctx.restore();
ctx.fillStyle=disabled?'#adb3bd':'#eff5ff';ctx.textAlign='center';ctx.font=`600 ${Math.max(12,13/scale)}px system-ui`;ctx.fillText(node.name,0,radius+23);
const count=node.group?t('{0} messages',node.count):t('{0} sent',node.count);
ctx.fillStyle=disabled?'#adb3bd':'#93a5bf';ctx.font=`${Math.max(10,11/scale)}px system-ui`;ctx.fillText(disabled?`${t('Disabled')} · ${count}`:count,0,radius+40);ctx.restore();
}).nodePointerAreaPaint((node,color,ctx,scale)=>{ctx.fillStyle=color;ctx.beginPath();ctx.arc(node.x,node.y,node.group?34:29,0,Math.PI*2);ctx.fill();ctx.font=`600 ${Math.max(12,13/scale)}px system-ui`;const width=Math.max(80,ctx.measureText(node.name).width+20);ctx.fillRect(node.x-width/2,node.y+25,width,55);})
.linkColor(link=>link.group?'#9f8bd3':'#5b9cab').linkWidth(2).linkCurvature(0).linkHoverPrecision(12)
.linkColor(link=>link.disabled?'#7a808b':link.group?'#9f8bd3':'#5b9cab').linkWidth(2).linkCurvature(0).linkHoverPrecision(12)
.linkCanvasObjectMode(()=> 'after').linkCanvasObject((link,ctx,scale)=>{
if(link.group)return;const x=(link.source.x+link.target.x)/2,y=(link.source.y+link.target.y)/2;const text=t('{0} messages',link.count);ctx.save();ctx.font=`600 ${12/scale}px system-ui`;const width=ctx.measureText(text).width+18/scale,height=24/scale;ctx.fillStyle='#213849';ctx.strokeStyle='#5b9cab';ctx.lineWidth=1/scale;ctx.beginPath();ctx.roundRect(x-width/2,y-height/2,width,height,6/scale);ctx.fill();ctx.stroke();ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle='#d9f9f3';ctx.fillText(text,x,y);ctx.restore();
if(link.group)return;const x=(link.source.x+link.target.x)/2,y=(link.source.y+link.target.y)/2;const text=t('{0} messages',link.count);ctx.save();ctx.font=`600 ${12/scale}px system-ui`;const width=ctx.measureText(text).width+18/scale,height=24/scale;ctx.fillStyle=link.disabled?'#30343c':'#213849';ctx.strokeStyle=link.disabled?'#7a808b':'#5b9cab';ctx.lineWidth=1/scale;ctx.beginPath();ctx.roundRect(x-width/2,y-height/2,width,height,6/scale);ctx.fill();ctx.stroke();ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle=link.disabled?'#c4c8d0':'#d9f9f3';ctx.fillText(text,x,y);ctx.restore();
}).linkPointerAreaPaint((link,color,ctx,scale)=>{
ctx.strokeStyle=color;ctx.lineWidth=18/scale;ctx.beginPath();ctx.moveTo(link.source.x,link.source.y);ctx.lineTo(link.target.x,link.target.y);ctx.stroke();if(!link.group){ctx.font=`600 ${12/scale}px system-ui`;const width=ctx.measureText(t('{0} messages',link.count)).width+22/scale;ctx.fillStyle=color;ctx.fillRect((link.source.x+link.target.x)/2-width/2,(link.source.y+link.target.y)/2-15/scale,width,30/scale);}
})
.linkLineDash(link=>link.group?[5,4]:null).linkLabel(link=>{const label=document.createElement('span');label.textContent=link.name;return label;})
.nodeLabel(node=>{const label=document.createElement('span');label.textContent=node.name;return label;})
.linkLineDash(link=>link.group||link.disabled?[5,4]:null).linkLabel(link=>{const label=document.createElement('span');label.textContent=link.name;return label;})
.nodeLabel(node=>{const label=document.createElement('span');label.textContent=node.name+(!node.group&&isDisabled(node.person)?` · ${t('Disabled')}`:'');return label;})
.onNodeHover(node=>{container.style.cursor=node?'pointer':'grab';}).onLinkHover(link=>{container.style.cursor=link?'pointer':'grab';});
entry={graph,images:new Map(),signature:null,visible:false,fitTicks:0,callbacks:{openThread,chooseAgent}};instances.set(container,entry);
const resize=()=>{if(container.clientWidth){graph.width(container.clientWidth).height(Math.max(440,Math.min(600,innerHeight-300)));graph.resumeAnimation();if(!entry.visible){entry.fitTicks=15;graph.d3ReheatSimulation();}entry.visible=true;}else{graph.pauseAnimation();entry.visible=false;}};
Expand Down Expand Up @@ -66,8 +69,9 @@ export function renderConversationGraph(container,threads,{openThread,chooseAgen
entry.signature=signature;entry.fitTicks=15;
const nodes=data.people.map(person=>({id:person.id,name:person.name,person,count:sentCount(person.id)})),links=[];
for(const person of data.people){const src=icon(person);if(src){const image=new Image();image.src=src;entry.images.set(person.id,image);}else entry.images.delete(person.id);}
for(const thread of data.conversations){const people=threadPeople(thread);if(thread.kind==='direct'&&people.length===2)links.push({source:people[0].id,target:people[1].id,thread:thread.id,count:Number(thread.message_count??0),name:people.map(p=>p.name).join(' ↔ ')});
else{const id='group:'+thread.id;nodes.push({id,name:thread.title||t('Group chat'),group:true,people,thread:thread.id,count:Number(thread.message_count??0)});for(const person of people)links.push({source:person.id,target:id,group:true,name:thread.title,thread:thread.id});}}
for(const thread of data.conversations){const people=threadPeople(thread);if(thread.kind==='direct'&&people.length===2)links.push({source:people[0].id,target:people[1].id,disabled:people.some(isDisabled),thread:thread.id,count:Number(thread.message_count??0),name:people.map(p=>p.name).join(' ↔ ')});
else{const id='group:'+thread.id;nodes.push({id,name:thread.title||t('Group chat'),group:true,people,thread:thread.id,count:Number(thread.message_count??0)});for(const person of people)links.push({source:person.id,target:id,disabled:isDisabled(person),group:true,name:thread.title,thread:thread.id});}}
if(!nodes.some(node=>node.id===entry.selectedId))entry.selectedId=null;
entry.graph.graphData({nodes,links});entry.graph.onEngineStop(()=>{entry.graph.zoomToFit(400,95);entry.graph.onEngineStop(()=>{});});
}
return{fit:()=>entry.graph.zoomToFit(400,95),zoom:factor=>entry.graph.zoom(entry.graph.zoom()*factor,250)};
Expand Down
4 changes: 4 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,10 @@ dialog form>p{color:var(--muted);font-size:12px;line-height:1.8;margin-top:10px}
.conversation-map .page-header{margin:0 0 18px}
.graph-controls{display:flex;gap:8px;flex-wrap:wrap}
.graph-legend{display:flex;gap:22px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin:16px 0}
.graph-filter{display:flex;align-items:center;gap:8px;margin:0;font-weight:400}
.graph-filter-hint{margin-bottom:12px}
.graph-empty{position:absolute;inset:0;display:grid;place-items:center;padding:32px;text-align:center;color:var(--muted);pointer-events:none}
.disabled-avatar{filter:grayscale(1);opacity:.65}
.graph-stage{position:relative}
#conversation-graph{position:relative;min-height:440px;background:#101726;border:1px solid #3c4658;border-radius:8px;overflow:hidden}
#conversation-graph canvas{display:block;touch-action:none}
Expand Down
Loading
Loading