Spaces:
Running
Running
Add backend coaching agent integration
Browse files- .env.example +3 -0
- src/agent-session.ts +96 -0
- src/analysis.ts +5 -1
- src/config.ts +2 -0
- src/server.ts +67 -1
.env.example
CHANGED
|
@@ -4,6 +4,9 @@ NODE_ENV=development
|
|
| 4 |
FRONTEND_URL=http://localhost:8080
|
| 5 |
BACKEND_URL=http://localhost:4000
|
| 6 |
ANALYSIS_PROXY_SECRET=replace-with-a-random-secret-of-at-least-32-characters
|
|
|
|
|
|
|
|
|
|
| 7 |
# Optional: use the same random secret in the Colab notebook to register its worker.
|
| 8 |
# COLAB_WORKER_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
|
| 9 |
SUPABASE_URL=https://your-project.supabase.co
|
|
|
|
| 4 |
FRONTEND_URL=http://localhost:8080
|
| 5 |
BACKEND_URL=http://localhost:4000
|
| 6 |
ANALYSIS_PROXY_SECRET=replace-with-a-random-secret-of-at-least-32-characters
|
| 7 |
+
# Deploy the stateless Agent service separately and use its private URL/key here.
|
| 8 |
+
AGENT_API_URL=https://your-agent-service.example.com
|
| 9 |
+
AGENT_API_KEY=replace-with-the-Agent-service-secret
|
| 10 |
# Optional: use the same random secret in the Colab notebook to register its worker.
|
| 11 |
# COLAB_WORKER_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
|
| 12 |
SUPABASE_URL=https://your-project.supabase.co
|
src/agent-session.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
| 2 |
+
import { env } from "./config.js";
|
| 3 |
+
|
| 4 |
+
type ActionResult = { id: string; label: string; rawAnalytics?: Record<string, unknown>; inActionMetrics?: { name: string; value: unknown; unit?: string }[] };
|
| 5 |
+
type SessionResult = { id: string; playerId: string; actions: ActionResult[] };
|
| 6 |
+
|
| 7 |
+
function ageContext(profile: Record<string, unknown>) {
|
| 8 |
+
const value = profile.bdate;
|
| 9 |
+
if (typeof value !== "string") return profile;
|
| 10 |
+
const birth = new Date(`${value.slice(0, 10)}T00:00:00Z`);
|
| 11 |
+
if (Number.isNaN(birth.getTime())) return profile;
|
| 12 |
+
const now = new Date();
|
| 13 |
+
let age = now.getUTCFullYear() - birth.getUTCFullYear();
|
| 14 |
+
if (now.getUTCMonth() < birth.getUTCMonth() || (now.getUTCMonth() === birth.getUTCMonth() && now.getUTCDate() < birth.getUTCDate())) age--;
|
| 15 |
+
return { ...profile, ageYears: age };
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
async function analysisResults(sessionId: string, userId: string): Promise<SessionResult> {
|
| 19 |
+
const response = await fetch(new URL(`/api/sessions/${sessionId}`, env.ANALYSIS_API_URL), {
|
| 20 |
+
headers: { "x-sporalize-proxy-secret": env.ANALYSIS_PROXY_SECRET, "x-sporalize-user-id": userId },
|
| 21 |
+
signal: AbortSignal.timeout(120000),
|
| 22 |
+
});
|
| 23 |
+
if (!response.ok) throw new Error(`Unable to load results for session ${sessionId} (${response.status})`);
|
| 24 |
+
return await response.json() as SessionResult;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export async function generateSessionFeedback(db: SupabaseClient, sessionId: string, userId: string) {
|
| 28 |
+
if (!env.AGENT_API_URL || !env.AGENT_API_KEY) throw new Error("Agent service is not configured");
|
| 29 |
+
const { data: session, error: sessionError } = await db.from("sessions")
|
| 30 |
+
.select("session_id,player_id,players(player_id,bdate,height,weight,position,gender)")
|
| 31 |
+
.eq("session_id", sessionId).eq("user_id", userId).maybeSingle();
|
| 32 |
+
if (sessionError || !session) throw new Error(`Unable to load completed session context: ${sessionError?.message ?? "session missing"}`);
|
| 33 |
+
const rawPlayer = Array.isArray(session.players) ? session.players[0] : session.players;
|
| 34 |
+
const player = rawPlayer ? ageContext(rawPlayer as Record<string, unknown>) : rawPlayer;
|
| 35 |
+
if (!player) throw new Error("Session player context is missing");
|
| 36 |
+
const result = await analysisResults(sessionId, userId);
|
| 37 |
+
const actionResults = new Map(result.actions.map((action) => [action.id, action]));
|
| 38 |
+
const { data: currentActions, error: actionsError } = await db.from("actions")
|
| 39 |
+
.select("action_id,type").eq("session_id", sessionId);
|
| 40 |
+
if (actionsError) throw new Error(`Unable to load completed actions: ${actionsError.message}`);
|
| 41 |
+
|
| 42 |
+
const actions = [];
|
| 43 |
+
for (const action of currentActions ?? []) {
|
| 44 |
+
const current = actionResults.get(action.action_id);
|
| 45 |
+
if (!current) continue;
|
| 46 |
+
const { data: examples, error: examplesError } = await db.from("annotations")
|
| 47 |
+
.select("action_id,score,annotations_notes(note),actions!inner(action_id,session_id,type,sessions!inner(player_id,players!inner(gender,bdate,height,weight,position)))")
|
| 48 |
+
.eq("actions.type", action.type)
|
| 49 |
+
.eq("actions.sessions.players.gender", player.gender)
|
| 50 |
+
.neq("action_id", action.action_id)
|
| 51 |
+
.limit(10);
|
| 52 |
+
if (examplesError) throw new Error(`Unable to load human annotations: ${examplesError.message}`);
|
| 53 |
+
const referenceRows = (examples ?? []).filter((row: any) =>
|
| 54 |
+
row.score !== null || (row.annotations_notes ?? []).some((note: { note: string }) => typeof note.note === "string" && note.note.trim().length > 0));
|
| 55 |
+
const referenceSessionIds = [...new Set(referenceRows.map((row: any) => row.actions?.session_id).filter(Boolean))] as string[];
|
| 56 |
+
const referenceResults = new Map<string, SessionResult>();
|
| 57 |
+
await Promise.all(referenceSessionIds.map(async (id) => {
|
| 58 |
+
try { referenceResults.set(id, await analysisResults(id, userId)); }
|
| 59 |
+
catch (error) { console.warn("Skipping unavailable annotation reference results", { sessionId: id, error }); }
|
| 60 |
+
}));
|
| 61 |
+
const references = referenceRows.flatMap((row: any) => {
|
| 62 |
+
const related = Array.isArray(row.actions) ? row.actions[0] : row.actions;
|
| 63 |
+
const refPlayerValue = related?.sessions?.players;
|
| 64 |
+
const refPlayer = refPlayerValue ? ageContext(refPlayerValue as Record<string, unknown>) : refPlayerValue;
|
| 65 |
+
const refSession = related?.session_id ? referenceResults.get(related.session_id) : undefined;
|
| 66 |
+
const refAction = refSession?.actions.find((item) => item.id === row.action_id);
|
| 67 |
+
if (!related || !refPlayer || !refAction) return [];
|
| 68 |
+
return [{ action_id: row.action_id, action_type: related.type, player: refPlayer,
|
| 69 |
+
results: { metrics: refAction.inActionMetrics ?? [], analytics: refAction.rawAnalytics ?? {} },
|
| 70 |
+
score: row.score, notes: (row.annotations_notes ?? []).map((note: { note: string }) => note.note) }];
|
| 71 |
+
});
|
| 72 |
+
actions.push({ action_id: action.action_id, action_type: action.type,
|
| 73 |
+
results: { metrics: current.inActionMetrics ?? [], analytics: current.rawAnalytics ?? {} }, references });
|
| 74 |
+
}
|
| 75 |
+
if (!actions.length) return;
|
| 76 |
+
const response = await fetch(new URL("/api/session/generate", env.AGENT_API_URL), {
|
| 77 |
+
method: "POST",
|
| 78 |
+
headers: { "content-type": "application/json", "x-api-key": env.AGENT_API_KEY, "x-user-id": userId },
|
| 79 |
+
body: JSON.stringify({ session_id: sessionId, player, actions }),
|
| 80 |
+
signal: AbortSignal.timeout(150000),
|
| 81 |
+
});
|
| 82 |
+
const payload = await response.json().catch(() => ({})) as { actions?: { action_id: string; feedback: string[] }[]; session_feedback?: string[]; detail?: string };
|
| 83 |
+
if (!response.ok || !payload.actions || !payload.session_feedback) throw new Error(payload.detail ?? `Agent generation failed (${response.status})`);
|
| 84 |
+
for (const item of payload.actions) {
|
| 85 |
+
const { error } = await db.from("actions_feedbacks").delete().eq("action_id", item.action_id);
|
| 86 |
+
if (error) throw new Error(`Unable to replace generated action feedback: ${error.message}`);
|
| 87 |
+
if (item.feedback.length) {
|
| 88 |
+
const inserted = await db.from("actions_feedbacks").insert(item.feedback.map((feedback) => ({ action_id: item.action_id, feedback })));
|
| 89 |
+
if (inserted.error) throw new Error(`Unable to save generated action feedback: ${inserted.error.message}`);
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
const { error: clearError } = await db.from("sessions_feedbacks").delete().eq("session_id", sessionId);
|
| 93 |
+
if (clearError) throw new Error(`Unable to replace generated session feedback: ${clearError.message}`);
|
| 94 |
+
const { error: saveError } = await db.from("sessions_feedbacks").insert(payload.session_feedback.map((feedback) => ({ session_id: sessionId, feedback })));
|
| 95 |
+
if (saveError) throw new Error(`Unable to save generated session feedback: ${saveError.message}`);
|
| 96 |
+
}
|
src/analysis.ts
CHANGED
|
@@ -30,7 +30,7 @@ export function actionRows(sessionId: string, reports: z.infer<typeof reportSche
|
|
| 30 |
});
|
| 31 |
}
|
| 32 |
|
| 33 |
-
export function analysisRouter(db: SupabaseClient, secret: string) {
|
| 34 |
const router = express.Router();
|
| 35 |
router.use((req, res, next) => {
|
| 36 |
const supplied = Buffer.from(req.header("x-sporalize-proxy-secret") ?? "");
|
|
@@ -189,6 +189,7 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
|
|
| 189 |
const { error } = await db.from("actions").upsert(rows, { onConflict: "action_id" });
|
| 190 |
if (error) return res.status(503).json({ error: "Unable to save analysis actions." });
|
| 191 |
}
|
|
|
|
| 192 |
}
|
| 193 |
return res.json({ ok: true });
|
| 194 |
}
|
|
@@ -200,6 +201,9 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
|
|
| 200 |
console.error("Unable to save analysis results", { code: error.code, message: error.message });
|
| 201 |
return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to save analysis" });
|
| 202 |
}
|
|
|
|
|
|
|
|
|
|
| 203 |
return res.json({ ok: true });
|
| 204 |
} catch (error) { next(error); }
|
| 205 |
});
|
|
|
|
| 30 |
});
|
| 31 |
}
|
| 32 |
|
| 33 |
+
export function analysisRouter(db: SupabaseClient, secret: string, onCompleted?: (sessionId: string, userId: string) => Promise<void>) {
|
| 34 |
const router = express.Router();
|
| 35 |
router.use((req, res, next) => {
|
| 36 |
const supplied = Buffer.from(req.header("x-sporalize-proxy-secret") ?? "");
|
|
|
|
| 189 |
const { error } = await db.from("actions").upsert(rows, { onConflict: "action_id" });
|
| 190 |
if (error) return res.status(503).json({ error: "Unable to save analysis actions." });
|
| 191 |
}
|
| 192 |
+
if (onCompleted) void onCompleted(sessionId, res.locals.userId as string).catch((error) => console.error("Automatic Agent feedback generation failed", { sessionId, error }));
|
| 193 |
}
|
| 194 |
return res.json({ ok: true });
|
| 195 |
}
|
|
|
|
| 201 |
console.error("Unable to save analysis results", { code: error.code, message: error.message });
|
| 202 |
return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to save analysis" });
|
| 203 |
}
|
| 204 |
+
if (input.status === "completed" && onCompleted) {
|
| 205 |
+
void onCompleted(sessionId, res.locals.userId as string).catch((generationError) => console.error("Automatic Agent feedback generation failed", { sessionId, generationError }));
|
| 206 |
+
}
|
| 207 |
return res.json({ ok: true });
|
| 208 |
} catch (error) { next(error); }
|
| 209 |
});
|
src/config.ts
CHANGED
|
@@ -8,6 +8,8 @@ const envSchema = z.object({
|
|
| 8 |
FRONTEND_URL: z.string().url(),
|
| 9 |
BACKEND_URL: z.string().url(),
|
| 10 |
ANALYSIS_API_URL: z.string().url().default("http://127.0.0.1:8000"),
|
|
|
|
|
|
|
| 11 |
ANALYSIS_PROXY_SECRET: z.string().min(32),
|
| 12 |
COLAB_WORKER_SECRET: z.string().optional(),
|
| 13 |
SUPABASE_URL: z.string().url(),
|
|
|
|
| 8 |
FRONTEND_URL: z.string().url(),
|
| 9 |
BACKEND_URL: z.string().url(),
|
| 10 |
ANALYSIS_API_URL: z.string().url().default("http://127.0.0.1:8000"),
|
| 11 |
+
AGENT_API_URL: z.string().url().optional(),
|
| 12 |
+
AGENT_API_KEY: z.string().min(16).optional(),
|
| 13 |
ANALYSIS_PROXY_SECRET: z.string().min(32),
|
| 14 |
COLAB_WORKER_SECRET: z.string().optional(),
|
| 15 |
SUPABASE_URL: z.string().url(),
|
src/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { pipeline } from "node:stream/promises";
|
|
| 9 |
import { z } from "zod";
|
| 10 |
import { authPublicOrigin, env } from "./config.js";
|
| 11 |
import { analysisRouter } from "./analysis.js";
|
|
|
|
| 12 |
import { authenticatedUser, clearPasswordRecoveryCookie, clearSessionCookies, hasPasswordRecoveryCookie, safeRedirect, setPasswordRecoveryCookie, setSessionCookies } from "./auth.js";
|
| 13 |
import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
|
| 14 |
import { registerProfileImageRoutes } from "./profile-images.js";
|
|
@@ -24,7 +25,8 @@ app.use(cors({
|
|
| 24 |
credentials: true,
|
| 25 |
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
| 26 |
}));
|
| 27 |
-
app.use("/internal/analysis", analysisRouter(supabaseAdmin, env.ANALYSIS_PROXY_SECRET
|
|
|
|
| 28 |
app.use(express.json({ limit: "32kb", type: ["application/json", "text/plain"] }));
|
| 29 |
app.use("/internal/colab-worker", colabWorkerRouter(env.ANALYSIS_PROXY_SECRET, env.COLAB_WORKER_SECRET));
|
| 30 |
app.use(cookieParser());
|
|
@@ -735,6 +737,70 @@ app.get("/api/sessions/:sessionId/feedbacks", async (req, res, next) => {
|
|
| 735 |
} catch (error) { next(error); }
|
| 736 |
});
|
| 737 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
app.put("/api/sessions/:sessionId/actions/:actionId/review", async (req, res, next) => {
|
| 739 |
try {
|
| 740 |
const current = await requireActiveSubscription(req, res);
|
|
|
|
| 9 |
import { z } from "zod";
|
| 10 |
import { authPublicOrigin, env } from "./config.js";
|
| 11 |
import { analysisRouter } from "./analysis.js";
|
| 12 |
+
import { generateSessionFeedback } from "./agent-session.js";
|
| 13 |
import { authenticatedUser, clearPasswordRecoveryCookie, clearSessionCookies, hasPasswordRecoveryCookie, safeRedirect, setPasswordRecoveryCookie, setSessionCookies } from "./auth.js";
|
| 14 |
import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
|
| 15 |
import { registerProfileImageRoutes } from "./profile-images.js";
|
|
|
|
| 25 |
credentials: true,
|
| 26 |
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
| 27 |
}));
|
| 28 |
+
app.use("/internal/analysis", analysisRouter(supabaseAdmin, env.ANALYSIS_PROXY_SECRET,
|
| 29 |
+
(sessionId, userId) => generateSessionFeedback(supabaseAdmin, sessionId, userId)));
|
| 30 |
app.use(express.json({ limit: "32kb", type: ["application/json", "text/plain"] }));
|
| 31 |
app.use("/internal/colab-worker", colabWorkerRouter(env.ANALYSIS_PROXY_SECRET, env.COLAB_WORKER_SECRET));
|
| 32 |
app.use(cookieParser());
|
|
|
|
| 737 |
} catch (error) { next(error); }
|
| 738 |
});
|
| 739 |
|
| 740 |
+
app.post("/api/agent/chat", async (req, res, next) => {
|
| 741 |
+
try {
|
| 742 |
+
const current = await authenticatedUser(req, res);
|
| 743 |
+
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 744 |
+
if (!env.AGENT_API_URL || !env.AGENT_API_KEY) {
|
| 745 |
+
return res.status(503).json({ error: "The coaching Agent is not configured." });
|
| 746 |
+
}
|
| 747 |
+
const input = z.object({
|
| 748 |
+
message: z.string().trim().min(1).max(6000),
|
| 749 |
+
history: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().min(1).max(6000) })).max(30).default([]),
|
| 750 |
+
sessionId: z.uuid().optional(), actionId: z.uuid().optional(), playerId: z.uuid().optional(),
|
| 751 |
+
}).strict().parse(req.body);
|
| 752 |
+
if (input.actionId && !input.sessionId) return res.status(400).json({ error: "A session is required for action context." });
|
| 753 |
+
const context: Record<string, unknown> = {};
|
| 754 |
+
if (input.sessionId) {
|
| 755 |
+
const { data: session, error } = await supabaseAdmin.from("sessions")
|
| 756 |
+
.select("session_id,user_id,name,player_id,players(fname,lname,bdate,gender,position,height,weight)")
|
| 757 |
+
.eq("session_id", input.sessionId).maybeSingle();
|
| 758 |
+
if (error) throw new Error(`Unable to verify chat session: ${error.message}`);
|
| 759 |
+
if (!session) return res.sendStatus(404);
|
| 760 |
+
if (session.user_id !== current.user.id && !await userIsAdmin(current.user.id) && !await activeSubscriptionFor(current.user.id, { refresh: true })) {
|
| 761 |
+
return res.status(403).json({ error: "An active subscription is required to discuss shared sessions.", code: "subscription_required" });
|
| 762 |
+
}
|
| 763 |
+
context.session = { name: session.name, player: session.players };
|
| 764 |
+
const { data: sessionFeedback, error: sessionFeedbackError } = await supabaseAdmin.from("sessions_feedbacks")
|
| 765 |
+
.select("feedback").eq("session_id", input.sessionId).order("feedback");
|
| 766 |
+
if (sessionFeedbackError) throw new Error(`Unable to load chat feedback: ${sessionFeedbackError.message}`);
|
| 767 |
+
context.sessionFeedback = (sessionFeedback ?? []).map((row) => row.feedback);
|
| 768 |
+
if (!input.actionId) {
|
| 769 |
+
const { data: sessionActions, error: sessionActionsError } = await supabaseAdmin.from("actions")
|
| 770 |
+
.select("action_id,type,start_frame,end_frame,actions_feedbacks(feedback),annotations(score,annotations_notes(note))")
|
| 771 |
+
.eq("session_id", input.sessionId).limit(30);
|
| 772 |
+
if (sessionActionsError) throw new Error(`Unable to load session action context: ${sessionActionsError.message}`);
|
| 773 |
+
context.actions = sessionActions ?? [];
|
| 774 |
+
}
|
| 775 |
+
if (input.actionId) {
|
| 776 |
+
const { data: action, error: actionError } = await supabaseAdmin.from("actions")
|
| 777 |
+
.select("action_id,type,start_frame,end_frame,annotations(score,annotations_notes(note))").eq("session_id", input.sessionId).eq("action_id", input.actionId).maybeSingle();
|
| 778 |
+
if (actionError) throw new Error(`Unable to verify chat action: ${actionError.message}`);
|
| 779 |
+
if (!action) return res.sendStatus(404);
|
| 780 |
+
const { data: feedback, error: feedbackError } = await supabaseAdmin.from("actions_feedbacks")
|
| 781 |
+
.select("feedback").eq("action_id", input.actionId).order("feedback");
|
| 782 |
+
if (feedbackError) throw new Error(`Unable to load chat feedback: ${feedbackError.message}`);
|
| 783 |
+
context.action = { ...action, feedback: (feedback ?? []).map((row) => row.feedback) };
|
| 784 |
+
}
|
| 785 |
+
}
|
| 786 |
+
if (input.playerId && !input.sessionId) {
|
| 787 |
+
const { data: player, error } = await supabaseAdmin.from("players")
|
| 788 |
+
.select("fname,lname,bdate,height,weight,position,gender").eq("player_id", input.playerId).maybeSingle();
|
| 789 |
+
if (error) throw new Error(`Unable to load chat player context: ${error.message}`);
|
| 790 |
+
if (!player) return res.sendStatus(404);
|
| 791 |
+
context.player = player;
|
| 792 |
+
}
|
| 793 |
+
const upstream = await fetch(new URL("/api/chat", env.AGENT_API_URL), {
|
| 794 |
+
method: "POST", headers: { "content-type": "application/json", "x-api-key": env.AGENT_API_KEY, "x-user-id": current.user.id },
|
| 795 |
+
body: JSON.stringify({ actions: [], context, message: input.message, history: input.history }),
|
| 796 |
+
signal: AbortSignal.timeout(95000),
|
| 797 |
+
});
|
| 798 |
+
const result = await upstream.json().catch(() => ({}));
|
| 799 |
+
if (!upstream.ok) return res.status(upstream.status).json({ error: typeof result.detail === "string" ? result.detail : "The coaching Agent could not answer." });
|
| 800 |
+
return res.json(result);
|
| 801 |
+
} catch (error) { next(error); }
|
| 802 |
+
});
|
| 803 |
+
|
| 804 |
app.put("/api/sessions/:sessionId/actions/:actionId/review", async (req, res, next) => {
|
| 805 |
try {
|
| 806 |
const current = await requireActiveSubscription(req, res);
|