galbook 1.2.4 → 1.2.5
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.en.md +114 -114
- package/README.md +133 -133
- package/dsh-plugin/README.md +49 -49
- package/dsh-plugin/install.py +1 -1
- package/galbook/__init__.py +1 -1
- package/galbook/cli.py +31 -29
- package/galbook/imaging.py +121 -0
- package/galbook/mcp_server.py +8 -3
- package/galbook/play.py +59 -5
- package/galbook/tools.py +34 -7
- package/galbook/web.py +88 -28
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +58 -58
package/galbook/cli.py
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
|
-
"""命令行里玩(不用起服务):
|
|
2
|
-
python3 -m galbook play <books目录> <存档目录> <书> <agent> <操作> [k=v ...]
|
|
3
|
-
例:python3 -m galbook play books play-data lighthouse alice next n=30"""
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
|
-
from .play import Library
|
|
7
|
-
from .web import render_text
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def main(argv=None):
|
|
11
|
-
a = list(argv if argv is not None else sys.argv[1:])
|
|
12
|
-
if len(a) < 5:
|
|
13
|
-
print(__doc__)
|
|
14
|
-
return 2
|
|
15
|
-
books, data, book, agent, do = a[:5]
|
|
16
|
-
kw = dict(x.split("=", 1) for x in a[5:])
|
|
17
|
-
if kw.pop("skip", "") in ("1", "true"):
|
|
18
|
-
kw["skip_read"] = True
|
|
19
|
-
img = kw.pop("img", "") in ("1", "true")
|
|
20
|
-
lang = kw.get("lang") if do == "new" else kw.pop("lang", None)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
"""命令行里玩(不用起服务):
|
|
2
|
+
python3 -m galbook play <books目录> <存档目录> <书> <agent> <操作> [k=v ...]
|
|
3
|
+
例:python3 -m galbook play books play-data lighthouse alice next n=30"""
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .play import Library
|
|
7
|
+
from .web import render_text, want_vis
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv=None):
|
|
11
|
+
a = list(argv if argv is not None else sys.argv[1:])
|
|
12
|
+
if len(a) < 5:
|
|
13
|
+
print(__doc__)
|
|
14
|
+
return 2
|
|
15
|
+
books, data, book, agent, do = a[:5]
|
|
16
|
+
kw = dict(x.split("=", 1) for x in a[5:])
|
|
17
|
+
if kw.pop("skip", "") in ("1", "true"):
|
|
18
|
+
kw["skip_read"] = True
|
|
19
|
+
img = kw.pop("img", "") in ("1", "true")
|
|
20
|
+
lang = kw.get("lang") if do == "new" else kw.pop("lang", None)
|
|
21
|
+
vis = kw.pop("vis", None)
|
|
22
|
+
lib = Library(books, data)
|
|
23
|
+
vis = want_vis(lib, book, vis)
|
|
24
|
+
r = lib.act(book, agent, do, **kw)
|
|
25
|
+
lang = lang or ((r.get("opts") or (lib.store.peek_run(book, agent) or {}).get("opts") or {}).get("lang")) or "zh"
|
|
26
|
+
sys.stdout.write(render_text(r, do, img, book, agent, lang, vis=vis))
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
sys.exit(main())
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""把书里的图变成能直接塞给多模态模型的格式(只用标准库)。
|
|
2
|
+
|
|
3
|
+
load_for_model(path, max_side=1280) -> (mime, bytes) 或 (None, 原因)
|
|
4
|
+
PNG / JPEG / GIF / WebP 原样给;BMP(QLIE 等老引擎常见)转成 PNG,太大时按整数倍缩小;
|
|
5
|
+
SVG 和其他格式不转(模型多半不认),返回原因,调用方改给路径。"""
|
|
6
|
+
import struct
|
|
7
|
+
import zlib
|
|
8
|
+
|
|
9
|
+
PASS = ((b"\x89PNG\r\n\x1a\n", "image/png"), (b"\xff\xd8\xff", "image/jpeg"), (b"GIF87a", "image/gif"),
|
|
10
|
+
(b"GIF89a", "image/gif"))
|
|
11
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sniff(head):
|
|
15
|
+
for magic, mime in PASS:
|
|
16
|
+
if head.startswith(magic):
|
|
17
|
+
return mime
|
|
18
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
19
|
+
return "image/webp"
|
|
20
|
+
if head[:2] == b"BM":
|
|
21
|
+
return "image/bmp"
|
|
22
|
+
if b"<svg" in head[:512].lower():
|
|
23
|
+
return "image/svg+xml"
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_for_model(path, max_side=1280):
|
|
28
|
+
try:
|
|
29
|
+
with open(path, "rb") as f:
|
|
30
|
+
data = f.read(MAX_BYTES + 1)
|
|
31
|
+
except OSError as e:
|
|
32
|
+
return None, "读不到图 / cannot read: %s" % e.__class__.__name__
|
|
33
|
+
if len(data) > MAX_BYTES:
|
|
34
|
+
return None, "图太大(>8MB)/ too large"
|
|
35
|
+
mime = sniff(data[:600])
|
|
36
|
+
if mime in ("image/png", "image/jpeg", "image/gif", "image/webp"):
|
|
37
|
+
return mime, data
|
|
38
|
+
if mime == "image/bmp":
|
|
39
|
+
try:
|
|
40
|
+
return "image/png", bmp_to_png(data, max_side)
|
|
41
|
+
except ValueError as e:
|
|
42
|
+
return None, "BMP 转不了 / unsupported BMP: %s" % e
|
|
43
|
+
if mime == "image/svg+xml":
|
|
44
|
+
return None, "SVG 不能直接当图片发 / SVG is not sent as an image"
|
|
45
|
+
return None, "不认识的图片格式 / unknown image format"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def bmp_to_png(data, max_side=1280):
|
|
49
|
+
"""未压缩 BMP(1/4/8 位调色板、16/24/32 位)→ PNG(RGB)。"""
|
|
50
|
+
if data[:2] != b"BM" or len(data) < 54:
|
|
51
|
+
raise ValueError("not a BMP")
|
|
52
|
+
off = struct.unpack_from("<I", data, 10)[0]
|
|
53
|
+
hsize = struct.unpack_from("<I", data, 14)[0]
|
|
54
|
+
if hsize == 12:
|
|
55
|
+
w, h, _, bpp = struct.unpack_from("<HHHH", data, 18)
|
|
56
|
+
comp, ncol, palsz = 0, 0, 3
|
|
57
|
+
else:
|
|
58
|
+
w, h, _, bpp, comp = struct.unpack_from("<iiHHI", data, 18)
|
|
59
|
+
ncol = struct.unpack_from("<I", data, 46)[0] if hsize >= 40 else 0
|
|
60
|
+
palsz = 4
|
|
61
|
+
if comp not in (0, 3) or (comp == 3 and bpp not in (16, 32)):
|
|
62
|
+
raise ValueError("compressed (%d)" % comp)
|
|
63
|
+
top_down = h < 0
|
|
64
|
+
h = abs(h)
|
|
65
|
+
if not (0 < w <= 10000 and 0 < h <= 10000):
|
|
66
|
+
raise ValueError("bad size")
|
|
67
|
+
stride = ((w * bpp + 31) // 32) * 4
|
|
68
|
+
if off + stride * h > len(data):
|
|
69
|
+
raise ValueError("truncated")
|
|
70
|
+
pal = None
|
|
71
|
+
if bpp <= 8:
|
|
72
|
+
n = ncol or (1 << bpp)
|
|
73
|
+
p0 = 14 + hsize
|
|
74
|
+
pal = [data[p0 + i * palsz:p0 + i * palsz + 3][::-1] for i in range(n)] # BGR → RGB
|
|
75
|
+
pal += [b"\0\0\0"] * (256 - len(pal))
|
|
76
|
+
step = 1
|
|
77
|
+
while max(w, h) // step > max_side:
|
|
78
|
+
step += 1
|
|
79
|
+
ow, oh = (w + step - 1) // step, (h + step - 1) // step
|
|
80
|
+
rows = []
|
|
81
|
+
for oy in range(oh):
|
|
82
|
+
y = oy * step
|
|
83
|
+
src = y if top_down else h - 1 - y
|
|
84
|
+
row = data[off + src * stride: off + src * stride + stride]
|
|
85
|
+
if bpp == 24:
|
|
86
|
+
px = bytearray(w * 3)
|
|
87
|
+
px[0::3], px[1::3], px[2::3] = row[2:w * 3:3], row[1:w * 3:3], row[0:w * 3:3]
|
|
88
|
+
elif bpp == 32:
|
|
89
|
+
px = bytearray(w * 3)
|
|
90
|
+
px[0::3], px[1::3], px[2::3] = row[2:w * 4:4], row[1:w * 4:4], row[0:w * 4:4]
|
|
91
|
+
elif bpp == 16:
|
|
92
|
+
px = bytearray()
|
|
93
|
+
for x in range(w):
|
|
94
|
+
v = row[2 * x] | (row[2 * x + 1] << 8)
|
|
95
|
+
px += bytes(((v >> 10 & 31) * 255 // 31, (v >> 5 & 31) * 255 // 31, (v & 31) * 255 // 31))
|
|
96
|
+
elif bpp == 8:
|
|
97
|
+
px = b"".join(pal[b] for b in row[:w])
|
|
98
|
+
elif bpp in (4, 1):
|
|
99
|
+
per = 8 // bpp
|
|
100
|
+
mask = (1 << bpp) - 1
|
|
101
|
+
idx = [(row[x // per] >> (8 - bpp * (x % per + 1))) & mask for x in range(w)]
|
|
102
|
+
px = b"".join(pal[i] for i in idx)
|
|
103
|
+
else:
|
|
104
|
+
raise ValueError("%d bpp" % bpp)
|
|
105
|
+
if step > 1:
|
|
106
|
+
px = b"".join(bytes(px[x * 3:x * 3 + 3]) for x in range(0, w, step))
|
|
107
|
+
rows.append(b"\0" + bytes(px))
|
|
108
|
+
return _png(ow, oh, b"".join(rows))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _png(w, h, raw):
|
|
112
|
+
def chunk(t, d):
|
|
113
|
+
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
|
|
114
|
+
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
|
115
|
+
+ chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def png_size(data):
|
|
119
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
120
|
+
return struct.unpack(">II", data[16:24])
|
|
121
|
+
return None
|
package/galbook/mcp_server.py
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
多个 agent 可以各起一个 MCP 进程(不同 --agent),共用同一个 --data 目录;存储层有文件锁,互不干扰。
|
|
9
9
|
也可以不起进程:python3 -m galbook serve 自带 Streamable HTTP 的 MCP 端点 POST /mcp?book=…&agent=…(见 server.py)。"""
|
|
10
10
|
import argparse
|
|
11
|
+
import base64
|
|
11
12
|
import os
|
|
12
13
|
import json
|
|
13
14
|
import sys
|
|
@@ -15,7 +16,7 @@ import traceback
|
|
|
15
16
|
|
|
16
17
|
from . import VERSION
|
|
17
18
|
from .play import Library
|
|
18
|
-
from .tools import mcp_tools, call_tool
|
|
19
|
+
from .tools import mcp_tools, call_tool, call_tool_rich
|
|
19
20
|
from .i18n import msg
|
|
20
21
|
|
|
21
22
|
PROTO = "2024-11-05"
|
|
@@ -46,8 +47,12 @@ def handle(lib, req, book=None, agent=None, lang="zh", force_agent=None):
|
|
|
46
47
|
if args.get("agent") not in (None, "", force_agent):
|
|
47
48
|
raise ValueError("这个令牌只能用名字 %s / this token can only play as %s" % (force_agent, force_agent))
|
|
48
49
|
args["agent"] = force_agent
|
|
49
|
-
text =
|
|
50
|
-
|
|
50
|
+
text, images = call_tool_rich(lib, params.get("name"), args, book, agent, lang)
|
|
51
|
+
content = [{"type": "text", "text": text}]
|
|
52
|
+
for lb, mime, data in images: # 图和文在同一条消息里(MCP image content)
|
|
53
|
+
content.append({"type": "text", "text": "〔图〕" + lb})
|
|
54
|
+
content.append({"type": "image", "data": base64.b64encode(data).decode("ascii"), "mimeType": mime})
|
|
55
|
+
res = {"content": content, "isError": False}
|
|
51
56
|
except (ValueError, KeyError) as e:
|
|
52
57
|
res = {"content": [{"type": "text", "text": msg(lang, str(e))}], "isError": True}
|
|
53
58
|
else:
|
package/galbook/play.py
CHANGED
|
@@ -11,8 +11,8 @@ from .ir import load_story, StoryError
|
|
|
11
11
|
from .importers import legacy
|
|
12
12
|
from .store import Store, StoreError
|
|
13
13
|
|
|
14
|
-
READONLY = ("status", "saves", "log", "profile", "gallery")
|
|
15
|
-
ACTIONS = ("new", "next", "choose", "save", "load", "saves", "rewind", "log", "status", "profile", "persona", "gallery", "click")
|
|
14
|
+
READONLY = ("status", "saves", "log", "profile", "gallery", "look")
|
|
15
|
+
ACTIONS = ("new", "next", "choose", "save", "load", "saves", "rewind", "log", "status", "profile", "persona", "gallery", "click", "look")
|
|
16
16
|
SLUG_OK = lambda s: bool(s) and "/" not in s and "\\" not in s and not s.startswith(".")
|
|
17
17
|
|
|
18
18
|
|
|
@@ -90,7 +90,7 @@ class Library(object):
|
|
|
90
90
|
st = engine.new_state(story, h=h, mode=mode, hints=hints, lang=lang or S.profile.get("lang") or "zh")
|
|
91
91
|
S.set_run(st)
|
|
92
92
|
S.profile["stats"]["games"] = S.profile["stats"].get("games", 0) + 1
|
|
93
|
-
r =
|
|
93
|
+
r = self._advance_clicks(story, S, st, 0, False)
|
|
94
94
|
S.profile["stats"]["lines"] = S.profile["stats"].get("lines", 0) + len(r["lines"])
|
|
95
95
|
self._decorate(story, S, st, r)
|
|
96
96
|
return {"ok": True, "msg": "新游戏开始(第 %d 周目)" % S.profile["stats"]["games"], "state": r,
|
|
@@ -105,7 +105,9 @@ class Library(object):
|
|
|
105
105
|
n = max(1, min(int(n or 20), 200))
|
|
106
106
|
before_end = st.get("ended")
|
|
107
107
|
st.pop("_click", None) # 上一批的「可点」只在那一批有效(原作的点击也只在那一句上)
|
|
108
|
+
vis_before = engine._vis_brief(st["vis"]) # 读这一批之前画面是什么样(文本接口据此只在变化时报〔画面〕)
|
|
108
109
|
r = self._advance_clicks(story, S, st, n, bool(skip_read))
|
|
110
|
+
r["vis_before"] = vis_before
|
|
109
111
|
S.touch_run()
|
|
110
112
|
S.profile["stats"]["lines"] = S.profile["stats"].get("lines", 0) + len(r["lines"])
|
|
111
113
|
if r.get("choice") and not (st.get("_autosaved_at") == [st["scene"], st["pc"], st["turn"]]):
|
|
@@ -113,7 +115,10 @@ class Library(object):
|
|
|
113
115
|
S.save_slot("auto", self._meta(story, st, "到选项自动存"))
|
|
114
116
|
if r.get("cg_seen"):
|
|
115
117
|
got = S.profile.setdefault("cg", [])
|
|
116
|
-
|
|
118
|
+
new_cg = [c for c in dict.fromkeys(r["cg_seen"]) if c not in got]
|
|
119
|
+
if new_cg:
|
|
120
|
+
r["cg_new"] = new_cg # 这个 agent 第一次看到的 CG(img=1 时只给这些附图)
|
|
121
|
+
got.extend(new_cg)
|
|
117
122
|
self._decorate(story, S, st, r)
|
|
118
123
|
if st.get("ended") and not before_end:
|
|
119
124
|
e = st["ended"]
|
|
@@ -200,6 +205,33 @@ class Library(object):
|
|
|
200
205
|
S.profile["stats"]["clicks"] = S.profile["stats"].get("clicks", 0) + 1
|
|
201
206
|
return {"ok": True, "msg": "点了一下。", "clicked": True, "where": engine.where(story, new)}
|
|
202
207
|
|
|
208
|
+
def _a_look(self, story, S, bg=None, **_):
|
|
209
|
+
"""看一眼现在的画面(只读):有 CG 就是 CG;否则是在场的立绘(每人一张,带位置和表情码);
|
|
210
|
+
没人在场或 bg=1 时加上背景。返回要看的图的列表,文本接口给地址,MCP 直接给图。"""
|
|
211
|
+
st = self._need_run(S)
|
|
212
|
+
v = engine._vis_brief(st["vis"])
|
|
213
|
+
return {"ok": True, "vis": v, "look": screen_picks(v, with_bg=str(bg).lower() in ("1", "true", "yes")),
|
|
214
|
+
"where": engine.where(story, st)}
|
|
215
|
+
|
|
216
|
+
def image_path(self, slug, rel):
|
|
217
|
+
"""书里的图的绝对路径;只认 assets/ 下面、真实存在、不越界的文件,否则 None。"""
|
|
218
|
+
if not rel or not SLUG_OK(slug) or not str(rel).startswith("assets/"):
|
|
219
|
+
return None
|
|
220
|
+
base = os.path.realpath(os.path.join(self.books_dir, slug, "assets"))
|
|
221
|
+
p = os.path.realpath(os.path.join(self.books_dir, slug, rel))
|
|
222
|
+
if not p.startswith(base + os.sep) or not os.path.isfile(p):
|
|
223
|
+
return None
|
|
224
|
+
return p
|
|
225
|
+
|
|
226
|
+
def has_images(self, slug):
|
|
227
|
+
"""书里有没有图(决定〔画面〕行默认开不开)。"""
|
|
228
|
+
d = os.path.join(self.books_dir, slug, "assets")
|
|
229
|
+
try:
|
|
230
|
+
with os.scandir(d) as it:
|
|
231
|
+
return any(True for _ in it)
|
|
232
|
+
except OSError:
|
|
233
|
+
return False
|
|
234
|
+
|
|
203
235
|
def _a_choose(self, story, S, i=None, reason="", **_):
|
|
204
236
|
st = self._need_run(S)
|
|
205
237
|
st.pop("_click", None)
|
|
@@ -244,7 +276,7 @@ class Library(object):
|
|
|
244
276
|
st["pending"]["shown_at"] = time.time()
|
|
245
277
|
S.set_run(st)
|
|
246
278
|
S.profile["stats"]["loads"] = S.profile["stats"].get("loads", 0) + 1
|
|
247
|
-
r =
|
|
279
|
+
r = self._advance_clicks(story, S, st, 0, False)
|
|
248
280
|
self._decorate(story, S, st, r)
|
|
249
281
|
return {"ok": True, "msg": "读档 %s" % slot, "meta": d.get("meta"), "state": r, "opts": st.get("opts"),
|
|
250
282
|
"recent": [dict(who=x["who"], text=x["text"]) for x in st.get("log", [])[-5:]]}
|
|
@@ -344,6 +376,28 @@ class Library(object):
|
|
|
344
376
|
"pending": bool(st.get("pending"))}
|
|
345
377
|
|
|
346
378
|
|
|
379
|
+
def face_code(sp):
|
|
380
|
+
"""立绘的表情码:文件名去掉角色名和扩展名(例 雫_中_02_01_01.png → 中_02_01_01)。
|
|
381
|
+
不是表情的名字,但同一个码就是同一张脸;配 look 看一眼就知道是什么表情。"""
|
|
382
|
+
b = os.path.splitext(os.path.basename(str(sp.get("img") or "")))[0]
|
|
383
|
+
nm = sp.get("name") or ""
|
|
384
|
+
if nm and b.startswith(nm):
|
|
385
|
+
b = b[len(nm):].lstrip("_- .")
|
|
386
|
+
return b
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def screen_picks(v, with_bg=False):
|
|
390
|
+
"""一屏「值得看」的图:[(说明, 路径)]。CG 盖住立绘,所以有 CG 只给 CG。"""
|
|
391
|
+
v = v or {}
|
|
392
|
+
if v.get("cg"):
|
|
393
|
+
return [("CG", v["cg"])]
|
|
394
|
+
out = [("%s:%s(%s)" % (s.get("name", "?"), s.get("pos") or "center", face_code(s)), s["img"])
|
|
395
|
+
for s in v.get("sprites") or [] if s.get("img")]
|
|
396
|
+
if v.get("bg") and (with_bg or not out):
|
|
397
|
+
out.insert(0, ("bg", v["bg"]))
|
|
398
|
+
return out
|
|
399
|
+
|
|
400
|
+
|
|
347
401
|
def has_clicks(story):
|
|
348
402
|
"""剧本里有没有可点的句子(say 上带 click 字段);结果缓存在 story 上。"""
|
|
349
403
|
v = getattr(story, "_gb_has_clicks", None)
|
package/galbook/tools.py
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import json
|
|
7
7
|
|
|
8
8
|
from .play import Library # noqa: F401 (类型提示用)
|
|
9
|
-
from .web import render_text, restyle, HELP
|
|
9
|
+
from .web import render_text, restyle, HELP, want_vis, image_picks
|
|
10
10
|
from .i18n import msg
|
|
11
11
|
from . import VERSION
|
|
12
12
|
|
|
@@ -25,7 +25,8 @@ TOOLS = [
|
|
|
25
25
|
("galbook_next", "next", "Read the next n lines; stops at a choice or ending. / 往下读", {
|
|
26
26
|
"n": {"type": "integer", "minimum": 1, "maximum": 200, "description": "lines to read (default 20)"},
|
|
27
27
|
"skip_read": {"type": "boolean", "description": "fast-forward lines you have already read"},
|
|
28
|
-
"img": {"type": "boolean", "description": "
|
|
28
|
+
"img": {"type": "boolean", "description": "attach images, but only for a CG you see for the first time and the ending screen / 只在第一次见到的 CG 和结局那屏附图"},
|
|
29
|
+
"vis": {"type": "boolean", "description": "one [screen] line when the picture changes (default: on if the book has images)"}}),
|
|
29
30
|
("galbook_choose", "choose", "Pick an option at a choice and say why. / 选选项并写理由", {
|
|
30
31
|
"i": {"type": ["integer", "string"], "description": "option number from 1, or \"random\""},
|
|
31
32
|
"reason": {"type": "string", "description": "one sentence: why your persona picks this"}}),
|
|
@@ -43,6 +44,8 @@ TOOLS = [
|
|
|
43
44
|
("galbook_gallery", "gallery", "Collection progress: endings, routes, CG, read ratio. / 收集进度", {}),
|
|
44
45
|
("galbook_profile", "profile", "Stats and human-likeness numbers. / 记录", {}),
|
|
45
46
|
("galbook_click", "click", "Click the screen. / 点一下画面", {}),
|
|
47
|
+
("galbook_look", "look", "Look at the current screen: the CG, or each character on stage (position + face code), as images. / 看一眼现在的画面", {
|
|
48
|
+
"bg": {"type": "boolean", "description": "also include the background"}}),
|
|
46
49
|
]
|
|
47
50
|
|
|
48
51
|
|
|
@@ -63,19 +66,29 @@ def openai_tools():
|
|
|
63
66
|
|
|
64
67
|
def call_tool(lib, name, args, default_book=None, default_agent=None, lang="zh", style="tool", lang_override=None):
|
|
65
68
|
"""执行一个工具,返回纯文本。style:提示里的下一步写成 tool(默认)/ cli / http 形式。"""
|
|
69
|
+
return call_tool_rich(lib, name, args, default_book, default_agent, lang, style, lang_override, with_images=False)[0]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def call_tool_rich(lib, name, args, default_book=None, default_agent=None, lang="zh", style="tool", lang_override=None,
|
|
73
|
+
with_images=True, max_side=1280):
|
|
74
|
+
"""同 call_tool,另外返回要附的图:[(说明, mime, bytes)](MCP 用 image content 直接发)。
|
|
75
|
+
只在 look、或 img=1 时「第一次见到的 CG / 结局那屏」才有图;转不了的图(SVG 等)在文本里给路径。"""
|
|
66
76
|
spec = next((x for x in TOOLS if x[0] == name), None)
|
|
67
77
|
if spec is None:
|
|
68
78
|
raise ValueError("unknown tool %s" % name)
|
|
69
79
|
args = dict(args or {})
|
|
70
80
|
if name == "galbook_books":
|
|
71
|
-
return json.dumps(lib.books(), ensure_ascii=False, indent=1)
|
|
81
|
+
return json.dumps(lib.books(), ensure_ascii=False, indent=1), []
|
|
72
82
|
if name == "galbook_help":
|
|
73
|
-
return HELP.get(lang, HELP["zh"]) % VERSION
|
|
83
|
+
return HELP.get(lang, HELP["zh"]) % VERSION, []
|
|
74
84
|
book = args.pop("book", None) or default_book
|
|
75
85
|
agent = args.pop("agent", None) or default_agent
|
|
76
86
|
if not book or not agent:
|
|
77
87
|
raise ValueError("需要 book 和 agent / book and agent are required")
|
|
78
|
-
img =
|
|
88
|
+
img = args.pop("img", False) in (True, 1, "1", "true")
|
|
89
|
+
vis = want_vis(lib, book, args.pop("vis", None))
|
|
90
|
+
if "bg" in args:
|
|
91
|
+
args["bg"] = "1" if args["bg"] in (True, "1", "true", 1) else "0"
|
|
79
92
|
if "hints" in args:
|
|
80
93
|
args["hints"] = "1" if args["hints"] in (True, "1", "true", 1) else "0"
|
|
81
94
|
if args.pop("skip_read", False):
|
|
@@ -84,7 +97,21 @@ def call_tool(lib, name, args, default_book=None, default_agent=None, lang="zh",
|
|
|
84
97
|
r = lib.act(book, agent, action, **args)
|
|
85
98
|
run = lib.store.peek_run(book, agent) or {}
|
|
86
99
|
lg = lang_override or ((r.get("opts") or run.get("opts") or {}).get("lang")) or lang
|
|
87
|
-
|
|
100
|
+
text = restyle(render_text(r, action, img, book, agent, lg, vis=vis), book, agent, style)
|
|
101
|
+
images = []
|
|
102
|
+
if with_images and (img or action == "look"):
|
|
103
|
+
from .imaging import load_for_model
|
|
104
|
+
notes = []
|
|
105
|
+
for lb, rel in image_picks(r, action):
|
|
106
|
+
p = lib.image_path(book, rel)
|
|
107
|
+
mime, data = load_for_model(p, max_side) if p else (None, "找不到文件 / file not found")
|
|
108
|
+
if mime:
|
|
109
|
+
images.append((lb, mime, data))
|
|
110
|
+
else:
|
|
111
|
+
notes.append("(%s:%s → %s)" % (lb, data, rel))
|
|
112
|
+
if notes:
|
|
113
|
+
text += "\n".join(notes) + "\n"
|
|
114
|
+
return text, images
|
|
88
115
|
|
|
89
116
|
|
|
90
117
|
def main(argv=None):
|
|
@@ -96,4 +123,4 @@ def main(argv=None):
|
|
|
96
123
|
return 0
|
|
97
124
|
|
|
98
125
|
|
|
99
|
-
__all__ = ["TOOLS", "mcp_tools", "openai_tools", "call_tool", "msg"]
|
|
126
|
+
__all__ = ["TOOLS", "mcp_tools", "openai_tools", "call_tool", "call_tool_rich", "msg"]
|
package/galbook/web.py
CHANGED
|
@@ -12,17 +12,17 @@ import json
|
|
|
12
12
|
import os
|
|
13
13
|
import re
|
|
14
14
|
import traceback
|
|
15
|
-
from urllib.parse import parse_qs, unquote
|
|
15
|
+
from urllib.parse import parse_qs, unquote, quote
|
|
16
16
|
|
|
17
17
|
from . import engine, VERSION
|
|
18
18
|
from .i18n import t, msg
|
|
19
|
-
from .play import Library, ACTIONS
|
|
19
|
+
from .play import Library, ACTIONS, face_code, screen_picks
|
|
20
20
|
from .ir import StoryError
|
|
21
21
|
from .store import StoreError
|
|
22
22
|
|
|
23
23
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
24
24
|
ERRS = (engine.PlayError, StoryError, StoreError, ValueError)
|
|
25
|
-
KW = ("n", "i", "steps", "slot", "note", "reason", "h", "mode", "hints", "lang", "text")
|
|
25
|
+
KW = ("n", "i", "steps", "slot", "note", "reason", "h", "mode", "hints", "lang", "text", "bg")
|
|
26
26
|
|
|
27
27
|
HELP = {"zh": """galbook —— 像人一样玩 galgame(%s)
|
|
28
28
|
每个 agent 用自己的名字玩,各自的进度、存档、通关记录互不影响,可以很多个同时玩。
|
|
@@ -35,7 +35,10 @@ HELP = {"zh": """galbook —— 像人一样玩 galgame(%s)
|
|
|
35
35
|
&mode=ironman 铁人模式:不能存读档和回溯,选了就认
|
|
36
36
|
&hints=1 选项旁标「选过」「新内容」
|
|
37
37
|
&lang=en 框架文字用英文
|
|
38
|
-
/play/t/<书>/<你的名字>?do=next&n=20 往下读 n 句(最多 200);&skip=1
|
|
38
|
+
/play/t/<书>/<你的名字>?do=next&n=20 往下读 n 句(最多 200);&skip=1 跳过读过的
|
|
39
|
+
画面一变就多一行〔画面〕bg=… | sp=名字:位置(表情码)(书里有图时默认开,&vis=0 关);路径拼成 /b/<书>/<路径> 就是图
|
|
40
|
+
&img=1 只在「第一次见到的 CG」和「结局那一屏」附图(MCP 直接给图,这里给地址)
|
|
41
|
+
/play/t/<书>/<你的名字>?do=look 看一眼现在的画面:CG,或在场每个人的立绘(&bg=1 连背景)
|
|
39
42
|
/play/t/<书>/<你的名字>?do=choose&i=2&reason=… 选第 2 个,reason 写一句你为什么这么选(i=random 随机)
|
|
40
43
|
/play/t/<书>/<你的名字>?do=save&slot=3¬e=… 存档(quick 或 1-99;到选项时会自动存 auto)
|
|
41
44
|
/play/t/<书>/<你的名字>?do=load&slot=3 读档(auto / quick / 1-99)
|
|
@@ -61,7 +64,11 @@ Plain-text API (GET, one action per request):
|
|
|
61
64
|
&mode=ironman no saving/loading/rewinding — choices are final
|
|
62
65
|
&hints=1 mark options "picked before" / "new"
|
|
63
66
|
&lang=en English UI text
|
|
64
|
-
/play/t/<book>/<name>?do=next&n=20 read n lines (max 200); &skip=1 skip read lines
|
|
67
|
+
/play/t/<book>/<name>?do=next&n=20 read n lines (max 200); &skip=1 skip read lines
|
|
68
|
+
a [screen] bg=… | sp=name:pos(face code) line whenever the picture changes (on by default if the book has images; &vis=0 off);
|
|
69
|
+
an image URL is /b/<book>/<path>
|
|
70
|
+
&img=1 attach images only for a CG you see for the first time and for the ending screen
|
|
71
|
+
/play/t/<book>/<name>?do=look look at the current screen: the CG, or each character on stage (&bg=1 adds the background)
|
|
65
72
|
/play/t/<book>/<name>?do=choose&i=2&reason=… pick option 2 and say why (i=random for random)
|
|
66
73
|
/play/t/<book>/<name>?do=save&slot=3¬e=… save (quick or 1-99; slot auto is written at every choice)
|
|
67
74
|
/play/t/<book>/<name>?do=load&slot=3 load (auto / quick / 1-99)
|
|
@@ -142,12 +149,13 @@ class App(object):
|
|
|
142
149
|
if do not in ACTIONS:
|
|
143
150
|
raise engine.PlayError("do 只能是:" + " ".join(ACTIONS))
|
|
144
151
|
img = str(q.get("img", "")) in ("1", "true")
|
|
152
|
+
vis = want_vis(self.lib, book, q.get("vis"))
|
|
145
153
|
kw = self._kw(q)
|
|
146
154
|
if do != "new":
|
|
147
155
|
kw.pop("lang", None)
|
|
148
156
|
r = self.lib.act(book, agent, do, **kw)
|
|
149
157
|
lg = lang or ((r.get("opts") or {}).get("lang")) or self._run_lang(book, agent) or self.default_lang
|
|
150
|
-
return self._text(200, render_text(r, do, img, book, agent, lg))
|
|
158
|
+
return self._text(200, render_text(r, do, img, book, agent, lg, vis=vis))
|
|
151
159
|
|
|
152
160
|
def _run_lang(self, book, agent):
|
|
153
161
|
try:
|
|
@@ -170,20 +178,58 @@ class App(object):
|
|
|
170
178
|
return 200, ct, f.read()
|
|
171
179
|
|
|
172
180
|
|
|
173
|
-
def
|
|
174
|
-
|
|
181
|
+
def want_vis(lib, book, v):
|
|
182
|
+
"""〔画面〕行开不开:显式 vis=1/0 优先;没说就看书里有没有图(没图的书不白占 token)。"""
|
|
183
|
+
if v not in (None, ""):
|
|
184
|
+
return str(v).lower() in ("1", "true", "yes", "on")
|
|
185
|
+
try:
|
|
186
|
+
return lib.has_images(book)
|
|
187
|
+
except Exception: # noqa
|
|
188
|
+
return False
|
|
175
189
|
|
|
176
190
|
|
|
177
|
-
def
|
|
178
|
-
|
|
191
|
+
def _vsig(v):
|
|
192
|
+
v = v or {}
|
|
179
193
|
if v.get("cg"):
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
194
|
+
return ("cg", v["cg"])
|
|
195
|
+
return ("bg", v.get("bg"), tuple((s.get("name"), s.get("img"), s.get("pos")) for s in v.get("sprites") or []))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def vis_line(v, lang="zh"):
|
|
199
|
+
"""〔画面〕bg=… | sp=雫:center(中_02_01_01),琥珀:right(…);有 CG 时只写 cg(立绘被盖住)。路径拼 /b/<书>/<路径> 就是图。"""
|
|
200
|
+
v = v or {}
|
|
201
|
+
hd = "[screen] " if lang == "en" else "〔画面〕"
|
|
202
|
+
if v.get("cg"):
|
|
203
|
+
return hd + "cg=" + v["cg"]
|
|
204
|
+
bits = []
|
|
205
|
+
if v.get("bg"):
|
|
206
|
+
bits.append("bg=" + v["bg"])
|
|
207
|
+
sp = [s for s in v.get("sprites") or [] if s.get("img") or s.get("name")]
|
|
184
208
|
if sp:
|
|
185
|
-
bits.append(
|
|
186
|
-
return
|
|
209
|
+
bits.append("sp=" + ",".join("%s:%s(%s)" % (s.get("name", "?"), s.get("pos") or "center", face_code(s)) for s in sp))
|
|
210
|
+
return hd + (" | ".join(bits) if bits else ("(empty)" if lang == "en" else "(空)"))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def img_url(book, path):
|
|
214
|
+
return "/b/%s/%s" % (quote(book, safe=""), quote(path, safe="/"))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def image_picks(r, do, cap=4):
|
|
218
|
+
"""这次该附哪些图:[(说明, 路径)]。do=look → 当前画面;img=1 时只在「第一次见到的 CG」和「结局那一屏」给。"""
|
|
219
|
+
if do == "look":
|
|
220
|
+
picks = list(r.get("look") or [])
|
|
221
|
+
else:
|
|
222
|
+
st = r.get("state") or {}
|
|
223
|
+
picks = [("CG(第一次)", c) for c in st.get("cg_new") or []]
|
|
224
|
+
e = st.get("ended") or {}
|
|
225
|
+
if e.get("kind") == "ending":
|
|
226
|
+
picks += [("结局:" + lb, p) for lb, p in screen_picks(st.get("vis"), with_bg=True)]
|
|
227
|
+
out, seen = [], set()
|
|
228
|
+
for lb, p in picks:
|
|
229
|
+
if p and p not in seen:
|
|
230
|
+
seen.add(p)
|
|
231
|
+
out.append((lb, p))
|
|
232
|
+
return out[:cap]
|
|
187
233
|
|
|
188
234
|
|
|
189
235
|
def _say(x, lang="zh"):
|
|
@@ -208,8 +254,11 @@ def restyle(text, book, agent, style):
|
|
|
208
254
|
return re.sub(r"(?<=[\s((])&(\w+=)", r"\1", text)
|
|
209
255
|
|
|
210
256
|
|
|
211
|
-
def render_text(r, do, img, book, agent, lang="zh"):
|
|
212
|
-
"""把一次操作的结果写成给 agent 看的纯文本(一句一行,最省 token)。
|
|
257
|
+
def render_text(r, do, img, book, agent, lang="zh", vis=None):
|
|
258
|
+
"""把一次操作的结果写成给 agent 看的纯文本(一句一行,最省 token)。
|
|
259
|
+
vis:画面变了才加一行〔画面〕(None = 跟 img 走,兼容旧调用);img:第一次见到的 CG / 结局那屏 / look 给图的地址。"""
|
|
260
|
+
if vis is None:
|
|
261
|
+
vis = bool(img)
|
|
213
262
|
L = []
|
|
214
263
|
base = "/play/t/%s/%s?do=" % (book, agent)
|
|
215
264
|
st = r.get("state")
|
|
@@ -222,6 +271,11 @@ def render_text(r, do, img, book, agent, lang="zh"):
|
|
|
222
271
|
L.append(t(lang, "you_chose", text=c["text"], i=c["i"], of=c.get("of") or "?"))
|
|
223
272
|
L.append(t(lang, "cont", base=base))
|
|
224
273
|
return "\n".join(L) + "\n"
|
|
274
|
+
if do == "look":
|
|
275
|
+
L.append(vis_line(r.get("vis"), lang))
|
|
276
|
+
for lb, p in image_picks(r, do):
|
|
277
|
+
L.append(("[image] %s %s" if lang == "en" else "〔图〕%s %s") % (lb, img_url(book, p)))
|
|
278
|
+
return "\n".join(L) + "\n"
|
|
225
279
|
if do == "persona":
|
|
226
280
|
p = r.get("persona") or ""
|
|
227
281
|
L.append(t(lang, "persona_now", p=p) if p else t(lang, "persona_none", base=base))
|
|
@@ -275,18 +329,24 @@ def render_text(r, do, img, book, agent, lang="zh"):
|
|
|
275
329
|
if st:
|
|
276
330
|
w = st["where"]
|
|
277
331
|
L.append(t(lang, "line_hdr", title=w.get("title", ""), turn=w.get("turn", 0)))
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if
|
|
281
|
-
L.append(
|
|
282
|
-
|
|
332
|
+
prev = st.get("vis_before")
|
|
333
|
+
if vis and not st["lines"] and (prev is None or _vsig(st.get("vis")) != _vsig(prev)):
|
|
334
|
+
if prev is not None or _vsig(st.get("vis")) != _vsig({}):
|
|
335
|
+
L.append(vis_line(st.get("vis"), lang))
|
|
336
|
+
prev = st.get("vis")
|
|
283
337
|
for ln in st["lines"]:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if
|
|
287
|
-
L.append(v)
|
|
288
|
-
|
|
338
|
+
v = ln.get("vis")
|
|
339
|
+
if vis and v is not None and (prev is None or _vsig(v) != _vsig(prev)):
|
|
340
|
+
if prev is not None or _vsig(v) != _vsig({}):
|
|
341
|
+
L.append(vis_line(v, lang))
|
|
342
|
+
prev = v
|
|
289
343
|
L.append(_say(ln, lang) + (" " + ln["mark"] if ln.get("mark") else ""))
|
|
344
|
+
if vis and st["lines"] and (st.get("ended") or st.get("choice")) and st.get("vis") is not None \
|
|
345
|
+
and _vsig(st["vis"]) != _vsig(prev):
|
|
346
|
+
L.append(vis_line(st["vis"], lang)) # 最后一句之后画面又变了(结局 CG 常这样)
|
|
347
|
+
if img:
|
|
348
|
+
for lb, p in image_picks(r, do):
|
|
349
|
+
L.append(("[image] %s %s" if lang == "en" else "〔图〕%s %s") % (lb, img_url(book, p)))
|
|
290
350
|
if st.get("skipped"):
|
|
291
351
|
L.append(t(lang, "skipped", n=st["skipped"]))
|
|
292
352
|
for e in st.get("events") or []:
|
package/locale/en.json
CHANGED