cortico-world-simple-trpg-check 0.1.1 → 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Phantivia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- Owner: src/definition.ts, src/world.ts, src/config.ts -->
1
+ <!-- Owner: src/definition.ts, src/world.ts, src/config.ts, src/console/client.ts -->
2
2
 
3
3
  # cortico-world-simple-trpg-check
4
4
 
@@ -12,10 +12,10 @@
12
12
 
13
13
  - `laya-multilingual`:在“多语言 Laya Python 环境”下拉选单中选择已安装 `laya` 的 Conda 环境,使用 `convaiinnovations/laya` 的 `multilingual` 检查点。选项来自本机 `~/.conda/environments.txt`,缺失或不可读时只显示当前配置值。首次运行可能由 Laya 下载权重;后续使用本地缓存。
14
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 专用变量名仍可读取。
15
+ - `jev`:按 `worlds.simple-trpg-check.jevSource` 选择 TypeSafe、OpenRouter 或自定义 System One API。自定义来源使用 `jevEndpoint` 地址和 `CORTICO_JEV_API_KEY`。工具调用会将 `scenario` 和技能名称发送给所选服务。配置页在“Jev 来源”旁显示密钥状态和“打开密钥文件”按钮;点击后打开部署目录的 `.env`,缺少当前来源密钥时补入空的密钥行。填写并保存文件后,下一次模型请求读取密钥。旧的 World 专用变量名仍可读取。
16
16
 
17
17
  World 左栏的状态灯在首次成功完成模型评估后变绿;配置改变或评估失败后更新状态。
18
- “判定模型”下方的“测试连接”会发送一项英文技能评分请求,不掷骰;结果显示在按钮旁,并更新左栏状态灯。
18
+ 配置页顶部的“测试连接”会发送一项英文技能评分请求,不掷骰;结果显示在按钮旁,并更新左栏状态灯。
19
19
 
20
20
  同一 Cortico 进程中,若 continuity 与此 World 使用相同的 Laya 类型(multilingual 还需同一 Python 路径),两者共用已加载的模型或 worker。请求依次执行;模型在所有使用者空闲后按其中最长的 TTL 回收。`layaIdleTtlMinutes` 默认 10;设为 `0` 且无其他使用者需要保留模型时,每次评分后释放。不同进程不共用运行实例。
21
21
 
package/dist/console.js CHANGED
@@ -1,69 +1,171 @@
1
+ // src/config.ts
2
+ var SIMPLE_TRPG_CHECK_CONFIG_GROUP = {
3
+ id: "world:simple-trpg-check",
4
+ owner: "world:simple-trpg-check",
5
+ schema: {
6
+ type: "object",
7
+ title: "Simple TRPG Check",
8
+ properties: {
9
+ "worlds.simple-trpg-check.backend": {
10
+ type: "string",
11
+ title: "\u5224\u5B9A\u6A21\u578B",
12
+ enum: ["laya-multilingual", "laya", "jev"],
13
+ "x-hot": false
14
+ },
15
+ "worlds.simple-trpg-check.jevSource": {
16
+ type: "string",
17
+ title: "Jev \u6765\u6E90",
18
+ enum: ["typesafe", "openrouter", "custom"],
19
+ "x-hot": true
20
+ },
21
+ "worlds.simple-trpg-check.jevEndpoint": {
22
+ type: "string",
23
+ title: "\u81EA\u5B9A\u4E49 Jev \u670D\u52A1\u5730\u5740",
24
+ "x-hot": true
25
+ },
26
+ "worlds.simple-trpg-check.forceMultilingual": {
27
+ type: "boolean",
28
+ title: "\u5F3A\u5236\u9002\u914D\u591A\u8BED\u8A00",
29
+ description: "\u8981\u6C42 agent \u7528\u82F1\u6587\u586B\u5199 scenario \u4E0E skill_lists\uFF1B\u4E0D\u7FFB\u8BD1\u8F93\u5165\uFF0C\u4E5F\u4E0D\u5207\u6362\u6A21\u578B\u3002",
30
+ "x-hot": true
31
+ },
32
+ "worlds.simple-trpg-check.pythonExecutable": {
33
+ type: "string",
34
+ title: "\u591A\u8BED\u8A00 Laya Python \u73AF\u5883",
35
+ "x-options": "simple-trpg-check-conda-python",
36
+ description: "\u4ECE\u672C\u673A Conda \u73AF\u5883\u4E2D\u9009\u62E9\uFF1B\u8BE5\u73AF\u5883\u9700\u5B89\u88C5 laya\u3002",
37
+ "x-hot": false
38
+ },
39
+ "worlds.simple-trpg-check.layaIdleTtlMinutes": {
40
+ type: "integer",
41
+ title: "Laya \u7A7A\u95F2\u91CA\u653E\u65F6\u95F4",
42
+ minimum: 0,
43
+ multipleOf: 1,
44
+ "x-suffix": "min",
45
+ "x-hot": true,
46
+ description: "\u6700\u540E\u4E00\u6B21\u672C\u5730\u8BC4\u5206\u540E\u4FDD\u7559\u6A21\u578B\u7684\u5206\u949F\u6570\uFF1B\u4E0E\u5176\u4ED6\u4F7F\u7528\u8005\u5171\u7528\u65F6\u53D6\u6700\u957F TTL\u3002"
47
+ }
48
+ }
49
+ }
50
+ };
51
+
1
52
  // src/console/client.ts
2
- async function mountCredentials(ctx) {
53
+ async function mountConfig(ctx) {
54
+ const { ui } = ctx;
55
+ const options = await ctx.invoke("options");
3
56
  let state = await ctx.invoke("state");
4
- render(state);
57
+ let status = "";
58
+ let failed = false;
59
+ render();
5
60
  ctx.interval(() => {
6
61
  void ctx.invoke("state").then((next) => {
7
- if (next.backend !== state.backend || next.source !== state.source || next.keySet !== state.keySet) {
62
+ if (JSON.stringify(next) !== JSON.stringify(state)) {
8
63
  state = next;
9
- render(next);
64
+ render();
10
65
  }
11
66
  }).catch(() => void 0);
12
67
  }, 2e3);
13
- function render(current) {
14
- const { ui } = ctx;
15
- if (current.backend !== "jev") {
16
- ctx.root.replaceChildren();
17
- return;
68
+ async function save(key, value) {
69
+ try {
70
+ state = await ctx.invoke("save", [key, value]);
71
+ status = "\u5DF2\u4FDD\u5B58";
72
+ failed = false;
73
+ } catch (error) {
74
+ status = `\u4FDD\u5B58\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`;
75
+ failed = true;
18
76
  }
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`, {
77
+ render();
78
+ void ctx.refresh().catch(() => void 0);
79
+ }
80
+ function render() {
81
+ const sheet = ui.sheet({ title: "\u914D\u7F6E" });
82
+ const fields = ui.h("div");
83
+ const config = state.config;
84
+ const result = ui.msgline(status, failed);
85
+ const test = ui.button("\u6D4B\u8BD5\u8FDE\u63A5", {
22
86
  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");
87
+ test.disabled = true;
88
+ result.textContent = "\u6D4B\u8BD5\u4E2D\u2026";
89
+ result.classList.remove("bad");
90
+ void ctx.invoke("testConnection").then((out) => {
91
+ result.textContent = out.ok ? "\u8FDE\u63A5\u6210\u529F" : `\u8FDE\u63A5\u5931\u8D25\uFF1A${out.error || "\u6A21\u578B\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7ED3\u679C"}`;
92
+ result.classList.toggle("bad", !out.ok);
93
+ void ctx.refresh().catch(() => void 0);
27
94
  }).catch((error) => {
28
- message.textContent = `\u6253\u5F00\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`;
29
- message.classList.add("bad");
95
+ result.textContent = `\u8FDE\u63A5\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`;
96
+ result.classList.add("bad");
30
97
  }).finally(() => {
31
- open.disabled = false;
98
+ test.disabled = false;
32
99
  });
33
100
  }
34
101
  });
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
- });
102
+ const testRow = ui.rowbar();
103
+ testRow.append(test, result);
104
+ sheet.body.appendChild(testRow);
105
+ for (const [path, property] of Object.entries(SIMPLE_TRPG_CHECK_CONFIG_GROUP.schema.properties)) {
106
+ const key = path.split(".").at(-1);
107
+ if (config.backend !== "jev" && (key === "jevSource" || key === "jevEndpoint")) continue;
108
+ if (config.backend === "jev" && (key === "forceMultilingual" || key === "pythonExecutable" || key === "layaIdleTtlMinutes")) continue;
109
+ if (key === "jevEndpoint" && config.jevSource !== "custom") continue;
110
+ const value = config[key];
111
+ let field;
112
+ if (property.type === "boolean") {
113
+ field = ui.checkbox(property.title, {
114
+ checked: Boolean(value),
115
+ onChange: (next) => {
116
+ void save(key, next);
117
+ }
118
+ }).el;
119
+ } else if (property.enum || property["x-options"]) {
120
+ const choices = property.enum ?? options.map((item) => item.value);
121
+ const entries = choices.map((choice) => ({
122
+ value: choice,
123
+ label: property["x-options"] ? options.find((item) => item.value === choice)?.label ?? choice : choice
124
+ }));
125
+ if (!entries.some((item) => item.value === value)) entries.unshift({ value: String(value), label: String(value) });
126
+ field = ui.field(property.title, ui.select({
127
+ value: String(value),
128
+ options: entries,
129
+ onChange: (next) => {
130
+ void save(key, next);
131
+ }
132
+ }));
133
+ } else {
134
+ field = ui.field(property.title, ui.input({
135
+ type: property.type === "integer" ? "number" : "text",
136
+ value: String(value),
137
+ onChange: (next) => {
138
+ void save(key, property.type === "integer" ? Number(next) : next);
139
+ }
140
+ }));
141
+ }
142
+ if (key === "jevSource") {
143
+ const sourceName = config.jevSource === "openrouter" ? "OpenRouter" : config.jevSource === "custom" ? "\u81EA\u5B9A\u4E49 Jev" : "TypeSafe";
144
+ const keyStatus = ui.msgline(state.keySet ? "\u5BC6\u94A5\u5DF2\u914D\u7F6E" : "\u5BC6\u94A5\u672A\u914D\u7F6E");
145
+ const open = ui.button(`\u6253\u5F00 ${sourceName} \u5BC6\u94A5\u6587\u4EF6`, {
146
+ onClick: () => {
147
+ open.disabled = true;
148
+ void ctx.invoke("openKeyFile", [config.jevSource]).then(({ file }) => {
149
+ keyStatus.textContent = `\u5DF2\u6253\u5F00 ${file}`;
150
+ }).catch((error) => {
151
+ keyStatus.textContent = `\u6253\u5F00\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`;
152
+ keyStatus.classList.add("bad");
153
+ }).finally(() => {
154
+ open.disabled = false;
155
+ });
156
+ }
157
+ });
158
+ const keyRow = ui.rowbar();
159
+ keyRow.append(field, keyStatus, open);
160
+ fields.appendChild(keyRow);
161
+ } else fields.appendChild(field);
162
+ if (property.description) fields.appendChild(ui.msgline(property.description));
58
163
  }
59
- });
60
- const actions = ui.actions();
61
- actions.append(message, button);
62
- ctx.root.replaceChildren(actions);
164
+ sheet.body.appendChild(fields);
165
+ ctx.root.replaceChildren(sheet.el);
166
+ }
63
167
  }
64
- var client = {
65
- panels: { credentials: { mount: mountCredentials }, "connection-test": { mount: mountConnectionTest } }
66
- };
168
+ var client = { panels: { config: { mount: mountConfig } } };
67
169
  var client_default = client;
68
170
  export {
69
171
  client_default as default
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-simple-trpg-check",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "TRPG skill checks with Laya or Jev probability scores and d100 rolls",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { ConfigGroup } from 'cortico/core/types.ts';
2
2
 
3
3
  export type CheckBackend = 'laya' | 'laya-multilingual' | 'jev';
4
- export type JevSource = 'typesafe' | 'openrouter';
4
+ export type JevSource = 'typesafe' | 'openrouter' | 'custom';
5
5
 
6
6
  export interface SimpleTrpgCheckConfigSection {
7
7
  enabled: boolean;
8
8
  backend: CheckBackend;
9
9
  jevSource: JevSource;
10
+ jevEndpoint: string;
10
11
  forceMultilingual: boolean;
11
12
  pythonExecutable: string;
12
13
  layaIdleTtlMinutes: number;
@@ -16,6 +17,7 @@ export const SIMPLE_TRPG_CHECK_DEFAULTS: SimpleTrpgCheckConfigSection = {
16
17
  enabled: false,
17
18
  backend: 'laya-multilingual',
18
19
  jevSource: 'typesafe',
20
+ jevEndpoint: '',
19
21
  forceMultilingual: false,
20
22
  pythonExecutable: 'python',
21
23
  layaIdleTtlMinutes: 10,
@@ -23,6 +25,7 @@ export const SIMPLE_TRPG_CHECK_DEFAULTS: SimpleTrpgCheckConfigSection = {
23
25
 
24
26
  export const SIMPLE_TRPG_CHECK_TYPESAFE_SECRET = 'CORTICO_JEV_TYPESAFE_API_KEY';
25
27
  export const SIMPLE_TRPG_CHECK_OPENROUTER_SECRET = 'CORTICO_JEV_OPENROUTER_API_KEY';
28
+ export const SIMPLE_TRPG_CHECK_CUSTOM_SECRET = 'CORTICO_JEV_API_KEY';
26
29
  export const SIMPLE_TRPG_CHECK_LEGACY_TYPESAFE_SECRET = 'CORTICO_SIMPLE_TRPG_CHECK_TYPESAFE_API_KEY';
27
30
  export const SIMPLE_TRPG_CHECK_LEGACY_OPENROUTER_SECRET = 'CORTICO_SIMPLE_TRPG_CHECK_OPENROUTER_API_KEY';
28
31
 
@@ -38,14 +41,17 @@ export const SIMPLE_TRPG_CHECK_CONFIG_GROUP: ConfigGroup = {
38
41
  title: '判定模型',
39
42
  enum: ['laya-multilingual', 'laya', 'jev'],
40
43
  'x-hot': false,
41
- 'x-panel-slot': 'model-test',
42
44
  },
43
45
  'worlds.simple-trpg-check.jevSource': {
44
46
  type: 'string',
45
47
  title: 'Jev 来源',
46
- enum: ['typesafe', 'openrouter'],
48
+ enum: ['typesafe', 'openrouter', 'custom'],
49
+ 'x-hot': true,
50
+ },
51
+ 'worlds.simple-trpg-check.jevEndpoint': {
52
+ type: 'string',
53
+ title: '自定义 Jev 服务地址',
47
54
  'x-hot': true,
48
- 'x-panel-slot': 'jev-key',
49
55
  },
50
56
  'worlds.simple-trpg-check.forceMultilingual': {
51
57
  type: 'boolean',
@@ -1,74 +1,116 @@
1
1
  import type { ConsoleClientBundle, ConsolePanelContext } from 'cortico/web/shared/client-panel.ts';
2
+ import { SIMPLE_TRPG_CHECK_CONFIG_GROUP, type SimpleTrpgCheckConfigSection } from '../config.ts';
2
3
 
3
- interface KeyState {
4
- backend: string;
5
- source: 'typesafe' | 'openrouter';
4
+ interface ConfigState {
5
+ config: SimpleTrpgCheckConfigSection;
6
6
  keySet: boolean;
7
7
  }
8
8
 
9
- async function mountCredentials(ctx: ConsolePanelContext): Promise<void> {
10
- let state = await ctx.invoke<KeyState>('state');
11
- render(state);
9
+ async function mountConfig(ctx: ConsolePanelContext): Promise<void> {
10
+ const { ui } = ctx;
11
+ const options = await ctx.invoke<Array<{ value: string; label: string }>>('options');
12
+ let state = await ctx.invoke<ConfigState>('state');
13
+ let status = '';
14
+ let failed = false;
15
+ render();
12
16
  ctx.interval(() => {
13
- void ctx.invoke<KeyState>('state').then((next) => {
14
- if (next.backend !== state.backend || next.source !== state.source || next.keySet !== state.keySet) {
17
+ void ctx.invoke<ConfigState>('state').then((next) => {
18
+ if (JSON.stringify(next) !== JSON.stringify(state)) {
15
19
  state = next;
16
- render(next);
20
+ render();
17
21
  }
18
22
  }).catch(() => undefined);
19
23
  }, 2_000);
20
24
 
21
- function render(current: KeyState): void {
22
- const { ui } = ctx;
23
- if (current.backend !== 'jev') {
24
- ctx.root.replaceChildren();
25
- return;
25
+ async function save(key: keyof SimpleTrpgCheckConfigSection, value: string | number | boolean): Promise<void> {
26
+ try {
27
+ state = await ctx.invoke<ConfigState>('save', [key, value]);
28
+ status = '已保存';
29
+ failed = false;
30
+ } catch (error) {
31
+ status = `保存失败:${error instanceof Error ? error.message : String(error)}`;
32
+ failed = true;
26
33
  }
27
- const sourceName = current.source === 'openrouter' ? 'OpenRouter' : 'TypeSafe';
28
- const message = ui.msgline(current.keySet ? '密钥已配置' : '密钥未配置');
29
- const open = ui.button(`打开 ${sourceName} 密钥文件`, {
34
+ render();
35
+ void ctx.refresh().catch(() => undefined);
36
+ }
37
+
38
+ function render(): void {
39
+ const sheet = ui.sheet({ title: '配置' });
40
+ const fields = ui.h('div');
41
+ const config = state.config;
42
+ const result = ui.msgline(status, failed);
43
+ const test = ui.button('测试连接', {
30
44
  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');
45
+ test.disabled = true;
46
+ result.textContent = '测试中…';
47
+ result.classList.remove('bad');
48
+ void ctx.invoke<{ ok: boolean; error?: string }>('testConnection').then((out) => {
49
+ result.textContent = out.ok ? '连接成功' : `连接失败:${out.error || '模型没有返回有效结果'}`;
50
+ result.classList.toggle('bad', !out.ok);
51
+ void ctx.refresh().catch(() => undefined);
35
52
  }).catch((error) => {
36
- message.textContent = `打开失败:${error instanceof Error ? error.message : String(error)}`;
37
- message.classList.add('bad');
38
- }).finally(() => { open.disabled = false; });
53
+ result.textContent = `连接失败:${error instanceof Error ? error.message : String(error)}`;
54
+ result.classList.add('bad');
55
+ }).finally(() => { test.disabled = false; });
39
56
  },
40
57
  });
41
- const actions = ui.actions();
42
- actions.append(message, open);
43
- ctx.root.replaceChildren(actions);
44
- }
45
- }
58
+ const testRow = ui.rowbar();
59
+ testRow.append(test, result);
60
+ sheet.body.appendChild(testRow);
61
+ for (const [path, property] of Object.entries(SIMPLE_TRPG_CHECK_CONFIG_GROUP.schema.properties)) {
62
+ const key = path.split('.').at(-1) as keyof SimpleTrpgCheckConfigSection;
63
+ if (config.backend !== 'jev' && (key === 'jevSource' || key === 'jevEndpoint')) continue;
64
+ if (config.backend === 'jev' && (key === 'forceMultilingual' || key === 'pythonExecutable' || key === 'layaIdleTtlMinutes')) continue;
65
+ if (key === 'jevEndpoint' && config.jevSource !== 'custom') continue;
66
+ const value = config[key];
67
+ let field: HTMLElement;
68
+ if (property.type === 'boolean') {
69
+ field = ui.checkbox(property.title, {
70
+ checked: Boolean(value), onChange: (next) => { void save(key, next); },
71
+ }).el;
72
+ } else if (property.enum || property['x-options']) {
73
+ const choices = property.enum ?? options.map((item) => item.value);
74
+ const entries = choices.map((choice) => ({
75
+ value: choice,
76
+ label: property['x-options'] ? options.find((item) => item.value === choice)?.label ?? choice : choice,
77
+ }));
78
+ if (!entries.some((item) => item.value === value)) entries.unshift({ value: String(value), label: String(value) });
79
+ field = ui.field(property.title, ui.select({
80
+ value: String(value), options: entries, onChange: (next) => { void save(key, next); },
81
+ }));
82
+ } else {
83
+ field = ui.field(property.title, ui.input({
84
+ type: property.type === 'integer' ? 'number' : 'text',
85
+ value: String(value),
86
+ onChange: (next) => { void save(key, property.type === 'integer' ? Number(next) : next); },
87
+ }));
88
+ }
89
+ if (key === 'jevSource') {
90
+ const sourceName = config.jevSource === 'openrouter' ? 'OpenRouter' : config.jevSource === 'custom' ? '自定义 Jev' : 'TypeSafe';
91
+ const keyStatus = ui.msgline(state.keySet ? '密钥已配置' : '密钥未配置');
92
+ const open = ui.button(`打开 ${sourceName} 密钥文件`, {
93
+ onClick: () => {
94
+ open.disabled = true;
95
+ void ctx.invoke<{ file: string }>('openKeyFile', [config.jevSource]).then(({ file }) => {
96
+ keyStatus.textContent = `已打开 ${file}`;
97
+ }).catch((error) => {
98
+ keyStatus.textContent = `打开失败:${error instanceof Error ? error.message : String(error)}`;
99
+ keyStatus.classList.add('bad');
100
+ }).finally(() => { open.disabled = false; });
101
+ },
102
+ });
103
+ const keyRow = ui.rowbar();
104
+ keyRow.append(field, keyStatus, open);
105
+ fields.appendChild(keyRow);
106
+ } else fields.appendChild(field);
107
+ if (property.description) fields.appendChild(ui.msgline(property.description));
108
+ }
109
+ sheet.body.appendChild(fields);
46
110
 
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);
111
+ ctx.root.replaceChildren(sheet.el);
112
+ }
68
113
  }
69
114
 
70
- const client: ConsoleClientBundle = {
71
- panels: { credentials: { mount: mountCredentials }, 'connection-test': { mount: mountConnectionTest } },
72
- };
73
-
115
+ const client: ConsoleClientBundle = { panels: { config: { mount: mountConfig } } };
74
116
  export default client;
package/src/model.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { WorldContext } from 'cortico/world.ts';
2
2
  import {
3
3
  SIMPLE_TRPG_CHECK_OPENROUTER_SECRET,
4
+ SIMPLE_TRPG_CHECK_CUSTOM_SECRET,
4
5
  SIMPLE_TRPG_CHECK_TYPESAFE_SECRET,
5
6
  SIMPLE_TRPG_CHECK_LEGACY_OPENROUTER_SECRET,
6
7
  SIMPLE_TRPG_CHECK_LEGACY_TYPESAFE_SECRET,
@@ -84,16 +85,19 @@ export class SystemOneSkillScorer implements SkillScorer {
84
85
 
85
86
  private async requestJev(state: unknown, questions: Questions): Promise<unknown> {
86
87
  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';
88
+ const apiKey = source === 'custom' ? this.ctx.secret(SIMPLE_TRPG_CHECK_CUSTOM_SECRET)
89
+ : source === 'openrouter'
90
+ ? this.ctx.secret(SIMPLE_TRPG_CHECK_OPENROUTER_SECRET) || this.ctx.secret(SIMPLE_TRPG_CHECK_LEGACY_OPENROUTER_SECRET)
91
+ : this.ctx.secret(SIMPLE_TRPG_CHECK_TYPESAFE_SECRET) || this.ctx.secret(SIMPLE_TRPG_CHECK_LEGACY_TYPESAFE_SECRET)
92
+ || this.ctx.secret(SIMPLE_TRPG_CHECK_CUSTOM_SECRET);
93
+ if (!apiKey) throw new Error(`${source === 'openrouter' ? 'OpenRouter' : source === 'custom' ? '自定义 Jev' : 'TypeSafe'} API key 未配置`);
94
+ const endpoint = source === 'openrouter' ? 'https://openrouter.ai/api/alpha/decisions'
95
+ : source === 'custom' ? this.ctx.cfg.jevEndpoint.trim() : 'https://api.typesafe.ai/v1/systemone';
96
+ if (source === 'custom') {
97
+ let url: URL;
98
+ try { url = new URL(endpoint); } catch { throw new Error('自定义 Jev 服务地址无效'); }
99
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('自定义 Jev 服务地址无效');
100
+ }
97
101
  const response = await fetch(endpoint, {
98
102
  method: 'POST',
99
103
  headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
package/src/world.ts CHANGED
@@ -6,12 +6,14 @@ import { classify, difficultyFromProbability, formatChecks } from './check.ts';
6
6
  import {
7
7
  SIMPLE_TRPG_CHECK_CONFIG_GROUP,
8
8
  SIMPLE_TRPG_CHECK_OPENROUTER_SECRET,
9
+ SIMPLE_TRPG_CHECK_CUSTOM_SECRET,
9
10
  SIMPLE_TRPG_CHECK_TYPESAFE_SECRET,
10
11
  SIMPLE_TRPG_CHECK_LEGACY_OPENROUTER_SECRET,
11
12
  SIMPLE_TRPG_CHECK_LEGACY_TYPESAFE_SECRET,
12
13
  type SimpleTrpgCheckConfigSection,
13
14
  } from './config.ts';
14
15
  import { SystemOneSkillScorer, type SkillScorer } from './model.ts';
16
+ import { discoverCondaPythonOptions } from './conda-environments.ts';
15
17
  import { ensureSecretPlaceholder, openSecretFile } from './secret-file.ts';
16
18
 
17
19
  const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
@@ -25,11 +27,11 @@ export class SimpleTrpgCheckWorld implements World {
25
27
 
26
28
  private hasJevKey(): boolean {
27
29
  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')));
30
+ return !!(source === 'custom' ? this.ctx.secret(SIMPLE_TRPG_CHECK_CUSTOM_SECRET)
31
+ : source === 'openrouter'
32
+ ? this.ctx.secret(SIMPLE_TRPG_CHECK_OPENROUTER_SECRET) || this.ctx.secret(SIMPLE_TRPG_CHECK_LEGACY_OPENROUTER_SECRET)
33
+ : this.ctx.secret(SIMPLE_TRPG_CHECK_TYPESAFE_SECRET) || this.ctx.secret(SIMPLE_TRPG_CHECK_LEGACY_TYPESAFE_SECRET)
34
+ || this.ctx.secret(SIMPLE_TRPG_CHECK_CUSTOM_SECRET));
33
35
  }
34
36
 
35
37
  constructor(
@@ -82,7 +84,6 @@ export class SimpleTrpgCheckWorld implements World {
82
84
  const signature = this.currentModelSignature();
83
85
  const modelState = signature === this.modelSignature ? this.modelState : 'offline';
84
86
  return {
85
- config: [SIMPLE_TRPG_CHECK_CONFIG_GROUP],
86
87
  lamps: [{
87
88
  label: '判定模型',
88
89
  state: modelState,
@@ -90,14 +91,28 @@ export class SimpleTrpgCheckWorld implements World {
90
91
  ? '最近一次模型请求失败' : modelState === 'loading' ? '正在请求模型' : '尚未成功请求模型',
91
92
  }],
92
93
  badges: this.ctx.cfg.backend === 'jev'
93
- ? [{ label: source === 'openrouter' ? 'OpenRouter' : 'TypeSafe', value: keySet ? '密钥已配置' : '密钥未配置', tone: keySet ? 'on' : 'off' }]
94
+ ? [{ label: source === 'openrouter' ? 'OpenRouter' : source === 'custom' ? '自定义 Jev' : 'TypeSafe', value: keySet ? '密钥已配置' : '密钥未配置', tone: keySet ? 'on' : 'off' }]
94
95
  : [{ 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'],
96
+ panels: [{ id: 'config', title: '配置' }],
99
97
  invoke: async (panel, method, args) => {
100
- if (panel === 'connection-test' && method === 'testConnection') {
98
+ if (panel !== 'config') throw new Error('未知面板');
99
+ if (method === 'state') {
100
+ return { config: { ...this.ctx.cfg }, keySet: this.hasJevKey() };
101
+ }
102
+ if (method === 'options') return discoverCondaPythonOptions();
103
+ if (method === 'save') {
104
+ const [key, value] = args;
105
+ const property = SIMPLE_TRPG_CHECK_CONFIG_GROUP.schema.properties?.[`worlds.simple-trpg-check.${key}`];
106
+ if (!property || typeof key !== 'string' || key === 'enabled') throw new Error('未知配置项');
107
+ if (property.type === 'boolean' ? typeof value !== 'boolean'
108
+ : property.type === 'integer' ? typeof value !== 'number' || !Number.isInteger(value) || value < (property.minimum ?? 0)
109
+ : typeof value !== 'string' || (property.enum && !property.enum.includes(value))) {
110
+ throw new Error('配置值无效');
111
+ }
112
+ this.ctx.persist({ [key]: value });
113
+ return { config: { ...this.ctx.cfg }, keySet: this.hasJevKey() };
114
+ }
115
+ if (method === 'testConnection') {
101
116
  try {
102
117
  await this.assess('A character climbs a waist-high wall to reach the other side.', ['climbing']);
103
118
  return { ok: true };
@@ -105,18 +120,13 @@ export class SimpleTrpgCheckWorld implements World {
105
120
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
106
121
  }
107
122
  }
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
123
  if (method === 'openKeyFile') {
113
124
  const source = args[0];
114
125
  if (source !== this.ctx.cfg.jevSource || this.ctx.cfg.backend !== 'jev') {
115
126
  throw new Error('Jev 来源已改变,请重试');
116
127
  }
117
- const secretName = this.ctx.cfg.jevSource === 'openrouter'
118
- ? SIMPLE_TRPG_CHECK_OPENROUTER_SECRET
119
- : SIMPLE_TRPG_CHECK_TYPESAFE_SECRET;
128
+ const secretName = this.ctx.cfg.jevSource === 'openrouter' ? SIMPLE_TRPG_CHECK_OPENROUTER_SECRET
129
+ : this.ctx.cfg.jevSource === 'custom' ? SIMPLE_TRPG_CHECK_CUSTOM_SECRET : SIMPLE_TRPG_CHECK_TYPESAFE_SECRET;
120
130
  const file = ensureSecretPlaceholder(this.ctx.botDir, secretName);
121
131
  await this.openFile(file);
122
132
  return { file };
@@ -141,8 +151,8 @@ export class SimpleTrpgCheckWorld implements World {
141
151
  }
142
152
 
143
153
  private currentModelSignature(): string {
144
- const { backend, jevSource, pythonExecutable } = this.ctx.cfg;
145
- return `${backend}|${backend === 'jev' ? jevSource : pythonExecutable}`;
154
+ const { backend, jevSource, jevEndpoint, pythonExecutable } = this.ctx.cfg;
155
+ return `${backend}|${backend === 'jev' ? `${jevSource}|${jevSource === 'custom' ? jevEndpoint : ''}` : pythonExecutable}`;
146
156
  }
147
157
 
148
158
  private async assess(scenario: string, skills: string[]): Promise<number[]> {