diff --git a/.env.example b/.env.example index 917413c..745cbe0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/api/agent-chat.js b/api/agent-chat.js new file mode 100644 index 0000000..6dfbfd9 --- /dev/null +++ b/api/agent-chat.js @@ -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." }); + } +} diff --git a/index.html b/index.html index b51b866..b466f6e 100644 --- a/index.html +++ b/index.html @@ -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 @@ -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){
-
${msgs.map(m=>`
${esc(m.text)}
`).join("")}
+
${msgs.map(m=>`
${esc(m.text)}
`).join("")}
`; - 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");