ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
|
||||
const state = {
|
||||
callId: null,
|
||||
plan: null,
|
||||
pc: null,
|
||||
dc: null,
|
||||
stream: null,
|
||||
statsTimer: null,
|
||||
remoteAudioTrack: false,
|
||||
localAudioTrack: false,
|
||||
lastStats: null,
|
||||
audioCommitted: false,
|
||||
};
|
||||
|
||||
function mode() {
|
||||
return document.querySelector('input[name="mode"]:checked').value;
|
||||
}
|
||||
|
||||
function setStatus(value) {
|
||||
$('#status').textContent = value;
|
||||
document.body.dataset.status = value;
|
||||
}
|
||||
|
||||
function appendTurn(speaker, text) {
|
||||
if (!text) return;
|
||||
const p = document.createElement('p');
|
||||
p.className = 'turn';
|
||||
const b = document.createElement('b');
|
||||
b.textContent = speaker === 'agent' ? 'Agent: ' : 'You: ';
|
||||
p.append(b, document.createTextNode(text));
|
||||
$('#transcript').appendChild(p);
|
||||
$('#transcript').scrollTop = $('#transcript').scrollHeight;
|
||||
}
|
||||
|
||||
async function jsonFetch(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {'Content-Type': 'application/json', ...(options.headers || {})},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function saveEvent(event) {
|
||||
if (!state.callId) return;
|
||||
try {
|
||||
await jsonFetch(`/api/calls/${state.callId}/events`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({event}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('event receipt failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
function sendControl(event) {
|
||||
if (!state.dc || state.dc.readyState !== 'open') throw new Error('data channel is not open');
|
||||
state.dc.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async function publishReady() {
|
||||
if (!state.pc) return;
|
||||
const open = state.dc?.readyState === 'open';
|
||||
const connected = ['connected', 'completed'].includes(state.pc.iceConnectionState);
|
||||
$('#transport').textContent = `WebRTC · ICE ${state.pc.iceConnectionState} · data ${state.dc?.readyState || 'new'}`;
|
||||
if (open && connected) setStatus('connected · listening');
|
||||
await saveEvent({
|
||||
type: 'rtc.ready',
|
||||
ice_connection_state: state.pc.iceConnectionState,
|
||||
data_channel_open: open,
|
||||
local_audio_track: state.localAudioTrack,
|
||||
remote_audio_track: state.remoteAudioTrack,
|
||||
});
|
||||
}
|
||||
|
||||
async function collectStats() {
|
||||
if (!state.pc) return null;
|
||||
const totals = {
|
||||
type: 'rtc.stats',
|
||||
ice_connection_state: state.pc.iceConnectionState,
|
||||
inbound_packets: 0,
|
||||
inbound_bytes: 0,
|
||||
outbound_packets: 0,
|
||||
outbound_bytes: 0,
|
||||
};
|
||||
const reports = await state.pc.getStats();
|
||||
reports.forEach((report) => {
|
||||
const kind = report.kind || report.mediaType;
|
||||
if (kind !== 'audio') return;
|
||||
if (report.type === 'inbound-rtp' && !report.isRemote) {
|
||||
totals.inbound_packets += report.packetsReceived || 0;
|
||||
totals.inbound_bytes += report.bytesReceived || 0;
|
||||
}
|
||||
if (report.type === 'outbound-rtp' && !report.isRemote) {
|
||||
totals.outbound_packets += report.packetsSent || 0;
|
||||
totals.outbound_bytes += report.bytesSent || 0;
|
||||
}
|
||||
});
|
||||
state.lastStats = totals;
|
||||
await saveEvent(totals);
|
||||
return totals;
|
||||
}
|
||||
|
||||
async function handleServerEvent(event) {
|
||||
if (event.type === 'agent.caption') appendTurn('agent', event.text);
|
||||
if (event.type === 'user.caption') appendTurn('user', event.text);
|
||||
if (event.type === 'tool.result') {
|
||||
setStatus('task completed · Agent audio playing');
|
||||
$('#evidence').textContent = JSON.stringify(event, null, 2);
|
||||
}
|
||||
if (event.type === 'error') {
|
||||
setStatus('error');
|
||||
$('#evidence').textContent = JSON.stringify(event.error || event, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function createRequest() {
|
||||
if (mode() === 'react') return {mode: 'react', task: $('#task').value};
|
||||
return {
|
||||
mode: 'direct',
|
||||
callee_name: $('#callee-name').value,
|
||||
goal: $('#goal').value,
|
||||
context: $('#context').value,
|
||||
instructions: $('#instructions').value,
|
||||
};
|
||||
}
|
||||
|
||||
async function startCall() {
|
||||
$('#start').disabled = true;
|
||||
setStatus('real LLM planning');
|
||||
try {
|
||||
const created = await jsonFetch('/api/calls', {method: 'POST', body: JSON.stringify(createRequest())});
|
||||
state.callId = created.call_id;
|
||||
state.plan = created.plan;
|
||||
state.audioCommitted = false;
|
||||
$('#call-id').textContent = state.callId;
|
||||
$('#evidence').textContent = JSON.stringify({plan: state.plan}, null, 2);
|
||||
|
||||
const pc = new RTCPeerConnection();
|
||||
state.pc = pc;
|
||||
const audio = $('#remote-audio');
|
||||
pc.ontrack = async (event) => {
|
||||
audio.srcObject = event.streams[0];
|
||||
state.remoteAudioTrack = event.track.kind === 'audio';
|
||||
try { await audio.play(); } catch (error) { console.warn('autoplay pending', error); }
|
||||
await publishReady();
|
||||
};
|
||||
pc.oniceconnectionstatechange = publishReady;
|
||||
pc.onconnectionstatechange = publishReady;
|
||||
|
||||
setStatus('requesting microphone');
|
||||
state.stream = await navigator.mediaDevices.getUserMedia({audio: true, video: false});
|
||||
const track = state.stream.getAudioTracks()[0];
|
||||
if (!track) throw new Error('no microphone audio track was returned');
|
||||
state.localAudioTrack = true;
|
||||
pc.addTrack(track, state.stream);
|
||||
|
||||
const dc = pc.createDataChannel('accessibility-and-control');
|
||||
state.dc = dc;
|
||||
dc.onmessage = (message) => handleServerEvent(JSON.parse(message.data)).catch(console.error);
|
||||
dc.onclose = publishReady;
|
||||
dc.onopen = async () => {
|
||||
await publishReady();
|
||||
sendControl({type: 'client.ready'});
|
||||
$('#commit-audio').disabled = false;
|
||||
};
|
||||
|
||||
setStatus('negotiating WebRTC');
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
const answerResponse = await fetch(`/api/calls/${state.callId}/session`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/sdp'},
|
||||
body: offer.sdp,
|
||||
});
|
||||
const answerSdp = await answerResponse.text();
|
||||
if (!answerResponse.ok) throw new Error(answerSdp);
|
||||
await pc.setRemoteDescription({type: 'answer', sdp: answerSdp});
|
||||
state.statsTimer = setInterval(() => collectStats().catch(console.warn), 750);
|
||||
$('#hangup').disabled = false;
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
$('#evidence').textContent = String(error);
|
||||
$('#start').disabled = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function commitAudio() {
|
||||
if (state.audioCommitted) throw new Error('microphone audio was already committed');
|
||||
state.audioCommitted = true;
|
||||
$('#commit-audio').disabled = true;
|
||||
setStatus('Whisper ASR · real LLM dialogue');
|
||||
sendControl({type: 'client.audio.commit'});
|
||||
}
|
||||
|
||||
async function hangup(reason = 'user_hangup') {
|
||||
if (!state.callId) return null;
|
||||
if (state.statsTimer) clearInterval(state.statsTimer);
|
||||
await collectStats();
|
||||
state.stream?.getTracks().forEach((track) => track.stop());
|
||||
state.dc?.close();
|
||||
state.pc?.close();
|
||||
const record = await jsonFetch(`/api/calls/${state.callId}/finish`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({reason}),
|
||||
});
|
||||
setStatus(record.acceptance.passed ? 'completed' : 'ended');
|
||||
$('#evidence').textContent = JSON.stringify(record, null, 2);
|
||||
$('#hangup').disabled = true;
|
||||
$('#commit-audio').disabled = true;
|
||||
return record;
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="mode"]').forEach((radio) => {
|
||||
radio.addEventListener('change', () => {
|
||||
const react = mode() === 'react';
|
||||
$('#react-fields').hidden = !react;
|
||||
$('#direct-fields').hidden = react;
|
||||
});
|
||||
});
|
||||
$('#start').addEventListener('click', () => startCall().catch(console.error));
|
||||
$('#commit-audio').addEventListener('click', () => commitAudio().catch(console.error));
|
||||
$('#hangup').addEventListener('click', () => hangup().catch(console.error));
|
||||
|
||||
window.exp92 = {state, startCall, commitAudio, hangup, collectStats};
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('mode') === 'direct') {
|
||||
document.querySelector('input[name="mode"][value="direct"]').click();
|
||||
}
|
||||
const queryFields = {
|
||||
task: '#task',
|
||||
callee_name: '#callee-name',
|
||||
goal: '#goal',
|
||||
context: '#context',
|
||||
instructions: '#instructions',
|
||||
};
|
||||
Object.entries(queryFields).forEach(([key, selector]) => {
|
||||
if (params.has(key)) $(selector).value = params.get(key);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Phone Agent add-on · WebRTC Call Agent</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<p class="eyebrow">AI Agent Book · Phone Agent add-on</p>
|
||||
<h1>A voice agent that calls you in the browser</h1>
|
||||
<p class="lede">No phone number or PSTN account is needed. You explicitly join this local WebRTC session and can hang up at any time.</p>
|
||||
</header>
|
||||
|
||||
<section class="card setup">
|
||||
<div class="mode-row">
|
||||
<label><input type="radio" name="mode" value="react" checked> ReAct plan</label>
|
||||
<label><input type="radio" name="mode" value="direct"> Direct parameters</label>
|
||||
</div>
|
||||
|
||||
<div id="react-fields">
|
||||
<label for="task">Natural-language task</label>
|
||||
<textarea id="task" rows="4">Call me to arrange a dental checkup for Jane Doe. I did not include a time, so ask me for it, repeat the details, and save the confirmation I provide.</textarea>
|
||||
</div>
|
||||
|
||||
<div id="direct-fields" hidden>
|
||||
<label for="callee-name">Participant name</label>
|
||||
<input id="callee-name" value="Jane Doe">
|
||||
<label for="goal">Goal</label>
|
||||
<textarea id="goal" rows="2">Collect and confirm Jane Doe's preferred dental-checkup time.</textarea>
|
||||
<label for="context">Context</label>
|
||||
<textarea id="context" rows="2">Tuesday afternoon from 2pm to 4pm is available.</textarea>
|
||||
<label for="instructions">Instructions</label>
|
||||
<textarea id="instructions" rows="3">Ask for one exact time, repeat it, request confirmation, and save the confirmed time and confirmation number with complete_task.</textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="start" type="button">Join call and enable microphone</button>
|
||||
<button id="hangup" class="secondary" type="button" disabled>Hang up</button>
|
||||
</div>
|
||||
<p class="privacy">Provider credentials stay on the local server. The browser receives only the SDP answer; no credential is embedded in this page.</p>
|
||||
</section>
|
||||
|
||||
<section class="card status-card">
|
||||
<div><span class="label">Status</span><strong id="status">idle</strong></div>
|
||||
<div><span class="label">Call ID</span><code id="call-id">—</code></div>
|
||||
<div><span class="label">Transport</span><span id="transport">WebRTC · not connected</span></div>
|
||||
</section>
|
||||
|
||||
<section class="card conversation">
|
||||
<h2>Audio transcript captions</h2>
|
||||
<div id="transcript" aria-live="polite"></div>
|
||||
<div class="actions">
|
||||
<button id="commit-audio" class="secondary" type="button" disabled>Finish speaking</button>
|
||||
</div>
|
||||
<p class="privacy">Speak into the microphone, then choose “Finish speaking.” User meaning comes only from server ASR over the microphone RTP track. The data channel carries this control event and accessibility captions; it never supplies the canonical user transcript.</p>
|
||||
</section>
|
||||
|
||||
<section class="card evidence">
|
||||
<h2>Session evidence</h2>
|
||||
<pre id="evidence">Start a call to see the negotiated local aiortc transport record.</pre>
|
||||
</section>
|
||||
|
||||
<audio id="remote-audio" autoplay></audio>
|
||||
</main>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #14221d;
|
||||
background: #edf3ef;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
main { width: min(880px, calc(100% - 32px)); margin: 48px auto 72px; }
|
||||
header { margin-bottom: 28px; }
|
||||
h1 { margin: 6px 0 12px; font-size: clamp(2rem, 6vw, 4.2rem); line-height: 0.98; letter-spacing: -0.045em; }
|
||||
h2 { margin-top: 0; font-size: 1.15rem; }
|
||||
.eyebrow, .label { color: #356450; text-transform: uppercase; letter-spacing: 0.09em; font-size: 0.76rem; font-weight: 750; }
|
||||
.lede { max-width: 700px; color: #41534b; font-size: 1.08rem; line-height: 1.55; }
|
||||
.card { background: #fff; border: 1px solid #cad8d0; border-radius: 18px; padding: 22px; margin: 14px 0; box-shadow: 0 12px 32px rgba(29, 67, 50, 0.06); }
|
||||
.mode-row, .actions, .text-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.mode-row { margin-bottom: 18px; }
|
||||
label { display: block; margin: 12px 0 6px; font-weight: 650; }
|
||||
input, textarea, button { font: inherit; }
|
||||
input:not([type="radio"]), textarea { width: 100%; border: 1px solid #aebfb6; border-radius: 10px; padding: 11px 12px; background: #fbfdfc; color: inherit; }
|
||||
textarea { resize: vertical; }
|
||||
button { border: 0; border-radius: 999px; padding: 11px 18px; background: #176b49; color: white; font-weight: 720; cursor: pointer; }
|
||||
button.secondary { background: #dde8e2; color: #254537; }
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.actions { margin-top: 20px; }
|
||||
.privacy { margin-bottom: 0; color: #68776f; font-size: 0.86rem; }
|
||||
.status-card { display: grid; grid-template-columns: 0.7fr 1fr 1.2fr; gap: 16px; }
|
||||
.status-card div { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||
code { overflow-wrap: anywhere; }
|
||||
#transcript { min-height: 110px; max-height: 330px; overflow: auto; padding: 12px; background: #f3f7f4; border-radius: 12px; }
|
||||
.turn { margin: 0 0 10px; line-height: 1.45; }
|
||||
.turn b { color: #176b49; }
|
||||
.text-row { flex-wrap: nowrap; }
|
||||
.text-row input { flex: 1; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; color: #31463d; font-size: 0.82rem; }
|
||||
@media (max-width: 680px) {
|
||||
main { margin-top: 24px; }
|
||||
.status-card { grid-template-columns: 1fr; }
|
||||
.text-row { flex-wrap: wrap; }
|
||||
}
|
||||
Reference in New Issue
Block a user