cortico-world-simple-trpg-check 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/README.md +38 -0
- package/dist/console.js +70 -0
- package/package.json +44 -0
- package/python/laya_multilingual_worker.py +25 -0
- package/src/ENV_PROMPT.md +11 -0
- package/src/check.ts +34 -0
- package/src/conda-environments.ts +35 -0
- package/src/config.ts +74 -0
- package/src/console/client.ts +74 -0
- package/src/definition.ts +12 -0
- package/src/index.ts +5 -0
- package/src/model.ts +106 -0
- package/src/multilingual-laya.ts +85 -0
- package/src/secret-file.ts +23 -0
- package/src/shared-laya.ts +123 -0
- package/src/world.ts +187 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!-- Owner: src/definition.ts, src/world.ts, src/config.ts -->
|
|
2
|
+
|
|
3
|
+
# cortico-world-simple-trpg-check
|
|
4
|
+
|
|
5
|
+
`simple_trpg_check_roll` 接收 `scenario` 和 `skill_lists`。`scenario` 写明角色使用技能的方法、目标和相关情境,可写熟练程度或身体条件。模型一次评估所有技能的成功把握;World 将每项 `0–1` 数值乘 100、向下取整,限制在 1–99,回执中称为“难度”。每项技能各掷一次 1–100,按输入顺序返回固定判定文本。
|
|
6
|
+
|
|
7
|
+
1 是大成功,100 是大失败。其余骰值不超过难度的四分之一为极难成功,不超过二分之一为困难成功,不超过难度为普通成功;否则为普通失败。模型不可用或任何一项答案无效时,整次调用返回失败回执,不掷骰。
|
|
8
|
+
|
|
9
|
+
## 判定模型
|
|
10
|
+
|
|
11
|
+
`worlds.simple-trpg-check.backend` 可选 `laya-multilingual`(默认)、`laya` 和 `jev`。
|
|
12
|
+
|
|
13
|
+
- `laya-multilingual`:在“多语言 Laya Python 环境”下拉选单中选择已安装 `laya` 的 Conda 环境,使用 `convaiinnovations/laya` 的 `multilingual` 检查点。选项来自本机 `~/.conda/environments.txt`,缺失或不可读时只显示当前配置值。首次运行可能由 Laya 下载权重;后续使用本地缓存。
|
|
14
|
+
- `laya`:通过可选依赖 `@receptron/laya` 在本机运行英文检查点。
|
|
15
|
+
- `jev`:按 `worlds.simple-trpg-check.jevSource` 选择 TypeSafe 或 OpenRouter 的 System One API。工具调用会将 `scenario` 和技能名称发送给所选服务。配置页的“Jev 来源”下方显示“打开密钥文件”按钮;点击后打开部署目录的 `.env`,缺少当前来源密钥时补入空的 `CORTICO_JEV_TYPESAFE_API_KEY=` 或 `CORTICO_JEV_OPENROUTER_API_KEY=` 行。填写并保存文件后,下一次模型请求读取密钥。旧的 World 专用变量名仍可读取。
|
|
16
|
+
|
|
17
|
+
World 左栏的状态灯在首次成功完成模型评估后变绿;配置改变或评估失败后更新状态。
|
|
18
|
+
“判定模型”下方的“测试连接”会发送一项英文技能评分请求,不掷骰;结果显示在按钮旁,并更新左栏状态灯。
|
|
19
|
+
|
|
20
|
+
同一 Cortico 进程中,若 continuity 与此 World 使用相同的 Laya 类型(multilingual 还需同一 Python 路径),两者共用已加载的模型或 worker。请求依次执行;模型在所有使用者空闲后按其中最长的 TTL 回收。`layaIdleTtlMinutes` 默认 10;设为 `0` 且无其他使用者需要保留模型时,每次评分后释放。不同进程不共用运行实例。
|
|
21
|
+
|
|
22
|
+
“强制适配多语言”勾选后,World 提示 agent 用英文填写 `scenario` 和 `skill_lists`;它不翻译输入,也不切换判定模型。
|
|
23
|
+
|
|
24
|
+
环境提示词让 agent 只在结果不确定且具有戏剧性的情境中酌情投骰,并在面向用户的文本中写出每项骰值、难度和结果。
|
|
25
|
+
|
|
26
|
+
## 开发与安装
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
corepack pnpm install
|
|
30
|
+
corepack pnpm typecheck
|
|
31
|
+
corepack pnpm typecheck:web
|
|
32
|
+
corepack pnpm test
|
|
33
|
+
corepack pnpm build:console
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
包内的 `pnpm-workspace.yaml` 已允许 `esbuild` 与 `onnxruntime-node` 的安装脚本。multilingual 模式需要在下拉选单中选择已安装 `laya` 的 Python 环境。
|
|
37
|
+
|
|
38
|
+
在 Cortico 仓库根目录运行 `corepack pnpm check:extension <本包绝对路径>`。然后在目标实例的“扩展”页手动安装此目录,并在 World 配置中启用。安装到运行中的实例需要重启进程;本包的测试和装载检查不会启动 bot。
|
package/dist/console.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/console/client.ts
|
|
2
|
+
async function mountCredentials(ctx) {
|
|
3
|
+
let state = await ctx.invoke("state");
|
|
4
|
+
render(state);
|
|
5
|
+
ctx.interval(() => {
|
|
6
|
+
void ctx.invoke("state").then((next) => {
|
|
7
|
+
if (next.backend !== state.backend || next.source !== state.source || next.keySet !== state.keySet) {
|
|
8
|
+
state = next;
|
|
9
|
+
render(next);
|
|
10
|
+
}
|
|
11
|
+
}).catch(() => void 0);
|
|
12
|
+
}, 2e3);
|
|
13
|
+
function render(current) {
|
|
14
|
+
const { ui } = ctx;
|
|
15
|
+
if (current.backend !== "jev") {
|
|
16
|
+
ctx.root.replaceChildren();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const sourceName = current.source === "openrouter" ? "OpenRouter" : "TypeSafe";
|
|
20
|
+
const message = ui.msgline(current.keySet ? "\u5BC6\u94A5\u5DF2\u914D\u7F6E" : "\u5BC6\u94A5\u672A\u914D\u7F6E");
|
|
21
|
+
const open = ui.button(`\u6253\u5F00 ${sourceName} \u5BC6\u94A5\u6587\u4EF6`, {
|
|
22
|
+
onClick: () => {
|
|
23
|
+
open.disabled = true;
|
|
24
|
+
void ctx.invoke("openKeyFile", [current.source]).then(({ file }) => {
|
|
25
|
+
message.textContent = `\u5DF2\u6253\u5F00 ${file}`;
|
|
26
|
+
message.classList.remove("bad");
|
|
27
|
+
}).catch((error) => {
|
|
28
|
+
message.textContent = `\u6253\u5F00\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`;
|
|
29
|
+
message.classList.add("bad");
|
|
30
|
+
}).finally(() => {
|
|
31
|
+
open.disabled = false;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const actions = ui.actions();
|
|
36
|
+
actions.append(message, open);
|
|
37
|
+
ctx.root.replaceChildren(actions);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function mountConnectionTest(ctx) {
|
|
41
|
+
const { ui } = ctx;
|
|
42
|
+
const message = ui.msgline();
|
|
43
|
+
const button = ui.button("\u6D4B\u8BD5\u8FDE\u63A5", {
|
|
44
|
+
onClick: () => {
|
|
45
|
+
button.disabled = true;
|
|
46
|
+
message.textContent = "\u6D4B\u8BD5\u4E2D\u2026";
|
|
47
|
+
message.classList.remove("bad");
|
|
48
|
+
void ctx.invoke("testConnection").then((result) => {
|
|
49
|
+
message.textContent = result.ok ? "\u8FDE\u63A5\u6210\u529F" : `\u8FDE\u63A5\u5931\u8D25\uFF1A${result.error || "\u6A21\u578B\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7ED3\u679C"}`;
|
|
50
|
+
message.classList.toggle("bad", !result.ok);
|
|
51
|
+
void ctx.refresh().catch(() => void 0);
|
|
52
|
+
}).catch((error) => {
|
|
53
|
+
message.textContent = `\u8FDE\u63A5\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`;
|
|
54
|
+
message.classList.add("bad");
|
|
55
|
+
}).finally(() => {
|
|
56
|
+
button.disabled = false;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
const actions = ui.actions();
|
|
61
|
+
actions.append(message, button);
|
|
62
|
+
ctx.root.replaceChildren(actions);
|
|
63
|
+
}
|
|
64
|
+
var client = {
|
|
65
|
+
panels: { credentials: { mount: mountCredentials }, "connection-test": { mount: mountConnectionTest } }
|
|
66
|
+
};
|
|
67
|
+
var client_default = client;
|
|
68
|
+
export {
|
|
69
|
+
client_default as default
|
|
70
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cortico-world-simple-trpg-check",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "TRPG skill checks with Laya or Jev probability scores and d100 rolls",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"cortico-world",
|
|
9
|
+
"cortico"
|
|
10
|
+
],
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22"
|
|
13
|
+
},
|
|
14
|
+
"packageManager": "pnpm@11.5.0",
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"cortico": {
|
|
17
|
+
"kind": "world",
|
|
18
|
+
"api": 5,
|
|
19
|
+
"consoleClient": "dist/console.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"python",
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"typecheck:web": "tsc -p tsconfig.web.json --noEmit",
|
|
31
|
+
"build:console": "esbuild src/console/client.ts --bundle --format=esm --outfile=dist/console.js"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.10.0",
|
|
35
|
+
"cortico": "^0.1.3",
|
|
36
|
+
"esbuild": "^0.28.2",
|
|
37
|
+
"tsx": "^4.19.2",
|
|
38
|
+
"typescript": "^5.7.2",
|
|
39
|
+
"vitest": "^2.1.8"
|
|
40
|
+
},
|
|
41
|
+
"optionalDependencies": {
|
|
42
|
+
"@receptron/laya": "0.1.2"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def send(message):
|
|
7
|
+
sys.__stdout__.write(json.dumps(message, ensure_ascii=False, separators=(',', ':')) + '\n')
|
|
8
|
+
sys.__stdout__.flush()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
12
|
+
import laya
|
|
13
|
+
agent = laya.load('convaiinnovations/laya', subfolder='multilingual')
|
|
14
|
+
|
|
15
|
+
send({'type': 'ready'})
|
|
16
|
+
for line in sys.stdin:
|
|
17
|
+
request = {}
|
|
18
|
+
try:
|
|
19
|
+
request = json.loads(line)
|
|
20
|
+
if request.get('type') == 'close':
|
|
21
|
+
break
|
|
22
|
+
result = agent.predict(request['state'], request['questions'])
|
|
23
|
+
send({'id': request['id'], 'ok': True, 'result': result})
|
|
24
|
+
except Exception as error:
|
|
25
|
+
send({'id': request.get('id'), 'ok': False, 'error': f'{type(error).__name__}: {error}'})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
仅在结果不确定且情境具有戏剧性时酌情进行技能判定:平静地走过明亮门廊无需判定;被怪物追赶时穿过遍布瓦砾的狭窄走廊则适合判定。需要判定时调用 simple_trpg_check_roll。
|
|
2
|
+
|
|
3
|
+
调用时自行填写 scenario 和 skill_lists。scenario 要写明角色的目标、如何使用列表中的每项技能,以及影响达成目标的条件,例如环境、熟练程度或身体状况。skill_lists 按判定顺序填写技能名称。
|
|
4
|
+
|
|
5
|
+
simple_trpg_check_roll 收到的 state 是 { scenario: "…" },并对 skill_lists 中的每项技能分别提出以下问题,其中 <技能名> 替换为该项名称:
|
|
6
|
+
Can the character achieve the stated goal by using the skill "<技能名>" in this scenario? Consider the described method, goal, familiarity, and relevant circumstances.
|
|
7
|
+
填写参数时,确保 scenario 与 skill_lists 自洽,且 scenario 包含回答这些问题所需的条件。
|
|
8
|
+
|
|
9
|
+
{{simpleTrpgCheck.languageRule}}
|
|
10
|
+
|
|
11
|
+
根据工具回执继续叙事。面向用户时,按输入顺序用中文技能名列出每项结果,格式为【<技能名>检定:<判定>/<难度> <结果>】;普通成功写“成功”,普通失败写“失败”,其他等级写回执中的结果。例如:【侦查检定:80/40 失败】【灵感检定:20/80 成功】。模型请求失败时依据失败回执处理,不编造骰值。
|
package/src/check.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type CheckResult = '大成功' | '极难成功' | '困难成功' | '普通成功' | '普通失败' | '大失败';
|
|
2
|
+
|
|
3
|
+
export interface SkillCheck {
|
|
4
|
+
skill: string;
|
|
5
|
+
difficulty: number;
|
|
6
|
+
roll: number;
|
|
7
|
+
result: CheckResult;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function difficultyFromProbability(probability: number): number {
|
|
11
|
+
if (!Number.isFinite(probability) || probability < 0 || probability > 1) {
|
|
12
|
+
throw new Error('模型返回的成功把握必须是 0–1 的有限数值');
|
|
13
|
+
}
|
|
14
|
+
return Math.max(1, Math.min(99, Math.floor(probability * 100)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function classify(roll: number, difficulty: number): CheckResult {
|
|
18
|
+
if (!Number.isInteger(roll) || roll < 1 || roll > 100) throw new Error('骰值必须是 1–100 的整数');
|
|
19
|
+
if (roll === 1) return '大成功';
|
|
20
|
+
if (roll === 100) return '大失败';
|
|
21
|
+
if (roll <= difficulty * 0.25) return '极难成功';
|
|
22
|
+
if (roll <= difficulty * 0.5) return '困难成功';
|
|
23
|
+
return roll <= difficulty ? '普通成功' : '普通失败';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatChecks(checks: SkillCheck[]): string {
|
|
27
|
+
return checks.map(({ skill, difficulty, roll, result }) => {
|
|
28
|
+
const advice = result === '大成功' ? '给予额外奖励。'
|
|
29
|
+
: result === '大失败' ? '给予额外惩罚。'
|
|
30
|
+
: result === '极难成功' || result === '困难成功' ? '酌情给予小奖励。'
|
|
31
|
+
: '';
|
|
32
|
+
return `${skill}:${result} [${roll}/${difficulty}]\n此次${skill}${result}了。${advice}`;
|
|
33
|
+
}).join('\n\n');
|
|
34
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir, platform } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface CondaPythonOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function listCondaPythonOptions(
|
|
11
|
+
manifest: string,
|
|
12
|
+
exists: (path: string) => boolean = existsSync,
|
|
13
|
+
targetPlatform: NodeJS.Platform = platform(),
|
|
14
|
+
): CondaPythonOption[] {
|
|
15
|
+
const executable = targetPlatform === 'win32' ? 'python.exe' : join('bin', 'python');
|
|
16
|
+
const seen = new Set<string>();
|
|
17
|
+
const options: CondaPythonOption[] = [];
|
|
18
|
+
for (const line of manifest.split(/\r?\n/u)) {
|
|
19
|
+
const prefix = line.trim();
|
|
20
|
+
if (!prefix || seen.has(prefix)) continue;
|
|
21
|
+
seen.add(prefix);
|
|
22
|
+
const pythonExecutable = join(prefix, executable);
|
|
23
|
+
if (!exists(pythonExecutable)) continue;
|
|
24
|
+
options.push({ value: pythonExecutable, label: `${basename(prefix)} · ${prefix}` });
|
|
25
|
+
}
|
|
26
|
+
return options;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function discoverCondaPythonOptions(): CondaPythonOption[] {
|
|
30
|
+
try {
|
|
31
|
+
return listCondaPythonOptions(readFileSync(join(homedir(), '.conda', 'environments.txt'), 'utf8'));
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ConfigGroup } from 'cortico/core/types.ts';
|
|
2
|
+
|
|
3
|
+
export type CheckBackend = 'laya' | 'laya-multilingual' | 'jev';
|
|
4
|
+
export type JevSource = 'typesafe' | 'openrouter';
|
|
5
|
+
|
|
6
|
+
export interface SimpleTrpgCheckConfigSection {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
backend: CheckBackend;
|
|
9
|
+
jevSource: JevSource;
|
|
10
|
+
forceMultilingual: boolean;
|
|
11
|
+
pythonExecutable: string;
|
|
12
|
+
layaIdleTtlMinutes: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SIMPLE_TRPG_CHECK_DEFAULTS: SimpleTrpgCheckConfigSection = {
|
|
16
|
+
enabled: false,
|
|
17
|
+
backend: 'laya-multilingual',
|
|
18
|
+
jevSource: 'typesafe',
|
|
19
|
+
forceMultilingual: false,
|
|
20
|
+
pythonExecutable: 'python',
|
|
21
|
+
layaIdleTtlMinutes: 10,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const SIMPLE_TRPG_CHECK_TYPESAFE_SECRET = 'CORTICO_JEV_TYPESAFE_API_KEY';
|
|
25
|
+
export const SIMPLE_TRPG_CHECK_OPENROUTER_SECRET = 'CORTICO_JEV_OPENROUTER_API_KEY';
|
|
26
|
+
export const SIMPLE_TRPG_CHECK_LEGACY_TYPESAFE_SECRET = 'CORTICO_SIMPLE_TRPG_CHECK_TYPESAFE_API_KEY';
|
|
27
|
+
export const SIMPLE_TRPG_CHECK_LEGACY_OPENROUTER_SECRET = 'CORTICO_SIMPLE_TRPG_CHECK_OPENROUTER_API_KEY';
|
|
28
|
+
|
|
29
|
+
export const SIMPLE_TRPG_CHECK_CONFIG_GROUP: ConfigGroup = {
|
|
30
|
+
id: 'world:simple-trpg-check',
|
|
31
|
+
owner: 'world:simple-trpg-check',
|
|
32
|
+
schema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
title: 'Simple TRPG Check',
|
|
35
|
+
properties: {
|
|
36
|
+
'worlds.simple-trpg-check.backend': {
|
|
37
|
+
type: 'string',
|
|
38
|
+
title: '判定模型',
|
|
39
|
+
enum: ['laya-multilingual', 'laya', 'jev'],
|
|
40
|
+
'x-hot': false,
|
|
41
|
+
'x-panel-slot': 'model-test',
|
|
42
|
+
},
|
|
43
|
+
'worlds.simple-trpg-check.jevSource': {
|
|
44
|
+
type: 'string',
|
|
45
|
+
title: 'Jev 来源',
|
|
46
|
+
enum: ['typesafe', 'openrouter'],
|
|
47
|
+
'x-hot': true,
|
|
48
|
+
'x-panel-slot': 'jev-key',
|
|
49
|
+
},
|
|
50
|
+
'worlds.simple-trpg-check.forceMultilingual': {
|
|
51
|
+
type: 'boolean',
|
|
52
|
+
title: '强制适配多语言',
|
|
53
|
+
description: '要求 agent 用英文填写 scenario 与 skill_lists;不翻译输入,也不切换模型。',
|
|
54
|
+
'x-hot': true,
|
|
55
|
+
},
|
|
56
|
+
'worlds.simple-trpg-check.pythonExecutable': {
|
|
57
|
+
type: 'string',
|
|
58
|
+
title: '多语言 Laya Python 环境',
|
|
59
|
+
'x-options': 'simple-trpg-check-conda-python',
|
|
60
|
+
description: '从本机 Conda 环境中选择;该环境需安装 laya。',
|
|
61
|
+
'x-hot': false,
|
|
62
|
+
},
|
|
63
|
+
'worlds.simple-trpg-check.layaIdleTtlMinutes': {
|
|
64
|
+
type: 'integer',
|
|
65
|
+
title: 'Laya 空闲释放时间',
|
|
66
|
+
minimum: 0,
|
|
67
|
+
multipleOf: 1,
|
|
68
|
+
'x-suffix': 'min',
|
|
69
|
+
'x-hot': true,
|
|
70
|
+
description: '最后一次本地评分后保留模型的分钟数;与其他使用者共用时取最长 TTL。',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
} as ConfigGroup;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ConsoleClientBundle, ConsolePanelContext } from 'cortico/web/shared/client-panel.ts';
|
|
2
|
+
|
|
3
|
+
interface KeyState {
|
|
4
|
+
backend: string;
|
|
5
|
+
source: 'typesafe' | 'openrouter';
|
|
6
|
+
keySet: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function mountCredentials(ctx: ConsolePanelContext): Promise<void> {
|
|
10
|
+
let state = await ctx.invoke<KeyState>('state');
|
|
11
|
+
render(state);
|
|
12
|
+
ctx.interval(() => {
|
|
13
|
+
void ctx.invoke<KeyState>('state').then((next) => {
|
|
14
|
+
if (next.backend !== state.backend || next.source !== state.source || next.keySet !== state.keySet) {
|
|
15
|
+
state = next;
|
|
16
|
+
render(next);
|
|
17
|
+
}
|
|
18
|
+
}).catch(() => undefined);
|
|
19
|
+
}, 2_000);
|
|
20
|
+
|
|
21
|
+
function render(current: KeyState): void {
|
|
22
|
+
const { ui } = ctx;
|
|
23
|
+
if (current.backend !== 'jev') {
|
|
24
|
+
ctx.root.replaceChildren();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const sourceName = current.source === 'openrouter' ? 'OpenRouter' : 'TypeSafe';
|
|
28
|
+
const message = ui.msgline(current.keySet ? '密钥已配置' : '密钥未配置');
|
|
29
|
+
const open = ui.button(`打开 ${sourceName} 密钥文件`, {
|
|
30
|
+
onClick: () => {
|
|
31
|
+
open.disabled = true;
|
|
32
|
+
void ctx.invoke<{ file: string }>('openKeyFile', [current.source]).then(({ file }) => {
|
|
33
|
+
message.textContent = `已打开 ${file}`;
|
|
34
|
+
message.classList.remove('bad');
|
|
35
|
+
}).catch((error) => {
|
|
36
|
+
message.textContent = `打开失败:${error instanceof Error ? error.message : String(error)}`;
|
|
37
|
+
message.classList.add('bad');
|
|
38
|
+
}).finally(() => { open.disabled = false; });
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const actions = ui.actions();
|
|
42
|
+
actions.append(message, open);
|
|
43
|
+
ctx.root.replaceChildren(actions);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mountConnectionTest(ctx: ConsolePanelContext): void {
|
|
48
|
+
const { ui } = ctx;
|
|
49
|
+
const message = ui.msgline();
|
|
50
|
+
const button = ui.button('测试连接', {
|
|
51
|
+
onClick: () => {
|
|
52
|
+
button.disabled = true;
|
|
53
|
+
message.textContent = '测试中…';
|
|
54
|
+
message.classList.remove('bad');
|
|
55
|
+
void ctx.invoke<{ ok: boolean; error?: string }>('testConnection').then((result) => {
|
|
56
|
+
message.textContent = result.ok ? '连接成功' : `连接失败:${result.error || '模型没有返回有效结果'}`;
|
|
57
|
+
message.classList.toggle('bad', !result.ok);
|
|
58
|
+
void ctx.refresh().catch(() => undefined);
|
|
59
|
+
}).catch((error) => {
|
|
60
|
+
message.textContent = `连接失败:${error instanceof Error ? error.message : String(error)}`;
|
|
61
|
+
message.classList.add('bad');
|
|
62
|
+
}).finally(() => { button.disabled = false; });
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const actions = ui.actions();
|
|
66
|
+
actions.append(message, button);
|
|
67
|
+
ctx.root.replaceChildren(actions);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const client: ConsoleClientBundle = {
|
|
71
|
+
panels: { credentials: { mount: mountCredentials }, 'connection-test': { mount: mountConnectionTest } },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export default client;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { WorldDefinition } from 'cortico/world.ts';
|
|
2
|
+
import { discoverCondaPythonOptions } from './conda-environments.ts';
|
|
3
|
+
import { SIMPLE_TRPG_CHECK_DEFAULTS, type SimpleTrpgCheckConfigSection } from './config.ts';
|
|
4
|
+
import { SimpleTrpgCheckWorld } from './world.ts';
|
|
5
|
+
|
|
6
|
+
export const SIMPLE_TRPG_CHECK: WorldDefinition<SimpleTrpgCheckConfigSection> = {
|
|
7
|
+
id: 'simple-trpg-check',
|
|
8
|
+
label: 'Simple TRPG Check',
|
|
9
|
+
defaults: () => ({ ...SIMPLE_TRPG_CHECK_DEFAULTS }),
|
|
10
|
+
configOptions: (kind) => kind === 'simple-trpg-check-conda-python' ? discoverCondaPythonOptions() : [],
|
|
11
|
+
create: (ctx) => new SimpleTrpgCheckWorld(ctx),
|
|
12
|
+
};
|
package/src/index.ts
ADDED
package/src/model.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { WorldContext } from 'cortico/world.ts';
|
|
2
|
+
import {
|
|
3
|
+
SIMPLE_TRPG_CHECK_OPENROUTER_SECRET,
|
|
4
|
+
SIMPLE_TRPG_CHECK_TYPESAFE_SECRET,
|
|
5
|
+
SIMPLE_TRPG_CHECK_LEGACY_OPENROUTER_SECRET,
|
|
6
|
+
SIMPLE_TRPG_CHECK_LEGACY_TYPESAFE_SECRET,
|
|
7
|
+
type SimpleTrpgCheckConfigSection,
|
|
8
|
+
} from './config.ts';
|
|
9
|
+
import { startMultilingualLaya } from './multilingual-laya.ts';
|
|
10
|
+
import { layaRuntimeKey, sharedLayaPool, type SharedLayaClient, type SharedLayaModel } from './shared-laya.ts';
|
|
11
|
+
|
|
12
|
+
type Questions = Record<string, { type: 'noul'; instructions: string }>;
|
|
13
|
+
export interface SkillScorer {
|
|
14
|
+
score(scenario: string, skills: string[]): Promise<number[]>;
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function questionsFor(skills: string[]): Questions {
|
|
19
|
+
return Object.fromEntries(skills.map((skill, index) => [
|
|
20
|
+
`skill_${index}`,
|
|
21
|
+
{
|
|
22
|
+
type: 'noul',
|
|
23
|
+
instructions: `Can the character achieve the stated goal by using the skill "${skill}" in this scenario? Consider the described method, goal, familiarity, and relevant circumstances.`,
|
|
24
|
+
},
|
|
25
|
+
]));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function probabilitiesFrom(result: unknown, count: number): number[] {
|
|
29
|
+
if (!result || typeof result !== 'object') throw new Error('模型没有返回判定结果');
|
|
30
|
+
const answers = (result as { answers?: unknown }).answers;
|
|
31
|
+
if (!answers || typeof answers !== 'object' || Array.isArray(answers)) throw new Error('模型没有返回技能答案');
|
|
32
|
+
const keyed = answers as Record<string, unknown>;
|
|
33
|
+
if (Object.keys(keyed).length !== count) throw new Error('模型返回的技能答案数量不匹配');
|
|
34
|
+
return Array.from({ length: count }, (_, index) => {
|
|
35
|
+
const item = keyed[`skill_${index}`];
|
|
36
|
+
const value = item && typeof item === 'object' ? (item as { noul?: unknown }).noul : undefined;
|
|
37
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
38
|
+
throw new Error(`第 ${index + 1} 项技能的模型答案无效`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class SystemOneSkillScorer implements SkillScorer {
|
|
45
|
+
private layaClient: SharedLayaClient | null = null;
|
|
46
|
+
private layaKey = '';
|
|
47
|
+
|
|
48
|
+
constructor(private readonly ctx: WorldContext<SimpleTrpgCheckConfigSection>) {}
|
|
49
|
+
|
|
50
|
+
async score(scenario: string, skills: string[]): Promise<number[]> {
|
|
51
|
+
const questions = questionsFor(skills);
|
|
52
|
+
const state = { scenario };
|
|
53
|
+
if (this.ctx.cfg.backend === 'jev') {
|
|
54
|
+
return probabilitiesFrom(await this.requestJev(state, questions), skills.length);
|
|
55
|
+
}
|
|
56
|
+
const result = await this.getLayaClient().systemOne(state, questions);
|
|
57
|
+
return probabilitiesFrom(result, skills.length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async close(): Promise<void> {
|
|
61
|
+
const client = this.layaClient;
|
|
62
|
+
this.layaClient = null;
|
|
63
|
+
this.layaKey = '';
|
|
64
|
+
await client?.dispose();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private getLayaClient(): SharedLayaClient {
|
|
68
|
+
const backend = this.ctx.cfg.backend;
|
|
69
|
+
const variant = backend === 'laya-multilingual' ? 'multilingual' : 'english';
|
|
70
|
+
const pythonExecutable = this.ctx.cfg.pythonExecutable;
|
|
71
|
+
const key = layaRuntimeKey(variant, pythonExecutable);
|
|
72
|
+
if (this.layaClient && this.layaKey === key) return this.layaClient;
|
|
73
|
+
void this.layaClient?.dispose();
|
|
74
|
+
this.layaKey = key;
|
|
75
|
+
this.layaClient = sharedLayaPool().create(
|
|
76
|
+
key,
|
|
77
|
+
() => variant === 'multilingual'
|
|
78
|
+
? startMultilingualLaya(pythonExecutable)
|
|
79
|
+
: import('@receptron/laya').then(({ Laya }) => Laya.load() as Promise<SharedLayaModel>),
|
|
80
|
+
() => this.ctx.cfg.layaIdleTtlMinutes,
|
|
81
|
+
);
|
|
82
|
+
return this.layaClient;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async requestJev(state: unknown, questions: Questions): Promise<unknown> {
|
|
86
|
+
const source = this.ctx.cfg.jevSource;
|
|
87
|
+
const secretName = source === 'openrouter'
|
|
88
|
+
? SIMPLE_TRPG_CHECK_OPENROUTER_SECRET
|
|
89
|
+
: SIMPLE_TRPG_CHECK_TYPESAFE_SECRET;
|
|
90
|
+
const apiKey = this.ctx.secret(secretName) || this.ctx.secret(source === 'openrouter'
|
|
91
|
+
? SIMPLE_TRPG_CHECK_LEGACY_OPENROUTER_SECRET : SIMPLE_TRPG_CHECK_LEGACY_TYPESAFE_SECRET)
|
|
92
|
+
|| (source === 'typesafe' ? this.ctx.secret('CORTICO_JEV_API_KEY') : '');
|
|
93
|
+
if (!apiKey) throw new Error(`${source === 'openrouter' ? 'OpenRouter' : 'TypeSafe'} API key 未配置`);
|
|
94
|
+
const endpoint = source === 'openrouter'
|
|
95
|
+
? 'https://openrouter.ai/api/alpha/decisions'
|
|
96
|
+
: 'https://api.typesafe.ai/v1/systemone';
|
|
97
|
+
const response = await fetch(endpoint, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({ model: source === 'openrouter' ? '~typesafe/jev-latest' : 'jev-latest', state, questions }),
|
|
101
|
+
signal: AbortSignal.timeout(20_000),
|
|
102
|
+
});
|
|
103
|
+
if (!response.ok) throw new Error(`Jev 请求失败:HTTP ${response.status}`);
|
|
104
|
+
return response.json();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const WORKER_FILE = fileURLToPath(new URL('../python/laya_multilingual_worker.py', import.meta.url));
|
|
6
|
+
|
|
7
|
+
export interface MultilingualLayaModel {
|
|
8
|
+
systemOne(state: unknown, questions: unknown): Promise<unknown>;
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class PythonLayaModel implements MultilingualLayaModel {
|
|
13
|
+
private nextId = 0;
|
|
14
|
+
private readonly pending = new Map<string, { resolve(value: unknown): void; reject(error: Error): void }>();
|
|
15
|
+
private readonly ready: Promise<void>;
|
|
16
|
+
private readonly exited: Promise<void>;
|
|
17
|
+
private closed = false;
|
|
18
|
+
|
|
19
|
+
constructor(private readonly worker: ChildProcessWithoutNullStreams) {
|
|
20
|
+
this.ready = new Promise((resolve, reject) => {
|
|
21
|
+
const timeout = setTimeout(() => reject(new Error('Laya multilingual 加载超时')), 120_000);
|
|
22
|
+
const lines = createInterface({ input: worker.stdout });
|
|
23
|
+
lines.on('line', (line) => {
|
|
24
|
+
let message: Record<string, unknown>;
|
|
25
|
+
try { message = JSON.parse(line) as Record<string, unknown>; }
|
|
26
|
+
catch { this.fail(new Error('Laya multilingual 返回了无效 JSON')); return; }
|
|
27
|
+
if (message.type === 'ready') {
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
resolve();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const id = message.id;
|
|
33
|
+
if (typeof id !== 'string') return;
|
|
34
|
+
const pending = this.pending.get(id);
|
|
35
|
+
if (!pending) return;
|
|
36
|
+
this.pending.delete(id);
|
|
37
|
+
if (message.ok === true) pending.resolve(message.result);
|
|
38
|
+
else pending.reject(new Error(typeof message.error === 'string' ? message.error : 'Laya multilingual 请求失败'));
|
|
39
|
+
});
|
|
40
|
+
worker.once('error', () => { clearTimeout(timeout); reject(new Error('Python 进程无法启动')); });
|
|
41
|
+
worker.once('exit', (code) => {
|
|
42
|
+
clearTimeout(timeout);
|
|
43
|
+
const error = new Error(`Laya multilingual 进程已退出:${code ?? 'unknown'}`);
|
|
44
|
+
reject(error);
|
|
45
|
+
this.fail(error);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
this.exited = new Promise((resolve) => worker.once('exit', () => resolve()));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async waitUntilReady(): Promise<void> { await this.ready; }
|
|
52
|
+
|
|
53
|
+
async systemOne(state: unknown, questions: unknown): Promise<unknown> {
|
|
54
|
+
if (this.closed) throw new Error('Laya multilingual 已停止');
|
|
55
|
+
const id = String(++this.nextId);
|
|
56
|
+
const result = new Promise<unknown>((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
|
57
|
+
this.worker.stdin.write(`${JSON.stringify({ id, state, questions })}\n`);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async close(): Promise<void> {
|
|
62
|
+
if (this.closed) return this.exited;
|
|
63
|
+
this.closed = true;
|
|
64
|
+
this.worker.stdin.write(`${JSON.stringify({ type: 'close' })}\n`);
|
|
65
|
+
this.worker.stdin.end();
|
|
66
|
+
await this.exited;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fail(error: Error): void {
|
|
70
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
71
|
+
this.pending.clear();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function startMultilingualLaya(pythonExecutable: string): Promise<MultilingualLayaModel> {
|
|
76
|
+
const worker = spawn(pythonExecutable, [WORKER_FILE], {
|
|
77
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
78
|
+
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
79
|
+
});
|
|
80
|
+
worker.stderr.pipe(process.stderr);
|
|
81
|
+
const model = new PythonLayaModel(worker);
|
|
82
|
+
try { await model.waitUntilReady(); }
|
|
83
|
+
catch (error) { worker.kill(); throw error; }
|
|
84
|
+
return model;
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function ensureSecretPlaceholder(botDir: string, name: string): string {
|
|
6
|
+
const file = join(botDir, '.env');
|
|
7
|
+
const contents = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
8
|
+
if (!new RegExp(`^${name}=`, 'm').test(contents)) {
|
|
9
|
+
const line = `${contents && !contents.endsWith('\n') ? '\n' : ''}${name}=\n`;
|
|
10
|
+
if (existsSync(file)) appendFileSync(file, line);
|
|
11
|
+
else writeFileSync(file, line, { mode: 0o600 });
|
|
12
|
+
}
|
|
13
|
+
return file;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function openSecretFile(file: string): Promise<void> {
|
|
17
|
+
const command = process.platform === 'win32' ? 'notepad.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const child = spawn(command, [file], { detached: true, stdio: 'ignore' });
|
|
20
|
+
child.once('error', reject);
|
|
21
|
+
child.once('spawn', () => { child.unref(); resolve(); });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { normalize } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export interface SharedLayaModel {
|
|
4
|
+
systemOne(state: unknown, questions: unknown): Promise<unknown>;
|
|
5
|
+
close(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SharedLayaClient {
|
|
9
|
+
systemOne(state: unknown, questions: unknown): Promise<unknown>;
|
|
10
|
+
dispose(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Entry {
|
|
14
|
+
model: Promise<SharedLayaModel> | null;
|
|
15
|
+
closing: Promise<void> | null;
|
|
16
|
+
active: number;
|
|
17
|
+
queue: Promise<void>;
|
|
18
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
19
|
+
owners: Map<symbol, () => number>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class LayaPool {
|
|
23
|
+
private readonly entries = new Map<string, Entry>();
|
|
24
|
+
|
|
25
|
+
create(
|
|
26
|
+
key: string,
|
|
27
|
+
load: () => Promise<SharedLayaModel>,
|
|
28
|
+
idleTtlMinutes: () => number,
|
|
29
|
+
): SharedLayaClient {
|
|
30
|
+
const entry = this.entries.get(key) ?? {
|
|
31
|
+
model: null, closing: null, active: 0, queue: Promise.resolve(),
|
|
32
|
+
timer: null, owners: new Map<symbol, () => number>(),
|
|
33
|
+
};
|
|
34
|
+
this.entries.set(key, entry);
|
|
35
|
+
const owner = Symbol(key);
|
|
36
|
+
entry.owners.set(owner, idleTtlMinutes);
|
|
37
|
+
let disposed = false;
|
|
38
|
+
return {
|
|
39
|
+
systemOne: async (state, questions) => {
|
|
40
|
+
if (disposed) throw new Error('Laya client is closed');
|
|
41
|
+
entry.active++;
|
|
42
|
+
this.clearTimer(entry);
|
|
43
|
+
const previous = entry.queue;
|
|
44
|
+
let releaseQueue!: () => void;
|
|
45
|
+
entry.queue = new Promise<void>((resolve) => { releaseQueue = resolve; });
|
|
46
|
+
try {
|
|
47
|
+
await previous;
|
|
48
|
+
const model = await this.load(entry, load);
|
|
49
|
+
return await model.systemOne(state, questions);
|
|
50
|
+
} finally {
|
|
51
|
+
releaseQueue();
|
|
52
|
+
entry.active--;
|
|
53
|
+
if (entry.active === 0) await this.schedule(key, entry);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
dispose: async () => {
|
|
57
|
+
if (disposed) return;
|
|
58
|
+
disposed = true;
|
|
59
|
+
entry.owners.delete(owner);
|
|
60
|
+
if (entry.active === 0) await this.schedule(key, entry);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async load(entry: Entry, loader: () => Promise<SharedLayaModel>): Promise<SharedLayaModel> {
|
|
66
|
+
if (entry.closing) await entry.closing;
|
|
67
|
+
if (!entry.model) {
|
|
68
|
+
const loading = loader();
|
|
69
|
+
entry.model = loading;
|
|
70
|
+
void loading.catch(() => { if (entry.model === loading) entry.model = null; });
|
|
71
|
+
}
|
|
72
|
+
return entry.model;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private clearTimer(entry: Entry): void {
|
|
76
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
77
|
+
entry.timer = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async schedule(key: string, entry: Entry): Promise<void> {
|
|
81
|
+
this.clearTimer(entry);
|
|
82
|
+
const ttl = Math.max(0, ...[...entry.owners.values()].map((read) => read()));
|
|
83
|
+
if (entry.owners.size === 0 || ttl === 0) {
|
|
84
|
+
await this.release(key, entry);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
entry.timer = setTimeout(() => { void this.release(key, entry); }, ttl * 60_000);
|
|
88
|
+
entry.timer.unref();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async release(key: string, entry: Entry): Promise<void> {
|
|
92
|
+
this.clearTimer(entry);
|
|
93
|
+
if (entry.active > 0) return;
|
|
94
|
+
const model = entry.model;
|
|
95
|
+
entry.model = null;
|
|
96
|
+
if (model) {
|
|
97
|
+
const closing = model.then((loaded) => loaded.close()).catch(() => undefined);
|
|
98
|
+
entry.closing = closing;
|
|
99
|
+
await closing;
|
|
100
|
+
if (entry.closing === closing) entry.closing = null;
|
|
101
|
+
} else if (entry.closing) {
|
|
102
|
+
await entry.closing;
|
|
103
|
+
}
|
|
104
|
+
if (entry.owners.size === 0 && entry.active === 0 && !entry.model) this.entries.delete(key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const POOL = Symbol.for('cortico.local-laya.pool.v1');
|
|
109
|
+
|
|
110
|
+
export function sharedLayaPool(): LayaPool {
|
|
111
|
+
const holder = globalThis as unknown as Record<symbol, LayaPool | undefined>;
|
|
112
|
+
return holder[POOL] ??= new LayaPool();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isolatedLayaPool(): LayaPool {
|
|
116
|
+
return new LayaPool();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function layaRuntimeKey(variant: 'english' | 'multilingual', pythonExecutable = ''): string {
|
|
120
|
+
if (variant === 'english') return 'english:@receptron/laya@0.1.2';
|
|
121
|
+
const path = normalize(pythonExecutable);
|
|
122
|
+
return `multilingual:convaiinnovations/laya:${process.platform === 'win32' ? path.toLowerCase() : path}`;
|
|
123
|
+
}
|
package/src/world.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import type { ToolDef, ToolOutcome, World, WorldConsoleDecl, WorldHost } from 'cortico/core/types.ts';
|
|
4
|
+
import type { WorldContext } from 'cortico/world.ts';
|
|
5
|
+
import { classify, difficultyFromProbability, formatChecks } from './check.ts';
|
|
6
|
+
import {
|
|
7
|
+
SIMPLE_TRPG_CHECK_CONFIG_GROUP,
|
|
8
|
+
SIMPLE_TRPG_CHECK_OPENROUTER_SECRET,
|
|
9
|
+
SIMPLE_TRPG_CHECK_TYPESAFE_SECRET,
|
|
10
|
+
SIMPLE_TRPG_CHECK_LEGACY_OPENROUTER_SECRET,
|
|
11
|
+
SIMPLE_TRPG_CHECK_LEGACY_TYPESAFE_SECRET,
|
|
12
|
+
type SimpleTrpgCheckConfigSection,
|
|
13
|
+
} from './config.ts';
|
|
14
|
+
import { SystemOneSkillScorer, type SkillScorer } from './model.ts';
|
|
15
|
+
import { ensureSecretPlaceholder, openSecretFile } from './secret-file.ts';
|
|
16
|
+
|
|
17
|
+
const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
|
|
18
|
+
|
|
19
|
+
export class SimpleTrpgCheckWorld implements World {
|
|
20
|
+
readonly id = 'simple-trpg-check';
|
|
21
|
+
private readonly scorer: SkillScorer;
|
|
22
|
+
private modelState: 'offline' | 'loading' | 'online' | 'error' = 'offline';
|
|
23
|
+
private modelSignature = '';
|
|
24
|
+
private modelRequestId = 0;
|
|
25
|
+
|
|
26
|
+
private hasJevKey(): boolean {
|
|
27
|
+
const source = this.ctx.cfg.jevSource;
|
|
28
|
+
return !!(this.ctx.secret(source === 'openrouter'
|
|
29
|
+
? SIMPLE_TRPG_CHECK_OPENROUTER_SECRET : SIMPLE_TRPG_CHECK_TYPESAFE_SECRET)
|
|
30
|
+
|| this.ctx.secret(source === 'openrouter'
|
|
31
|
+
? SIMPLE_TRPG_CHECK_LEGACY_OPENROUTER_SECRET : SIMPLE_TRPG_CHECK_LEGACY_TYPESAFE_SECRET)
|
|
32
|
+
|| (source === 'typesafe' && this.ctx.secret('CORTICO_JEV_API_KEY')));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly ctx: WorldContext<SimpleTrpgCheckConfigSection>,
|
|
37
|
+
scorer?: SkillScorer,
|
|
38
|
+
private readonly roll: () => number = () => randomInt(1, 101),
|
|
39
|
+
private readonly openFile: (path: string) => void | Promise<void> = openSecretFile,
|
|
40
|
+
) {
|
|
41
|
+
this.scorer = scorer ?? new SystemOneSkillScorer(ctx);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
envPromptVars(): Record<string, string> {
|
|
45
|
+
return {
|
|
46
|
+
'simpleTrpgCheck.languageRule': this.ctx.cfg.forceMultilingual
|
|
47
|
+
? '调用此工具时,scenario 和 skill_lists 必须用英文填写。'
|
|
48
|
+
: '',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
tools(): ToolDef[] {
|
|
53
|
+
return [{
|
|
54
|
+
name: 'simple_trpg_check_roll',
|
|
55
|
+
description: '对 scenario 中的目标并行评估每项 skill_lists 的成功把握,再逐项掷 1–100。返回 [判定/难度] 与固定的结果建议;模型失败时不掷骰。',
|
|
56
|
+
tags: ['act'],
|
|
57
|
+
parameters: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
properties: {
|
|
61
|
+
scenario: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
minLength: 1,
|
|
64
|
+
description: '角色如何用这些技能达成什么目标,相关情境;可写熟练程度或身体条件。',
|
|
65
|
+
},
|
|
66
|
+
skill_lists: {
|
|
67
|
+
type: 'array',
|
|
68
|
+
minItems: 1,
|
|
69
|
+
items: { type: 'string', minLength: 1 },
|
|
70
|
+
description: '按判定顺序列出技能名称。',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ['scenario', 'skill_lists'],
|
|
74
|
+
},
|
|
75
|
+
handler: (args) => this.runCheck(args),
|
|
76
|
+
}];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console(): WorldConsoleDecl {
|
|
80
|
+
const source = this.ctx.cfg.jevSource;
|
|
81
|
+
const keySet = this.hasJevKey();
|
|
82
|
+
const signature = this.currentModelSignature();
|
|
83
|
+
const modelState = signature === this.modelSignature ? this.modelState : 'offline';
|
|
84
|
+
return {
|
|
85
|
+
config: [SIMPLE_TRPG_CHECK_CONFIG_GROUP],
|
|
86
|
+
lamps: [{
|
|
87
|
+
label: '判定模型',
|
|
88
|
+
state: modelState,
|
|
89
|
+
hint: modelState === 'online' ? '最近一次模型请求成功' : modelState === 'error'
|
|
90
|
+
? '最近一次模型请求失败' : modelState === 'loading' ? '正在请求模型' : '尚未成功请求模型',
|
|
91
|
+
}],
|
|
92
|
+
badges: this.ctx.cfg.backend === 'jev'
|
|
93
|
+
? [{ label: source === 'openrouter' ? 'OpenRouter' : 'TypeSafe', value: keySet ? '密钥已配置' : '密钥未配置', tone: keySet ? 'on' : 'off' }]
|
|
94
|
+
: [{ label: '判定模型', value: this.ctx.cfg.backend }],
|
|
95
|
+
panels: [
|
|
96
|
+
{ id: 'connection-test', title: '测试连接', slot: 'model-test' },
|
|
97
|
+
{ id: 'credentials', title: 'Jev 密钥', slot: 'jev-key' },
|
|
98
|
+
] as unknown as WorldConsoleDecl['panels'],
|
|
99
|
+
invoke: async (panel, method, args) => {
|
|
100
|
+
if (panel === 'connection-test' && method === 'testConnection') {
|
|
101
|
+
try {
|
|
102
|
+
await this.assess('A character climbs a waist-high wall to reach the other side.', ['climbing']);
|
|
103
|
+
return { ok: true };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (panel !== 'credentials') throw new Error('未知面板');
|
|
109
|
+
if (method === 'state') {
|
|
110
|
+
return { backend: this.ctx.cfg.backend, source: this.ctx.cfg.jevSource, keySet: this.hasJevKey() };
|
|
111
|
+
}
|
|
112
|
+
if (method === 'openKeyFile') {
|
|
113
|
+
const source = args[0];
|
|
114
|
+
if (source !== this.ctx.cfg.jevSource || this.ctx.cfg.backend !== 'jev') {
|
|
115
|
+
throw new Error('Jev 来源已改变,请重试');
|
|
116
|
+
}
|
|
117
|
+
const secretName = this.ctx.cfg.jevSource === 'openrouter'
|
|
118
|
+
? SIMPLE_TRPG_CHECK_OPENROUTER_SECRET
|
|
119
|
+
: SIMPLE_TRPG_CHECK_TYPESAFE_SECRET;
|
|
120
|
+
const file = ensureSecretPlaceholder(this.ctx.botDir, secretName);
|
|
121
|
+
await this.openFile(file);
|
|
122
|
+
return { file };
|
|
123
|
+
}
|
|
124
|
+
throw new Error('未知操作');
|
|
125
|
+
},
|
|
126
|
+
promptDocs: [{
|
|
127
|
+
key: 'worlds.simple-trpg-check.envPrompt',
|
|
128
|
+
title: 'Simple TRPG Check · 环境提示词',
|
|
129
|
+
description: '何时调用技能判定,以及如何填写情境和技能。',
|
|
130
|
+
path: ENV_PROMPT_FILE,
|
|
131
|
+
role: 'envPrompt',
|
|
132
|
+
vars: [{ name: 'simpleTrpgCheck.languageRule', description: '英文输入开关的当前要求。' }],
|
|
133
|
+
}],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async start(_host: WorldHost): Promise<void> {}
|
|
138
|
+
async stop(): Promise<void> {
|
|
139
|
+
this.modelState = 'offline';
|
|
140
|
+
await this.scorer.close();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private currentModelSignature(): string {
|
|
144
|
+
const { backend, jevSource, pythonExecutable } = this.ctx.cfg;
|
|
145
|
+
return `${backend}|${backend === 'jev' ? jevSource : pythonExecutable}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async assess(scenario: string, skills: string[]): Promise<number[]> {
|
|
149
|
+
const signature = this.currentModelSignature();
|
|
150
|
+
const requestId = ++this.modelRequestId;
|
|
151
|
+
this.modelSignature = signature;
|
|
152
|
+
this.modelState = 'loading';
|
|
153
|
+
try {
|
|
154
|
+
const probabilities = await this.scorer.score(scenario, skills);
|
|
155
|
+
if (probabilities.length !== skills.length) throw new Error('模型返回的技能答案数量不匹配');
|
|
156
|
+
probabilities.forEach(difficultyFromProbability);
|
|
157
|
+
if (signature !== this.currentModelSignature()) throw new Error('判定模型配置已改变,请重试');
|
|
158
|
+
if (requestId === this.modelRequestId) this.modelState = 'online';
|
|
159
|
+
return probabilities;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (requestId === this.modelRequestId && signature === this.currentModelSignature()) this.modelState = 'error';
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async runCheck(args: Record<string, unknown>): Promise<ToolOutcome> {
|
|
167
|
+
try {
|
|
168
|
+
const scenario = args.scenario;
|
|
169
|
+
const skillLists = args.skill_lists;
|
|
170
|
+
if (typeof scenario !== 'string' || !scenario.trim()) throw new Error('scenario 不能为空');
|
|
171
|
+
if (!Array.isArray(skillLists) || skillLists.length === 0 || skillLists.some(
|
|
172
|
+
(skill) => typeof skill !== 'string' || !skill.trim(),
|
|
173
|
+
)) throw new Error('skill_lists 必须是非空技能名称列表');
|
|
174
|
+
const skills = skillLists as string[];
|
|
175
|
+
const probabilities = await this.assess(scenario, skills);
|
|
176
|
+
const difficulties = probabilities.map(difficultyFromProbability);
|
|
177
|
+
const checks = skills.map((skill, index) => {
|
|
178
|
+
const difficulty = difficulties[index]!;
|
|
179
|
+
const roll = this.roll();
|
|
180
|
+
return { skill, difficulty, roll, result: classify(roll, difficulty) };
|
|
181
|
+
});
|
|
182
|
+
return { text: formatChecks(checks) };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return { text: `技能判定失败:${error instanceof Error ? error.message : String(error)}`, failed: true };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|