Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
51 lines (49 loc) · 5.23 KB
/
Copy pathserver.js
File metadata and controls
51 lines (49 loc) · 5.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { createServer } from 'node:http';
import { createState, publicState, queueActions, step, buyers, info } from './src/engine.js';
let state = createState();
let openaiApiKey = process.env.OPENAI_API_KEY || '';
// Keep model selection on the server so it can be changed without rebuilding the client.
const model = process.env.OPENAI_MODEL || 'gpt-5-mini';
const send = (res, code, body) => { res.writeHead(code, {'Content-Type':'application/json; charset=utf-8','Access-Control-Allow-Origin':'*','Access-Control-Allow-Methods':'GET,POST,OPTIONS','Access-Control-Allow-Headers':'Content-Type'}); res.end(JSON.stringify(body)); };
const readJson = req => new Promise((resolve, reject) => { let raw = ''; req.on('data', chunk => { raw += chunk; if (raw.length > 100_000) reject(new Error('Request body too large')); }); req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('Invalid JSON')); } }); req.on('error', reject); });
const aiSchema = { type:'object', additionalProperties:false, required:['decisions'], properties:{ decisions:{ type:'array', maxItems:5, items:{ type:'object', additionalProperties:false, required:['buyer','listingId','buy','review'], properties:{ buyer:{type:'string'}, listingId:{type:'string'}, buy:{type:'boolean'}, review:{ type:'object', additionalProperties:false, required:['score','text'], properties:{score:{type:'number'},text:{type:'string'}} } } } } } };
const responseText = body => {
// `output_text` is an SDK convenience property. Raw Responses API JSON stores
// the generated text inside output message content instead.
if (typeof body?.output_text === 'string') return body.output_text;
return body?.output
?.flatMap(item => item?.content || [])
.filter(content => content?.type === 'output_text' && typeof content.text === 'string')
.map(content => content.text)
.join('');
};
async function getBuyerDecisions(currentState) {
const listings = currentState.listings.filter(listing => listing.inventory > 0).map(listing => ({ id:listing.id, product:info[listing.productId].name, category:info[listing.productId].category, price:listing.price, rating:Number(listing.rating.toFixed(1)), inventory:listing.inventory }));
const prompt = `你正在扮演電商平台上的五位買家。今天是第 ${currentState.day + 1} 天。每位買家最多買一件,也可以不買。只從提供的 listingId 中選擇;依預算、喜好、價格與評分做自然決定。buy 為 false 時仍提供 review 但 text 留空。buy 為 true 時,寫一則繁體中文、30 字內的真實短評,score 是 1 到 5。\n買家:${JSON.stringify(buyers)}\n商品:${JSON.stringify(listings)}`;
const response = await fetch('https://api.openai.com/v1/responses', { method:'POST', headers:{'Content-Type':'application/json','Authorization':`Bearer ${openaiApiKey}`}, body:JSON.stringify({ model, store:false, input:[{role:'developer',content:'請嚴格依照 JSON schema 回傳,不要購買不存在的商品。'},{role:'user',content:prompt}], text:{format:{type:'json_schema',name:'buyer_decisions',strict:true,schema:aiSchema}} }) });
const body = await response.json();
if (!response.ok) throw new Error(body?.error?.message || 'OpenAI request failed');
const text = responseText(body);
if (!text) throw new Error('OpenAI 未回傳可用的買家決策。請確認模型與 API Key 後再試一次。');
try {
const decisions = JSON.parse(text).decisions;
if (!Array.isArray(decisions)) throw new Error('missing decisions');
return decisions;
} catch {
throw new Error('OpenAI 回傳的買家決策格式無效,請再試一次。');
}
}
createServer(async (req, res) => {
if (req.method === 'OPTIONS') return send(res, 204, {});
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'GET' && url.pathname === '/api/state') return send(res, 200, publicState(state));
if (req.method === 'GET' && url.pathname === '/api/openai') return send(res, 200, {enabled:Boolean(openaiApiKey), model});
const body = await readJson(req);
if (req.method === 'POST' && url.pathname === '/api/openai') { if (typeof body.apiKey !== 'string' || !body.apiKey.trim()) throw new Error('請輸入 API Key'); openaiApiKey = body.apiKey.trim(); return send(res, 200, {enabled:true, model}); }
if (req.method === 'POST' && url.pathname === '/api/reset') { state = createState(body.seed); return send(res, 200, publicState(state)); }
if (req.method === 'POST' && url.pathname === '/api/actions') { const actions = Array.isArray(body.actions) ? body.actions : [body]; const result = queueActions(state, actions); return send(res, result.rejected.length ? 422 : 202, {...result, pendingActions:state.pendingActions.length}); }
if (req.method === 'POST' && url.pathname === '/api/step') { const days = Math.min(365, Math.max(1, Number(body.days) || 1)); for (let day = 0; day < days; day++) state = step(state, openaiApiKey ? await getBuyerDecisions(state) : null); return send(res, 200, publicState(state)); }
return send(res, 404, {error:'Not found'});
} catch (error) { return send(res, 400, {error:error.message}); }
}).listen(process.env.PORT || 3001, '127.0.0.1', () => console.log(`Simulator API listening on http://localhost:${process.env.PORT || 3001}`));