playable-lessons 0.1.1 → 0.2.1
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/README.md +30 -3
- package/out/cli/index.js +151 -1
- package/out/shared/aiClient.js +183 -0
- package/out/shared/generate.js +112 -0
- package/out/shared/prompts.js +194 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@ Turn educational source material into **playable interactive fiction**. Paste in
|
|
|
4
4
|
|
|
5
5
|
It ships in two forms:
|
|
6
6
|
|
|
7
|
-
- **Desktop app** (Electron + React) — the full authoring experience: AI generation
|
|
8
|
-
- **CLI** (`playable-lessons`, on npm) —
|
|
7
|
+
- **Desktop app** (Electron + React) — the full authoring experience: AI generation with an interactive clarification step, a node-graph editor, live player, and exports. Aimed at non-technical authors (e.g. educators).
|
|
8
|
+
- **CLI** (`playable-lessons`, on npm) — the same engine, headless: **generate**, **validate**, and **export**. Handy for scripts, CI, batch runs, and local/offline generation against Ollama.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
The desktop app and the CLI share one generation pipeline; the app adds the interactive clarification step and visual editor on top.
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -19,6 +19,33 @@ It ships in two forms:
|
|
|
19
19
|
npm install -g playable-lessons
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
### Generate a story from source material
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Local + offline against Ollama (no API key needed):
|
|
26
|
+
playable-lessons generate --input notes.md --provider ollama --model llama3.1:8b --output lesson/
|
|
27
|
+
|
|
28
|
+
# Anthropic Claude (key from the environment):
|
|
29
|
+
ANTHROPIC_API_KEY=sk-ant-... playable-lessons generate -i case-study.md -p claude -o lesson/
|
|
30
|
+
|
|
31
|
+
# From a topic instead of a file, choosing formats:
|
|
32
|
+
playable-lessons generate --topic "Phishing awareness for staff" -p openai -o lesson/ -f ink,html,json
|
|
33
|
+
|
|
34
|
+
# Pipe source material via stdin:
|
|
35
|
+
cat lecture.txt | playable-lessons generate -o lesson/ -p ollama -m llama3.1:8b
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Provider selection and API keys:
|
|
39
|
+
|
|
40
|
+
| Provider | `--provider` | Key (env var) | Notes |
|
|
41
|
+
| --- | --- | --- | --- |
|
|
42
|
+
| Claude | `claude` | `ANTHROPIC_API_KEY` | default model `claude-opus-4-8` |
|
|
43
|
+
| OpenAI | `openai` | `OPENAI_API_KEY` | default model `gpt-4o` |
|
|
44
|
+
| Ollama | `ollama` | none (or `OLLAMA_API_KEY`) | local by default; `--ollama-url` for remote |
|
|
45
|
+
| Custom | `custom` | `PLAYABLE_LESSONS_API_KEY` / `OPENAI_API_KEY` | OpenAI-compatible; requires `--base-url` + `--model` |
|
|
46
|
+
|
|
47
|
+
If `--provider` is omitted it's inferred from the environment (Claude → OpenAI → Ollama). Keys are read from env vars only — the CLI never touches the OS keychain (that's the desktop app). Other flags: `--mode`, `--length` (short/medium/long), `--tone`, `--protagonist`, `--answers`, `--title`. Run `playable-lessons generate --help` for all options.
|
|
48
|
+
|
|
22
49
|
### Validate an Ink file
|
|
23
50
|
|
|
24
51
|
```bash
|
package/out/cli/index.js
CHANGED
|
@@ -9,6 +9,67 @@ const helpers_1 = require("yargs/helpers");
|
|
|
9
9
|
const promises_1 = require("fs/promises");
|
|
10
10
|
const path_1 = require("path");
|
|
11
11
|
const storyExport_js_1 = require("../shared/storyExport.js");
|
|
12
|
+
const generate_js_1 = require("../shared/generate.js");
|
|
13
|
+
/** Pick a provider from the environment when --provider isn't given. */
|
|
14
|
+
function resolveProviderFromEnv() {
|
|
15
|
+
if (process.env.ANTHROPIC_API_KEY)
|
|
16
|
+
return 'claude';
|
|
17
|
+
if (process.env.OPENAI_API_KEY)
|
|
18
|
+
return 'openai';
|
|
19
|
+
return 'ollama';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build a ProviderConfig from CLI flags + environment. API keys come from
|
|
23
|
+
* env vars (CI/headless-friendly), never the OS keychain (that's GUI-only).
|
|
24
|
+
*/
|
|
25
|
+
function buildCliConfig(o) {
|
|
26
|
+
const provider = o.provider || resolveProviderFromEnv();
|
|
27
|
+
switch (provider) {
|
|
28
|
+
case 'openai': {
|
|
29
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
30
|
+
if (!apiKey)
|
|
31
|
+
throw new Error('OPENAI_API_KEY is not set');
|
|
32
|
+
return { provider, model: o.model || 'gpt-4o', apiKey };
|
|
33
|
+
}
|
|
34
|
+
case 'custom': {
|
|
35
|
+
if (!o.baseUrl)
|
|
36
|
+
throw new Error('--base-url is required for the custom provider');
|
|
37
|
+
if (!o.model)
|
|
38
|
+
throw new Error('--model is required for the custom provider');
|
|
39
|
+
return {
|
|
40
|
+
provider,
|
|
41
|
+
model: o.model,
|
|
42
|
+
baseUrl: o.baseUrl,
|
|
43
|
+
apiKey: process.env.PLAYABLE_LESSONS_API_KEY || process.env.OPENAI_API_KEY || ''
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
case 'ollama':
|
|
47
|
+
return {
|
|
48
|
+
provider,
|
|
49
|
+
model: o.model || 'llama3.1:8b',
|
|
50
|
+
ollamaUrl: o.ollamaUrl,
|
|
51
|
+
ollamaToken: process.env.OLLAMA_API_KEY || ''
|
|
52
|
+
};
|
|
53
|
+
case 'claude':
|
|
54
|
+
default: {
|
|
55
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
56
|
+
if (!apiKey)
|
|
57
|
+
throw new Error('ANTHROPIC_API_KEY is not set');
|
|
58
|
+
return { provider: 'claude', model: o.model || 'claude-opus-4-8', apiKey };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function slug(s) {
|
|
63
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'story';
|
|
64
|
+
}
|
|
65
|
+
async function readStdin() {
|
|
66
|
+
if (process.stdin.isTTY)
|
|
67
|
+
return '';
|
|
68
|
+
const chunks = [];
|
|
69
|
+
for await (const chunk of process.stdin)
|
|
70
|
+
chunks.push(Buffer.from(chunk));
|
|
71
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
72
|
+
}
|
|
12
73
|
(0, yargs_1.default)((0, helpers_1.hideBin)(process.argv))
|
|
13
74
|
.scriptName('playable-lessons')
|
|
14
75
|
.usage('$0 <command> [options]')
|
|
@@ -70,8 +131,97 @@ const storyExport_js_1 = require("../shared/storyExport.js");
|
|
|
70
131
|
console.error('Export failed:', err instanceof Error ? err.message : err);
|
|
71
132
|
process.exit(1);
|
|
72
133
|
}
|
|
134
|
+
})
|
|
135
|
+
.command('generate', 'Generate an Ink story from source material using AI', (yargs) => {
|
|
136
|
+
return yargs
|
|
137
|
+
.option('input', { alias: 'i', type: 'string', describe: 'Path to source material (text/markdown). Omit to use --topic or stdin.' })
|
|
138
|
+
.option('topic', { type: 'string', describe: 'A topic or brief to generate from (instead of --input)' })
|
|
139
|
+
.option('output', { alias: 'o', type: 'string', demandOption: true, describe: 'Output directory' })
|
|
140
|
+
.option('provider', { alias: 'p', type: 'string', describe: 'claude | openai | ollama | custom (default: inferred from env)' })
|
|
141
|
+
.option('model', { alias: 'm', type: 'string', describe: 'Model id (provider-specific default if omitted)' })
|
|
142
|
+
.option('base-url', { type: 'string', describe: 'Base URL for a custom OpenAI-compatible endpoint' })
|
|
143
|
+
.option('ollama-url', { type: 'string', default: 'http://localhost:11434', describe: 'Ollama base URL' })
|
|
144
|
+
.option('mode', { type: 'string', default: 'topic', describe: 'Input mode: topic | lesson | methodology | case-study | lecture-notes | scenario' })
|
|
145
|
+
.option('length', { alias: 'l', type: 'string', default: 'medium', describe: 'Story length: short | medium | long' })
|
|
146
|
+
.option('tone', { type: 'string', default: 'professional', describe: 'Narrative tone' })
|
|
147
|
+
.option('protagonist', { type: 'string', default: 'the reader', describe: 'Protagonist type' })
|
|
148
|
+
.option('answers', { type: 'string', describe: 'Optional answers to clarifying questions' })
|
|
149
|
+
.option('retries', { type: 'number', default: 3, describe: 'Max compile attempts, with AI-assisted fixes between them (raise for weaker local models)' })
|
|
150
|
+
.option('format', { alias: 'f', type: 'string', default: 'ink,html', describe: 'Output formats: ink, html, json' })
|
|
151
|
+
.option('title', { alias: 't', type: 'string', describe: 'Story title (defaults to the input/topic name)' });
|
|
152
|
+
}, async (argv) => {
|
|
153
|
+
try {
|
|
154
|
+
// Resolve the source material: --input file, --topic string, or stdin.
|
|
155
|
+
let inputText = '';
|
|
156
|
+
let base = 'story';
|
|
157
|
+
if (argv.input) {
|
|
158
|
+
inputText = await (0, promises_1.readFile)(argv.input, 'utf-8');
|
|
159
|
+
base = (0, path_1.basename)(argv.input).replace(/\.[^.]+$/, '');
|
|
160
|
+
}
|
|
161
|
+
else if (argv.topic) {
|
|
162
|
+
inputText = argv.topic;
|
|
163
|
+
base = slug(argv.topic);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
inputText = await readStdin();
|
|
167
|
+
}
|
|
168
|
+
if (!inputText.trim()) {
|
|
169
|
+
throw new Error('No source material — provide --input <file>, --topic "<brief>", or pipe text via stdin');
|
|
170
|
+
}
|
|
171
|
+
const config = buildCliConfig({
|
|
172
|
+
provider: argv.provider,
|
|
173
|
+
model: argv.model,
|
|
174
|
+
baseUrl: argv['base-url'],
|
|
175
|
+
ollamaUrl: argv['ollama-url'] || 'http://localhost:11434'
|
|
176
|
+
});
|
|
177
|
+
console.error(`Generating with ${config.provider} (${config.model})…`);
|
|
178
|
+
const result = await (0, generate_js_1.generateInk)({
|
|
179
|
+
inputMode: argv.mode,
|
|
180
|
+
inputText,
|
|
181
|
+
storyLength: argv.length,
|
|
182
|
+
protagonistType: argv.protagonist,
|
|
183
|
+
tone: argv.tone,
|
|
184
|
+
answers: argv.answers
|
|
185
|
+
}, config, { log: (m) => console.error(m), maxCompileRetries: argv.retries });
|
|
186
|
+
const outputDir = argv.output;
|
|
187
|
+
await (0, promises_1.mkdir)(outputDir, { recursive: true });
|
|
188
|
+
const title = argv.title || base;
|
|
189
|
+
const formats = argv.format.split(',').map((f) => f.trim()).filter(Boolean);
|
|
190
|
+
if (!formats.includes('ink'))
|
|
191
|
+
formats.unshift('ink'); // always keep the source
|
|
192
|
+
for (const format of formats) {
|
|
193
|
+
switch (format) {
|
|
194
|
+
case 'ink': {
|
|
195
|
+
const outPath = (0, path_1.join)(outputDir, `${base}.ink`);
|
|
196
|
+
await (0, promises_1.writeFile)(outPath, result.inkSource);
|
|
197
|
+
console.log(`Wrote ${outPath}`);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case 'json': {
|
|
201
|
+
const outPath = (0, path_1.join)(outputDir, `${base}.ink.json`);
|
|
202
|
+
await (0, promises_1.writeFile)(outPath, result.compiledJson);
|
|
203
|
+
console.log(`Wrote ${outPath}`);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case 'html': {
|
|
207
|
+
const html = await (0, storyExport_js_1.exportStandaloneHTML)(result.inkSource, title);
|
|
208
|
+
const outPath = (0, path_1.join)(outputDir, `${base}.html`);
|
|
209
|
+
await (0, promises_1.writeFile)(outPath, html);
|
|
210
|
+
console.log(`Wrote ${outPath}`);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
default:
|
|
214
|
+
console.warn(`Unknown format: ${format}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
console.log('Done.');
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
console.error('Generation failed:', err instanceof Error ? err.message : err);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
73
223
|
})
|
|
74
224
|
.demandCommand(1, 'You must specify a command')
|
|
75
225
|
.help()
|
|
76
|
-
.version('0.1
|
|
226
|
+
.version('0.2.1')
|
|
77
227
|
.parse();
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Provider-agnostic AI client, shared by the renderer and the CLI.
|
|
4
|
+
*
|
|
5
|
+
* Pure and config-driven: callers pass a ProviderConfig (no Zustand store, no
|
|
6
|
+
* DOM). Uses the global `fetch`/`AbortController`, available in both the
|
|
7
|
+
* browser (renderer) and Node 18+ (CLI).
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.normalizeBaseUrl = normalizeBaseUrl;
|
|
11
|
+
exports.callAI = callAI;
|
|
12
|
+
exports.listModels = listModels;
|
|
13
|
+
exports.testConnection = testConnection;
|
|
14
|
+
// AI generations can be slow, but should not hang forever.
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
16
|
+
async function fetchWithTimeout(url, options, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
19
|
+
try {
|
|
20
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
24
|
+
throw new Error(`Request timed out after ${timeoutMs / 1000}s`);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function anthropicHeaders(apiKey) {
|
|
33
|
+
return {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
'x-api-key': apiKey,
|
|
36
|
+
'anthropic-version': '2023-06-01',
|
|
37
|
+
'anthropic-dangerous-direct-browser-access': 'true'
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Bearer headers, omitting Authorization when no token is configured. */
|
|
41
|
+
function bearerHeaders(token) {
|
|
42
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
43
|
+
if (token)
|
|
44
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
45
|
+
return headers;
|
|
46
|
+
}
|
|
47
|
+
/** Strip trailing slashes so `${base}/chat/completions` is well-formed. */
|
|
48
|
+
function normalizeBaseUrl(url) {
|
|
49
|
+
return url.trim().replace(/\/+$/, '');
|
|
50
|
+
}
|
|
51
|
+
const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
|
|
52
|
+
/** Send a chat completion to whichever provider the config selects. */
|
|
53
|
+
async function callAI(messages, config) {
|
|
54
|
+
switch (config.provider) {
|
|
55
|
+
case 'claude':
|
|
56
|
+
return callClaude(messages, config.apiKey ?? '', config.model);
|
|
57
|
+
case 'openai':
|
|
58
|
+
return callOpenAICompatible(messages, config.apiKey ?? '', 'https://api.openai.com/v1', config.model);
|
|
59
|
+
case 'custom':
|
|
60
|
+
return callOpenAICompatible(messages, config.apiKey ?? '', normalizeBaseUrl(config.baseUrl ?? ''), config.model);
|
|
61
|
+
case 'ollama':
|
|
62
|
+
default:
|
|
63
|
+
return callOllama(messages, config.ollamaUrl ?? DEFAULT_OLLAMA_URL, config.model, config.ollamaToken ?? '');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function callClaude(messages, apiKey, model) {
|
|
67
|
+
const systemMsg = messages.find((m) => m.role === 'system')?.content || '';
|
|
68
|
+
const nonSystemMessages = messages.filter((m) => m.role !== 'system');
|
|
69
|
+
const response = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: anthropicHeaders(apiKey),
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
model,
|
|
74
|
+
max_tokens: 8192,
|
|
75
|
+
system: systemMsg,
|
|
76
|
+
messages: nonSystemMessages.map((m) => ({ role: m.role, content: m.content }))
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new Error(`Claude API error (${response.status}): ${await response.text()}`);
|
|
81
|
+
}
|
|
82
|
+
const data = await response.json();
|
|
83
|
+
const text = data?.content?.[0]?.text;
|
|
84
|
+
if (typeof text !== 'string')
|
|
85
|
+
throw new Error('Claude API returned an unexpected response shape');
|
|
86
|
+
return text;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Any OpenAI-compatible /chat/completions endpoint — OpenAI itself, plus custom
|
|
90
|
+
* gateways (OpenRouter, LiteLLM, vLLM, a remote Ollama, …) behind a bearer token.
|
|
91
|
+
*/
|
|
92
|
+
async function callOpenAICompatible(messages, apiKey, baseUrl, model) {
|
|
93
|
+
if (!baseUrl)
|
|
94
|
+
throw new Error('No base URL configured for the OpenAI-compatible endpoint');
|
|
95
|
+
const response = await fetchWithTimeout(`${baseUrl}/chat/completions`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: bearerHeaders(apiKey),
|
|
98
|
+
body: JSON.stringify({ model, messages, max_tokens: 8192 })
|
|
99
|
+
});
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new Error(`API error (${response.status}): ${await response.text()}`);
|
|
102
|
+
}
|
|
103
|
+
const data = await response.json();
|
|
104
|
+
const content = data?.choices?.[0]?.message?.content;
|
|
105
|
+
if (typeof content !== 'string')
|
|
106
|
+
throw new Error('API returned an unexpected response shape');
|
|
107
|
+
return content;
|
|
108
|
+
}
|
|
109
|
+
async function callOllama(messages, url, model, token) {
|
|
110
|
+
const response = await fetchWithTimeout(`${normalizeBaseUrl(url)}/api/chat`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: bearerHeaders(token),
|
|
113
|
+
body: JSON.stringify({ model, messages, stream: false })
|
|
114
|
+
});
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
throw new Error(`Ollama error (${response.status}): ${await response.text()}`);
|
|
117
|
+
}
|
|
118
|
+
const data = await response.json();
|
|
119
|
+
const content = data?.message?.content;
|
|
120
|
+
if (typeof content !== 'string')
|
|
121
|
+
throw new Error('Ollama returned an unexpected response shape');
|
|
122
|
+
return content;
|
|
123
|
+
}
|
|
124
|
+
// ─── Model discovery & connection testing ───
|
|
125
|
+
/** List available models for the configured provider. */
|
|
126
|
+
async function listModels(config) {
|
|
127
|
+
switch (config.provider) {
|
|
128
|
+
case 'claude':
|
|
129
|
+
return listAnthropicModels(config.apiKey ?? '');
|
|
130
|
+
case 'openai':
|
|
131
|
+
return listOpenAICompatibleModels('https://api.openai.com/v1', config.apiKey ?? '');
|
|
132
|
+
case 'custom':
|
|
133
|
+
return listOpenAICompatibleModels(normalizeBaseUrl(config.baseUrl ?? ''), config.apiKey ?? '');
|
|
134
|
+
case 'ollama':
|
|
135
|
+
default:
|
|
136
|
+
return listOllamaModels(config.ollamaUrl ?? DEFAULT_OLLAMA_URL, config.ollamaToken ?? '');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function listAnthropicModels(apiKey) {
|
|
140
|
+
const response = await fetchWithTimeout('https://api.anthropic.com/v1/models?limit=100', {
|
|
141
|
+
method: 'GET',
|
|
142
|
+
headers: anthropicHeaders(apiKey)
|
|
143
|
+
}, 30_000);
|
|
144
|
+
if (!response.ok)
|
|
145
|
+
throw new Error(`Claude API error (${response.status}): ${await response.text()}`);
|
|
146
|
+
const data = await response.json();
|
|
147
|
+
return (data?.data ?? []).map((m) => m.id).filter(Boolean);
|
|
148
|
+
}
|
|
149
|
+
async function listOpenAICompatibleModels(baseUrl, apiKey) {
|
|
150
|
+
if (!baseUrl)
|
|
151
|
+
throw new Error('No base URL configured');
|
|
152
|
+
const response = await fetchWithTimeout(`${baseUrl}/models`, {
|
|
153
|
+
method: 'GET',
|
|
154
|
+
headers: bearerHeaders(apiKey)
|
|
155
|
+
}, 30_000);
|
|
156
|
+
if (!response.ok)
|
|
157
|
+
throw new Error(`API error (${response.status}): ${await response.text()}`);
|
|
158
|
+
const data = await response.json();
|
|
159
|
+
return (data?.data ?? []).map((m) => m.id).filter(Boolean).sort();
|
|
160
|
+
}
|
|
161
|
+
async function listOllamaModels(url, token) {
|
|
162
|
+
const response = await fetchWithTimeout(`${normalizeBaseUrl(url)}/api/tags`, {
|
|
163
|
+
method: 'GET',
|
|
164
|
+
headers: bearerHeaders(token)
|
|
165
|
+
}, 30_000);
|
|
166
|
+
if (!response.ok)
|
|
167
|
+
throw new Error(`Ollama error (${response.status}): ${await response.text()}`);
|
|
168
|
+
const data = await response.json();
|
|
169
|
+
return (data?.models ?? []).map((m) => m.name).filter(Boolean);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Probe the configured endpoint + credentials by listing models. Doubles as the
|
|
173
|
+
* "Test connection" check and the source for "Refresh models".
|
|
174
|
+
*/
|
|
175
|
+
async function testConnection(config) {
|
|
176
|
+
try {
|
|
177
|
+
const models = await listModels(config);
|
|
178
|
+
return { ok: true, message: `Connected — ${models.length} model(s) available.`, models };
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
return { ok: false, message: err instanceof Error ? err.message : 'Connection failed', models: [] };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Headless story generation pipeline, shared by the renderer and the CLI.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the renderer's interactive flow (which pauses to ask the user
|
|
6
|
+
* clarification questions), this runs end-to-end with no interaction:
|
|
7
|
+
* outline → Ink generation → review → compile (with AI-assisted fix retries).
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.extractInk = extractInk;
|
|
11
|
+
exports.generateInk = generateInk;
|
|
12
|
+
const aiClient_js_1 = require("./aiClient.js");
|
|
13
|
+
const prompts_js_1 = require("./prompts.js");
|
|
14
|
+
const storyExport_js_1 = require("./storyExport.js");
|
|
15
|
+
/** Pull Ink source out of an AI response — prefer a ```ink block, then any fence. */
|
|
16
|
+
function extractInk(raw) {
|
|
17
|
+
const inkBlock = raw.match(/```ink\s*([\s\S]*?)```/);
|
|
18
|
+
if (inkBlock)
|
|
19
|
+
return inkBlock[1].trim();
|
|
20
|
+
const anyBlock = raw.match(/```\s*([\s\S]*?)```/);
|
|
21
|
+
if (anyBlock)
|
|
22
|
+
return anyBlock[1].trim();
|
|
23
|
+
return raw.trim();
|
|
24
|
+
}
|
|
25
|
+
/** Match a corrected ```ink block from a review/fix response (only `ink` fences). */
|
|
26
|
+
function extractInkCorrection(raw) {
|
|
27
|
+
const m = raw.match(/```ink\s*([\s\S]*?)```/);
|
|
28
|
+
return m ? m[1].trim() : null;
|
|
29
|
+
}
|
|
30
|
+
async function generateInk(params, config, opts = {}) {
|
|
31
|
+
const log = opts.log ?? (() => { });
|
|
32
|
+
const call = opts.call ?? ((messages) => (0, aiClient_js_1.callAI)(messages, config));
|
|
33
|
+
const maxRetries = opts.maxCompileRetries ?? 3;
|
|
34
|
+
const mode = params.inputMode;
|
|
35
|
+
const length = params.storyLength || 'medium';
|
|
36
|
+
const protagonist = params.protagonistType || 'the reader';
|
|
37
|
+
const tone = params.tone || 'professional';
|
|
38
|
+
const answers = params.answers || '(no specific preferences provided — use sensible defaults)';
|
|
39
|
+
const inkSystem = prompts_js_1.PROMPTS.system + '\n\n' + prompts_js_1.PROMPTS.inkSyntaxRef;
|
|
40
|
+
// ─── Stage 1: Outline ───
|
|
41
|
+
log('[1/4] Generating story outline…');
|
|
42
|
+
const outlineRaw = await call([
|
|
43
|
+
{ role: 'system', content: prompts_js_1.PROMPTS.system },
|
|
44
|
+
{
|
|
45
|
+
role: 'user',
|
|
46
|
+
content: prompts_js_1.PROMPTS.outline
|
|
47
|
+
.replace('{{inputMode}}', mode)
|
|
48
|
+
.replace('{{inputText}}', params.inputText)
|
|
49
|
+
.replace('{{storyLength}}', length)
|
|
50
|
+
.replace('{{answers}}', answers)
|
|
51
|
+
.replace('{{protagonistType}}', protagonist)
|
|
52
|
+
.replace('{{tone}}', tone)
|
|
53
|
+
}
|
|
54
|
+
]);
|
|
55
|
+
// ─── Stage 2: Ink generation ───
|
|
56
|
+
log('[2/4] Generating Ink source…');
|
|
57
|
+
const inkRaw = await call([
|
|
58
|
+
{ role: 'system', content: inkSystem },
|
|
59
|
+
{
|
|
60
|
+
role: 'user',
|
|
61
|
+
content: prompts_js_1.PROMPTS.inkGeneration
|
|
62
|
+
.replace('{{outline}}', outlineRaw)
|
|
63
|
+
.replace('{{inputMode}}', mode)
|
|
64
|
+
.replace('{{inputText}}', params.inputText)
|
|
65
|
+
.replace('{{storyLength}}', length)
|
|
66
|
+
}
|
|
67
|
+
]);
|
|
68
|
+
let inkSource = extractInk(inkRaw);
|
|
69
|
+
// ─── Stage 3: Review pass ───
|
|
70
|
+
log('[3/4] Reviewing for errors and inconsistencies…');
|
|
71
|
+
const reviewRaw = await call([
|
|
72
|
+
{ role: 'system', content: inkSystem },
|
|
73
|
+
{ role: 'user', content: prompts_js_1.PROMPTS.review.replace('{{inkSource}}', inkSource) }
|
|
74
|
+
]);
|
|
75
|
+
const corrected = extractInkCorrection(reviewRaw);
|
|
76
|
+
// Only accept the rewrite if it retains at least half the original's length
|
|
77
|
+
// (guards against a truncated/partial snippet).
|
|
78
|
+
if (corrected && corrected.length > inkSource.length * 0.5) {
|
|
79
|
+
inkSource = corrected;
|
|
80
|
+
log('Applied corrections from review.');
|
|
81
|
+
}
|
|
82
|
+
// ─── Stage 4: Compile, with AI-assisted fix retries ───
|
|
83
|
+
log('[4/4] Compiling Ink source…');
|
|
84
|
+
let current = inkSource;
|
|
85
|
+
for (let attempt = 1;; attempt++) {
|
|
86
|
+
try {
|
|
87
|
+
const compiledJson = await (0, storyExport_js_1.compileInk)(current);
|
|
88
|
+
log('Compiled successfully.');
|
|
89
|
+
return { inkSource: current, compiledJson, outlineRaw };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
93
|
+
if (attempt >= maxRetries) {
|
|
94
|
+
throw new Error(`Failed to compile after ${maxRetries} attempt(s): ${errMsg}`);
|
|
95
|
+
}
|
|
96
|
+
log(`Compilation failed (attempt ${attempt}/${maxRetries}): ${errMsg}`);
|
|
97
|
+
log('Asking the model to fix it…');
|
|
98
|
+
const fixRaw = await call([
|
|
99
|
+
{ role: 'system', content: inkSystem },
|
|
100
|
+
{
|
|
101
|
+
role: 'user',
|
|
102
|
+
content: `The following Ink source failed to compile with this error:\n\n${errMsg}\n\n` +
|
|
103
|
+
`Here is the Ink source:\n\`\`\`ink\n${current}\n\`\`\`\n\n` +
|
|
104
|
+
'Please fix the errors and return the complete corrected Ink source in a ```ink code block.'
|
|
105
|
+
}
|
|
106
|
+
]);
|
|
107
|
+
const fixed = extractInkCorrection(fixRaw);
|
|
108
|
+
if (fixed)
|
|
109
|
+
current = fixed;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PROMPTS = void 0;
|
|
4
|
+
exports.PROMPTS = {
|
|
5
|
+
system: `You are the Playable Lessons assistant, an AI that transforms educational source material into playable interactive fiction using the Ink scripting language.
|
|
6
|
+
|
|
7
|
+
Your goals:
|
|
8
|
+
- Create engaging, educational interactive stories from the provided source material
|
|
9
|
+
- Ensure all generated Ink code is syntactically valid and compiles without errors
|
|
10
|
+
- Preserve factual accuracy from the source material
|
|
11
|
+
- Never invent facts about named real people or institutions
|
|
12
|
+
- Create meaningful branching choices that explore different perspectives and outcomes
|
|
13
|
+
- Track relevant variables (experience, trust, knowledge) to make the story feel responsive
|
|
14
|
+
|
|
15
|
+
When generating Ink, follow these rules:
|
|
16
|
+
- Always start with variable declarations (VAR), then a -> start divert to the first knot
|
|
17
|
+
- The first knot MUST be named === start ===
|
|
18
|
+
- Use === knot_name === for passages (knots)
|
|
19
|
+
- Use = stitch_name for sub-sections within knots
|
|
20
|
+
- Use * for once-only choices, + for sticky choices
|
|
21
|
+
- Use -> knot_name for diverts
|
|
22
|
+
- Use { variable > X: text } for conditional content
|
|
23
|
+
- Use ~ variable = value for variable assignments
|
|
24
|
+
- Always include a -> END or -> DONE to terminate paths
|
|
25
|
+
- Use # IMAGE: and # TIMER: tags for Playable Lessons custom features`,
|
|
26
|
+
analysis: `Analyze the following source material provided in "{{inputMode}}" mode.
|
|
27
|
+
|
|
28
|
+
Source material:
|
|
29
|
+
"""
|
|
30
|
+
{{inputText}}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
Extract and list:
|
|
34
|
+
1. The main subject/theme
|
|
35
|
+
2. Key concepts and decision points
|
|
36
|
+
3. Potential protagonist(s) and setting
|
|
37
|
+
4. Learning outcomes (what should the reader understand after playing?)
|
|
38
|
+
5. Factual claims that must be preserved accurately
|
|
39
|
+
6. Potential branching points where different choices could lead to different outcomes
|
|
40
|
+
|
|
41
|
+
Provide your analysis in a clear, structured format.`,
|
|
42
|
+
clarification: `Based on this analysis of the source material:
|
|
43
|
+
|
|
44
|
+
{{analysis}}
|
|
45
|
+
|
|
46
|
+
The input mode is: {{inputMode}}
|
|
47
|
+
|
|
48
|
+
Generate 3-5 clarification questions to ask the user before generating the story. Questions should cover:
|
|
49
|
+
- Who is the protagonist? (generic professional / named character / "you" the reader)
|
|
50
|
+
- What is the primary learning outcome?
|
|
51
|
+
- What tone? (professional/realistic, light/exploratory, challenging/provocative)
|
|
52
|
+
- Should there be a "best path" or are all paths equally valid?
|
|
53
|
+
- Any specific constraints or requirements?
|
|
54
|
+
|
|
55
|
+
Format each question as a numbered list item.`,
|
|
56
|
+
outline: `Create a detailed story outline for an interactive fiction based on this:
|
|
57
|
+
|
|
58
|
+
Input mode: {{inputMode}}
|
|
59
|
+
Source material: """{{inputText}}"""
|
|
60
|
+
Story length: {{storyLength}} (short=~5 nodes, medium=~15, long=~30+)
|
|
61
|
+
Protagonist type: {{protagonistType}}
|
|
62
|
+
Tone: {{tone}}
|
|
63
|
+
|
|
64
|
+
User's answers to clarification questions:
|
|
65
|
+
{{answers}}
|
|
66
|
+
|
|
67
|
+
Generate a JSON outline with this structure:
|
|
68
|
+
\`\`\`json
|
|
69
|
+
{
|
|
70
|
+
"nodes": [
|
|
71
|
+
{ "id": "start", "title": "Opening Scene", "summary": "Brief description" }
|
|
72
|
+
],
|
|
73
|
+
"edges": [
|
|
74
|
+
{ "from": "start", "to": "next_node", "choiceText": "What the player clicks" }
|
|
75
|
+
],
|
|
76
|
+
"variables": [
|
|
77
|
+
{ "name": "experience", "type": "number", "initialValue": 0 }
|
|
78
|
+
],
|
|
79
|
+
"canonPath": ["start", "node2", "node3", "best_ending"]
|
|
80
|
+
}
|
|
81
|
+
\`\`\`
|
|
82
|
+
|
|
83
|
+
Ensure:
|
|
84
|
+
- Every node is reachable from "start"
|
|
85
|
+
- Every path reaches an ending node
|
|
86
|
+
- Variables are used meaningfully to track decisions
|
|
87
|
+
- The story has at least 2 distinct endings
|
|
88
|
+
- The canon path represents the "ideal" learning path`,
|
|
89
|
+
inkGeneration: `Convert this story outline into valid Ink source code.
|
|
90
|
+
|
|
91
|
+
Outline:
|
|
92
|
+
{{outline}}
|
|
93
|
+
|
|
94
|
+
Original source material (for factual accuracy):
|
|
95
|
+
Input mode: {{inputMode}}
|
|
96
|
+
"""{{inputText}}"""
|
|
97
|
+
|
|
98
|
+
Target length: {{storyLength}}
|
|
99
|
+
|
|
100
|
+
Requirements:
|
|
101
|
+
- Begin with VAR declarations for all tracked variables
|
|
102
|
+
- Each node from the outline becomes an === knot ===
|
|
103
|
+
- Each edge becomes a choice (* [choice text])
|
|
104
|
+
- Include variable assignments (~ var = val) where decisions affect state
|
|
105
|
+
- Use conditional text { var > X: text } to make scenes respond to past choices
|
|
106
|
+
- Every path must reach -> END
|
|
107
|
+
- Write engaging, educational prose — not just placeholder text
|
|
108
|
+
- Write ALL prose yourself. Do NOT copy any example or placeholder text from the
|
|
109
|
+
syntax reference verbatim (e.g. "[Write the opening scene here]"); those
|
|
110
|
+
bracketed placeholders show structure only — replace them with real content
|
|
111
|
+
- Include at least one conditional branch that depends on a variable
|
|
112
|
+
- Mark endings with a # ENDING: tag followed by a label (good/neutral/bad)
|
|
113
|
+
|
|
114
|
+
Return ONLY the complete Ink source inside a \`\`\`ink code block.`,
|
|
115
|
+
review: `Review the following Ink source for errors, inconsistencies, dead ends, and unreachable nodes.
|
|
116
|
+
|
|
117
|
+
\`\`\`ink
|
|
118
|
+
{{inkSource}}
|
|
119
|
+
\`\`\`
|
|
120
|
+
|
|
121
|
+
Check for:
|
|
122
|
+
1. Syntax errors (unclosed braces, missing diverts, invalid variable references)
|
|
123
|
+
2. Dead ends (passages with no choices and no -> END/DONE)
|
|
124
|
+
3. Unreachable nodes (knots that nothing diverts to)
|
|
125
|
+
4. Variable inconsistencies (used but not declared, or declared but never used)
|
|
126
|
+
5. Missing endings (paths that don't terminate)
|
|
127
|
+
6. Narrative inconsistencies (contradictions, tone shifts)
|
|
128
|
+
|
|
129
|
+
If you find issues, return the corrected complete Ink source in a \`\`\`ink code block.
|
|
130
|
+
If the source is clean, respond with "No issues found."`,
|
|
131
|
+
inkSyntaxRef: `=== INK SYNTAX REFERENCE ===
|
|
132
|
+
|
|
133
|
+
// Variable declarations (top of file), then divert to first knot
|
|
134
|
+
VAR name = "value"
|
|
135
|
+
VAR score = 0
|
|
136
|
+
VAR has_key = false
|
|
137
|
+
-> start
|
|
138
|
+
|
|
139
|
+
// First knot MUST be named "start"
|
|
140
|
+
// (bracketed text below = placeholders — replace with your own prose, do not copy)
|
|
141
|
+
=== start ===
|
|
142
|
+
[Write the opening scene here]
|
|
143
|
+
* [First choice] -> next_scene
|
|
144
|
+
|
|
145
|
+
// Other knots
|
|
146
|
+
=== knot_name ===
|
|
147
|
+
[Write the passage prose here]
|
|
148
|
+
* [Choice text visible to player]
|
|
149
|
+
[Result prose shown after choosing]
|
|
150
|
+
-> next_knot
|
|
151
|
+
* [Another choice]
|
|
152
|
+
-> another_knot
|
|
153
|
+
|
|
154
|
+
// Stitches (sub-sections within a knot)
|
|
155
|
+
=== knot ===
|
|
156
|
+
= stitch_one
|
|
157
|
+
[Stitch prose here]
|
|
158
|
+
-> stitch_two
|
|
159
|
+
= stitch_two
|
|
160
|
+
[More stitch prose here]
|
|
161
|
+
|
|
162
|
+
// Sticky choices (can be chosen repeatedly)
|
|
163
|
+
+ [Repeatable choice]
|
|
164
|
+
|
|
165
|
+
// Conditional text
|
|
166
|
+
{ score > 5:
|
|
167
|
+
You've done well.
|
|
168
|
+
- else:
|
|
169
|
+
You need more experience.
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Inline conditional
|
|
173
|
+
You feel {has_key: confident|nervous}.
|
|
174
|
+
|
|
175
|
+
// Variable assignment
|
|
176
|
+
~ score = score + 1
|
|
177
|
+
~ has_key = true
|
|
178
|
+
|
|
179
|
+
// Diverts
|
|
180
|
+
-> knot_name
|
|
181
|
+
-> END
|
|
182
|
+
-> DONE
|
|
183
|
+
|
|
184
|
+
// Tags (for Playable Lessons)
|
|
185
|
+
# IMAGE: assets/images/scene.jpg
|
|
186
|
+
# TIMER: 15
|
|
187
|
+
# ENDING: good
|
|
188
|
+
|
|
189
|
+
// Glue (prevents line break)
|
|
190
|
+
Text<>
|
|
191
|
+
<>more text on same line
|
|
192
|
+
|
|
193
|
+
=== END ===`
|
|
194
|
+
};
|
package/package.json
CHANGED