Add AI-backed agent chat
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user