welian-app 1.1.0__tar.gz

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.
Files changed (31) hide show
  1. welian-app-1.1.0/LICENSE +21 -0
  2. welian-app-1.1.0/PKG-INFO +19 -0
  3. welian-app-1.1.0/README.md +35 -0
  4. welian-app-1.1.0/setup.cfg +4 -0
  5. welian-app-1.1.0/setup.py +34 -0
  6. welian-app-1.1.0/src/welian/__init__.py +5 -0
  7. welian-app-1.1.0/src/welian/agent.py +499 -0
  8. welian-app-1.1.0/src/welian/ai.py +235 -0
  9. welian-app-1.1.0/src/welian/api/__init__.py +1 -0
  10. welian-app-1.1.0/src/welian/api/server.py +196 -0
  11. welian-app-1.1.0/src/welian/bot/__init__.py +1 -0
  12. welian-app-1.1.0/src/welian/bot/handler.py +470 -0
  13. welian-app-1.1.0/src/welian/cli.py +737 -0
  14. welian-app-1.1.0/src/welian/edge.py +1015 -0
  15. welian-app-1.1.0/src/welian/engine.py +668 -0
  16. welian-app-1.1.0/src/welian/intent.py +276 -0
  17. welian-app-1.1.0/src/welian/llm/__init__.py +0 -0
  18. welian-app-1.1.0/src/welian/llm/base.py +131 -0
  19. welian-app-1.1.0/src/welian/llm/claude.py +187 -0
  20. welian-app-1.1.0/src/welian/llm/cloud.py +150 -0
  21. welian-app-1.1.0/src/welian/llm/openai.py +204 -0
  22. welian-app-1.1.0/src/welian/llm/router.py +203 -0
  23. welian-app-1.1.0/src/welian/payment.py +247 -0
  24. welian-app-1.1.0/src/welian/tokens.py +373 -0
  25. welian-app-1.1.0/src/welian/weekly.py +268 -0
  26. welian-app-1.1.0/src/welian_app.egg-info/PKG-INFO +19 -0
  27. welian-app-1.1.0/src/welian_app.egg-info/SOURCES.txt +29 -0
  28. welian-app-1.1.0/src/welian_app.egg-info/dependency_links.txt +1 -0
  29. welian-app-1.1.0/src/welian_app.egg-info/entry_points.txt +3 -0
  30. welian-app-1.1.0/src/welian_app.egg-info/requires.txt +5 -0
  31. welian-app-1.1.0/src/welian_app.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Welian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.1
2
+ Name: welian-app
3
+ Version: 1.1.0
4
+ Summary: Welian — AI companion for relationships. Be a better friend, a better family, a better you.
5
+ Home-page: https://welian.app
6
+ Author: Welian
7
+ License: MIT
8
+ Platform: UNKNOWN
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.9
16
+ License-File: LICENSE
17
+
18
+ Welian helps you nurture two types of relationships: goal-driven ties (leverage) and lifelong bonds (nurture).
19
+
@@ -0,0 +1,35 @@
1
+ # Welian
2
+
3
+ > 关系的质量,决定人生的质量。
4
+
5
+ Welian(维联)是一个社交垂直应用——帮你用心对待两类关系:为目标联结的合作网络,和值得陪伴的情感纽带。
6
+
7
+ ## 使命
8
+
9
+ 让每个人成为更好的社会角色——更好的朋友、更好的家人、更好的合作者——最终成为更好的自己。
10
+
11
+ ## 双关系模型
12
+
13
+ | 类型 | 隐喻 | 本质 |
14
+ |:----|:----|:----|
15
+ | 撬动型 | 果园 | 为目标联结,用心栽培,静候收获 |
16
+ | 维系型 | 浇一盆花 | 关系本身就是意义,在场就是全部 |
17
+
18
+ ## 官网
19
+
20
+ [welian.app](https://welian.app)
21
+
22
+ ## 部署
23
+
24
+ ```bash
25
+ # 前端(Cloudflare Pages)
26
+ node /opt/homebrew/lib/node_modules/wrangler/wrangler-dist/cli.js \
27
+ pages deploy public/ --project-name=welian
28
+
29
+ # 后端 Worker
30
+ cd cloud-worker && npx wrangler deploy
31
+ ```
32
+
33
+ ## License
34
+
35
+ MIT
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,34 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="welian-app",
5
+ version="1.1.0",
6
+ description="Welian — AI companion for relationships. Be a better friend, a better family, a better you.",
7
+ long_description="Welian helps you nurture two types of relationships: goal-driven ties (leverage) and lifelong bonds (nurture).",
8
+ author="Welian",
9
+ url="https://welian.app",
10
+ license="MIT",
11
+ packages=find_packages(where="src"),
12
+ package_dir={"": "src"},
13
+ python_requires=">=3.9",
14
+ install_requires=[
15
+ "pyyaml>=6.0",
16
+ "httpx>=0.24",
17
+ "fastapi>=0.100",
18
+ "uvicorn>=0.23",
19
+ "websockets>=11.0",
20
+ ],
21
+ entry_points={
22
+ "console_scripts": [
23
+ "welian=welian.cli:main",
24
+ ],
25
+ },
26
+ classifiers=[
27
+ "License :: OSI Approved :: MIT License",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.9",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ ],
34
+ )
@@ -0,0 +1,5 @@
1
+ """Welian — AI companion for relationships.
2
+
3
+ Be a better friend, a better family member, a better collaborator.
4
+ """
5
+ __version__ = "1.1.0"
@@ -0,0 +1,499 @@
1
+ """Welian local agent — HTTP + WebSocket bridge between browser and local data.
2
+
3
+ The agent runs on the user's device (launched via `welian agent`).
4
+ It serves a small HTTP page at http://localhost:PORT that acts as an
5
+ iframe bridge — the welian.app page embeds this iframe, which connects
6
+ to the local WebSocket (same-origin) and relays messages via postMessage.
7
+
8
+ Architecture:
9
+ Browser (welian.app)
10
+ ├─ iframe → http://localhost:PORT/bridge.html (same-origin as WS)
11
+ │ └─ WebSocket → ws://localhost:PORT/ws
12
+ └─ postMessage ↔ iframe
13
+
14
+ Local Agent
15
+ ├─ HTTP server (aiohttp)
16
+ │ ├─ GET / → bridge.html (iframe page)
17
+ │ ├─ GET /health → health check JSON
18
+ │ └─ WS /ws → WebSocket handler
19
+ └─ engine (local data) + EdgeClient (cloud AI)
20
+
21
+ Usage:
22
+ welian agent [--port 9800] [--cloud https://api.welian.app]
23
+ """
24
+ import json
25
+ import os
26
+ import asyncio
27
+ import secrets
28
+ from typing import Optional
29
+
30
+ from . import engine, intent, ai, tokens
31
+ from .edge import EdgeClient
32
+
33
+ # ── Page HTML (served at http://host:PORT/) ──
34
+ # Dual mode:
35
+ # 1. Iframe bridge: embedded in welian.app, relays via postMessage
36
+ # 2. Standalone: full chat UI when accessed directly (e.g. from phone)
37
+
38
+ BRIDGE_HTML = """<!DOCTYPE html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="UTF-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
43
+ <title>Welian — Local Agent</title>
44
+ <style>
45
+ *{margin:0;padding:0;box-sizing:border-box}
46
+ :root{--bg:#F5F4EE;--surface:#EDEBE3;--surface2:#E4E1D6;--border:#D9D5C7;--text:#1A1915;--dim:#6B6860;--dimmer:#9A968C;--accent:#C96442;--accent-bg:#F2E8E0;--green:#4A7C59}
47
+ body{font-family:-apple-system,Inter,sans-serif;background:var(--bg);color:var(--text);font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased}
48
+ .chat-frame{max-width:540px;margin:20px auto;background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.03),0 6px 20px rgba(0,0,0,.04)}
49
+ .chat-head{display:flex;align-items:center;gap:5px;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--surface2)}
50
+ .chat-head .dot{width:9px;height:9px;border-radius:50%}
51
+ .chat-head .dot.r{background:#D65D5D}.chat-head .dot.y{background:#D9A441}.chat-head .dot.g{background:#5B9462}
52
+ .chat-head .title{font-size:.8em;color:var(--dim);margin-left:6px;font-weight:500}
53
+ .chat-head .badge{margin-left:auto;font-size:.6em;padding:2px 8px;border-radius:3px;font-weight:500}
54
+ .chat-head .badge.live{background:var(--green);color:#fff}
55
+ .chat-head .badge.off{background:var(--dimmer);color:var(--bg)}
56
+ .chat-body{height:60vh;min-height:280px;overflow-y:auto;padding:16px 14px;scrollbar-width:thin}
57
+ .msg{margin-bottom:14px;animation:fadein .4s ease}
58
+ @keyframes fadein{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
59
+ .msg .who{font-size:.62em;font-weight:600;margin-bottom:3px;letter-spacing:.04em;text-transform:uppercase}
60
+ .msg .who.you{color:var(--accent)}
61
+ .msg .who.ai{color:var(--green)}
62
+ .msg .bubble{padding:10px 14px;border-radius:7px;font-size:.85em;white-space:pre-wrap;line-height:1.55;word-break:break-word}
63
+ .msg .bubble.you{background:var(--accent-bg);border:1px solid #E8D5C8}
64
+ .msg .bubble.ai{background:var(--bg);border:1px solid var(--border)}
65
+ .msg .bubble.sys{background:var(--surface2);border:1px solid var(--border);color:var(--dim);font-size:.8em}
66
+ .msg .typing{display:inline-block;width:6px;height:13px;background:var(--green);border-radius:1px;animation:blink 1s infinite;vertical-align:text-bottom}
67
+ @keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}}
68
+ .chat-input{display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--border);background:var(--bg)}
69
+ .chat-input input{flex:1;background:none;border:none;color:var(--text);font-size:.85em;font-family:inherit;outline:none;min-width:0}
70
+ .chat-input input::placeholder{color:var(--dimmer)}
71
+ .chat-input .send{color:var(--dim);cursor:pointer;font-size:.78em;border:none;background:none;font-family:inherit;flex-shrink:0;padding:4px 6px}
72
+ .hint{font-size:.67em;color:var(--dimmer);text-align:center;padding:8px}
73
+ .hint span{color:var(--dim);cursor:pointer;border-bottom:1px solid transparent;padding:1px 3px}
74
+ </style>
75
+ </head>
76
+ <body>
77
+
78
+ <div class="chat-frame">
79
+ <div class="chat-head">
80
+ <span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
81
+ <span class="title">Welian Local</span>
82
+ <span class="badge off" id="badge">Connecting…</span>
83
+ </div>
84
+ <div class="chat-body" id="body"></div>
85
+ <div class="chat-input" id="inputBar" style="display:none">
86
+ <input id="input" type="text" placeholder="Talk to Welian…" onkeydown="if(event.key==='Enter')send()">
87
+ <button class="send" onclick="send()">→</button>
88
+ </div>
89
+ <div class="hint" id="hints" style="display:none">
90
+ <span onclick="qs('who to reach out')">Who to reach out</span>
91
+ <span onclick="qs('note: met with X about Y')">Quick note</span>
92
+ <span onclick="qs('draft a message to an old friend')">Draft a message</span>
93
+ </div>
94
+ </div>
95
+
96
+ <script>
97
+ const PAIRING_TOKEN = "__PAIRING_TOKEN__";
98
+ const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
99
+ const wsUrl = `${wsProto}//${location.host}/ws`;
100
+ let ws = null;
101
+ let isIframe = window.parent !== window;
102
+ let parentOrigin = '*';
103
+
104
+ function $(id){return document.getElementById(id)}
105
+ function addMsg(who,text){
106
+ const d=document.createElement('div');d.className='msg';
107
+ const label=who==='ai'?'Welian':'You';
108
+ d.innerHTML='<div class="who '+who+'">'+label+'</div><div class="bubble '+who+'"></div>';
109
+ d.querySelector('.bubble').textContent=text;
110
+ $('body').appendChild(d);$('body').scrollTop=$('body').scrollHeight;
111
+ }
112
+ function addTyping(){
113
+ const d=document.createElement('div');d.className='msg';d.id='typing';
114
+ d.innerHTML='<div class="who ai">Welian</div><div class="bubble ai"><span class="typing"></span></div>';
115
+ $('body').appendChild(d);$('body').scrollTop=$('body').scrollHeight;
116
+ }
117
+ function rmTyping(){$('typing')&&$('typing').remove()}
118
+
119
+ function notifyParent(type,data){
120
+ if(isIframe) parent.postMessage({source:'welian-bridge',type:type,data:data},parentOrigin);
121
+ }
122
+
123
+ function connect(){
124
+ ws=new WebSocket(wsUrl);
125
+ ws.onopen=()=>{ws.send(JSON.stringify({type:'auth',token:PAIRING_TOKEN}))};
126
+ ws.onmessage=(e)=>{
127
+ const data=JSON.parse(e.data);
128
+ notifyParent('ws-message',data);
129
+ if(data.type==='auth_ok'){
130
+ onConnected();
131
+ } else if(!isIframe){
132
+ rmTyping();
133
+ if(data.type==='error'){$('badge').textContent='Error';addMsg('ai','Error: '+data.message)}
134
+ else if(data.type==='response'&&data.reply){addMsg('ai',data.reply)}
135
+ else if(data.type==='response'&&data.data){addMsg('ai',JSON.stringify(data.data,null,2))}
136
+ }
137
+ };
138
+ ws.onerror=()=>{notifyParent('ws-error',{});if(!isIframe){$('badge').textContent='Offline'}};
139
+ ws.onclose=()=>{notifyParent('ws-close',{})};
140
+ }
141
+
142
+ function onConnected(){
143
+ $('inputBar').style.display='flex';
144
+ $('hints').style.display='block';
145
+ $('badge').textContent='Live';
146
+ $('badge').className='badge live';
147
+ if(!isIframe){
148
+ addMsg('ai','Connected to your local agent ✅\\n\\nYour data is on your device.\\n\\nTry: "who to reach out" or "note: met with X about Y"');
149
+ }
150
+ // Send device_id to parent for discovery linking
151
+ if(isIframe){
152
+ fetch('/health').then(r=>r.json()).then(d=>{
153
+ if(d.device_id){
154
+ parent.postMessage({source:'welian-bridge',type:'device-id',device_id:d.device_id},parentOrigin);
155
+ }
156
+ }).catch(()=>{});
157
+ }
158
+ }
159
+
160
+ function send(){
161
+ const text=$('input').value.trim();if(!text)return;
162
+ $('input').value='';
163
+ addMsg('you',text);addTyping();
164
+ ws.send(JSON.stringify({cmd:'chat',id:Date.now().toString(),text:text}));
165
+ }
166
+
167
+ function qs(text){$('input').value=text;send()}
168
+
169
+ // Iframe bridge mode: listen for parent commands
170
+ window.addEventListener('message',(e)=>{
171
+ const msg=e.data;
172
+ if(!msg||msg.source!=='welian-parent')return;
173
+ parentOrigin=e.origin;
174
+ if(msg.type==='send'&&ws&&ws.readyState===WebSocket.OPEN){ws.send(JSON.stringify(msg.payload))}
175
+ });
176
+
177
+ // Notify parent if in iframe, then auto-connect
178
+ if(isIframe){
179
+ parent.postMessage({source:'welian-bridge',type:'ready'},'*');
180
+ }
181
+ // Auto-connect on load (token is injected by agent)
182
+ connect();
183
+ </script>
184
+ </body>
185
+ </html>"""
186
+
187
+
188
+ class LocalAgent:
189
+ """HTTP + WebSocket server that bridges browser ↔ local data ↔ cloud AI."""
190
+
191
+ DISCOVERY_URL = "https://welian-ai.farmost.workers.dev"
192
+
193
+ def __init__(self, port: int = 9800, cloud_url: str = "", token: str = "",
194
+ user_token: str = "", tunnel: bool = False):
195
+ self.port = port
196
+ self.cloud_url = cloud_url
197
+ self.user_token = user_token
198
+ self.pairing_token = token or self._generate_token()
199
+ self.edge = EdgeClient(cloud_url=cloud_url, user_token=user_token)
200
+ self.connected_clients = set()
201
+ self.tunnel = tunnel
202
+ self.tunnel_url = ""
203
+ self.device_id = self._get_device_id()
204
+
205
+ def _generate_token(self) -> str:
206
+ return secrets.token_urlsafe(16)
207
+
208
+ def _get_device_id(self) -> str:
209
+ """Generate a stable device ID from machine info."""
210
+ import hashlib, platform
211
+ raw = f"{platform.node()}-{platform.machine()}-{os.getuid() if hasattr(os, 'getuid') else 0}"
212
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
213
+
214
+ async def _start_tunnel(self):
215
+ """Start cloudflared tunnel and register with discovery service.
216
+
217
+ Uses Clerk user_id (from 'welian login') as the key, so any device
218
+ logged into the same Clerk account can discover this tunnel.
219
+ Falls back to device_id if not logged in.
220
+ """
221
+ import subprocess, re, time, urllib.request
222
+
223
+ # Determine registry key: prefer Clerk user_id, fall back to device_id
224
+ from .cli import _get_user_id
225
+ user_id = _get_user_id()
226
+ registry_key = user_id or self.device_id
227
+
228
+ try:
229
+ # Try named tunnel first (permanent URL)
230
+ tunnel_url = "https://agent.welian.app"
231
+ try:
232
+ proc = subprocess.Popen(
233
+ ["cloudflared", "tunnel", "run", "welian-agent"],
234
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
235
+ env={**os.environ, "NO_AUTO_UPDATE": "true"},
236
+ )
237
+ for _ in range(15):
238
+ line = proc.stdout.readline().decode("utf-8", errors="replace")
239
+ if "Registered tunnel connection" in line:
240
+ break
241
+ time.sleep(1)
242
+ except Exception:
243
+ # Fall back to quick tunnel
244
+ proc = subprocess.Popen(
245
+ ["cloudflared", "tunnel", "--url", f"http://localhost:{self.port}"],
246
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
247
+ env={**os.environ, "NO_AUTO_UPDATE": "true"},
248
+ )
249
+ tunnel_url = ""
250
+ for _ in range(30):
251
+ line = proc.stdout.readline().decode("utf-8", errors="replace")
252
+ if "trycloudflare.com" in line:
253
+ m = re.search(r'https://[a-z0-9-]+\.trycloudflare\.com', line)
254
+ if m:
255
+ tunnel_url = m.group(0)
256
+ break
257
+ time.sleep(1)
258
+
259
+ if not tunnel_url:
260
+ print(" ⚠ Tunnel failed to start")
261
+ return
262
+
263
+ self.tunnel_url = tunnel_url
264
+ print(f" Tunnel: {tunnel_url}")
265
+
266
+ # Register with discovery service using user_id (or device_id)
267
+ req = urllib.request.Request(
268
+ f"{self.DISCOVERY_URL}/discover/register",
269
+ data=json.dumps({"device_id": registry_key, "tunnel_url": tunnel_url}).encode(),
270
+ headers={"Content-Type": "application/json"},
271
+ method="POST",
272
+ )
273
+ urllib.request.urlopen(req, timeout=10)
274
+ if user_id:
275
+ print(f" Registered to Clerk user: {user_id}")
276
+ else:
277
+ print(f" Registered to device: {self.device_id}")
278
+ print(f" ⚠ Run 'welian login' to enable multi-device discovery")
279
+ except FileNotFoundError:
280
+ print(" ⚠ cloudflared not installed — tunnel disabled")
281
+ except Exception as e:
282
+ print(f" ⚠ Tunnel error: {e}")
283
+
284
+ async def process_command(self, msg: dict) -> dict:
285
+ """Process a command from the browser and return a response."""
286
+ cmd = msg.get("cmd")
287
+ req_id = msg.get("id", "")
288
+
289
+ try:
290
+ if cmd == "chat":
291
+ text = msg.get("text", "")
292
+ reply = self.edge.chat(text)
293
+ return {"type": "response", "id": req_id, "reply": reply}
294
+
295
+ elif cmd == "status":
296
+ d = engine.get_dashboard()
297
+ return {"type": "response", "id": req_id, "data": d}
298
+
299
+ elif cmd == "contacts":
300
+ nature = msg.get("nature")
301
+ role = msg.get("role")
302
+ contacts = engine.list_contacts(nature=nature, role=role)
303
+ minimal = [{
304
+ "id": c["id"],
305
+ "name": c["name"],
306
+ "nature": engine.infer_nature(c),
307
+ "role": engine.contact_role(c),
308
+ "relation": c.get("relation", ""),
309
+ "strength": c.get("strength", 1),
310
+ } for c in contacts]
311
+ return {"type": "response", "id": req_id, "data": minimal}
312
+
313
+ elif cmd == "dashboard":
314
+ dash = engine.role_dashboard()
315
+ return {"type": "response", "id": req_id, "data": dash}
316
+
317
+ elif cmd == "balance":
318
+ user_id = msg.get("user_id", "default")
319
+ bal = tokens.get_balance(user_id)
320
+ return {"type": "response", "id": req_id, "data": bal}
321
+
322
+ elif cmd == "add_contact":
323
+ ok, msg_text = engine.add_contact(
324
+ msg.get("id", ""),
325
+ msg.get("name", ""),
326
+ relation=msg.get("relation", ""),
327
+ nature=msg.get("nature", "leverage"),
328
+ )
329
+ return {"type": "response", "id": req_id, "ok": ok, "message": msg_text}
330
+
331
+ elif cmd == "check":
332
+ target = msg.get("target", "")
333
+ reply = ai.format_nurture_check(target)
334
+ return {"type": "response", "id": req_id, "reply": reply}
335
+
336
+ elif cmd == "ping":
337
+ return {"type": "response", "id": req_id, "pong": True}
338
+
339
+ else:
340
+ return {"type": "error", "id": req_id, "message": f"Unknown command: {cmd}"}
341
+
342
+ except Exception as e:
343
+ return {"type": "error", "id": req_id, "message": str(e)}
344
+
345
+ async def handle_ws(self, request):
346
+ """Handle WebSocket upgrade request."""
347
+ ws_server = websockets.WebSocketServerProtocol(
348
+ request.protocol,
349
+ request.transport,
350
+ )
351
+ await self._handle_connection(ws_server)
352
+ return ws_server
353
+
354
+ async def _handle_connection(self, websocket):
355
+ """Handle a WebSocket connection (auth + message loop)."""
356
+ try:
357
+ raw = await asyncio.wait_for(websocket.recv(), timeout=10)
358
+ msg = json.loads(raw)
359
+
360
+ if msg.get("type") != "auth" or msg.get("token") != self.pairing_token:
361
+ await websocket.send(json.dumps({
362
+ "type": "error",
363
+ "message": "Authentication failed. Check pairing token."
364
+ }))
365
+ await websocket.close()
366
+ return
367
+
368
+ await websocket.send(json.dumps({
369
+ "type": "auth_ok",
370
+ "message": "Connected to Welian local agent"
371
+ }))
372
+ except (asyncio.TimeoutError, json.JSONDecodeError, websockets.exceptions.ConnectionClosed):
373
+ return
374
+
375
+ self.connected_clients.add(websocket)
376
+ print(f"✓ Browser connected ({len(self.connected_clients)} active)")
377
+
378
+ try:
379
+ async for raw in websocket:
380
+ try:
381
+ msg = json.loads(raw)
382
+ except json.JSONDecodeError:
383
+ await websocket.send(json.dumps({
384
+ "type": "error",
385
+ "message": "Invalid JSON"
386
+ }))
387
+ continue
388
+ response = await self.process_command(msg)
389
+ await websocket.send(json.dumps(response, ensure_ascii=False))
390
+ except websockets.exceptions.ConnectionClosed:
391
+ pass
392
+ finally:
393
+ self.connected_clients.discard(websocket)
394
+ print(f" Browser disconnected ({len(self.connected_clients)} active)")
395
+
396
+ async def start(self):
397
+ """Start HTTP + WebSocket server."""
398
+ from aiohttp import web
399
+ import socket
400
+
401
+ # Get LAN IP for mobile access
402
+ try:
403
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
404
+ s.connect(("8.8.8.8", 80))
405
+ lan_ip = s.getsockname()[0]
406
+ s.close()
407
+ except Exception:
408
+ lan_ip = "localhost"
409
+
410
+ print(f"Welian Local Agent")
411
+ print(f" Port: {self.port}")
412
+ print(f" Cloud: {self.cloud_url or '(offline mode)'}")
413
+ print(f" Pairing token: {self.pairing_token}")
414
+ print(f" Device ID: {self.device_id}")
415
+ print(f" Data: {os.environ.get('WELIAN_HOME', '~/.welian')}")
416
+ print()
417
+ if self.tunnel:
418
+ print(f" Starting tunnel for remote access…")
419
+ await self._start_tunnel()
420
+ print()
421
+ print(f" Desktop: https://welian.app → auto-connect")
422
+ print(f" Mobile: https://welian.app → auto-connect via tunnel")
423
+ if lan_ip != "localhost":
424
+ print(f" LAN: http://{lan_ip}:{self.port}")
425
+ print()
426
+
427
+ async def index_handler(request):
428
+ # Inject pairing token directly into the page —
429
+ # no manual token entry needed.
430
+ html = BRIDGE_HTML.replace("__PAIRING_TOKEN__", self.pairing_token)
431
+ return web.Response(text=html, content_type="text/html")
432
+
433
+ async def health_handler(request):
434
+ return web.json_response({
435
+ "status": "ok",
436
+ "port": self.port,
437
+ "clients": len(self.connected_clients),
438
+ "device_id": self.device_id,
439
+ "tunnel": self.tunnel_url or None,
440
+ })
441
+
442
+ async def ws_handler(request):
443
+ ws_server = web.WebSocketResponse()
444
+ await ws_server.prepare(request)
445
+
446
+ # Auth
447
+ try:
448
+ raw = await asyncio.wait_for(ws_server.receive(), timeout=10)
449
+ msg = json.loads(raw.data)
450
+
451
+ if msg.get("type") != "auth" or msg.get("token") != self.pairing_token:
452
+ await ws_server.send_json({
453
+ "type": "error",
454
+ "message": "Authentication failed. Check pairing token."
455
+ })
456
+ await ws_server.close()
457
+ return ws_server
458
+
459
+ await ws_server.send_json({
460
+ "type": "auth_ok",
461
+ "message": "Connected to Welian local agent"
462
+ })
463
+ except (asyncio.TimeoutError, json.JSONDecodeError):
464
+ return ws_server
465
+
466
+ self.connected_clients.add(ws_server)
467
+ print(f"✓ Browser connected ({len(self.connected_clients)} active)")
468
+
469
+ try:
470
+ async for raw_msg in ws_server:
471
+ try:
472
+ msg = json.loads(raw_msg.data)
473
+ except json.JSONDecodeError:
474
+ await ws_server.send_json({
475
+ "type": "error",
476
+ "message": "Invalid JSON"
477
+ })
478
+ continue
479
+ response = await self.process_command(msg)
480
+ await ws_server.send_json(response)
481
+ except Exception as e:
482
+ print(f" WS error: {e}")
483
+ finally:
484
+ self.connected_clients.discard(ws_server)
485
+ print(f" Browser disconnected ({len(self.connected_clients)} active)")
486
+
487
+ return ws_server
488
+
489
+ app = web.Application()
490
+ app.router.add_get("/", index_handler)
491
+ app.router.add_get("/health", health_handler)
492
+ app.router.add_get("/ws", ws_handler)
493
+
494
+ runner = web.AppRunner(app)
495
+ await runner.setup()
496
+ site = web.TCPSite(runner, "0.0.0.0", self.port)
497
+ await site.start()
498
+
499
+ await asyncio.Future() # run forever