narraeon 0.1.1-oidc.0 → 0.1.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 +25 -2
- package/README.zh-CN.md +15 -2
- package/dist/node/cli/NarraeonCli.js +5 -1
- package/dist/node/runtime/model/ModelHostStream.js +12 -6
- package/dist/node/server/createServer.js +0 -27
- package/dist/node/server/main.js +3 -1
- package/dist/node/server/startWebServer.js +10 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ The model handles the meaning of the story: it portrays characters, responds to
|
|
|
17
17
|
|
|
18
18
|
## Requirements
|
|
19
19
|
|
|
20
|
-
- Node.js **24.12.0 or newer**
|
|
20
|
+
- Either Docker Engine / Docker Desktop, or Node.js **24.12.0 or newer**
|
|
21
21
|
- A modern browser
|
|
22
22
|
- For AI play or setting improvement, a model endpoint and API credential compatible with one of the supported adapters:
|
|
23
23
|
- OpenAI Responses API
|
|
@@ -46,6 +46,25 @@ npx narraeon --help
|
|
|
46
46
|
|
|
47
47
|
The server listens only on `127.0.0.1`. If the chosen port already hosts a compatible Narraeon instance, the CLI reuses it. If another program owns the port, startup fails instead of attaching to an unknown service.
|
|
48
48
|
|
|
49
|
+
Alternatively, run the release image from GitHub Container Registry:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
docker run --detach --name narraeon \
|
|
53
|
+
--publish 127.0.0.1:4317:4317 \
|
|
54
|
+
--volume narraeon-data:/var/lib/narraeon \
|
|
55
|
+
ghcr.io/narraeon/narraeon:latest
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then open <http://127.0.0.1:4317>. The named volume preserves worlds,
|
|
59
|
+
configuration, credentials, logs, and recovery state across container
|
|
60
|
+
recreation. Keep the host-side address as `127.0.0.1`; publishing the port on
|
|
61
|
+
all interfaces would unnecessarily expose a local, single-user Runtime.
|
|
62
|
+
Narraeon does not currently provide built-in web authentication or access
|
|
63
|
+
control. Run it only on loopback or a trusted private network; public exposure
|
|
64
|
+
is unsupported until a unified security model is designed.
|
|
65
|
+
Stable releases move `latest`, prereleases move `next`, and every release also
|
|
66
|
+
has an exact version tag.
|
|
67
|
+
|
|
49
68
|
## Run from source
|
|
50
69
|
|
|
51
70
|
```bash
|
|
@@ -136,7 +155,11 @@ Narraeon uses the operating system's standard per-user application directories.
|
|
|
136
155
|
| `NARRAEON_LOG_ROOT` | Runtime logs and AI failure diagnostics |
|
|
137
156
|
| `NARRAEON_PORT` | Local web port, default `4317` |
|
|
138
157
|
|
|
139
|
-
The
|
|
158
|
+
The container image maps all three storage roots under
|
|
159
|
+
`/var/lib/narraeon`, which is why the Docker example persists that single
|
|
160
|
+
directory as a volume.
|
|
161
|
+
|
|
162
|
+
By default, the browser talks only to the same-machine Runtime. Local-first does not mean that remote model inference is offline: prompts, selected world material, tool exchanges, and generated text are sent to the provider you configure. API keys are stored in the local configuration and are not returned to the browser after saving.
|
|
140
163
|
|
|
141
164
|
When a provider, response-format, Runtime-tool, or candidate-check error occurs, Narraeon creates a JSONL incident under `NARRAEON_LOG_ROOT/ai-failures`. It preserves the raw provider exchange and later recovery attempts, including reasoning or thinking actually returned by the provider. It does not invent hidden reasoning or record API keys and request headers. The files can still contain private prompts, world content, tool arguments, and provider-returned reasoning, so treat the log directory as sensitive.
|
|
142
165
|
|
package/README.zh-CN.md
CHANGED
|
@@ -17,7 +17,7 @@ Narraeon 是一个用于开放式 AI 角色扮演的本地优先工作区。你
|
|
|
17
17
|
|
|
18
18
|
## 运行要求
|
|
19
19
|
|
|
20
|
-
- Node.js **24.12.0
|
|
20
|
+
- Docker Engine/Docker Desktop,或 Node.js **24.12.0 或更高版本**,二选一
|
|
21
21
|
- 现代浏览器
|
|
22
22
|
- 如果要进行 AI 游玩或设定完善,还需要与以下任一适配器兼容的模型端点和 API 凭据:
|
|
23
23
|
- OpenAI Responses API
|
|
@@ -46,6 +46,17 @@ npx narraeon --help
|
|
|
46
46
|
|
|
47
47
|
服务只监听 `127.0.0.1`。如果选定端口已经运行兼容的 Narraeon,CLI 会直接复用;如果端口被其他程序占用,启动会明确失败,不会连接未知服务。
|
|
48
48
|
|
|
49
|
+
也可以直接运行 GitHub Container Registry 上的发布镜像:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
docker run --detach --name narraeon \
|
|
53
|
+
--publish 127.0.0.1:4317:4317 \
|
|
54
|
+
--volume narraeon-data:/var/lib/narraeon \
|
|
55
|
+
ghcr.io/narraeon/narraeon:latest
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
然后打开 <http://127.0.0.1:4317>。命名卷会在重建容器后继续保留世界、配置、凭据、日志和恢复数据。宿主机一侧应继续绑定 `127.0.0.1`;绑定所有网卡会把本地单用户 Runtime 不必要地暴露出去。Narraeon 当前不内置 Web 认证或访问控制,只应运行在回环地址或受信任私网中;在统一安全模型设计完成前,不支持暴露到公网。稳定版会移动 `latest`,预发布版会移动 `next`,每次发布还会保留精确版本标签。
|
|
59
|
+
|
|
49
60
|
## 从源码运行
|
|
50
61
|
|
|
51
62
|
```bash
|
|
@@ -136,7 +147,9 @@ Narraeon 默认使用当前操作系统的标准用户应用目录。如果要
|
|
|
136
147
|
| `NARRAEON_LOG_ROOT` | Runtime 日志和 AI 失败诊断 |
|
|
137
148
|
| `NARRAEON_PORT` | 本地 Web 端口,默认为 `4317` |
|
|
138
149
|
|
|
139
|
-
|
|
150
|
+
容器镜像把前三类存储目录统一放在 `/var/lib/narraeon` 下,因此 Docker 示例只需要持久化这一个目录。
|
|
151
|
+
|
|
152
|
+
默认情况下,浏览器只与同机 Runtime 通信。“本地优先”不等于远程模型推理也离线:提示词、选中的世界材料、工具交换和生成文本会发送到你配置的 Provider。API Key 保存在本地配置中,保存后不会再返回给浏览器。
|
|
140
153
|
|
|
141
154
|
发生 Provider、响应格式、Runtime 工具或候选检查错误时,Narraeon 会在 `NARRAEON_LOG_ROOT/ai-failures` 下创建 JSONL 事故记录。它会保存原始 Provider 交换和后续恢复尝试,包括 Provider 真实返回的 reasoning/thinking。它不会伪造隐藏思考,也不会记录 API Key 和请求 header。不过日志仍可能包含私密提示词、世界内容、工具参数和 Provider 返回的思考,因此应当把该目录视为敏感数据。
|
|
142
155
|
|
|
@@ -2,7 +2,7 @@ import { createConnection } from "node:net";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { resolveAppPaths } from "../runtime/config/appPaths.js";
|
|
5
|
-
import { parseWebPort, startWebServer, } from "../server/startWebServer.js";
|
|
5
|
+
import { parseWebHost, parseWebPort, startWebServer, } from "../server/startWebServer.js";
|
|
6
6
|
export class NarraeonCliUsageError extends Error {
|
|
7
7
|
}
|
|
8
8
|
export async function runNarraeonCli(input) {
|
|
@@ -29,6 +29,7 @@ export async function runNarraeonCli(input) {
|
|
|
29
29
|
server = await start({
|
|
30
30
|
paths: resolveAppPaths(input.environment),
|
|
31
31
|
staticRoot: input.staticRoot,
|
|
32
|
+
host: command.host,
|
|
32
33
|
port: command.port,
|
|
33
34
|
});
|
|
34
35
|
}
|
|
@@ -93,7 +94,9 @@ function parseCliCommand(args, environment) {
|
|
|
93
94
|
if (parsed.positionals.length !== 1 || parsed.positionals[0] !== "web")
|
|
94
95
|
throw new NarraeonCliUsageError(`Unknown command: ${parsed.positionals.join(" ") || "(empty)"}`);
|
|
95
96
|
let port;
|
|
97
|
+
let host;
|
|
96
98
|
try {
|
|
99
|
+
host = parseWebHost(environment.NARRAEON_HOST);
|
|
97
100
|
port = parseWebPort(parsed.values.port ?? environment.NARRAEON_PORT, parsed.values.port === undefined ? "NARRAEON_PORT" : "--port");
|
|
98
101
|
}
|
|
99
102
|
catch (error) {
|
|
@@ -101,6 +104,7 @@ function parseCliCommand(args, environment) {
|
|
|
101
104
|
}
|
|
102
105
|
return {
|
|
103
106
|
kind: "web",
|
|
107
|
+
host,
|
|
104
108
|
port,
|
|
105
109
|
openBrowser: parsed.values.open !== false,
|
|
106
110
|
};
|
|
@@ -51,7 +51,10 @@ export async function aggregateChatModelStream(body, onDelta) {
|
|
|
51
51
|
reasoningContent += delta.reasoning_content;
|
|
52
52
|
onDelta?.({ kind: "reasoning", text: delta.reasoning_content });
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
// Gateways in front of a Chat Completions Model often write `null` where
|
|
55
|
+
// the protocol omits a field, both for the whole tool-call list and for
|
|
56
|
+
// individual fragment fields. A null carries no continuation content.
|
|
57
|
+
if (delta.tool_calls === undefined || delta.tool_calls === null)
|
|
55
58
|
continue;
|
|
56
59
|
if (!Array.isArray(delta.tool_calls))
|
|
57
60
|
throw new Error("Chat SSE tool_calls delta is invalid");
|
|
@@ -69,26 +72,26 @@ export async function aggregateChatModelStream(body, onDelta) {
|
|
|
69
72
|
type: "function",
|
|
70
73
|
arguments: "",
|
|
71
74
|
};
|
|
72
|
-
if (fragmentValue.id
|
|
75
|
+
if (isPresent(fragmentValue.id)) {
|
|
73
76
|
if (typeof fragmentValue.id !== "string")
|
|
74
77
|
throw new Error("Chat SSE tool-call id is invalid");
|
|
75
78
|
current.id += fragmentValue.id;
|
|
76
79
|
}
|
|
77
|
-
if (fragmentValue.type
|
|
80
|
+
if (isPresent(fragmentValue.type)) {
|
|
78
81
|
if (fragmentValue.type !== "function")
|
|
79
82
|
throw new Error("Chat SSE tool-call type is invalid");
|
|
80
83
|
current.type = fragmentValue.type;
|
|
81
84
|
}
|
|
82
|
-
if (fragmentValue.function
|
|
85
|
+
if (isPresent(fragmentValue.function)) {
|
|
83
86
|
if (!isRecord(fragmentValue.function))
|
|
84
87
|
throw new Error("Chat SSE tool-call function is invalid");
|
|
85
88
|
const function_ = fragmentValue.function;
|
|
86
|
-
if (function_.name
|
|
89
|
+
if (isPresent(function_.name)) {
|
|
87
90
|
if (typeof function_.name !== "string")
|
|
88
91
|
throw new Error("Chat SSE tool-call name is invalid");
|
|
89
92
|
current.name += function_.name;
|
|
90
93
|
}
|
|
91
|
-
if (function_.arguments
|
|
94
|
+
if (isPresent(function_.arguments)) {
|
|
92
95
|
if (typeof function_.arguments !== "string")
|
|
93
96
|
throw new Error("Chat SSE tool-call arguments are invalid");
|
|
94
97
|
current.arguments += function_.arguments;
|
|
@@ -362,6 +365,9 @@ function mergeUsage(target, value) {
|
|
|
362
365
|
function stringOrUndefined(value) {
|
|
363
366
|
return typeof value === "string" ? value : undefined;
|
|
364
367
|
}
|
|
368
|
+
function isPresent(value) {
|
|
369
|
+
return value !== undefined && value !== null;
|
|
370
|
+
}
|
|
365
371
|
function isRecord(value) {
|
|
366
372
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
367
373
|
}
|
|
@@ -58,24 +58,7 @@ export async function createServer(input) {
|
|
|
58
58
|
: { stream: input.logStream }),
|
|
59
59
|
},
|
|
60
60
|
});
|
|
61
|
-
const suffix = input.port === 80 ? "" : `:${input.port}`;
|
|
62
|
-
const origins = new Set([
|
|
63
|
-
`http://127.0.0.1${suffix}`,
|
|
64
|
-
`http://localhost${suffix}`,
|
|
65
|
-
]);
|
|
66
61
|
const html = await readFile(join(input.staticRoot, "index.html"), "utf8");
|
|
67
|
-
// Origin is what actually stops a hostile page from driving this server: a
|
|
68
|
-
// cross-site request carries its own origin, and a JSON POST always triggers
|
|
69
|
-
// a preflight, so the header is always there to check. A per-boot token on
|
|
70
|
-
// top bought nothing against that threat and made every restart invalidate
|
|
71
|
-
// the tab the user already had open.
|
|
72
|
-
const sameOrigin = (request, reply, done) => {
|
|
73
|
-
if (request.headers.origin === undefined ||
|
|
74
|
-
!origins.has(request.headers.origin))
|
|
75
|
-
void forbidden(reply);
|
|
76
|
-
else
|
|
77
|
-
done();
|
|
78
|
-
};
|
|
79
62
|
const logRuntimeRequest = (request, _reply, done) => {
|
|
80
63
|
if (!isPollingRequest(request))
|
|
81
64
|
request.log.info({ req: request }, "incoming request");
|
|
@@ -100,7 +83,6 @@ export async function createServer(input) {
|
|
|
100
83
|
}));
|
|
101
84
|
server.post("/api/runtime/v1", {
|
|
102
85
|
bodyLimit: runtimeRequestBodyLimit,
|
|
103
|
-
onRequest: [sameOrigin],
|
|
104
86
|
preHandler: [logRuntimeRequest],
|
|
105
87
|
}, async (request, reply) => {
|
|
106
88
|
let runtimeRequestType;
|
|
@@ -207,12 +189,3 @@ function isPlayCallChainView(value) {
|
|
|
207
189
|
value.status === "running" ||
|
|
208
190
|
value.status === "interrupted"));
|
|
209
191
|
}
|
|
210
|
-
function forbidden(reply) {
|
|
211
|
-
return reply.status(403).send({
|
|
212
|
-
protocol: "narraeon.runtime/v1",
|
|
213
|
-
error: {
|
|
214
|
-
code: "forbidden_request",
|
|
215
|
-
message: "Local web request is not authorized",
|
|
216
|
-
},
|
|
217
|
-
});
|
|
218
|
-
}
|
package/dist/node/server/main.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { resolveAppPaths } from "../runtime/config/appPaths.js";
|
|
3
|
-
import { parseWebPort, startWebServer } from "./startWebServer.js";
|
|
3
|
+
import { parseWebHost, parseWebPort, startWebServer, } from "./startWebServer.js";
|
|
4
|
+
const host = parseWebHost(process.env.NARRAEON_HOST);
|
|
4
5
|
const port = parseWebPort(process.env.NARRAEON_PORT);
|
|
5
6
|
const paths = resolveAppPaths(process.env);
|
|
6
7
|
const server = await startWebServer({
|
|
7
8
|
paths,
|
|
8
9
|
staticRoot: resolve("dist/web"),
|
|
10
|
+
host,
|
|
9
11
|
port,
|
|
10
12
|
});
|
|
11
13
|
installGracefulShutdown(server);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createRuntime } from "./createRuntime.js";
|
|
2
2
|
import { createServer } from "./createServer.js";
|
|
3
3
|
export const defaultWebPort = 4317;
|
|
4
|
+
export const defaultWebHost = "127.0.0.1";
|
|
5
|
+
export const containerWebHost = "0.0.0.0";
|
|
4
6
|
export async function startWebServer(input) {
|
|
5
7
|
const runtime = await createRuntime(input.paths);
|
|
6
8
|
const server = await createServer({
|
|
@@ -10,7 +12,7 @@ export async function startWebServer(input) {
|
|
|
10
12
|
...(input.logger === undefined ? {} : { logger: input.logger }),
|
|
11
13
|
});
|
|
12
14
|
try {
|
|
13
|
-
await server.listen({ host:
|
|
15
|
+
await server.listen({ host: input.host, port: input.port });
|
|
14
16
|
return server;
|
|
15
17
|
}
|
|
16
18
|
catch (error) {
|
|
@@ -18,6 +20,13 @@ export async function startWebServer(input) {
|
|
|
18
20
|
throw error;
|
|
19
21
|
}
|
|
20
22
|
}
|
|
23
|
+
export function parseWebHost(value, source = "NARRAEON_HOST") {
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return defaultWebHost;
|
|
26
|
+
if (value === defaultWebHost || value === containerWebHost)
|
|
27
|
+
return value;
|
|
28
|
+
throw new Error(`${source} must be ${defaultWebHost} or ${containerWebHost}: ${value}`);
|
|
29
|
+
}
|
|
21
30
|
export function parseWebPort(value, source = "NARRAEON_PORT") {
|
|
22
31
|
if (value === undefined)
|
|
23
32
|
return defaultWebPort;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "narraeon",
|
|
3
|
-
"version": "0.1.1
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A local-first workspace for living worlds.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"prepack": "npm run build",
|
|
45
45
|
"start": "node src/server/main.ts",
|
|
46
46
|
"test": "npm run test:unit && npm run test:release && npm run test:e2e",
|
|
47
|
+
"test:container": "docker build --tag narraeon:release-smoke . && node scripts/smoke-container.mjs --image narraeon:release-smoke",
|
|
47
48
|
"test:e2e": "npm run build && playwright test",
|
|
48
49
|
"test:package": "node scripts/smoke-package.mjs",
|
|
49
50
|
"test:release": "node --test scripts/check-release.test.mjs",
|