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.
- welian-app-1.1.0/LICENSE +21 -0
- welian-app-1.1.0/PKG-INFO +19 -0
- welian-app-1.1.0/README.md +35 -0
- welian-app-1.1.0/setup.cfg +4 -0
- welian-app-1.1.0/setup.py +34 -0
- welian-app-1.1.0/src/welian/__init__.py +5 -0
- welian-app-1.1.0/src/welian/agent.py +499 -0
- welian-app-1.1.0/src/welian/ai.py +235 -0
- welian-app-1.1.0/src/welian/api/__init__.py +1 -0
- welian-app-1.1.0/src/welian/api/server.py +196 -0
- welian-app-1.1.0/src/welian/bot/__init__.py +1 -0
- welian-app-1.1.0/src/welian/bot/handler.py +470 -0
- welian-app-1.1.0/src/welian/cli.py +737 -0
- welian-app-1.1.0/src/welian/edge.py +1015 -0
- welian-app-1.1.0/src/welian/engine.py +668 -0
- welian-app-1.1.0/src/welian/intent.py +276 -0
- welian-app-1.1.0/src/welian/llm/__init__.py +0 -0
- welian-app-1.1.0/src/welian/llm/base.py +131 -0
- welian-app-1.1.0/src/welian/llm/claude.py +187 -0
- welian-app-1.1.0/src/welian/llm/cloud.py +150 -0
- welian-app-1.1.0/src/welian/llm/openai.py +204 -0
- welian-app-1.1.0/src/welian/llm/router.py +203 -0
- welian-app-1.1.0/src/welian/payment.py +247 -0
- welian-app-1.1.0/src/welian/tokens.py +373 -0
- welian-app-1.1.0/src/welian/weekly.py +268 -0
- welian-app-1.1.0/src/welian_app.egg-info/PKG-INFO +19 -0
- welian-app-1.1.0/src/welian_app.egg-info/SOURCES.txt +29 -0
- welian-app-1.1.0/src/welian_app.egg-info/dependency_links.txt +1 -0
- welian-app-1.1.0/src/welian_app.egg-info/entry_points.txt +3 -0
- welian-app-1.1.0/src/welian_app.egg-info/requires.txt +5 -0
- welian-app-1.1.0/src/welian_app.egg-info/top_level.txt +1 -0
welian-app-1.1.0/LICENSE
ADDED
|
@@ -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,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,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
|