pasm-framework 0.3.0__py3-none-any.whl

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.
@@ -0,0 +1,97 @@
1
+ """warmth —— 回复温度 / 情感润色插件(零依赖,模板式)。
2
+
3
+ 把 ``emotion_system`` 的情绪与 persona 的语气"渲染"进回复,
4
+ 实现智能客服要的"有情感、有温度"。纯模板实现,不依赖 LLM,
5
+ 因此即使离线 / 未开 llm_responder 也能生效。
6
+
7
+ 挂在**收尾阶段** ``on_reply_final``(不是 ``on_reply``):润色对象是
8
+ "已经定稿的回复",无论它来自 LLM 还是模板兜底,都会且只会被润色一次。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import zlib
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from ..core import BasePlugin, Message, PluginContext
16
+
17
+
18
+ def _pick(seq: List[str], key: str) -> str:
19
+ """确定性选句。
20
+
21
+ 不用内置 ``hash()``:CPython 对 str 的 hash 带每进程随机盐
22
+ (PYTHONHASHSEED),同一句话在不同进程会选到不同措辞 —— 既让
23
+ 测试不可复现,也让线上表现随机。改用 ``zlib.crc32`` 取稳定值。
24
+ """
25
+ if not seq:
26
+ return ""
27
+ return seq[zlib.crc32(key.encode("utf-8")) % len(seq)]
28
+
29
+ # 负面情绪(mood<0)时的共情开场,按强度挑。
30
+ _EMPATHY_LOW = [
31
+ "我理解这让你不太舒服,",
32
+ "听起来你遇到了些困扰,别急,",
33
+ "感受到你的焦急了,",
34
+ ]
35
+ # 正面 / 中性时的温和开场。
36
+ _WARM_OPENERS = [
37
+ "很高兴为你服务,",
38
+ "好的,",
39
+ "没问题,",
40
+ ]
41
+ # 收尾柔和句(按 persona.tone 选)。
42
+ _CLOSERS = [
43
+ "如果还有不清楚的地方,随时告诉我哦~",
44
+ "希望这能帮到你,有任何问题我都在。",
45
+ "还有什么我可以帮你的吗?",
46
+ ]
47
+
48
+
49
+ class WarmthPlugin(BasePlugin):
50
+ """情绪驱动的回复润色。"""
51
+
52
+ name = "warmth"
53
+ version = "0.1.0"
54
+
55
+ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
56
+ super().__init__(config)
57
+ self._enabled = bool(self.config.get("enabled", True))
58
+ self._max_len = int(self.config.get("max_len", 600))
59
+ # 是否润色"能力产出的确定性文本"。默认 False —— 能力返回的可能是
60
+ # 精确字符串 / 链接 / 结构化内容,加语气词会破坏其语义(也会让
61
+ # 开发者的断言失效)。需要时显式打开。
62
+ self._polish_caps = bool(self.config.get("polish_capabilities", False))
63
+
64
+ @staticmethod
65
+ def _has_code_block(text: str) -> bool:
66
+ return "```" in text or text.lstrip().startswith("<")
67
+
68
+ def on_reply_final(self, ctx: PluginContext) -> None:
69
+ if not self._enabled:
70
+ return
71
+ msg: Message = ctx.message
72
+ reply = msg.reply
73
+ if not reply or len(reply) > self._max_len:
74
+ return
75
+ # 能力产出是"确定性结果",风格润色默认不碰它。
76
+ # 注意:护栏(safety)不走这个开关 —— 安全对所有路径生效。
77
+ if not self._polish_caps and str(
78
+ msg.meta.get("generated_by", "")
79
+ ).startswith("capability:"):
80
+ return
81
+ # 结构化输出(代码 / HTML)不做润色,避免破坏格式。
82
+ if self._has_code_block(reply):
83
+ return
84
+ mood = ctx.mood()
85
+ opener = ""
86
+ if mood < -0.15:
87
+ opener = _pick(_EMPATHY_LOW, msg.text)
88
+ elif mood > 0.15:
89
+ opener = _pick(_WARM_OPENERS, msg.text)
90
+ # 仅在未以标点/表情开头时加开场,避免",好的,..."这类堆叠。
91
+ tail_punct = ",。!?、~~"
92
+ if opener and reply[0] not in tail_punct:
93
+ reply = opener + reply
94
+ # 收尾:仅当原文没有明显问句结尾时补一句柔和收尾。
95
+ if not reply.rstrip().endswith(("?", "?", "吗", "呢")):
96
+ reply = reply.rstrip() + _pick(_CLOSERS, reply)
97
+ msg.reply = reply
@@ -0,0 +1,457 @@
1
+ """web_gateway —— 零依赖 HTTP 网关插件(iframe / REST / 外部链接)。
2
+
3
+ 让 pasm-framework 的 AI 应用"对外可接入":站点用一行 ``<iframe>`` 即可嵌入
4
+ 智能客服,或从任意语言(C#/Java/PHP/Node/Go…)用 REST 对接。
5
+ 完全用标准库 ``http.server``,不引入任何 Web 框架。
6
+
7
+ 接口
8
+ ----
9
+ · ``POST /api/chat`` ``{text, session_id?, user_id?, meta?}`` → ``{reply, session_id}``
10
+ · ``POST /api/chat/stream`` 同上入参,返回 **SSE**(``text/event-stream``):
11
+ ``{"type":"delta","text":…}`` /
12
+ ``{"type":"replace","text":…}`` /
13
+ ``{"type":"done",…}`` / ``{"type":"error",…}``
14
+ · ``POST /api/ingest`` ``{items:[{title,content,source?,tags?}]}`` → ``{added}``
15
+ · ``POST /api/sessions/reset`` ``{session_id}`` → ``{ok}``
16
+ · ``GET /healthz`` 健康 + 指标(来自 observability 插件)
17
+ · ``GET /api/plugins`` 已加载/已启用插件清单
18
+ · ``GET /api/summary`` 应用快照(能力、插件、指标)
19
+ · ``GET /api/kb/stats`` 资料库统计
20
+ · ``GET /`` 或 ``/widget`` 自包含的可嵌入聊天 Widget
21
+
22
+ 安全
23
+ ----
24
+ · ``token`` 配置后所有接口(除 ``/healthz``)需带
25
+ ``Authorization: Bearer <token>`` 或 ``X-Pasm-Token``;
26
+ · ``rate_limit`` 每 IP 每分钟请求上限(0 = 不限流);
27
+ · ``max_body`` 请求体字节上限(防超大 payload 打爆内存);
28
+ · ``allowed_origins`` CORS 白名单(``*`` 或具体来源)。
29
+
30
+ 默认 ``enabled=False``(需要端口);用户通过后端配置开启后调用 ``app.serve()``。
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import threading
36
+ import time
37
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
38
+ from typing import Any, Dict, List, Optional
39
+
40
+ from ..core import BasePlugin, PluginContext
41
+
42
+ # 模板里的 __TOKEN__ 会被替换为配置的令牌(未配置则留空 → 前端不带鉴权头)。
43
+ _WIDGET_HTML = """<!doctype html><html lang="zh"><head><meta charset="utf-8">
44
+ <meta name="viewport" content="width=device-width,initial-scale=1">
45
+ <title>智能客服</title><style>
46
+ *{box-sizing:border-box}body{margin:0;font-family:system-ui,"PingFang SC","Microsoft YaHei",sans-serif;background:#f5f7fb}
47
+ .box{display:flex;flex-direction:column;height:100vh;max-width:480px;margin:0 auto;border:1px solid #e3e8f0}
48
+ .hd{background:#3b82f6;color:#fff;padding:12px 16px;font-weight:600}
49
+ .log{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
50
+ .msg{padding:8px 12px;border-radius:12px;max-width:80%;line-height:1.5;white-space:pre-wrap}
51
+ .u{align-self:flex-end;background:#3b82f6;color:#fff}
52
+ .a{align-self:flex-start;background:#fff;border:1px solid #e3e8f0}
53
+ .in{display:flex;gap:8px;padding:10px;border-top:1px solid #e3e8f0}
54
+ .in input{flex:1;border:1px solid #d8dee9;border-radius:8px;padding:8px}
55
+ .in button{border:0;background:#3b82f6;color:#fff;border-radius:8px;padding:8px 14px;cursor:pointer}
56
+ </style></head><body><div class="box"><div class="hd">智能客服</div>
57
+ <div class="log" id="log"></div>
58
+ <div class="in"><input id="t" placeholder="请输入您的问题…" onkeydown="if(event.key==='Enter')send()">
59
+ <button onclick="send()">发送</button></div></div>
60
+ <script>
61
+ const SID='web-'+Math.random().toString(36).slice(2);
62
+ const TOKEN='__TOKEN__';
63
+ function LOG(){return document.getElementById('log')}
64
+ function scroll(){LOG().scrollTop=1e9}
65
+ function add(c,m){const d=document.createElement('div');d.className='msg '+c;d.textContent=m||'';
66
+ LOG().appendChild(d);scroll();return d}
67
+ async function send(){const t=document.getElementById('t');const v=t.value.trim();if(!v)return;
68
+ add('u',v);t.value='';const bubble=add('a','');
69
+ const h={'Content-Type':'application/json'};if(TOKEN)h['Authorization']='Bearer '+TOKEN;
70
+ try{
71
+ const r=await fetch('/api/chat/stream',{method:'POST',headers:h,body:JSON.stringify({text:v,session_id:SID})});
72
+ if(!r.ok||!r.body){const j=await r.json().catch(()=>({}));bubble.textContent=j.error||'(无回复)';return}
73
+ const rd=r.body.getReader(),dec=new TextDecoder();let buf='',acc='';
74
+ for(;;){const c=await rd.read();if(c.done)break;
75
+ buf+=dec.decode(c.value,{stream:true});
76
+ const parts=buf.split('\n\n');buf=parts.pop()||'';
77
+ for(const p of parts){const line=p.trim();if(line.indexOf('data:')!==0)continue;
78
+ let ev;try{ev=JSON.parse(line.slice(5).trim())}catch(e){continue}
79
+ if(ev.type==='delta'){acc+=ev.text;bubble.textContent=acc;scroll()}
80
+ else if(ev.type==='replace'){acc=ev.text;bubble.textContent=acc;scroll()}
81
+ else if(ev.type==='error'){bubble.textContent='出错了:'+ev.error}}}
82
+ if(!bubble.textContent)bubble.textContent='(无回复)';
83
+ }catch(e){bubble.textContent='网络异常,请稍后再试'}}
84
+ </script></body></html>"""
85
+
86
+
87
+ class _RateLimiter:
88
+ """每 IP 固定窗口限流(够用且零依赖;生产建议放到反向代理层)。"""
89
+
90
+ def __init__(self, per_minute: int) -> None:
91
+ self._limit = per_minute
92
+ self._hits: Dict[str, List[float]] = {}
93
+ self._lock = threading.Lock()
94
+
95
+ def allow(self, key: str) -> bool:
96
+ if self._limit <= 0:
97
+ return True
98
+ now = time.time()
99
+ with self._lock:
100
+ bucket = [t for t in self._hits.get(key, []) if now - t < 60.0]
101
+ if len(bucket) >= self._limit:
102
+ self._hits[key] = bucket
103
+ return False
104
+ bucket.append(now)
105
+ self._hits[key] = bucket
106
+ return True
107
+
108
+
109
+ class _Handler(BaseHTTPRequestHandler):
110
+ server_version = "PasmGateway/0.2"
111
+ protocol_version = "HTTP/1.1"
112
+
113
+ # 关闭默认日志噪音
114
+ def log_message(self, *args): # pragma: no cover
115
+ pass
116
+
117
+ # ---- 工具 ----
118
+ @property
119
+ def _gw(self) -> "WebGatewayPlugin":
120
+ return self.server.gateway # type: ignore[attr-defined]
121
+
122
+ def _cors(self) -> None:
123
+ origins = self.server.allowed_origins # type: ignore[attr-defined]
124
+ self.send_header("Access-Control-Allow-Origin",
125
+ origins if origins and origins != "*" else "*")
126
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
127
+ self.send_header("Access-Control-Allow-Headers",
128
+ "Content-Type, Authorization, X-Pasm-Token")
129
+
130
+ def _send(self, code: int, obj: Any, ctype: str = "application/json") -> None:
131
+ body = obj if ctype != "application/json" else json.dumps(obj, ensure_ascii=False)
132
+ data = body.encode("utf-8") if isinstance(body, str) else body
133
+ self.send_response(code)
134
+ self.send_header("Content-Type", ctype + "; charset=utf-8")
135
+ self.send_header("Content-Length", str(len(data)))
136
+ self._cors()
137
+ self.end_headers()
138
+ if self.command != "HEAD":
139
+ self.wfile.write(data)
140
+
141
+ def _sse_start(self) -> None:
142
+ """开始 SSE 响应。
143
+
144
+ 不用 ``Content-Length``(长度未知)—— 改发 ``Connection: close``
145
+ 并置 ``close_connection``,让客户端以 EOF 判定流结束。
146
+ 比 chunked 少一层编码,且所有 HTTP 客户端都认。
147
+ """
148
+ self.send_response(200)
149
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
150
+ self.send_header("Cache-Control", "no-cache")
151
+ self.send_header("X-Accel-Buffering", "no") # 让 nginx 别缓冲
152
+ self._cors()
153
+ self.send_header("Connection", "close")
154
+ self.end_headers()
155
+ self.close_connection = True
156
+
157
+ def _sse_send(self, obj: Dict[str, Any]) -> None:
158
+ payload = "data: " + json.dumps(obj, ensure_ascii=False) + "\n\n"
159
+ self.wfile.write(payload.encode("utf-8"))
160
+ self.wfile.flush()
161
+
162
+ def _client_ip(self) -> str:
163
+ return (self.headers.get("X-Forwarded-For", "").split(",")[0].strip()
164
+ or self.client_address[0])
165
+
166
+ def _authorized(self, path: str) -> bool:
167
+ token = self._gw.token
168
+ if not token or path == "/healthz":
169
+ return True
170
+ got = self.headers.get("X-Pasm-Token", "")
171
+ if not got:
172
+ auth = self.headers.get("Authorization", "")
173
+ if auth.lower().startswith("bearer "):
174
+ got = auth[7:].strip()
175
+ return got == token
176
+
177
+ def _guard(self, path: str) -> bool:
178
+ """鉴权 + 限流。返回 True 表示可以继续处理。"""
179
+ if not self._authorized(path):
180
+ self._send(401, {"error": "unauthorized",
181
+ "hint": "请在请求头带 Authorization: Bearer <token>"})
182
+ return False
183
+ if not self._gw.limiter.allow(self._client_ip()):
184
+ self._send(429, {"error": "rate limited",
185
+ "hint": "请求过于频繁,请稍后再试"})
186
+ return False
187
+ return True
188
+
189
+ def _read_json(self) -> Optional[Dict[str, Any]]:
190
+ try:
191
+ length = int(self.headers.get("Content-Length", 0) or 0)
192
+ except ValueError:
193
+ self._send(400, {"error": "bad content-length"})
194
+ return None
195
+ if length > self._gw.max_body:
196
+ self._send(413, {"error": "payload too large",
197
+ "max_body": self._gw.max_body})
198
+ return None
199
+ if not length:
200
+ return {}
201
+ try:
202
+ raw = self.rfile.read(length)
203
+ obj = json.loads(raw.decode("utf-8") or "{}")
204
+ except Exception:
205
+ self._send(400, {"error": "bad json"})
206
+ return None
207
+ if not isinstance(obj, dict):
208
+ self._send(400, {"error": "body must be a JSON object"})
209
+ return None
210
+ return obj
211
+
212
+ # ---- HTTP 方法 ----
213
+ def do_OPTIONS(self): # noqa: N802
214
+ self.send_response(204)
215
+ self._cors()
216
+ self.send_header("Content-Length", "0")
217
+ self.end_headers()
218
+
219
+ def do_HEAD(self): # noqa: N802
220
+ self.do_GET()
221
+
222
+ def do_GET(self): # noqa: N802
223
+ gw = self._gw
224
+ path = self.path.split("?")[0]
225
+ # 健康检查免鉴权(容器/反代探针常用),其余一律走守卫。
226
+ if not self._guard(path):
227
+ return
228
+ if path in ("/", "/widget", "/index.html"):
229
+ html = _WIDGET_HTML.replace("__TOKEN__", gw.token or "")
230
+ self._send(200, html, "text/html")
231
+ elif path == "/healthz":
232
+ self._send(200, gw.health())
233
+ elif path == "/api/plugins":
234
+ self._send(200, gw.plugins_info())
235
+ elif path == "/api/summary":
236
+ self._send(200, gw.summary())
237
+ elif path == "/api/kb/stats":
238
+ self._send(200, gw.kb_stats())
239
+ else:
240
+ self._send(404, {"error": "not found", "paths": [
241
+ "/", "/widget", "/healthz", "/api/chat", "/api/chat/stream",
242
+ "/api/ingest", "/api/plugins", "/api/summary", "/api/kb/stats",
243
+ "/api/sessions/reset"]})
244
+
245
+ def do_POST(self): # noqa: N802
246
+ gw = self._gw
247
+ path = self.path.split("?")[0]
248
+ if not self._guard(path):
249
+ return
250
+ if path == "/api/chat":
251
+ payload = self._read_json()
252
+ if payload is None:
253
+ return
254
+ text = str(payload.get("text") or "").strip()
255
+ if not text:
256
+ self._send(400, {"error": "text required"})
257
+ return
258
+ sid = str(payload.get("session_id") or "default")
259
+ uid = payload.get("user_id")
260
+ meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else None
261
+ try:
262
+ reply = gw.handle(text, session_id=sid, user_id=uid, meta=meta)
263
+ self._send(200, {"reply": reply, "session_id": sid})
264
+ except Exception as ex: # noqa: BLE001
265
+ self._send(500, {"error": str(ex)})
266
+ elif path == "/api/chat/stream":
267
+ payload = self._read_json()
268
+ if payload is None:
269
+ return
270
+ text = str(payload.get("text") or "").strip()
271
+ if not text:
272
+ self._send(400, {"error": "text required"})
273
+ return
274
+ sid = str(payload.get("session_id") or "default")
275
+ uid = payload.get("user_id")
276
+ meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else None
277
+ self._sse_start()
278
+ try:
279
+ for ev in gw.stream(text, session_id=sid, user_id=uid, meta=meta):
280
+ self._sse_send(ev)
281
+ except Exception as ex: # noqa: BLE001
282
+ # 已经开流,只能把错误作为事件下发(不能再改状态码)。
283
+ try:
284
+ self._sse_send({"type": "error", "error": str(ex)})
285
+ except Exception:
286
+ pass
287
+ elif path == "/api/ingest":
288
+ payload = self._read_json()
289
+ if payload is None:
290
+ return
291
+ items = payload.get("items")
292
+ if isinstance(payload.get("content"), str): # 单条也接受
293
+ items = [payload]
294
+ if not isinstance(items, list):
295
+ self._send(400, {"error": "items must be a list"})
296
+ return
297
+ added = gw.ingest(items)
298
+ if added < 0:
299
+ self._send(409, {"error": "knowledge_base 插件未启用",
300
+ "hint": "在 backend_config 中开启 knowledge_base"})
301
+ return
302
+ self._send(200, {"added": added, **gw.kb_stats()})
303
+ elif path == "/api/sessions/reset":
304
+ payload = self._read_json()
305
+ if payload is None:
306
+ return
307
+ sid = str(payload.get("session_id") or "").strip()
308
+ if not sid:
309
+ self._send(400, {"error": "session_id required"})
310
+ return
311
+ self._send(200, {"ok": gw.reset_session(sid), "session_id": sid})
312
+ else:
313
+ self._send(404, {"error": "not found"})
314
+
315
+
316
+ class WebGatewayPlugin(BasePlugin):
317
+ """零依赖 HTTP 网关。"""
318
+
319
+ name = "web_gateway"
320
+ version = "0.3.0"
321
+
322
+ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
323
+ super().__init__(config)
324
+ self._host: str = str(self.config.get("host", "127.0.0.1"))
325
+ self._port: int = int(self.config.get("port", 8080))
326
+ self._allowed_origins: str = str(self.config.get("allowed_origins", "*"))
327
+ self.token: str = str(self.config.get("token", "") or "")
328
+ self.max_body: int = int(self.config.get("max_body", 256 * 1024))
329
+ self.limiter = _RateLimiter(int(self.config.get("rate_limit", 0)))
330
+ self._app: Optional[Any] = None
331
+ self._server: Optional[ThreadingHTTPServer] = None
332
+ self._thread: Optional[threading.Thread] = None
333
+
334
+ def on_init(self, ctx: PluginContext) -> None:
335
+ self._app = ctx.app
336
+
337
+ # ---- 供 handler 调用的能力 ----
338
+ def handle(self, text: str, *, session_id: str = "default",
339
+ user_id: Optional[str] = None,
340
+ meta: Optional[Dict[str, Any]] = None) -> str:
341
+ """把 HTTP 请求转成应用处理。"""
342
+ if self._app is None:
343
+ return "(网关未绑定应用)"
344
+ return self._app.handle(text, session_id=session_id, user_id=user_id,
345
+ meta=meta)
346
+
347
+ def stream(self, text: str, *, session_id: str = "default",
348
+ user_id: Optional[str] = None,
349
+ meta: Optional[Dict[str, Any]] = None):
350
+ """流式版 ``handle``:逐条产出事件(供 SSE 下发)。
351
+
352
+ 应用没实现 ``stream`` 时**降级**为"整块当一次 delta",保证接口永远可用。
353
+ """
354
+ if self._app is None:
355
+ yield {"type": "error", "error": "网关未绑定应用"}
356
+ return
357
+ fn = getattr(self._app, "stream", None)
358
+ if not callable(fn):
359
+ yield {"type": "delta",
360
+ "text": self.handle(text, session_id=session_id,
361
+ user_id=user_id, meta=meta)}
362
+ yield {"type": "done", "session_id": session_id}
363
+ return
364
+ for ev in fn(text, session_id=session_id, user_id=user_id, meta=meta):
365
+ yield ev
366
+
367
+ def ingest(self, items: List[Dict[str, Any]]) -> int:
368
+ """把外部(站点 CMS / 爬虫 / 客服后台)的数据推进资料库。"""
369
+ pm = getattr(self._app, "plugins", None) if self._app is not None else None
370
+ kb = pm.get("knowledge_base") if pm is not None else None
371
+ if kb is None or not pm.is_enabled("knowledge_base"):
372
+ return -1
373
+ return kb.ingest(items)
374
+
375
+ def kb_stats(self) -> Dict[str, Any]:
376
+ pm = getattr(self._app, "plugins", None) if self._app is not None else None
377
+ kb = pm.get("knowledge_base") if pm is not None else None
378
+ if kb is None:
379
+ return {"enabled": False}
380
+ try:
381
+ return dict(kb.stats(), enabled=True)
382
+ except Exception:
383
+ return {"enabled": True}
384
+
385
+ def reset_session(self, session_id: str) -> bool:
386
+ pm = getattr(self._app, "plugins", None) if self._app is not None else None
387
+ sp = pm.get("sessions") if pm is not None else None
388
+ if sp is None:
389
+ return False
390
+ try:
391
+ sp.reset(session_id)
392
+ return True
393
+ except Exception:
394
+ return False
395
+
396
+ def plugins_info(self) -> Dict[str, Any]:
397
+ pm = getattr(self._app, "plugins", None) if self._app is not None else None
398
+ if pm is None:
399
+ return {"all": [], "enabled": []}
400
+ return {"all": pm.names(), "enabled": pm.enabled_names()}
401
+
402
+ def summary(self) -> Dict[str, Any]:
403
+ if self._app is None:
404
+ return {}
405
+ fn = getattr(self._app, "app_summary", None)
406
+ return fn() if callable(fn) else {}
407
+
408
+ def health(self) -> Dict[str, Any]:
409
+ out: Dict[str, Any] = {
410
+ "status": "healthy",
411
+ "plugin": "web_gateway",
412
+ "listening": self._server is not None,
413
+ "host": self._host,
414
+ "port": self._port,
415
+ "auth_required": bool(self.token),
416
+ }
417
+ pm = getattr(self._app, "plugins", None) if self._app is not None else None
418
+ obs = pm.get("observability") if pm is not None else None
419
+ if obs is not None:
420
+ try:
421
+ h = obs.health()
422
+ out["status"] = h.get("status", out["status"])
423
+ out["metrics"] = h
424
+ except Exception:
425
+ pass
426
+ return out
427
+
428
+ # ---- 生命周期 ----
429
+ def start(self, app: Optional[Any] = None, *, host: Optional[str] = None,
430
+ port: Optional[int] = None) -> None:
431
+ if self._server is not None:
432
+ return
433
+ if app is not None:
434
+ self._app = app
435
+ if host:
436
+ self._host = host
437
+ if port:
438
+ self._port = port
439
+ server = ThreadingHTTPServer((self._host, self._port), _Handler)
440
+ server.gateway = self # type: ignore[attr-defined]
441
+ server.allowed_origins = self._allowed_origins # type: ignore[attr-defined]
442
+ self._server = server
443
+ self._thread = threading.Thread(target=server.serve_forever, daemon=True)
444
+ self._thread.start()
445
+
446
+ def stop(self) -> None:
447
+ if self._server is not None:
448
+ try:
449
+ self._server.shutdown()
450
+ self._server.server_close()
451
+ except Exception:
452
+ pass
453
+ self._server = None
454
+ self._thread = None
455
+
456
+ def on_shutdown(self, ctx: PluginContext) -> None:
457
+ self.stop()