pi-learning-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +34 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/SECURITY.md +9 -0
- package/docs/architecture.md +75 -0
- package/docs/images/learning-workspace.png +0 -0
- package/docs/releasing.md +51 -0
- package/extension/commands.ts +181 -0
- package/extension/index.ts +50 -0
- package/extension/learning-session.ts +75 -0
- package/extension/runner/code-runner.ts +275 -0
- package/extension/server/interaction-broker.ts +345 -0
- package/extension/server/learning-server.ts +753 -0
- package/extension/server/sse-hub.ts +63 -0
- package/extension/state/learning-state.ts +458 -0
- package/extension/state/session-persistence.ts +72 -0
- package/extension/tools/ask-code.ts +97 -0
- package/extension/tools/ask-free-response.ts +88 -0
- package/extension/tools/ask-multi-choice.ts +109 -0
- package/extension/tools/ask-record-attempt.ts +50 -0
- package/extension/tools/ask-single-choice.ts +110 -0
- package/extension/tools/index.ts +39 -0
- package/extension/tools/tui-presenter.ts +609 -0
- package/extension/transcript-sync.ts +197 -0
- package/extension/tutor-prompt.ts +64 -0
- package/extension/utils/browser.ts +35 -0
- package/extension/utils/ids.ts +5 -0
- package/extension/utils/validation.ts +138 -0
- package/package.json +82 -0
- package/shared/learning-state.ts +62 -0
- package/shared/protocol.ts +236 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/abap-08VXUWAP.js +1 -0
- package/web/dist/assets/apex-BWPQTe0t.js +1 -0
- package/web/dist/assets/azcli-Bc_sGQ0U.js +1 -0
- package/web/dist/assets/bat-i0X4ZdIN.js +1 -0
- package/web/dist/assets/bicep-B5-_aFwp.js +2 -0
- package/web/dist/assets/cameligo-DMUM7wLl.js +1 -0
- package/web/dist/assets/clojure-Cm7r79vr.js +1 -0
- package/web/dist/assets/codicon-Brq4_Ui5.ttf +0 -0
- package/web/dist/assets/coffee-Ba7i2nA0.js +1 -0
- package/web/dist/assets/cpp-C7h46wYY.js +1 -0
- package/web/dist/assets/csharp-BKxtCVv1.js +1 -0
- package/web/dist/assets/csp-bTuwJoIa.js +1 -0
- package/web/dist/assets/css-DIMkf-bt.js +3 -0
- package/web/dist/assets/css.worker-DRH2OcAG.js +93 -0
- package/web/dist/assets/cssMode-BpvqYhpN.js +1 -0
- package/web/dist/assets/cypher-CVaqCwHa.js +1 -0
- package/web/dist/assets/dart-onAF5SnQ.js +1 -0
- package/web/dist/assets/dockerfile-DZFCIeNp.js +1 -0
- package/web/dist/assets/ecl-D05T4iGw.js +1 -0
- package/web/dist/assets/editor.worker-D9zwrD0f.js +30 -0
- package/web/dist/assets/elixir-6RTg0lbw.js +1 -0
- package/web/dist/assets/flow9-C5_-GSwl.js +1 -0
- package/web/dist/assets/freemarker2-CmTivYCM.js +3 -0
- package/web/dist/assets/fsharp-C8Ef5oNN.js +1 -0
- package/web/dist/assets/go-C-y9NEjX.js +1 -0
- package/web/dist/assets/graphql-fmXr3nnJ.js +1 -0
- package/web/dist/assets/handlebars-ClhpojlY.js +1 -0
- package/web/dist/assets/hcl-CpzslTdj.js +1 -0
- package/web/dist/assets/html-CTqIxFH7.js +1 -0
- package/web/dist/assets/html.worker-DQLddT9h.js +506 -0
- package/web/dist/assets/htmlMode-BKCTQ4_a.js +1 -0
- package/web/dist/assets/index-6N_lnfyE.js +1243 -0
- package/web/dist/assets/index-B5x0MzHm.css +1 -0
- package/web/dist/assets/ini-sBoK_t0W.js +1 -0
- package/web/dist/assets/java-BEtHBSE6.js +1 -0
- package/web/dist/assets/javascript-Cp_q6aWP.js +1 -0
- package/web/dist/assets/json.worker-D_Dqrls5.js +62 -0
- package/web/dist/assets/jsonMode-CMDRrSMX.js +7 -0
- package/web/dist/assets/julia-Bri6UV-V.js +1 -0
- package/web/dist/assets/kotlin-BOotOW0E.js +1 -0
- package/web/dist/assets/less-B9JPFI3C.js +2 -0
- package/web/dist/assets/lexon-CfSJPG6W.js +1 -0
- package/web/dist/assets/liquid-BFK8j46k.js +1 -0
- package/web/dist/assets/lspLanguageFeatures-nT2b0EDJ.js +4 -0
- package/web/dist/assets/lua-CsQS60Ue.js +1 -0
- package/web/dist/assets/m3-D-oSqn_W.js +1 -0
- package/web/dist/assets/markdown-Cimd5fb3.js +1 -0
- package/web/dist/assets/mdx-cEJtnt-p.js +1 -0
- package/web/dist/assets/mips-CIPQ_RoX.js +1 -0
- package/web/dist/assets/msdax-DauUninz.js +1 -0
- package/web/dist/assets/mysql-SOo6toE5.js +1 -0
- package/web/dist/assets/objective-c-FvmIjYaQ.js +1 -0
- package/web/dist/assets/pascal-DrH0SRf2.js +1 -0
- package/web/dist/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/web/dist/assets/perl-oz_6vUea.js +1 -0
- package/web/dist/assets/pgsql-DTj74zXo.js +1 -0
- package/web/dist/assets/php-nr791fC2.js +1 -0
- package/web/dist/assets/pla-CopQ2nXW.js +1 -0
- package/web/dist/assets/postiats-43DmfD33.js +1 -0
- package/web/dist/assets/powerquery-D3hlyOfw.js +1 -0
- package/web/dist/assets/powershell-DmHpPYUd.js +1 -0
- package/web/dist/assets/protobuf-C531GsRP.js +2 -0
- package/web/dist/assets/pug-Z5eAx3Zn.js +1 -0
- package/web/dist/assets/python-bhI_n2_n.js +1 -0
- package/web/dist/assets/qsharp-DkqhCAOL.js +1 -0
- package/web/dist/assets/r-BwWrilGY.js +1 -0
- package/web/dist/assets/razor-D40-I8aA.js +1 -0
- package/web/dist/assets/redis-ClamHrr6.js +1 -0
- package/web/dist/assets/redshift-DT7zqm-g.js +1 -0
- package/web/dist/assets/restructuredtext-BYgofb2h.js +1 -0
- package/web/dist/assets/ruby-DezsRK8O.js +1 -0
- package/web/dist/assets/rust-DdL9SqIa.js +1 -0
- package/web/dist/assets/sb-CcwsVR0C.js +1 -0
- package/web/dist/assets/scala-DHpiXF5c.js +1 -0
- package/web/dist/assets/scheme-BeGwcela.js +1 -0
- package/web/dist/assets/scss-gp-XZpBa.js +3 -0
- package/web/dist/assets/shell-CC2rA5mh.js +1 -0
- package/web/dist/assets/solidity-BEEn4gHE.js +1 -0
- package/web/dist/assets/sophia-CRfGWb83.js +1 -0
- package/web/dist/assets/sparql-D_Lu-MrJ.js +1 -0
- package/web/dist/assets/sql-NEE52Syq.js +1 -0
- package/web/dist/assets/st-DbInun42.js +1 -0
- package/web/dist/assets/swift-Bxkupp3x.js +1 -0
- package/web/dist/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/web/dist/assets/tcl-DISqw1ZD.js +1 -0
- package/web/dist/assets/ts.worker-DkYoA6bF.js +67738 -0
- package/web/dist/assets/tsMode-C9OJ2Aji.js +11 -0
- package/web/dist/assets/twig-De2hgUGE.js +1 -0
- package/web/dist/assets/typescript-CZ5RbSdB.js +1 -0
- package/web/dist/assets/typespec-B8J7ngcE.js +1 -0
- package/web/dist/assets/vb-DV3o63ZY.js +1 -0
- package/web/dist/assets/wgsl-DpFanUEy.js +298 -0
- package/web/dist/assets/workers-qb_EZhxe.js +1 -0
- package/web/dist/assets/xml-BdDnAXLl.js +1 -0
- package/web/dist/assets/yaml-BSYHoVQy.js +1 -0
- package/web/dist/index.html +23 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import type { LearningStateStore } from "../state/learning-state.js";
|
|
8
|
+
import {
|
|
9
|
+
CodeRunUnavailableError,
|
|
10
|
+
isSupportedLanguage
|
|
11
|
+
} from "../runner/code-runner.js";
|
|
12
|
+
import type { CodeRunner } from "../runner/code-runner.js";
|
|
13
|
+
import type { InteractionBroker } from "./interaction-broker.js";
|
|
14
|
+
import type {
|
|
15
|
+
InteractionSubmission,
|
|
16
|
+
LearningEvent,
|
|
17
|
+
LearningSessionSnapshot
|
|
18
|
+
} from "../../shared/protocol.js";
|
|
19
|
+
import { SseHub } from "./sse-hub.js";
|
|
20
|
+
|
|
21
|
+
const HOST = "127.0.0.1";
|
|
22
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
23
|
+
const MAX_CODE_BYTES = 100 * 1024;
|
|
24
|
+
|
|
25
|
+
/** 规格 25:/api/code/run 的 timeoutMs 取值范围。 */
|
|
26
|
+
const MIN_TIMEOUT_MS = 100;
|
|
27
|
+
const MAX_TIMEOUT_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
export interface LearningServerOptions {
|
|
30
|
+
broker: InteractionBroker;
|
|
31
|
+
state: LearningStateStore;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
/** Injectable for tests; defaults to a random 48-hex-char session token. */
|
|
34
|
+
token?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Root of the built web app (spec 9.2 GET / + assets). Defaults to
|
|
37
|
+
* <repo>/web/dist; injectable for tests.
|
|
38
|
+
*/
|
|
39
|
+
staticRoot?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Explicitly enables POST /api/code/run. Omit unless the host provides an
|
|
42
|
+
* appropriately isolated runner; local execution is disabled by default.
|
|
43
|
+
*/
|
|
44
|
+
runner?: CodeRunner;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Local HTTP/SSE server for the web learning workspace (spec 9).
|
|
49
|
+
*
|
|
50
|
+
* Listens on 127.0.0.1 with an OS-chosen port; never on public interfaces.
|
|
51
|
+
* Every /api/* route requires the session token (spec 31); /api/events also
|
|
52
|
+
* accepts ?token= because EventSource cannot send headers.
|
|
53
|
+
*
|
|
54
|
+
* The server starts lazily via start(). GET / serves the built web app from
|
|
55
|
+
* web/dist when present (path traversal guarded), otherwise a small inline
|
|
56
|
+
* placeholder page; everything else is JSON or SSE.
|
|
57
|
+
*/
|
|
58
|
+
export class LearningServer {
|
|
59
|
+
private readonly broker: InteractionBroker;
|
|
60
|
+
private readonly state: LearningStateStore;
|
|
61
|
+
private readonly token: string;
|
|
62
|
+
private readonly now: () => number;
|
|
63
|
+
private readonly sseHub = new SseHub();
|
|
64
|
+
private readonly staticRoot: string;
|
|
65
|
+
private readonly runner: CodeRunner | undefined;
|
|
66
|
+
private server: Server | undefined;
|
|
67
|
+
private startPromise: Promise<void> | undefined;
|
|
68
|
+
|
|
69
|
+
constructor(options: LearningServerOptions) {
|
|
70
|
+
this.broker = options.broker;
|
|
71
|
+
this.state = options.state;
|
|
72
|
+
this.token = options.token ?? randomBytes(24).toString("hex");
|
|
73
|
+
this.now = options.now ?? Date.now;
|
|
74
|
+
this.staticRoot =
|
|
75
|
+
options.staticRoot ?? path.resolve(import.meta.dirname, "../../web/dist");
|
|
76
|
+
this.runner = options.runner;
|
|
77
|
+
this.broker.subscribe({
|
|
78
|
+
onPresented: (interaction) =>
|
|
79
|
+
this.sseHub.broadcast({ event: "interaction.presented", interaction }),
|
|
80
|
+
onResolved: (answer) =>
|
|
81
|
+
this.sseHub.broadcast({
|
|
82
|
+
event: "interaction.resolved",
|
|
83
|
+
interactionId: answer.interactionId,
|
|
84
|
+
answer
|
|
85
|
+
}),
|
|
86
|
+
onCancelled: (interactionId, reason) =>
|
|
87
|
+
this.sseHub.broadcast({
|
|
88
|
+
event: "interaction.cancelled",
|
|
89
|
+
interactionId,
|
|
90
|
+
reason
|
|
91
|
+
})
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Start listening on 127.0.0.1:0. Idempotent; resolves once 'listening'. */
|
|
96
|
+
start(): Promise<void> {
|
|
97
|
+
if (this.startPromise !== undefined) {
|
|
98
|
+
return this.startPromise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.startPromise = new Promise<void>((resolve, reject) => {
|
|
102
|
+
const server = createServer((request, response) => {
|
|
103
|
+
void this.handleRequest(request, response);
|
|
104
|
+
});
|
|
105
|
+
this.server = server;
|
|
106
|
+
server.on("error", (error) => {
|
|
107
|
+
console.error("[pi-learning-server] server error:", error);
|
|
108
|
+
if (this.server === server) {
|
|
109
|
+
this.server = undefined;
|
|
110
|
+
}
|
|
111
|
+
reject(error);
|
|
112
|
+
});
|
|
113
|
+
server.listen(0, HOST, () => {
|
|
114
|
+
if (this.server !== server) {
|
|
115
|
+
server.close();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
resolve();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return this.startPromise;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Workspace URL, e.g. http://127.0.0.1:54321/?token=<token>.
|
|
127
|
+
* Returns undefined while the server is not running (or has been closed).
|
|
128
|
+
*/
|
|
129
|
+
url(): string | undefined {
|
|
130
|
+
const address = this.server?.address();
|
|
131
|
+
if (address === undefined || address === null || typeof address === "string") {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
return `http://${HOST}:${address.port}/?token=${this.token}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* True while at least one SSE client is connected.
|
|
139
|
+
* ponytail: brief false window during a page refresh (old SSE closing,
|
|
140
|
+
* new one not yet connected) is accepted for MVP auto-open decisions.
|
|
141
|
+
*/
|
|
142
|
+
hasWebClient(): boolean {
|
|
143
|
+
return this.sseHub.size > 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** End all SSE connections and stop the HTTP server. Idempotent. */
|
|
147
|
+
async close(): Promise<void> {
|
|
148
|
+
this.sseHub.close();
|
|
149
|
+
const server = this.server;
|
|
150
|
+
this.server = undefined;
|
|
151
|
+
this.startPromise = undefined;
|
|
152
|
+
if (server === undefined) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
await new Promise<void>((resolve) => {
|
|
156
|
+
server.close(() => resolve());
|
|
157
|
+
server.closeAllConnections();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Broadcast the tutor's final visible assistant text (spec 26). Safe no-op
|
|
163
|
+
* while not started / with no SSE clients: the hub simply has no receivers.
|
|
164
|
+
*/
|
|
165
|
+
broadcastTutorMessage(
|
|
166
|
+
role: "assistant",
|
|
167
|
+
text: string,
|
|
168
|
+
messageId: string | undefined,
|
|
169
|
+
done: boolean
|
|
170
|
+
): void {
|
|
171
|
+
// messageId undefined 时不带该字段(SSE JSON 兼容旧客户端)。
|
|
172
|
+
this.sseHub.broadcast({
|
|
173
|
+
event: "tutor.message",
|
|
174
|
+
role,
|
|
175
|
+
text,
|
|
176
|
+
...(messageId === undefined ? {} : { messageId }),
|
|
177
|
+
done
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Broadcast tutor waiting/idle status (spec 26). Safe no-op with no clients. */
|
|
182
|
+
broadcastTutorStatus(status: "waiting" | "idle", toolName?: string): void {
|
|
183
|
+
this.sseHub.broadcast(
|
|
184
|
+
toolName === undefined
|
|
185
|
+
? { event: "tutor.status", status }
|
|
186
|
+
: { event: "tutor.status", status, toolName }
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Broadcast the current learning state as session.updated (spec 16/30). Safe
|
|
192
|
+
* no-op while not started / with no SSE clients: the hub has no receivers.
|
|
193
|
+
*/
|
|
194
|
+
broadcastSessionUpdated(): void {
|
|
195
|
+
this.sseHub.broadcast({
|
|
196
|
+
event: "session.updated",
|
|
197
|
+
session: this.sessionSnapshot()
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async handleRequest(
|
|
202
|
+
request: IncomingMessage,
|
|
203
|
+
response: ServerResponse
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
try {
|
|
206
|
+
await this.route(request, response);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (response.destroyed) return;
|
|
209
|
+
console.error("[pi-learning-server] request failed:", error);
|
|
210
|
+
if (response.headersSent) {
|
|
211
|
+
response.end();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
sendJson(response, 500, { ok: false, message: "Internal server error." });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async route(
|
|
219
|
+
request: IncomingMessage,
|
|
220
|
+
response: ServerResponse
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
const url = new URL(request.url ?? "/", `http://${HOST}`);
|
|
223
|
+
const { pathname } = url;
|
|
224
|
+
const method = request.method ?? "GET";
|
|
225
|
+
|
|
226
|
+
if (method === "GET" && pathname === "/") {
|
|
227
|
+
await this.serveStatic(response, "/");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!pathname.startsWith("/api/")) {
|
|
232
|
+
if (method === "GET") {
|
|
233
|
+
await this.serveStatic(response, pathname);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
sendJson(response, 404, { ok: false, message: "Not found." });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const allowQueryToken = pathname === "/api/events";
|
|
241
|
+
if (!this.isAuthorized(request, allowQueryToken)) {
|
|
242
|
+
sendJson(response, 401, { ok: false, message: "Unauthorized." });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (method === "GET" && pathname === "/api/health") {
|
|
247
|
+
sendJson(response, 200, {
|
|
248
|
+
ok: true,
|
|
249
|
+
service: "pi-learning-agent",
|
|
250
|
+
capabilities: { codeExecution: this.runner !== undefined }
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (method === "GET" && pathname === "/api/session") {
|
|
256
|
+
sendJson(response, 200, this.sessionSnapshot());
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (method === "GET" && pathname === "/api/interactions/pending") {
|
|
261
|
+
sendJson(response, 200, { interactions: this.broker.getPending() });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (method === "GET" && pathname === "/api/events") {
|
|
266
|
+
this.handleSse(response);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const submitMatch = /^\/api\/interactions\/([^/]+)\/submit$/u.exec(pathname);
|
|
271
|
+
if (method === "POST" && submitMatch !== null) {
|
|
272
|
+
await this.handleSubmit(request, response, submitMatch[1] ?? "");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 规格 9.2:跳过题目(仅 allowSkip 的题目)。与 submit 一样走 broker
|
|
277
|
+
// 解析,返回结构化 skipped answer(规格 7.5)。
|
|
278
|
+
const skipMatch = /^\/api\/interactions\/([^/]+)\/skip$/u.exec(pathname);
|
|
279
|
+
if (method === "POST" && skipMatch !== null) {
|
|
280
|
+
await this.handleSkip(request, response, skipMatch[1] ?? "");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 规格 25:只有宿主显式注入 runner 时才开放。临时目录、env 白名单、
|
|
285
|
+
// 超时和输出截断都不是安全沙箱,因此正式扩展默认返回 403。结果只回给
|
|
286
|
+
// 学习者自测,不进入 learning_ask_code 的 tool result。
|
|
287
|
+
if (method === "POST" && pathname === "/api/code/run") {
|
|
288
|
+
await this.handleCodeRun(request, response);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
sendJson(response, 404, { ok: false, message: "Not found." });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Serve a file from the built web app root (spec 9.2). Paths are resolved
|
|
297
|
+
* against staticRoot and must stay inside it (blocks ../ and encoded
|
|
298
|
+
* traversal); GET / defaults to index.html and falls back to the inline
|
|
299
|
+
* placeholder page when no build exists (spec 33: frontend bundle missing).
|
|
300
|
+
*/
|
|
301
|
+
private async serveStatic(response: ServerResponse, pathname: string): Promise<void> {
|
|
302
|
+
let decoded: string;
|
|
303
|
+
try {
|
|
304
|
+
decoded = decodeURIComponent(pathname);
|
|
305
|
+
} catch {
|
|
306
|
+
sendJson(response, 404, { ok: false, message: "Not found." });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/u, "");
|
|
310
|
+
const resolved = path.resolve(this.staticRoot, relative);
|
|
311
|
+
if (resolved !== this.staticRoot && !resolved.startsWith(this.staticRoot + path.sep)) {
|
|
312
|
+
// Resolved outside the dist root: path traversal attempt.
|
|
313
|
+
sendJson(response, 404, { ok: false, message: "Not found." });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let content: Buffer;
|
|
318
|
+
try {
|
|
319
|
+
content = await readFile(resolved);
|
|
320
|
+
} catch {
|
|
321
|
+
if (decoded === "/") {
|
|
322
|
+
sendIndex(response);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
sendJson(response, 404, { ok: false, message: "Not found." });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const contentType =
|
|
330
|
+
CONTENT_TYPES[path.extname(resolved).toLowerCase()] ??
|
|
331
|
+
"application/octet-stream";
|
|
332
|
+
response.writeHead(200, {
|
|
333
|
+
"Content-Type": contentType,
|
|
334
|
+
"Content-Length": content.length
|
|
335
|
+
});
|
|
336
|
+
response.end(content);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private isAuthorized(request: IncomingMessage, allowQueryToken: boolean): boolean {
|
|
340
|
+
if (request.headers.authorization === `Bearer ${this.token}`) {
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
if (allowQueryToken) {
|
|
344
|
+
const url = new URL(request.url ?? "/", `http://${HOST}`);
|
|
345
|
+
return url.searchParams.get("token") === this.token;
|
|
346
|
+
}
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private sessionSnapshot(): LearningSessionSnapshot {
|
|
351
|
+
const snapshot = this.state.snapshot();
|
|
352
|
+
return {
|
|
353
|
+
learningMode: snapshot.enabled,
|
|
354
|
+
...(snapshot.course === undefined ? {} : { course: { ...snapshot.course } }),
|
|
355
|
+
...(snapshot.topic === undefined ? {} : { topic: { ...snapshot.topic } }),
|
|
356
|
+
phase: snapshot.phase,
|
|
357
|
+
concepts: Object.values(snapshot.concepts)
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private handleSse(response: ServerResponse): void {
|
|
362
|
+
response.writeHead(200, {
|
|
363
|
+
"Content-Type": "text/event-stream",
|
|
364
|
+
"Cache-Control": "no-cache",
|
|
365
|
+
Connection: "keep-alive",
|
|
366
|
+
"X-Accel-Buffering": "no"
|
|
367
|
+
});
|
|
368
|
+
response.write(
|
|
369
|
+
`event: connected\ndata: ${JSON.stringify({ event: "connected", time: this.now() })}\n\n`
|
|
370
|
+
);
|
|
371
|
+
this.sseHub.addClient(response);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async handleSubmit(
|
|
375
|
+
request: IncomingMessage,
|
|
376
|
+
response: ServerResponse,
|
|
377
|
+
rawPathId: string
|
|
378
|
+
): Promise<void> {
|
|
379
|
+
let interactionId: string;
|
|
380
|
+
try {
|
|
381
|
+
interactionId = decodeURIComponent(rawPathId);
|
|
382
|
+
} catch {
|
|
383
|
+
sendJson(response, 400, { ok: false, message: "Invalid interaction id." });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (interactionId === "") {
|
|
387
|
+
sendJson(response, 400, { ok: false, message: "Invalid interaction id." });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let rawBody: string;
|
|
392
|
+
try {
|
|
393
|
+
rawBody = await readBody(request);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (error instanceof BodyTooLargeError) {
|
|
396
|
+
sendJson(response, 413, { ok: false, message: "Request body too large." });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let body: unknown;
|
|
403
|
+
try {
|
|
404
|
+
body = JSON.parse(rawBody) as unknown;
|
|
405
|
+
} catch {
|
|
406
|
+
sendJson(response, 400, {
|
|
407
|
+
ok: false,
|
|
408
|
+
message: "Request body must be valid JSON."
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (!isRecord(body)) {
|
|
413
|
+
sendJson(response, 400, { ok: false, message: "Request body must be a JSON object." });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (body.interactionId !== interactionId) {
|
|
417
|
+
sendJson(response, 400, {
|
|
418
|
+
ok: false,
|
|
419
|
+
message: "interactionId does not match the URL."
|
|
420
|
+
});
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (!("answer" in body)) {
|
|
424
|
+
sendJson(response, 400, { ok: false, message: "answer is required." });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (typeof body.clientTimestamp !== "number") {
|
|
428
|
+
sendJson(response, 400, {
|
|
429
|
+
ok: false,
|
|
430
|
+
message: "clientTimestamp must be a number."
|
|
431
|
+
});
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const submission: InteractionSubmission = {
|
|
436
|
+
interactionId,
|
|
437
|
+
answer: body.answer,
|
|
438
|
+
clientTimestamp: body.clientTimestamp
|
|
439
|
+
};
|
|
440
|
+
const result = this.broker.submit(submission);
|
|
441
|
+
if (!result.ok) {
|
|
442
|
+
const status =
|
|
443
|
+
result.reason === "not_found"
|
|
444
|
+
? 404
|
|
445
|
+
: result.reason === "already_resolved"
|
|
446
|
+
? 409
|
|
447
|
+
: 400;
|
|
448
|
+
sendJson(response, status, {
|
|
449
|
+
ok: false,
|
|
450
|
+
reason: result.reason,
|
|
451
|
+
message: result.message
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
sendJson(response, 200, { ok: true, answer: result.answer });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* POST /api/interactions/:id/skip (spec 9.2): learner declines the question.
|
|
461
|
+
* Body: { interactionId } (no answer). Delegate to broker.skip so the
|
|
462
|
+
* pending tool promise resolves with a structured skipped answer.
|
|
463
|
+
*/
|
|
464
|
+
private async handleSkip(
|
|
465
|
+
request: IncomingMessage,
|
|
466
|
+
response: ServerResponse,
|
|
467
|
+
rawPathId: string
|
|
468
|
+
): Promise<void> {
|
|
469
|
+
let interactionId: string;
|
|
470
|
+
try {
|
|
471
|
+
interactionId = decodeURIComponent(rawPathId);
|
|
472
|
+
} catch {
|
|
473
|
+
sendJson(response, 400, { ok: false, message: "Invalid interaction id." });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (interactionId === "") {
|
|
477
|
+
sendJson(response, 400, { ok: false, message: "Invalid interaction id." });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let rawBody: string;
|
|
482
|
+
try {
|
|
483
|
+
rawBody = await readBody(request);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (error instanceof BodyTooLargeError) {
|
|
486
|
+
sendJson(response, 413, { ok: false, message: "Request body too large." });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let body: unknown;
|
|
493
|
+
try {
|
|
494
|
+
body = JSON.parse(rawBody) as unknown;
|
|
495
|
+
} catch {
|
|
496
|
+
sendJson(response, 400, {
|
|
497
|
+
ok: false,
|
|
498
|
+
message: "Request body must be valid JSON."
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (!isRecord(body)) {
|
|
503
|
+
sendJson(response, 400, { ok: false, message: "Request body must be a JSON object." });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (body.interactionId !== interactionId) {
|
|
507
|
+
sendJson(response, 400, {
|
|
508
|
+
ok: false,
|
|
509
|
+
message: "interactionId does not match the URL."
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const result = this.broker.skip(interactionId);
|
|
515
|
+
if (!result.ok) {
|
|
516
|
+
const status =
|
|
517
|
+
result.reason === "not_found"
|
|
518
|
+
? 404
|
|
519
|
+
: result.reason === "already_resolved"
|
|
520
|
+
? 409
|
|
521
|
+
: 400;
|
|
522
|
+
sendJson(response, status, {
|
|
523
|
+
ok: false,
|
|
524
|
+
reason: result.reason,
|
|
525
|
+
message: result.message
|
|
526
|
+
});
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
sendJson(response, 200, { ok: true, answer: result.answer });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* POST /api/code/run (spec 25): run learner code locally for self-testing.
|
|
535
|
+
* Validation: language must be in the program-defined whitelist, code is a
|
|
536
|
+
* string ≤ 100KB, timeoutMs optional within [100, 30000] (default 8000).
|
|
537
|
+
* 503 when the runtime binary is missing; other failures bubble to the 500
|
|
538
|
+
* handler in handleRequest.
|
|
539
|
+
*/
|
|
540
|
+
private async handleCodeRun(
|
|
541
|
+
request: IncomingMessage,
|
|
542
|
+
response: ServerResponse
|
|
543
|
+
): Promise<void> {
|
|
544
|
+
const runner = this.runner;
|
|
545
|
+
if (runner === undefined) {
|
|
546
|
+
sendJson(response, 403, {
|
|
547
|
+
ok: false,
|
|
548
|
+
reason: "code_execution_disabled",
|
|
549
|
+
message: "Local code execution is disabled for this session."
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let rawBody: string;
|
|
555
|
+
try {
|
|
556
|
+
rawBody = await readBody(request);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
if (error instanceof BodyTooLargeError) {
|
|
559
|
+
sendJson(response, 413, { ok: false, message: "Request body too large." });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
let body: unknown;
|
|
566
|
+
try {
|
|
567
|
+
body = JSON.parse(rawBody) as unknown;
|
|
568
|
+
} catch {
|
|
569
|
+
sendJson(response, 400, {
|
|
570
|
+
ok: false,
|
|
571
|
+
message: "Request body must be valid JSON."
|
|
572
|
+
});
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (!isRecord(body)) {
|
|
576
|
+
sendJson(response, 400, { ok: false, message: "Request body must be a JSON object." });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (typeof body.language !== "string" || !isSupportedLanguage(body.language)) {
|
|
580
|
+
sendJson(response, 400, {
|
|
581
|
+
ok: false,
|
|
582
|
+
message: `Unsupported language: ${String(body.language)}.`
|
|
583
|
+
});
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (typeof body.code !== "string") {
|
|
587
|
+
sendJson(response, 400, { ok: false, message: "code must be a string." });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (Buffer.byteLength(body.code, "utf8") > MAX_CODE_BYTES) {
|
|
591
|
+
sendJson(response, 400, {
|
|
592
|
+
ok: false,
|
|
593
|
+
message: `code must be at most ${MAX_CODE_BYTES} bytes.`
|
|
594
|
+
});
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
let timeoutMs = 8000;
|
|
598
|
+
if (body.timeoutMs !== undefined) {
|
|
599
|
+
if (
|
|
600
|
+
typeof body.timeoutMs !== "number" ||
|
|
601
|
+
!Number.isFinite(body.timeoutMs) ||
|
|
602
|
+
body.timeoutMs < MIN_TIMEOUT_MS ||
|
|
603
|
+
body.timeoutMs > MAX_TIMEOUT_MS
|
|
604
|
+
) {
|
|
605
|
+
sendJson(response, 400, {
|
|
606
|
+
ok: false,
|
|
607
|
+
message: `timeoutMs must be a number between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS}.`
|
|
608
|
+
});
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
timeoutMs = body.timeoutMs;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const controller = new AbortController();
|
|
615
|
+
const onClose = () => controller.abort();
|
|
616
|
+
response.once("close", onClose);
|
|
617
|
+
if (response.destroyed) controller.abort();
|
|
618
|
+
try {
|
|
619
|
+
const result = await runner.run({
|
|
620
|
+
language: body.language,
|
|
621
|
+
code: body.code,
|
|
622
|
+
timeoutMs
|
|
623
|
+
}, controller.signal);
|
|
624
|
+
sendJson(response, 200, { ok: true, result });
|
|
625
|
+
} catch (error) {
|
|
626
|
+
if (error instanceof CodeRunUnavailableError) {
|
|
627
|
+
sendJson(response, 503, {
|
|
628
|
+
ok: false,
|
|
629
|
+
reason: "runtime_unavailable",
|
|
630
|
+
message: error.message
|
|
631
|
+
});
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
throw error;
|
|
635
|
+
} finally {
|
|
636
|
+
response.off("close", onClose);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
class BodyTooLargeError extends Error {
|
|
642
|
+
constructor() {
|
|
643
|
+
super("Request body exceeds 256KB.");
|
|
644
|
+
this.name = "BodyTooLargeError";
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Read the request body, rejecting as soon as it exceeds MAX_BODY_BYTES. */
|
|
649
|
+
function readBody(request: IncomingMessage): Promise<string> {
|
|
650
|
+
const declaredLength = Number(request.headers["content-length"] ?? 0);
|
|
651
|
+
return new Promise((resolve, reject) => {
|
|
652
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
|
653
|
+
reject(new BodyTooLargeError());
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const chunks: Buffer[] = [];
|
|
658
|
+
let received = 0;
|
|
659
|
+
let overflow = false;
|
|
660
|
+
request.on("data", (chunk: Buffer) => {
|
|
661
|
+
if (overflow) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
received += chunk.length;
|
|
665
|
+
if (received > MAX_BODY_BYTES) {
|
|
666
|
+
overflow = true;
|
|
667
|
+
reject(new BodyTooLargeError());
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
chunks.push(chunk);
|
|
671
|
+
});
|
|
672
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
673
|
+
request.on("error", (error) => reject(error));
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
|
678
|
+
const payload = JSON.stringify(body);
|
|
679
|
+
response.writeHead(status, {
|
|
680
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
681
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
682
|
+
});
|
|
683
|
+
response.end(payload);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function sendIndex(response: ServerResponse): void {
|
|
687
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
688
|
+
response.end(INDEX_HTML);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
692
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Minimal content-type map for the built web app assets. */
|
|
696
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
697
|
+
".html": "text/html; charset=utf-8",
|
|
698
|
+
".js": "text/javascript; charset=utf-8",
|
|
699
|
+
".css": "text/css; charset=utf-8",
|
|
700
|
+
".svg": "image/svg+xml",
|
|
701
|
+
".map": "application/json; charset=utf-8",
|
|
702
|
+
".woff2": "font/woff2",
|
|
703
|
+
".ico": "image/x-icon",
|
|
704
|
+
".json": "application/json; charset=utf-8",
|
|
705
|
+
".png": "image/png"
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Minimal placeholder page (spec 9.2 GET /), used only when no built web app
|
|
710
|
+
* is present. Reads the token from the URL, keeps it in sessionStorage, then
|
|
711
|
+
* verifies /api/health. The real React app (web/dist) replaces this after
|
|
712
|
+
* `npm run build:web`.
|
|
713
|
+
*/
|
|
714
|
+
const INDEX_HTML = `<!doctype html>
|
|
715
|
+
<html lang="en">
|
|
716
|
+
<head>
|
|
717
|
+
<meta charset="utf-8" />
|
|
718
|
+
<title>Pi Learning Session</title>
|
|
719
|
+
<style>
|
|
720
|
+
body { font-family: system-ui, sans-serif; margin: 2rem; }
|
|
721
|
+
#status { font-variant-numeric: tabular-nums; }
|
|
722
|
+
</style>
|
|
723
|
+
</head>
|
|
724
|
+
<body>
|
|
725
|
+
<h1>Pi Learning Session</h1>
|
|
726
|
+
<p>Connected to Pi Learning Session</p>
|
|
727
|
+
<p id="status">Checking server...</p>
|
|
728
|
+
<script>
|
|
729
|
+
(function () {
|
|
730
|
+
var params = new URLSearchParams(location.search);
|
|
731
|
+
var token = params.get("token");
|
|
732
|
+
if (token) {
|
|
733
|
+
sessionStorage.setItem("pi_learning_token", token);
|
|
734
|
+
}
|
|
735
|
+
var saved = sessionStorage.getItem("pi_learning_token");
|
|
736
|
+
var status = document.getElementById("status");
|
|
737
|
+
if (!saved) {
|
|
738
|
+
status.textContent = "No token found in URL.";
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
fetch("/api/health", {
|
|
742
|
+
headers: { Authorization: "Bearer " + saved }
|
|
743
|
+
})
|
|
744
|
+
.then(function (res) {
|
|
745
|
+
status.textContent = res.ok ? "Server: ok" : "Server: HTTP " + res.status;
|
|
746
|
+
})
|
|
747
|
+
.catch(function () {
|
|
748
|
+
status.textContent = "Server unreachable.";
|
|
749
|
+
});
|
|
750
|
+
})();
|
|
751
|
+
</script>
|
|
752
|
+
</body>
|
|
753
|
+
</html>`;
|