mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-11-05 19:04:13 +00:00
226 lines
16 KiB
JavaScript
226 lines
16 KiB
JavaScript
import { A2AClient, TaskView, displayResponse, isStopped, needsInput, selectVersion, stateName, uid } from './a2a-client.js';
|
|
|
|
const $ = id => document.getElementById(id);
|
|
const value = id => $(id).value.trim();
|
|
const labels = { submitted:'Tarefa recebida', working:'Agente trabalhando', completed:'Concluída', failed:'Falha no processamento', canceled:'Tarefa cancelada', rejected:'Solicitação recusada', 'input-required':'Aguardando sua resposta', 'auth-required':'Aguardando autorização' };
|
|
const eventLabels = { 'flow.start':'Fluxo iniciado', 'session.upserted':'Sessão preparada', 'message.received':'Mensagem recebida', 'workflow.started':'Workflow iniciado', 'workflow.completed':'Workflow concluído', 'message.responded':'Resposta disponível', 'flow.end':'Fluxo finalizado', 'a2a.backend.status':'Status do backend atualizado' };
|
|
const state = { client:null, card:null, contextId:uid(), view:new TaskView(), connected:false, busy:false, epoch:0, controller:null, turn:null, events:0, lastState:'', scope:null };
|
|
const savedKey = 'framework-a2a-session-v1';
|
|
const connectionFields = ['serviceUrl','target','version','skill','tenant','tracking','customerKey','contractKey','agentOverride','authHeader','authValue'];
|
|
const messageGroups = new Map();
|
|
|
|
function notice(text = '') { $('notice').textContent = text; $('notice').hidden = !text; }
|
|
function setStatus(text, kind = '') { $('taskStatus').textContent = text; $('taskDot').className = `dot ${kind}`; }
|
|
function setBusy(busy) { state.busy = busy; document.body.setAttribute('aria-busy', String(busy)); controls(); }
|
|
function controls() {
|
|
const active = state.view.task.id && !isStopped(state.view.task.status?.state);
|
|
const allowSend = state.connected && !state.busy && !active;
|
|
$('message').disabled = !allowSend; $('sendButton').disabled = !allowSend;
|
|
$('cancelButton').disabled = !state.connected || !(active || needsInput(state.view.task.status?.state));
|
|
$('resumeButton').disabled = !state.connected || state.busy || !state.view.task.id;
|
|
$('newConversation').disabled = state.busy;
|
|
$('connectButton').disabled = state.busy;
|
|
for (const id of connectionFields) $(id).disabled = state.busy;
|
|
}
|
|
function log(text) {
|
|
const item = document.createElement('li');
|
|
const time = document.createElement('time'); time.textContent = new Date().toLocaleTimeString('pt-BR');
|
|
item.append(time, document.createTextNode(text)); $('activity').append(item);
|
|
while ($('activity').children.length > 80) $('activity').firstChild.remove();
|
|
$('eventCount').textContent = `${++state.events} eventos`;
|
|
}
|
|
function saveSession() {
|
|
// Only identifiers and connection settings: no credentials or message content.
|
|
try { sessionStorage.setItem(savedKey, JSON.stringify({
|
|
serviceUrl: state.client?.urls.base, version:state.client?.version, target:value('target'),
|
|
contextId:state.contextId, taskId:state.view.task.id, taskState:state.view.task.status?.state,
|
|
tenant:value('tenant'), skill:value('skill'), agentOverride:value('agentOverride'),
|
|
})); } catch { /* Storage can be unavailable in private browsing. */ }
|
|
}
|
|
function ids() {
|
|
$('taskId').textContent = state.view.task.id || '—'; $('taskShort').textContent = state.view.task.id ? `#${state.view.task.id.slice(0,8)}` : '';
|
|
$('contextId').textContent = state.contextId; $('contextShort').textContent = `Conversa ${state.contextId.slice(0,8)}`;
|
|
}
|
|
function stopWatcher() { ++state.epoch; state.controller?.abort(); state.controller = null; }
|
|
function newConversation() {
|
|
stopWatcher(); state.contextId = uid(); state.view = new TaskView(); state.turn = null; state.lastState = ''; messageGroups.clear();
|
|
$('chat').replaceChildren(); $('activity').replaceChildren(); state.events = 0; $('eventCount').textContent='0 eventos'; notice();
|
|
setBusy(false); ids(); setStatus(state.connected ? 'Pronto para conversar' : 'Pronto para conectar'); saveSession(); $('message').focus();
|
|
}
|
|
function userMessage(text) {
|
|
const div = document.createElement('div'); div.className='message user'; div.textContent=text;
|
|
$('welcome')?.remove(); $('chat').append(div); scrollChat();
|
|
}
|
|
function agentMessages(response) {
|
|
const metadata = response.metadata || {};
|
|
const candidates = response.supervisor_results || metadata.supervisor_results || response.agent_responses || metadata.agent_responses;
|
|
if (Array.isArray(candidates)) {
|
|
const parts = candidates.map(p => ({ agent:p.agent || p.agent_id || p.name || 'Agente', text:p.answer ?? p.text ?? p.message ?? p.response ?? p.content, primary:p.primary }));
|
|
const valid = parts.filter(p => p.text != null && String(p.text).trim());
|
|
if (valid.length) return valid.sort((a,b)=>Number(Boolean(a.primary))-Number(Boolean(b.primary)));
|
|
}
|
|
const text = response.text ?? response.speak ?? response.content ?? '';
|
|
return [{ agent:metadata.active_agent || metadata.agent_id || state.card?.name || 'Agente', text }];
|
|
}
|
|
function scrollChat() { $('chat').scrollTop = $('chat').scrollHeight; }
|
|
function renderResponse(response) {
|
|
if (!response) return;
|
|
$('welcome')?.remove(); state.turn ||= uid();
|
|
let group = messageGroups.get(state.turn);
|
|
if (!group) { group=document.createElement('section'); group.className='message agent'; group.setAttribute('aria-label','Resposta do agente'); messageGroups.set(state.turn,group); $('chat').append(group); }
|
|
const signature=JSON.stringify(response); if (group.dataset.signature === signature) return;
|
|
group.dataset.signature=signature; group.replaceChildren();
|
|
for (const part of agentMessages(response)) {
|
|
const section=document.createElement('section'); section.className='agent-section';
|
|
const header=document.createElement('div'); header.className='agent-header';
|
|
const avatar=document.createElement('span'); avatar.className='agent-avatar'; avatar.textContent='O'; avatar.setAttribute('aria-hidden','true');
|
|
const name=document.createElement('span'); name.textContent=String(part.agent).replaceAll('_',' ');
|
|
const body=document.createElement('div'); body.className='agent-body'; body.textContent=typeof part.text==='string' ? part.text : JSON.stringify(part.text,null,2);
|
|
header.append(avatar,name); section.append(header,body); group.append(section);
|
|
}
|
|
scrollChat();
|
|
}
|
|
function updateTask(event) {
|
|
const task=state.view.apply(event);
|
|
if (task.contextId) state.contextId=task.contextId;
|
|
const current=stateName(task.status?.state);
|
|
if (current && current!==state.lastState) { log(labels[current] || current); state.lastState=current; }
|
|
if (event.type==='status' && event.value.metadata?.framework_event) log(eventLabels[event.value.metadata.framework_event] || event.value.metadata.framework_event);
|
|
setStatus(labels[current] || 'Acompanhando tarefa', ['failed','rejected'].includes(current) ? 'error' : isStopped(current) ? '' : 'busy');
|
|
if (event.type==='artifact' || isStopped(current)) renderResponse(displayResponse(task));
|
|
ids(); saveSession(); controls();
|
|
}
|
|
function sleep(ms,signal) {
|
|
return new Promise((resolve,reject)=>{
|
|
if (signal.aborted) { reject(new DOMException('Interrompido','AbortError')); return; }
|
|
const abort=()=>{clearTimeout(timer);reject(new DOMException('Interrompido','AbortError'));};
|
|
const timer=setTimeout(()=>{signal.removeEventListener('abort',abort);resolve();},ms);
|
|
signal.addEventListener('abort',abort,{once:true});
|
|
});
|
|
}
|
|
async function watchTask() {
|
|
stopWatcher(); const epoch=state.epoch; const controller=new AbortController(); state.controller=controller;
|
|
const signal=controller.signal; const id=state.view.task.id; setBusy(true);
|
|
try {
|
|
if (value('tracking')==='stream' && state.card?.capabilities?.streaming && !isStopped(state.view.task.status?.state)) {
|
|
try {
|
|
log('Conectando ao fluxo da tarefa');
|
|
for await (const event of state.client.subscribe(id,signal)) {
|
|
if (epoch!==state.epoch) return; updateTask(event);
|
|
if (isStopped(state.view.task.status?.state)) break;
|
|
}
|
|
} catch (error) {
|
|
if (signal.aborted) return;
|
|
log('Fluxo interrompido; recuperando por consulta');
|
|
}
|
|
}
|
|
// Always obtain the authoritative final snapshot; also recovers a fast Task
|
|
// that completed before SubscribeToTask or a dropped/truncated SSE stream.
|
|
let failures=0;
|
|
while (true) {
|
|
try {
|
|
const task=await state.client.getTask(id,signal);
|
|
if (epoch!==state.epoch) return;
|
|
updateTask({type:'task',value:task}); failures=0;
|
|
if (isStopped(task.status?.state)) break;
|
|
} catch (error) {
|
|
if (signal.aborted) return;
|
|
if (++failures>=3 || error.code) throw error;
|
|
log('Conexão indisponível; tentando consultar novamente');
|
|
}
|
|
await sleep(failures ? failures*1000 : 900,signal);
|
|
}
|
|
} catch (error) {
|
|
if (!signal.aborted && epoch===state.epoch) {
|
|
notice(`Acompanhamento pausado: ${error.message} Use Retomar para consultar a mesma tarefa. Nenhuma mensagem foi reenviada.`);
|
|
setStatus('Acompanhamento pausado','error'); log('Acompanhamento pausado');
|
|
}
|
|
} finally {
|
|
if (epoch===state.epoch) { setBusy(false); $('message').focus(); }
|
|
}
|
|
}
|
|
function authHeaders() {
|
|
const secret=$('authValue').value;
|
|
if (!secret) return {};
|
|
const name=value('authHeader');
|
|
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || /[\r\n]/.test(secret)) throw new Error('Cabeçalho de autenticação inválido.');
|
|
if (['content-type','accept','a2a-version','host','origin','cookie'].includes(name.toLowerCase())) throw new Error('Use Authorization ou o cabeçalho de autenticação configurado no serviço.');
|
|
return {[name]:secret};
|
|
}
|
|
$('connectionForm').addEventListener('submit',async event=>{
|
|
event.preventDefault(); notice(); setBusy(true); setStatus('Conectando…','busy');
|
|
try {
|
|
const candidate=new A2AClient({url:value('serviceUrl'),version:value('version')==='0.3'?'0.3':'1.0',headers:authHeaders()});
|
|
const card=await candidate.discover(AbortSignal.timeout(12000)); candidate.version=selectVersion(card,value('version')); candidate.headers['A2A-Version']=candidate.version;
|
|
const scope=JSON.stringify([candidate.urls.base,candidate.version,value('target'),value('tenant'),value('skill'),value('agentOverride')]);
|
|
if (state.scope && scope!==state.scope) newConversation();
|
|
state.scope=scope; state.client=candidate; state.card=card; state.connected=true;
|
|
const previous=value('skill'); $('skill').replaceChildren(new Option('Seleção automática',''));
|
|
for (const skill of card.skills || []) $('skill').append(new Option(skill.name || skill.id,skill.id));
|
|
$('skill').value=[...$('skill').options].some(o=>o.value===previous)?previous:'';
|
|
$('serviceName').textContent=card.name || 'Serviço A2A'; $('connectionDetail').textContent=`${(card.skills || []).length === 1 ? '1 opção disponível' : `${(card.skills || []).length} opções disponíveis`} · ${card.capabilities?.streaming ? 'Atualizações em tempo real' : 'Consulta de tarefas'}`;
|
|
$('connectionDot').className='dot'; $('versionBadge').textContent=`A2A ${candidate.version}`;
|
|
setStatus(state.view.task.id?'Conectado · tarefa disponível para consulta':'Pronto para conversar');
|
|
log('Conectado ao serviço'); ids(); saveSession();
|
|
} catch(error) {
|
|
state.connected=false; $('connectionDot').className='dot error'; $('connectionDetail').textContent='Não foi possível conectar.';
|
|
setStatus('Falha na conexão','error'); notice(`${error.message} Se o navegador bloquear a conexão, confira CORS_ORIGINS no serviço e use HTTP/HTTPS para servir esta página.`);
|
|
} finally { setBusy(false); }
|
|
});
|
|
$('messageForm').addEventListener('submit',async event=>{
|
|
event.preventDefault(); const text=value('message'); if(!text || !state.connected || $('sendButton').disabled) return;
|
|
notice(); const previousTask=state.view.task; const resumeId=needsInput(previousTask.status?.state)?previousTask.id:undefined;
|
|
state.turn=uid(); userMessage(text); $('message').value=''; $('message').style.height='auto'; setBusy(true); setStatus('Enviando mensagem…','busy');
|
|
const messageId=uid();
|
|
const business_context={customer_key:value('customerKey')||null,contract_key:value('contractKey')||null,interaction_key:messageId,session_key:state.contextId};
|
|
const data={message_id:messageId,business_context,context:{business_context}};
|
|
if(value('customerKey')) data.customer_key=value('customerKey');
|
|
if(value('contractKey')) data.contract_key=value('contractKey');
|
|
try {
|
|
const gateway=value('target')==='gateway';
|
|
const result=await state.client.sendMessage(text,{contextId:state.contextId,taskId:resumeId,tenantId:value('tenant')||'default',
|
|
agentId:gateway?value('agentOverride'):value('skill'),backendId:gateway?value('skill'):undefined,data});
|
|
if(result.type==='message') {
|
|
state.view=new TaskView(); if(result.value.contextId) state.contextId=result.value.contextId;
|
|
renderResponse(displayResponse(result.value)); setStatus('Resposta recebida'); ids(); saveSession(); setBusy(false);
|
|
} else if(result.type==='task') {
|
|
state.view=new TaskView(); state.lastState=''; updateTask(result); log(`Tarefa ${state.view.task.id.slice(0,8)} recebida`); await watchTask();
|
|
} else throw new Error('Resposta de envio inesperada.');
|
|
} catch(error) {
|
|
notice(`Não foi possível confirmar o envio: ${error.message} Ele pode ter sido aceito pelo serviço; não houve reenvio automático.`);
|
|
setStatus('Envio não confirmado','error'); setBusy(false);
|
|
}
|
|
});
|
|
$('resumeButton').addEventListener('click',()=>{notice();state.turn ||= uid();watchTask();});
|
|
$('cancelButton').addEventListener('click',async()=>{
|
|
const id=state.view.task.id; if(!id || !state.connected) return;
|
|
stopWatcher(); setBusy(true); setStatus('Solicitando cancelamento…','busy');
|
|
try { updateTask({type:'task',value:await state.client.cancelTask(id)});log('Cancelamento confirmado pelo serviço');notice(); }
|
|
catch(error) {
|
|
try {updateTask({type:'task',value:await state.client.getTask(id)});} catch { /* Keep the last known Task. */ }
|
|
notice(`O serviço não confirmou o cancelamento: ${error.message} Consulte a tarefa com Retomar.`);
|
|
} finally {setBusy(false);}
|
|
});
|
|
$('newConversation').addEventListener('click',newConversation);
|
|
$('message').addEventListener('keydown',event=>{if(event.key==='Enter'&&!event.shiftKey&&!event.isComposing){event.preventDefault();$('messageForm').requestSubmit();}});
|
|
$('message').addEventListener('input',()=>{ $('message').style.height='auto';$('message').style.height=`${Math.min($('message').scrollHeight,150)}px`; });
|
|
for(const button of document.querySelectorAll('[data-prompt]')) button.addEventListener('click',()=>{if(!$('message').disabled){$('message').value=button.dataset.prompt;$('message').focus();}else notice('Conecte ao serviço para iniciar a conversa.');});
|
|
$('target').addEventListener('change',()=>{ $('skillLabel').firstChild.textContent=value('target')==='gateway'?'Backend no Gateway':'Agente'; });
|
|
for(const id of ['serviceUrl','version','target','tenant','skill','agentOverride','authHeader','authValue']) $(id).addEventListener('change',()=>{
|
|
if(state.connected){state.connected=false;$('connectionDot').className='dot muted';setStatus('Configuração alterada · conecte novamente');controls();}
|
|
});
|
|
try {
|
|
const saved=JSON.parse(sessionStorage.getItem(savedKey)||'null');
|
|
if(saved?.serviceUrl){
|
|
$('serviceUrl').value=saved.serviceUrl;$('version').value=saved.version||'auto';$('target').value=saved.target||'backend';$('tenant').value=saved.tenant||'default';$('agentOverride').value=saved.agentOverride||'';
|
|
if(saved.skill)$('skill').append(new Option(saved.skill,saved.skill));$('skill').value=saved.skill||'';
|
|
state.contextId=saved.contextId||uid();state.view=new TaskView(saved.taskId?{id:saved.taskId,contextId:state.contextId,status:{state:saved.taskState}}:{});
|
|
state.scope=JSON.stringify([saved.serviceUrl,saved.version,saved.target||'backend',saved.tenant||'default',saved.skill||'',saved.agentOverride||'']);
|
|
if(saved.taskId)notice('Uma tarefa desta aba está disponível. Conecte ao mesmo serviço e clique em Retomar para consultar o resultado.');
|
|
}
|
|
} catch { /* An unavailable or invalid saved session starts a fresh conversation. */ }
|
|
const requestedService=new URLSearchParams(location.search).get('service');
|
|
if(requestedService)$('serviceUrl').value=requestedService;
|
|
else if(!state.scope && location.port==='8000')$('serviceUrl').value=location.origin;
|
|
$('target').dispatchEvent(new Event('change'));ids();controls();
|