galbook 0.0.0-stage → 1.2.4
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/.agents/skills/galbook/SKILL.md +44 -0
- package/LICENSE +21 -0
- package/README.en.md +114 -0
- package/README.md +133 -3
- package/bin/galbook.js +53 -0
- package/cordis.patch.yml +30 -0
- package/dsh-plugin/README.md +49 -0
- package/dsh-plugin/install.py +190 -0
- package/galbook/__init__.py +15 -0
- package/galbook/__main__.py +138 -0
- package/galbook/build.py +89 -0
- package/galbook/cli.py +29 -0
- package/galbook/demo/fog-harbor/assets/cg_dawn_together.svg +1 -0
- package/galbook/demo/fog-harbor/assets/cg_old_letter.svg +1 -0
- package/galbook/demo/fog-harbor/assets/cg_ship.svg +1 -0
- package/galbook/demo/fog-harbor/assets/harbor_night.svg +1 -0
- package/galbook/demo/fog-harbor/assets/lamp_room.svg +1 -0
- package/galbook/demo/fog-harbor/assets/lamp_room_dawn.svg +1 -0
- package/galbook/demo/fog-harbor/assets/path_tide.svg +1 -0
- package/galbook/demo/fog-harbor/assets/shio_normal.svg +1 -0
- package/galbook/demo/fog-harbor/assets/shio_smile.svg +1 -0
- package/galbook/demo/fog-harbor/assets/shio_surprised.svg +1 -0
- package/galbook/demo/fog-harbor/assets/warehouse.svg +1 -0
- package/galbook/demo/fog-harbor/story.gal +97 -0
- package/galbook/demo/fog-harbor-en/assets/cg_dawn_together.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/cg_old_letter.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/cg_ship.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/harbor_night.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/lamp_room.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/lamp_room_dawn.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/path_tide.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/shio_normal.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/shio_smile.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/shio_surprised.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/warehouse.svg +1 -0
- package/galbook/demo/fog-harbor-en/story.gal +97 -0
- package/galbook/demo_boot.py +63 -0
- package/galbook/engine.py +325 -0
- package/galbook/expr.py +107 -0
- package/galbook/i18n.py +85 -0
- package/galbook/importers/__init__.py +106 -0
- package/galbook/importers/common.py +94 -0
- package/galbook/importers/galscript.py +218 -0
- package/galbook/importers/kag.py +326 -0
- package/galbook/importers/legacy.py +122 -0
- package/galbook/importers/nscripter.py +314 -0
- package/galbook/importers/qlie.py +498 -0
- package/galbook/importers/renpy.py +333 -0
- package/galbook/importers/textdump.py +128 -0
- package/galbook/ir.py +266 -0
- package/galbook/mcp_server.py +102 -0
- package/galbook/play.py +374 -0
- package/galbook/server.py +199 -0
- package/galbook/static/play.html +151 -0
- package/galbook/store.py +160 -0
- package/galbook/tools.py +99 -0
- package/galbook/web.py +316 -0
- package/icon.svg +21 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -6
package/galbook/ir.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""中间格式 galbook/1(引擎无关)。完整说明见 SCHEMA.md。
|
|
2
|
+
|
|
3
|
+
books/<slug>/story/story.json
|
|
4
|
+
{"format": "galbook/1", "title", "engine", "importer", "entry": {"scene", "label"},
|
|
5
|
+
"scenes": {"<id>": {"title", "file", "tags": [...], "route"}},
|
|
6
|
+
"routes": [{"id", "title", "start": {"scene", "label"}}],
|
|
7
|
+
"system_vars": [...], "stats": {...}, "warnings": [...]}
|
|
8
|
+
books/<slug>/story/scenes/<file>.json
|
|
9
|
+
{"id", "title", "ops": [ {op...}, ... ]}
|
|
10
|
+
|
|
11
|
+
op 一览(任何 op 都可带 "if": "<条件>",条件不成立就跳过这一条)
|
|
12
|
+
say {"who": "灯", "text": "「…」", "voice": "ak_0001"} who="" 是旁白
|
|
13
|
+
bg {"img": "assets/画像/bg_001.bmp" | null} 背景(null=清掉)
|
|
14
|
+
cg {"img": ... | null} 事件图(盖整页)
|
|
15
|
+
sprite {"name": "灯", "img": ... | null, "pos": "center"} 立绘(img=null 下场)
|
|
16
|
+
sprites_clear {}
|
|
17
|
+
bgm {"name": "bgm006" | null}
|
|
18
|
+
label {"name": "MAIN"}
|
|
19
|
+
choice {"id": "sel1", "prompt": "", "options": [{"text", "goto": {"scene", "label"}, "set", "if"}],
|
|
20
|
+
"auto_single": false}
|
|
21
|
+
jump {"scene": "<id>"?, "label": "<name>"?} 省略 scene = 本场景
|
|
22
|
+
call {"scene"?, "label"?} ret {}
|
|
23
|
+
set {"expr": "f.x = 1; s.clear_a = 1"}
|
|
24
|
+
event {"name": "endroll" | "movie" | ..., "arg": ...} 只做标记,给 agent 看
|
|
25
|
+
end {"kind": "ending" | "title" | "stop", "name": "..."}
|
|
26
|
+
"""
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import threading
|
|
30
|
+
|
|
31
|
+
FORMAT = "galbook/1"
|
|
32
|
+
OPS = {"say", "bg", "cg", "sprite", "sprites_clear", "bgm", "label", "choice", "jump", "call", "ret",
|
|
33
|
+
"set", "event", "end"}
|
|
34
|
+
EXITS = ("choice", "jump", "call", "ret", "end")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StoryError(ValueError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Scene(object):
|
|
42
|
+
__slots__ = ("id", "title", "ops", "labels", "tags", "route", "says")
|
|
43
|
+
|
|
44
|
+
def __init__(self, sid, data, meta=None):
|
|
45
|
+
meta = meta or {}
|
|
46
|
+
self.id = sid
|
|
47
|
+
self.title = data.get("title") or meta.get("title") or sid
|
|
48
|
+
self.ops = data.get("ops") or []
|
|
49
|
+
self.tags = list(meta.get("tags") or data.get("tags") or [])
|
|
50
|
+
self.route = meta.get("route") or data.get("route") or ""
|
|
51
|
+
self.labels = {}
|
|
52
|
+
self.says = 0
|
|
53
|
+
for i, op in enumerate(self.ops):
|
|
54
|
+
if op.get("op") == "label":
|
|
55
|
+
self.labels.setdefault(op.get("name"), i)
|
|
56
|
+
elif op.get("op") == "say":
|
|
57
|
+
self.says += 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Story(object):
|
|
61
|
+
"""已加载的一本可玩剧本(只读,多线程共享)。"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, meta, scenes, base_dir=None):
|
|
64
|
+
self.meta = meta
|
|
65
|
+
self.title = meta.get("title", "")
|
|
66
|
+
self.entry = meta.get("entry") or {}
|
|
67
|
+
self.scenes = scenes # id -> Scene
|
|
68
|
+
self.base_dir = base_dir
|
|
69
|
+
self.routes = meta.get("routes") or []
|
|
70
|
+
self.mode = meta.get("mode", "branch")
|
|
71
|
+
|
|
72
|
+
def scene(self, sid):
|
|
73
|
+
sc = self.scenes.get(sid)
|
|
74
|
+
if sc is None:
|
|
75
|
+
raise StoryError("剧本里没有场景 %r" % sid)
|
|
76
|
+
return sc
|
|
77
|
+
|
|
78
|
+
def first_say(self, sid, pc, look=200):
|
|
79
|
+
"""从 (sid, pc) 往后找第一句台词的位置(跟着无条件 jump 走),找不到返回 None。用于「新内容」标记。"""
|
|
80
|
+
for _ in range(8):
|
|
81
|
+
sc = self.scenes.get(sid)
|
|
82
|
+
if sc is None:
|
|
83
|
+
return None
|
|
84
|
+
for j in range(pc, min(len(sc.ops), pc + look)):
|
|
85
|
+
op = sc.ops[j]
|
|
86
|
+
k = op.get("op")
|
|
87
|
+
if k == "say":
|
|
88
|
+
return sid, j
|
|
89
|
+
if k in ("choice", "end", "call", "ret") or (k == "jump" and op.get("if")):
|
|
90
|
+
return None
|
|
91
|
+
if k == "jump":
|
|
92
|
+
try:
|
|
93
|
+
sid, pc = self.resolve(op.get("scene"), op.get("label"), sid)
|
|
94
|
+
except StoryError:
|
|
95
|
+
return None
|
|
96
|
+
break
|
|
97
|
+
else:
|
|
98
|
+
return None
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def warnings(self):
|
|
103
|
+
"""可疑但不致命:分支被压成直线这类。跟 problems(真的坏了)分开。"""
|
|
104
|
+
if not hasattr(self, "_warnings"):
|
|
105
|
+
w = []
|
|
106
|
+
ch = self.op_counts.get("choice", 0)
|
|
107
|
+
if len(self.routes) > 1 and ch == 0:
|
|
108
|
+
w.append("这本标了 %d 条路线,但全库 0 个选择点——转换时选项可能被当成普通台词吞掉了"
|
|
109
|
+
% len(self.routes))
|
|
110
|
+
elif len(self.scenes) > 20 and ch == 0:
|
|
111
|
+
w.append("全库 %d 场、0 个选择点;若原作有分支,检查转换器是否丢了 .select / 选项块"
|
|
112
|
+
% len(self.scenes))
|
|
113
|
+
self._warnings = w
|
|
114
|
+
return self._warnings
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def op_counts(self):
|
|
118
|
+
"""全库 op 计数(say/choice/jump/end…)。choice 为 0 的分支书 = 转换时把选项丢了。"""
|
|
119
|
+
if not hasattr(self, "_op_counts"):
|
|
120
|
+
c = {}
|
|
121
|
+
for sc in self.scenes.values():
|
|
122
|
+
for op in sc.ops:
|
|
123
|
+
k = op.get("op")
|
|
124
|
+
c[k] = c.get(k, 0) + 1
|
|
125
|
+
self._op_counts = c
|
|
126
|
+
return self._op_counts
|
|
127
|
+
|
|
128
|
+
def _count(self):
|
|
129
|
+
if not hasattr(self, "_counts"):
|
|
130
|
+
cgs, ends = set(), set()
|
|
131
|
+
for sc in self.scenes.values():
|
|
132
|
+
for op in sc.ops:
|
|
133
|
+
if op.get("op") == "cg" and op.get("img"):
|
|
134
|
+
cgs.add(op["img"])
|
|
135
|
+
elif op.get("op") == "end" and (op.get("kind") or "ending") == "ending":
|
|
136
|
+
ends.add((sc.id, op.get("name")))
|
|
137
|
+
self._counts = (len(cgs), len(ends))
|
|
138
|
+
return self._counts
|
|
139
|
+
|
|
140
|
+
def cg_total(self):
|
|
141
|
+
return self._count()[0]
|
|
142
|
+
|
|
143
|
+
def endings_total(self):
|
|
144
|
+
return self._count()[1]
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def problems(self):
|
|
148
|
+
"""加载后能查出来的结构问题(没出口的场景等);空列表 = 没发现。"""
|
|
149
|
+
if not hasattr(self, "_problems"):
|
|
150
|
+
probs = []
|
|
151
|
+
for sid, sc in self.scenes.items():
|
|
152
|
+
if not any(op.get("op") in EXITS for op in sc.ops):
|
|
153
|
+
probs.append("%s(%s): 没有出口(缺 choice / jump / end / ret)" % (sid, sc.title))
|
|
154
|
+
self._problems = probs
|
|
155
|
+
return self._problems
|
|
156
|
+
|
|
157
|
+
def resolve(self, scene, label, here=None):
|
|
158
|
+
sid = scene or here
|
|
159
|
+
sc = self.scene(sid)
|
|
160
|
+
if label in (None, ""):
|
|
161
|
+
return sid, 0
|
|
162
|
+
if label not in sc.labels:
|
|
163
|
+
raise StoryError("场景 %r 里没有标签 %r" % (sid, label))
|
|
164
|
+
return sid, sc.labels[label]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def validate(meta, scene_datas):
|
|
168
|
+
"""返回问题清单(空 = 通过)。scene_datas: id -> scene dict"""
|
|
169
|
+
probs = []
|
|
170
|
+
if meta.get("format") != FORMAT:
|
|
171
|
+
probs.append("format 不是 %s" % FORMAT)
|
|
172
|
+
labels = {}
|
|
173
|
+
for sid, d in scene_datas.items():
|
|
174
|
+
labels[sid] = {op.get("name") for op in d.get("ops", []) if op.get("op") == "label"}
|
|
175
|
+
def chk(where, sc, lb):
|
|
176
|
+
if sc is not None and sc not in scene_datas:
|
|
177
|
+
probs.append("%s: 跳到不存在的场景 %r" % (where, sc))
|
|
178
|
+
elif lb not in (None, "") and lb not in labels.get(sc, set()):
|
|
179
|
+
probs.append("%s: 场景 %r 没有标签 %r" % (where, sc, lb))
|
|
180
|
+
e = meta.get("entry") or {}
|
|
181
|
+
chk("entry", e.get("scene"), e.get("label"))
|
|
182
|
+
for sid, d in scene_datas.items():
|
|
183
|
+
ops_ = d.get("ops") or []
|
|
184
|
+
if not any(op.get("op") in EXITS for op in ops_):
|
|
185
|
+
probs.append("%s(%s): 场景没有出口——没有 choice / jump / end / ret,"
|
|
186
|
+
"玩家会卡在最后一句(多半是转换时丢了控制流)" % (sid, d.get("title") or sid))
|
|
187
|
+
for sid, d in scene_datas.items():
|
|
188
|
+
for i, op in enumerate(d.get("ops", [])):
|
|
189
|
+
k = op.get("op")
|
|
190
|
+
w = "%s#%d" % (sid, i)
|
|
191
|
+
if k not in OPS:
|
|
192
|
+
probs.append("%s: 未知 op %r" % (w, k))
|
|
193
|
+
if op.get("click") is not None: # 只校验,不改任何 op 的语义(点击由 play.py 处理)
|
|
194
|
+
ck = op["click"]
|
|
195
|
+
if k != "say" or not isinstance(ck, dict) or not (ck.get("set") or ck.get("goto")):
|
|
196
|
+
probs.append("%s: click 只能挂在 say 上,且要有 set 或 goto" % w)
|
|
197
|
+
elif ck.get("goto"):
|
|
198
|
+
chk(w + ".click", ck["goto"].get("scene") or sid, ck["goto"].get("label"))
|
|
199
|
+
elif k in ("jump", "call"):
|
|
200
|
+
chk(w, op.get("scene") or sid, op.get("label"))
|
|
201
|
+
elif k == "choice":
|
|
202
|
+
if not op.get("options"):
|
|
203
|
+
probs.append("%s: 选项为空" % w)
|
|
204
|
+
for j, o in enumerate(op.get("options") or []):
|
|
205
|
+
g = o.get("goto") or {}
|
|
206
|
+
chk("%s.opt%d" % (w, j), g.get("scene") or sid, g.get("label"))
|
|
207
|
+
return probs
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def save_story(out_dir, meta, scene_datas):
|
|
211
|
+
"""写出 story.json + scenes/*.json(原子写)。返回写出的文件数。"""
|
|
212
|
+
os.makedirs(os.path.join(out_dir, "scenes"), exist_ok=True)
|
|
213
|
+
meta = dict(meta)
|
|
214
|
+
meta["format"] = FORMAT
|
|
215
|
+
scenes_meta = meta.setdefault("scenes", {})
|
|
216
|
+
n = 0
|
|
217
|
+
for i, (sid, d) in enumerate(scene_datas.items(), 1):
|
|
218
|
+
fn = "scenes/s%04d.json" % i
|
|
219
|
+
m = scenes_meta.setdefault(sid, {})
|
|
220
|
+
m.setdefault("title", d.get("title") or sid)
|
|
221
|
+
m["file"] = fn
|
|
222
|
+
m["says"] = sum(1 for op in d.get("ops", []) if op.get("op") == "say")
|
|
223
|
+
_atomic_json(os.path.join(out_dir, fn), dict(d, id=sid))
|
|
224
|
+
n += 1
|
|
225
|
+
_atomic_json(os.path.join(out_dir, "story.json"), meta)
|
|
226
|
+
return n + 1
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _atomic_json(path, obj):
|
|
230
|
+
tmp = path + ".tmp%d" % os.getpid()
|
|
231
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
232
|
+
json.dump(obj, f, ensure_ascii=False, separators=(",", ":"))
|
|
233
|
+
os.replace(tmp, path)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_cache = {}
|
|
237
|
+
_cache_lock = threading.Lock()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def load_story(story_dir):
|
|
241
|
+
"""按 story.json 的 mtime 缓存;改了剧本不用重启服务。"""
|
|
242
|
+
sj = os.path.join(story_dir, "story.json")
|
|
243
|
+
st = os.stat(sj)
|
|
244
|
+
key = (story_dir, st.st_mtime_ns, st.st_size)
|
|
245
|
+
with _cache_lock:
|
|
246
|
+
hit = _cache.get(story_dir)
|
|
247
|
+
if hit and hit[0] == key:
|
|
248
|
+
return hit[1]
|
|
249
|
+
with open(sj, encoding="utf-8") as f:
|
|
250
|
+
meta = json.load(f)
|
|
251
|
+
if meta.get("format") != FORMAT:
|
|
252
|
+
raise StoryError("%s 不是 %s 格式" % (sj, FORMAT))
|
|
253
|
+
scenes = {}
|
|
254
|
+
for sid, m in (meta.get("scenes") or {}).items():
|
|
255
|
+
with open(os.path.join(story_dir, m["file"]), encoding="utf-8") as f:
|
|
256
|
+
scenes[sid] = Scene(sid, json.load(f), m)
|
|
257
|
+
story = Story(meta, scenes, story_dir)
|
|
258
|
+
with _cache_lock:
|
|
259
|
+
_cache[story_dir] = (key, story)
|
|
260
|
+
return story
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def story_from_dict(meta, scene_datas):
|
|
264
|
+
"""内存里直接构造(legacy 书稿适配、测试用)。"""
|
|
265
|
+
scenes = {sid: Scene(sid, d, (meta.get("scenes") or {}).get(sid)) for sid, d in scene_datas.items()}
|
|
266
|
+
return Story(meta, scenes)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""MCP 服务(stdio,JSON-RPC 2.0,一行一条):让任何支持 MCP 的 agent 直接玩。
|
|
2
|
+
|
|
3
|
+
python3 -m galbook mcp --books ./books --data ./play-data [--book 默认书] [--agent 默认名] [--lang zh|en]
|
|
4
|
+
|
|
5
|
+
在 MCP 客户端里配置(示例):
|
|
6
|
+
{"mcpServers": {"galbook": {"command": "python3",
|
|
7
|
+
"args": ["-m", "galbook", "mcp", "--books", "/path/books", "--data", "/path/play-data", "--agent", "alice"]}}}
|
|
8
|
+
多个 agent 可以各起一个 MCP 进程(不同 --agent),共用同一个 --data 目录;存储层有文件锁,互不干扰。
|
|
9
|
+
也可以不起进程:python3 -m galbook serve 自带 Streamable HTTP 的 MCP 端点 POST /mcp?book=…&agent=…(见 server.py)。"""
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import traceback
|
|
15
|
+
|
|
16
|
+
from . import VERSION
|
|
17
|
+
from .play import Library
|
|
18
|
+
from .tools import mcp_tools, call_tool
|
|
19
|
+
from .i18n import msg
|
|
20
|
+
|
|
21
|
+
PROTO = "2024-11-05"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def handle(lib, req, book=None, agent=None, lang="zh", force_agent=None):
|
|
25
|
+
"""处理一条 JSON-RPC 请求,返回响应 dict;通知返回 None。stdio 和 HTTP(/mcp)共用。
|
|
26
|
+
force_agent:HTTP 令牌绑定了名字时,强制用这个名字(防冒名)。"""
|
|
27
|
+
if not isinstance(req, dict):
|
|
28
|
+
return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "invalid request"}}
|
|
29
|
+
mid, method, params = req.get("id"), req.get("method"), req.get("params") or {}
|
|
30
|
+
if mid is None: # 通知(notifications/initialized 等)不回
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
if method == "initialize":
|
|
34
|
+
res = {"protocolVersion": params.get("protocolVersion") or PROTO, "capabilities": {"tools": {}},
|
|
35
|
+
"serverInfo": {"name": "galbook", "version": VERSION},
|
|
36
|
+
"instructions": "Play visual novels like a human: galbook_help first, set galbook_persona, "
|
|
37
|
+
"then galbook_new / galbook_next / galbook_choose (always give a reason)."}
|
|
38
|
+
elif method == "ping":
|
|
39
|
+
res = {}
|
|
40
|
+
elif method == "tools/list":
|
|
41
|
+
res = {"tools": mcp_tools()}
|
|
42
|
+
elif method == "tools/call":
|
|
43
|
+
args = dict(params.get("arguments") or {})
|
|
44
|
+
try:
|
|
45
|
+
if force_agent:
|
|
46
|
+
if args.get("agent") not in (None, "", force_agent):
|
|
47
|
+
raise ValueError("这个令牌只能用名字 %s / this token can only play as %s" % (force_agent, force_agent))
|
|
48
|
+
args["agent"] = force_agent
|
|
49
|
+
text = call_tool(lib, params.get("name"), args, book, agent, lang)
|
|
50
|
+
res = {"content": [{"type": "text", "text": text}], "isError": False}
|
|
51
|
+
except (ValueError, KeyError) as e:
|
|
52
|
+
res = {"content": [{"type": "text", "text": msg(lang, str(e))}], "isError": True}
|
|
53
|
+
else:
|
|
54
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": "method not found: %s" % method}}
|
|
55
|
+
return {"jsonrpc": "2.0", "id": mid, "result": res}
|
|
56
|
+
except Exception as e: # noqa
|
|
57
|
+
traceback.print_exc(file=sys.stderr)
|
|
58
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32603, "message": "%s: %s" % (type(e).__name__, e)}}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def serve(lib, book=None, agent=None, lang="zh", fin=None, fout=None):
|
|
62
|
+
fin = fin or sys.stdin
|
|
63
|
+
fout = fout or sys.stdout
|
|
64
|
+
|
|
65
|
+
def send(obj):
|
|
66
|
+
fout.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
67
|
+
fout.flush()
|
|
68
|
+
|
|
69
|
+
for line in fin:
|
|
70
|
+
line = line.strip()
|
|
71
|
+
if not line:
|
|
72
|
+
continue
|
|
73
|
+
try:
|
|
74
|
+
req = json.loads(line)
|
|
75
|
+
except ValueError:
|
|
76
|
+
send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
|
|
77
|
+
continue
|
|
78
|
+
r = handle(lib, req, book, agent, lang)
|
|
79
|
+
if r is not None:
|
|
80
|
+
send(r)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv=None):
|
|
84
|
+
ap = argparse.ArgumentParser(prog="galbook mcp")
|
|
85
|
+
ap.add_argument("--books", default="books")
|
|
86
|
+
ap.add_argument("--data", default="play-data")
|
|
87
|
+
ap.add_argument("--book")
|
|
88
|
+
ap.add_argument("--agent")
|
|
89
|
+
ap.add_argument("--lang", default="zh", choices=("zh", "en"))
|
|
90
|
+
ap.add_argument("--no-demo", action="store_true", help="books 为空时不自动装演示书(同 GALBOOK_NO_DEMO=1)")
|
|
91
|
+
a = ap.parse_args(argv)
|
|
92
|
+
if a.no_demo:
|
|
93
|
+
os.environ["GALBOOK_NO_DEMO"] = "1"
|
|
94
|
+
for f in (sys.stdin, sys.stdout): # Windows 管道默认不是 UTF-8,MCP 要求 UTF-8
|
|
95
|
+
if hasattr(f, "reconfigure"):
|
|
96
|
+
f.reconfigure(encoding="utf-8")
|
|
97
|
+
serve(Library(a.books, a.data), a.book, a.agent, a.lang)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
sys.exit(main())
|