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.
- pasm_framework/__init__.py +509 -0
- pasm_framework/__main__.py +282 -0
- pasm_framework/adapter.py +80 -0
- pasm_framework/application.py +426 -0
- pasm_framework/config.py +319 -0
- pasm_framework/demo.py +85 -0
- pasm_framework/discovery.py +91 -0
- pasm_framework/errors.py +26 -0
- pasm_framework/plugins/__init__.py +42 -0
- pasm_framework/plugins/builtins/__init__.py +30 -0
- pasm_framework/plugins/builtins/knowledge_base.py +319 -0
- pasm_framework/plugins/builtins/llm_responder.py +443 -0
- pasm_framework/plugins/builtins/observability.py +64 -0
- pasm_framework/plugins/builtins/safety.py +89 -0
- pasm_framework/plugins/builtins/sessions.py +82 -0
- pasm_framework/plugins/builtins/warmth.py +97 -0
- pasm_framework/plugins/builtins/web_gateway.py +457 -0
- pasm_framework/plugins/core.py +420 -0
- pasm_framework/plugins/registry.py +80 -0
- pasm_framework/py.typed +0 -0
- pasm_framework/scaffold.py +292 -0
- pasm_framework/service.py +154 -0
- pasm_framework/simple.py +174 -0
- pasm_framework/skill.py +77 -0
- pasm_framework-0.3.0.dist-info/METADATA +281 -0
- pasm_framework-0.3.0.dist-info/RECORD +30 -0
- pasm_framework-0.3.0.dist-info/WHEEL +5 -0
- pasm_framework-0.3.0.dist-info/entry_points.txt +2 -0
- pasm_framework-0.3.0.dist-info/licenses/LICENSE +21 -0
- pasm_framework-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|