Add AI-backed agent chat

This commit is contained in:
Theodore Song
2026-07-16 22:09:02 -04:00
parent be0d0bddb5
commit d203b10963
3 changed files with 219 additions and 4 deletions
+4
View File
@@ -83,3 +83,7 @@ RISK_ADMIN_TOKEN=
ADMIN_ALERT_EMAIL=
CUSTOMER_SUPPORT_EMAIL=
RISK_ADMIN_WALLET=
# Agent chat
OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.6-luna
+168
View File
@@ -0,0 +1,168 @@
const MAX_HISTORY = 12;
const MAX_POSITIONS = 10;
const MAX_SUGGESTIONS = 8;
function cleanText(value, max = 1200) {
return String(value || "").replace(/\s+/g, " ").trim().slice(0, max);
}
function safeNumber(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function compactPosition(pos) {
return {
question: cleanText(pos.question, 180),
side: cleanText(pos.side, 8),
entry_price: safeNumber(pos.entry_price),
current_price: safeNumber(pos.current_price),
value: safeNumber(pos.value),
unrealized_pnl: safeNumber(pos.unrealized_pnl),
conviction: safeNumber(pos.conviction),
category: cleanText(pos.category, 60),
opened_at: cleanText(pos.opened_at, 40),
url: cleanText(pos.url, 240),
};
}
function compactSuggestion(sug) {
return {
question: cleanText(sug.question, 180),
side: cleanText(sug.side, 8),
entry_price: safeNumber(sug.entry_price),
conviction: safeNumber(sug.conviction),
edge: safeNumber(sug.edge),
category: cleanText(sug.category, 60),
rationale: cleanText(sug.rationale, 240),
url: cleanText(sug.url, 240),
};
}
function compactHistory(history) {
return (Array.isArray(history) ? history : []).slice(-MAX_HISTORY).map((m) => ({
role: m && m.role === "user" ? "user" : "assistant",
text: cleanText(m && m.text, 900),
})).filter((m) => m.text);
}
function outputText(data) {
if (data && typeof data.output_text === "string") return data.output_text.trim();
const chunks = [];
for (const item of data && Array.isArray(data.output) ? data.output : []) {
for (const content of Array.isArray(item.content) ? item.content : []) {
if (content.type === "output_text" && content.text) chunks.push(content.text);
if (content.type === "text" && content.text) chunks.push(content.text);
}
}
return chunks.join("\n").trim();
}
export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store");
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({ ok: false, error: "Method not allowed" });
}
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
return res.status(501).json({
ok: false,
code: "missing_openai_key",
error: "Agent AI chat needs OPENAI_API_KEY in Vercel environment variables.",
});
}
const body = req.body || {};
const question = cleanText(body.question, 1000);
if (!question) return res.status(400).json({ ok: false, error: "Question is required." });
const agent = body.agent || {};
const portfolio = body.portfolio || {};
const context = {
agent: {
id: cleanText(agent.id, 40),
name: cleanText(agent.name, 80),
kind: cleanText(agent.kind, 40),
style: cleanText(agent.style || agent.blurb, 800),
voice: cleanText(agent.voice, 200),
},
portfolio: {
equity: safeNumber(portfolio.equity),
cash: safeNumber(portfolio.cash),
return_pct: safeNumber(portfolio.return_pct),
pnl: safeNumber(portfolio.pnl),
rank: safeNumber(portfolio.rank),
open_positions: safeNumber(portfolio.open_positions),
last_decision: cleanText(portfolio.last_decision, 700),
recent_actions: (Array.isArray(portfolio.recent_actions) ? portfolio.recent_actions : []).slice(-8).map((x) => cleanText(x, 260)),
positions: (Array.isArray(portfolio.positions) ? portfolio.positions : []).slice(0, MAX_POSITIONS).map(compactPosition),
snapshots: (Array.isArray(portfolio.snapshots) ? portfolio.snapshots : []).slice(-8).map((s) => ({
date: cleanText(s.date || s.timestamp, 40),
equity: safeNumber(s.equity),
return_pct: safeNumber(s.return_pct),
open_positions: safeNumber(s.open_positions),
})),
},
leaderboard: (Array.isArray(body.leaderboard) ? body.leaderboard : []).slice(0, 10).map((r) => ({
name: cleanText(r.name, 80),
return_pct: safeNumber(r.return_pct),
equity: safeNumber(r.equity),
rank: safeNumber(r.rank),
})),
suggestions: (Array.isArray(body.suggestions) ? body.suggestions : []).slice(0, MAX_SUGGESTIONS).map(compactSuggestion),
history: compactHistory(body.history),
};
const instructions = [
`You are ${context.agent.name || "a Polymarket paper-trading agent"} inside Poly Arena.`,
"Speak in first person as the selected agent, like a real chat partner.",
"Answer the user's exact question instead of repeating a generic performance summary.",
"Use the supplied portfolio, positions, leaderboard, suggestions, and chat history as your facts.",
"Be specific about trades, risk, performance, and uncertainty when the data is available.",
"Do not claim you can guarantee profits or force agents to make money.",
"Do not give personalized financial advice. Keep it framed as paper trading, research, or manual review.",
"If asked what you will do next, describe likely decision rules, not a guaranteed action.",
"Keep responses concise: 2 to 5 short paragraphs or a tight bullet list.",
].join("\n");
const input = [
{
role: "user",
content: [{
type: "input_text",
text: `Context JSON:\n${JSON.stringify(context)}\n\nUser message:\n${question}`,
}],
},
];
try {
const response = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: process.env.OPENAI_MODEL || "gpt-5.6-luna",
instructions,
input,
max_output_tokens: 550,
}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
return res.status(response.status).json({
ok: false,
error: data && data.error && data.error.message ? data.error.message : "OpenAI chat request failed.",
});
}
const text = outputText(data);
return res.status(200).json({ ok: true, text: text || "I am here, but I could not form a useful answer from the current context." });
} catch (err) {
return res.status(500).json({ ok: false, error: err && err.message ? err.message : "Agent chat failed." });
}
}
+47 -4
View File
@@ -184,6 +184,7 @@ body:not(.personal-mode) [data-tab="live"]{display:none!important}
.chat-msg{border:1px solid var(--border);border-radius:14px;padding:11px 13px;font-size:13px;line-height:1.45;white-space:pre-wrap}
.chat-msg.user{align-self:flex-end;max-width:82%;background:rgba(124,140,255,.16);border-color:rgba(124,140,255,.35)}
.chat-msg.agent{align-self:flex-start;max-width:92%;background:rgba(255,255,255,.045)}
.chat-msg.pending{color:var(--muted);font-style:italic}
.chat-input-row{display:flex;gap:8px;margin-top:12px}
.chat-input-row .input{flex:1}
@media (max-width:760px){.agent-chat{grid-template-columns:1fr}.chat-msg.user,.chat-msg.agent{max-width:100%}}
@@ -722,7 +723,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
</section>
<footer>
Build 8400310+risk-tune · Paper trading only · Live prices from Polymarket's public Gamma API · Not financial advice ·
Build ai-chat · Paper trading only · Live prices from Polymarket's public Gamma API · Not financial advice ·
<a class="market-link" href="https://github.com/theodore-song/polymarket-analyst" target="_blank" rel="noopener">Source on GitHub</a>
</footer>
</div>
@@ -2246,6 +2247,36 @@ function agentChatReply(agentId,question,history=[]){
}
return conversationalAgentReply(cfg,row,p,q);
}
function agentChatPayload(agentId,question,history=[]){
const st=loadState(),cfg=agentById(agentId),p=st.agents[agentId]||defaultPortfolio();
const rows=board(),row=rows.find(r=>r.c.id===agentId)||{c:cfg,p,eq:equity(p),pnl:equity(p)-p.starting_balance,ret:(equity(p)/p.starting_balance-1)*100};
const rank=(rows.findIndex(r=>r.c.id===agentId)+1)||rows.length;
const ranked=cfg.rank?safeRankSuggestions(cfg,(loadSuggestions().suggestions||[])):(loadSuggestions().suggestions||[]);
return {
agent:{id:cfg.id,name:cfg.name,kind:cfg.kind,blurb:agentPlainBlurb(cfg,st),voice:agentVoice(cfg).tone},
question,
history:(history||[]).slice(-12).filter(m=>!m.pending).map(m=>({role:m.role==="user"?"user":"assistant",text:m.text})),
portfolio:{
equity:+row.eq.toFixed(2),cash:+(p.cash||0).toFixed(2),return_pct:+row.ret.toFixed(2),pnl:+row.pnl.toFixed(2),
rank,open_positions:(p.positions||[]).length,last_decision:decisionSummary(p),
positions:(p.positions||[]).slice().sort((a,b)=>Math.abs(b.unrealized_pnl||0)-Math.abs(a.unrealized_pnl||0)).slice(0,10),
recent_actions:(p.history||[]).slice(-8).map(h=>h.detail||`${h.action||"ACTION"} ${h.question||""}`),
snapshots:(p.snapshots||[]).slice(-8),
},
leaderboard:rows.slice(0,10).map((r,i)=>({rank:i+1,name:r.c.name,return_pct:+r.ret.toFixed(2),equity:+r.eq.toFixed(2)})),
suggestions:ranked.filter(s=>s.side==="YES"||s.side==="NO").slice(0,8),
};
}
async function askAgentAI(agentId,question,history=[]){
const r=await fetch("/api/agent-chat",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify(agentChatPayload(agentId,question,history)),
});
const data=await r.json().catch(()=>({}));
if(!r.ok||!data.ok)throw new Error(data.error||"Agent AI chat is not available yet.");
return data.text||"I am here, but I do not have a strong answer from the current context.";
}
function renderAgentChat(agentId){
const root=$("agentChatRoot");if(!root)return;
const cfg=agentById(agentId),st=loadState(),p=st.agents[agentId]||defaultPortfolio(),eq=equity(p),ret=(eq/p.starting_balance-1)*100;
@@ -2267,16 +2298,28 @@ function renderAgentChat(agentId){
</div>
</div>
<div class="chat-panel">
<div class="chat-window" id="agentChatWindow">${msgs.map(m=>`<div class="chat-msg ${m.role==="user"?"user":"agent"}">${esc(m.text)}</div>`).join("")}</div>
<div class="chat-window" id="agentChatWindow">${msgs.map(m=>`<div class="chat-msg ${m.role==="user"?"user":"agent"} ${m.pending?"pending":""}">${esc(m.text)}</div>`).join("")}</div>
<div class="chat-input-row"><input class="input" id="agentChatInput" placeholder="Ask ${esc(cfg.name)} about performance, trades, risk, or plans..." /><button class="btn primary" id="agentChatSend">Send</button></div>
</div>
</div>`;
const send=(text)=>{
const send=async(text)=>{
const q=String(text||"").trim();if(!q)return;
const next=loadAgentChats();if(!next[agentId])next[agentId]=agentChatSeed(agentId);
const history=next[agentId].slice();
next[agentId].push({role:"user",text:q},{role:"agent",text:agentChatReply(agentId,q,history)});
next[agentId].push({role:"user",text:q},{role:"agent",text:"Thinking with my live portfolio context...",pending:true});
next[agentId]=next[agentId].slice(-60);saveAgentChats(next);renderAgentChat(agentId);
let answer;
try{
answer=await askAgentAI(agentId,q,history);
}catch(e){
const local=agentChatReply(agentId,q,history);
answer=`${local}\n\nAI chat is not fully connected yet: ${e.message||"missing server configuration"}`;
}
const done=loadAgentChats();if(!done[agentId])done[agentId]=[];
const idx=[...done[agentId]].reverse().findIndex(m=>m.pending);
if(idx>=0)done[agentId][done[agentId].length-1-idx]={role:"agent",text:answer};
else done[agentId].push({role:"agent",text:answer});
done[agentId]=done[agentId].slice(-60);saveAgentChats(done);renderAgentChat(agentId);
};
document.querySelectorAll("[data-chat-prompt]").forEach(btn=>btn.onclick=()=>send(btn.dataset.chatPrompt));
const input=$("agentChatInput"),sendBtn=$("agentChatSend");