@oxiaom/adoremix 1.0.37 → 1.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config-manager/app.py +589 -0
- package/config-manager/templates/index.html +1396 -0
- package/package.json +3 -2
- package/src/cli.js +40 -0
- package/src/config-manager.js +139 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from flask import Flask, render_template, request, jsonify
|
|
8
|
+
|
|
9
|
+
app = Flask(__name__)
|
|
10
|
+
|
|
11
|
+
# 工作目录(由 adoremix config-manager 服务的环境变量指定,默认 /opt/adoremix)
|
|
12
|
+
WORKDIR = os.environ.get('ADOREMIX_WORKDIR', '/opt/adoremix')
|
|
13
|
+
|
|
14
|
+
# 配置文件路径
|
|
15
|
+
CONFIG_INI = os.path.join(WORKDIR, 'config.ini')
|
|
16
|
+
OPENCLAW_CONFIG = "/root/.openclaw/openclaw.json"
|
|
17
|
+
|
|
18
|
+
# 常用模型列表
|
|
19
|
+
POPULAR_MODELS = [
|
|
20
|
+
# 智谱 GLM 系列 (完整)
|
|
21
|
+
{"id": "zai/glm-4.7", "name": "智谱 GLM-4.7", "provider": "zhipu"},
|
|
22
|
+
{"id": "zai/glm-4-plus", "name": "智谱 GLM-4 Plus", "provider": "zhipu"},
|
|
23
|
+
{"id": "zai/glm-4-0520", "name": "智谱 GLM-4-0520", "provider": "zhipu"},
|
|
24
|
+
{"id": "zai/glm-4-air", "name": "智谱 GLM-4 Air", "provider": "zhipu"},
|
|
25
|
+
{"id": "zai/glm-4-airx", "name": "智谱 GLM-4 AirX", "provider": "zhipu"},
|
|
26
|
+
{"id": "zai/glm-4-flash", "name": "智谱 GLM-4 Flash", "provider": "zhipu"},
|
|
27
|
+
{"id": "zai/glm-4-long", "name": "智谱 GLM-4 Long (长文本)", "provider": "zhipu"},
|
|
28
|
+
{"id": "zai/glm-4", "name": "智谱 GLM-4", "provider": "zhipu"},
|
|
29
|
+
{"id": "zai/glm-4v", "name": "智谱 GLM-4V (视觉)", "provider": "zhipu"},
|
|
30
|
+
{"id": "zai/glm-4v-plus", "name": "智谱 GLM-4V Plus (视觉)", "provider": "zhipu"},
|
|
31
|
+
{"id": "zai/glm-3-turbo", "name": "智谱 GLM-3 Turbo", "provider": "zhipu"},
|
|
32
|
+
{"id": "zai/glm-5", "name": "智谱 GLM-5", "provider": "zhipu"},
|
|
33
|
+
{"id": "zai/glm-5-turbo", "name": "智谱 GLM-5 Turbo", "provider": "zhipu"},
|
|
34
|
+
# OpenAI
|
|
35
|
+
{"id": "openai/gpt-4o", "name": "OpenAI GPT-4o", "provider": "openai"},
|
|
36
|
+
{"id": "openai/gpt-4o-mini", "name": "OpenAI GPT-4o Mini", "provider": "openai"},
|
|
37
|
+
{"id": "openai/gpt-4-turbo", "name": "OpenAI GPT-4 Turbo", "provider": "openai"},
|
|
38
|
+
{"id": "openai/gpt-4", "name": "OpenAI GPT-4", "provider": "openai"},
|
|
39
|
+
{"id": "openai/o1-preview", "name": "OpenAI O1 Preview", "provider": "openai"},
|
|
40
|
+
{"id": "openai/o1-mini", "name": "OpenAI O1 Mini", "provider": "openai"},
|
|
41
|
+
# Claude
|
|
42
|
+
{"id": "anthropic/claude-3-5-sonnet", "name": "Claude 3.5 Sonnet", "provider": "anthropic"},
|
|
43
|
+
{"id": "anthropic/claude-3-5-haiku", "name": "Claude 3.5 Haiku", "provider": "anthropic"},
|
|
44
|
+
{"id": "anthropic/claude-3-opus", "name": "Claude 3 Opus", "provider": "anthropic"},
|
|
45
|
+
{"id": "anthropic/claude-3-sonnet", "name": "Claude 3 Sonnet", "provider": "anthropic"},
|
|
46
|
+
{"id": "anthropic/claude-3-haiku", "name": "Claude 3 Haiku", "provider": "anthropic"},
|
|
47
|
+
# Google
|
|
48
|
+
{"id": "google/gemini-1.5-pro", "name": "Google Gemini 1.5 Pro", "provider": "google"},
|
|
49
|
+
{"id": "google/gemini-1.5-flash", "name": "Google Gemini 1.5 Flash", "provider": "google"},
|
|
50
|
+
{"id": "google/gemini-2.0-flash", "name": "Google Gemini 2.0 Flash", "provider": "google"},
|
|
51
|
+
{"id": "google/gemini-pro", "name": "Google Gemini Pro", "provider": "google"},
|
|
52
|
+
# DeepSeek
|
|
53
|
+
{"id": "deepseek/deepseek-chat", "name": "DeepSeek Chat", "provider": "deepseek"},
|
|
54
|
+
{"id": "deepseek/deepseek-coder", "name": "DeepSeek Coder", "provider": "deepseek"},
|
|
55
|
+
{"id": "deepseek/deepseek-reasoner", "name": "DeepSeek Reasoner", "provider": "deepseek"},
|
|
56
|
+
# Moonshot
|
|
57
|
+
{"id": "moonshot/moonshot-v1-8k", "name": "Moonshot Kimi 8K", "provider": "moonshot"},
|
|
58
|
+
{"id": "moonshot/moonshot-v1-32k", "name": "Moonshot Kimi 32K", "provider": "moonshot"},
|
|
59
|
+
{"id": "moonshot/moonshot-v1-128k", "name": "Moonshot Kimi 128K", "provider": "moonshot"},
|
|
60
|
+
# 通义千问
|
|
61
|
+
{"id": "qwen/qwen-turbo", "name": "通义千问 Turbo", "provider": "qwen"},
|
|
62
|
+
{"id": "qwen/qwen-plus", "name": "通义千问 Plus", "provider": "qwen"},
|
|
63
|
+
{"id": "qwen/qwen-max", "name": "通义千问 Max", "provider": "qwen"},
|
|
64
|
+
{"id": "qwen/qwen-long", "name": "通义千问 Long", "provider": "qwen"},
|
|
65
|
+
# Ollama 本地
|
|
66
|
+
{"id": "ollama/llama3", "name": "Ollama Llama3 (本地)", "provider": "ollama"},
|
|
67
|
+
{"id": "ollama/llama3.1", "name": "Ollama Llama3.1 (本地)", "provider": "ollama"},
|
|
68
|
+
{"id": "ollama/qwen2", "name": "Ollama Qwen2 (本地)", "provider": "ollama"},
|
|
69
|
+
{"id": "ollama/qwen2.5", "name": "Ollama Qwen2.5 (本地)", "provider": "ollama"},
|
|
70
|
+
{"id": "ollama/deepseek-v2", "name": "Ollama DeepSeek V2 (本地)", "provider": "ollama"},
|
|
71
|
+
{"id": "ollama/glm4", "name": "Ollama GLM4 (本地)", "provider": "ollama"},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
def parse_ini(filepath):
|
|
75
|
+
"""解析 INI 文件"""
|
|
76
|
+
config = {}
|
|
77
|
+
current_section = None
|
|
78
|
+
|
|
79
|
+
if not os.path.exists(filepath):
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
83
|
+
for line in f:
|
|
84
|
+
line = line.strip()
|
|
85
|
+
if not line or line.startswith(';') or line.startswith('#'):
|
|
86
|
+
continue
|
|
87
|
+
if line.startswith('[') and line.endswith(']'):
|
|
88
|
+
current_section = line[1:-1]
|
|
89
|
+
config[current_section] = {}
|
|
90
|
+
elif '=' in line and current_section:
|
|
91
|
+
key, value = line.split('=', 1)
|
|
92
|
+
config[current_section][key.strip()] = value.strip()
|
|
93
|
+
|
|
94
|
+
return config
|
|
95
|
+
|
|
96
|
+
def write_ini(filepath, config):
|
|
97
|
+
"""写入 INI 文件"""
|
|
98
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
99
|
+
for section, values in config.items():
|
|
100
|
+
f.write(f'[{section}]\n')
|
|
101
|
+
for key, value in values.items():
|
|
102
|
+
f.write(f'{key}={value}\n')
|
|
103
|
+
f.write('\n')
|
|
104
|
+
|
|
105
|
+
def get_network_info():
|
|
106
|
+
"""获取当前网络信息"""
|
|
107
|
+
result = subprocess.run(['ip', 'addr', 'show'], capture_output=True, text=True)
|
|
108
|
+
|
|
109
|
+
interfaces = []
|
|
110
|
+
current_iface = None
|
|
111
|
+
|
|
112
|
+
for line in result.stdout.split('\n'):
|
|
113
|
+
if ': ' in line and not line.startswith(' '):
|
|
114
|
+
parts = line.split(': ')
|
|
115
|
+
if len(parts) >= 2:
|
|
116
|
+
iface = parts[1].split('@')[0]
|
|
117
|
+
if iface != 'lo':
|
|
118
|
+
current_iface = {'name': iface, 'ip': '', 'netmask': '', 'gateway': '', 'dhcp': False}
|
|
119
|
+
interfaces.append(current_iface)
|
|
120
|
+
elif 'inet ' in line and current_iface:
|
|
121
|
+
match = re.search(r'inet (\d+\.\d+\.\d+\.\d+)/(\d+)', line)
|
|
122
|
+
if match:
|
|
123
|
+
current_iface['ip'] = match.group(1)
|
|
124
|
+
current_iface['netmask'] = match.group(2)
|
|
125
|
+
|
|
126
|
+
# 获取网关
|
|
127
|
+
result = subprocess.run(['ip', 'route'], capture_output=True, text=True)
|
|
128
|
+
for line in result.stdout.split('\n'):
|
|
129
|
+
if 'default' in line:
|
|
130
|
+
match = re.search(r'via (\d+\.\d+\.\d+\.\d+)', line)
|
|
131
|
+
if match:
|
|
132
|
+
for iface in interfaces:
|
|
133
|
+
iface['gateway'] = match.group(1)
|
|
134
|
+
|
|
135
|
+
# 通过 systemd-networkd 配置检测 DHCP/静态状态
|
|
136
|
+
for iface in interfaces:
|
|
137
|
+
net_file = f'/etc/systemd/network/10-{iface["name"]}.network'
|
|
138
|
+
if os.path.exists(net_file):
|
|
139
|
+
try:
|
|
140
|
+
with open(net_file, 'r') as f:
|
|
141
|
+
cfg = f.read()
|
|
142
|
+
iface['dhcp'] = 'DHCP=yes' in cfg
|
|
143
|
+
except:
|
|
144
|
+
iface['dhcp'] = True
|
|
145
|
+
else:
|
|
146
|
+
iface['dhcp'] = True
|
|
147
|
+
|
|
148
|
+
return interfaces
|
|
149
|
+
|
|
150
|
+
def set_static_ip(interface, ip, netmask, gateway, dns='8.8.8.8', use_dhcp=False):
|
|
151
|
+
"""使用 systemd-networkd 设置静态IP或DHCP"""
|
|
152
|
+
net_file = f'/etc/systemd/network/10-{interface}.network'
|
|
153
|
+
|
|
154
|
+
if use_dhcp:
|
|
155
|
+
config = f"""[Match]
|
|
156
|
+
Name={interface}
|
|
157
|
+
|
|
158
|
+
[Network]
|
|
159
|
+
DHCP=yes
|
|
160
|
+
"""
|
|
161
|
+
else:
|
|
162
|
+
config = f"""[Match]
|
|
163
|
+
Name={interface}
|
|
164
|
+
|
|
165
|
+
[Network]
|
|
166
|
+
Address={ip}/{netmask}
|
|
167
|
+
Gateway={gateway}
|
|
168
|
+
DNS={dns}
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
with open(net_file, 'w') as f:
|
|
172
|
+
f.write(config)
|
|
173
|
+
|
|
174
|
+
return net_file
|
|
175
|
+
|
|
176
|
+
def apply_network():
|
|
177
|
+
"""应用网络配置"""
|
|
178
|
+
result = subprocess.run(['systemctl', 'restart', 'systemd-networkd'], capture_output=True, text=True, timeout=15)
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
@app.route('/')
|
|
182
|
+
def index():
|
|
183
|
+
return render_template('index.html')
|
|
184
|
+
|
|
185
|
+
@app.route('/api/network', methods=['GET'])
|
|
186
|
+
def get_network():
|
|
187
|
+
"""获取网络配置"""
|
|
188
|
+
interfaces = get_network_info()
|
|
189
|
+
return jsonify({'interfaces': interfaces})
|
|
190
|
+
|
|
191
|
+
@app.route('/api/network', methods=['POST'])
|
|
192
|
+
def set_network():
|
|
193
|
+
"""设置网络配置"""
|
|
194
|
+
data = request.json
|
|
195
|
+
interface = data.get('interface')
|
|
196
|
+
ip = data.get('ip')
|
|
197
|
+
netmask = data.get('netmask', '24')
|
|
198
|
+
gateway = data.get('gateway')
|
|
199
|
+
dns = data.get('dns', '8.8.8.8')
|
|
200
|
+
use_dhcp = data.get('useDhcp', False)
|
|
201
|
+
apply_now = data.get('applyNow', False)
|
|
202
|
+
|
|
203
|
+
if not interface:
|
|
204
|
+
return jsonify({'error': '缺少网络接口参数'}), 400
|
|
205
|
+
|
|
206
|
+
# DHCP 模式不需要 IP/网关
|
|
207
|
+
if not use_dhcp and not all([ip, gateway]):
|
|
208
|
+
return jsonify({'error': '静态 IP 模式需要填写 IP 地址和网关'}), 400
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
netplan_file = set_static_ip(interface, ip, netmask, gateway, dns, use_dhcp)
|
|
212
|
+
|
|
213
|
+
if apply_now:
|
|
214
|
+
# 应用网络配置
|
|
215
|
+
result = apply_network()
|
|
216
|
+
if result.returncode != 0:
|
|
217
|
+
return jsonify({'error': f'配置已保存但应用失败: {result.stderr}'}), 500
|
|
218
|
+
|
|
219
|
+
# 等待网络恢复
|
|
220
|
+
import time
|
|
221
|
+
time.sleep(2)
|
|
222
|
+
|
|
223
|
+
# 重启依赖服务
|
|
224
|
+
services = ['config-manager.service', 'broadcast-panel.service', 'openclaw-gateway.service']
|
|
225
|
+
restarted = []
|
|
226
|
+
for svc in services:
|
|
227
|
+
try:
|
|
228
|
+
subprocess.run(['systemctl', 'restart', svc], capture_output=True, text=True, timeout=30)
|
|
229
|
+
restarted.append(svc)
|
|
230
|
+
except:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
return jsonify({
|
|
234
|
+
'success': True,
|
|
235
|
+
'message': f'配置已保存并应用,已重启服务: {", ".join(restarted) if restarted else "无"}',
|
|
236
|
+
'restarted': restarted
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
return jsonify({
|
|
240
|
+
'success': True,
|
|
241
|
+
'message': f'配置已保存到 {netplan_file}',
|
|
242
|
+
})
|
|
243
|
+
except Exception as e:
|
|
244
|
+
return jsonify({'error': str(e)}), 500
|
|
245
|
+
|
|
246
|
+
@app.route('/api/config', methods=['GET'])
|
|
247
|
+
def get_config():
|
|
248
|
+
"""获取 config.ini"""
|
|
249
|
+
config = parse_ini(CONFIG_INI)
|
|
250
|
+
return jsonify({'config': config})
|
|
251
|
+
|
|
252
|
+
@app.route('/api/config', methods=['POST'])
|
|
253
|
+
def set_config():
|
|
254
|
+
"""保存 config.ini"""
|
|
255
|
+
data = request.json
|
|
256
|
+
config = data.get('config')
|
|
257
|
+
|
|
258
|
+
if not config:
|
|
259
|
+
return jsonify({'error': '无配置数据'}), 400
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
# 备份
|
|
263
|
+
subprocess.run(['cp', CONFIG_INI, f'{CONFIG_INI}.bak'], stderr=subprocess.DEVNULL)
|
|
264
|
+
write_ini(CONFIG_INI, config)
|
|
265
|
+
return jsonify({'success': True, 'message': '配置已保存'})
|
|
266
|
+
except Exception as e:
|
|
267
|
+
return jsonify({'error': str(e)}), 500
|
|
268
|
+
|
|
269
|
+
@app.route('/api/qqbot', methods=['GET'])
|
|
270
|
+
def get_qqbot():
|
|
271
|
+
"""获取 QQBot 配置"""
|
|
272
|
+
try:
|
|
273
|
+
with open(OPENCLAW_CONFIG, 'r') as f:
|
|
274
|
+
config = json.load(f)
|
|
275
|
+
channels = config.get('channels', {}).get('qqbot', {})
|
|
276
|
+
plugin_enabled = config.get('plugins', {}).get('entries', {}).get('openclaw-qqbot', {}).get('enabled', True)
|
|
277
|
+
return jsonify({
|
|
278
|
+
'config': {
|
|
279
|
+
'uin': channels.get('appId', ''),
|
|
280
|
+
'password': channels.get('clientSecret', ''),
|
|
281
|
+
'enabled': plugin_enabled
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
except Exception as e:
|
|
285
|
+
return jsonify({'config': {'uin': '', 'password': ''}, 'error': str(e)})
|
|
286
|
+
|
|
287
|
+
@app.route('/api/qqbot', methods=['POST'])
|
|
288
|
+
def set_qqbot():
|
|
289
|
+
"""保存 QQBot 配置到 OpenClaw 配置文件"""
|
|
290
|
+
data = request.json
|
|
291
|
+
uin = data.get('uin', '')
|
|
292
|
+
password = data.get('password', '')
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
with open(OPENCLAW_CONFIG, 'r') as f:
|
|
296
|
+
config = json.load(f)
|
|
297
|
+
|
|
298
|
+
if 'channels' not in config:
|
|
299
|
+
config['channels'] = {}
|
|
300
|
+
if 'qqbot' not in config['channels']:
|
|
301
|
+
config['channels']['qqbot'] = {}
|
|
302
|
+
|
|
303
|
+
config['channels']['qqbot']['appId'] = uin
|
|
304
|
+
config['channels']['qqbot']['clientSecret'] = password
|
|
305
|
+
|
|
306
|
+
with open(OPENCLAW_CONFIG, 'w') as f:
|
|
307
|
+
json.dump(config, f, indent=2)
|
|
308
|
+
|
|
309
|
+
return jsonify({
|
|
310
|
+
'success': True,
|
|
311
|
+
'message': 'QQBot 配置已保存,需要重启 OpenClaw Gateway 才能生效'
|
|
312
|
+
})
|
|
313
|
+
except Exception as e:
|
|
314
|
+
return jsonify({'error': str(e)}), 500
|
|
315
|
+
|
|
316
|
+
@app.route('/api/qqbot-toggle', methods=['POST'])
|
|
317
|
+
def toggle_qqbot():
|
|
318
|
+
"""启用/禁用 QQBot"""
|
|
319
|
+
data = request.json
|
|
320
|
+
enabled = data.get('enabled', True)
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
with open(OPENCLAW_CONFIG, 'r') as f:
|
|
324
|
+
config = json.load(f)
|
|
325
|
+
|
|
326
|
+
if 'plugins' not in config:
|
|
327
|
+
config['plugins'] = {}
|
|
328
|
+
if 'entries' not in config['plugins']:
|
|
329
|
+
config['plugins']['entries'] = {}
|
|
330
|
+
if 'openclaw-qqbot' not in config['plugins']['entries']:
|
|
331
|
+
config['plugins']['entries']['openclaw-qqbot'] = {}
|
|
332
|
+
|
|
333
|
+
config['plugins']['entries']['openclaw-qqbot']['enabled'] = enabled
|
|
334
|
+
|
|
335
|
+
with open(OPENCLAW_CONFIG, 'w') as f:
|
|
336
|
+
json.dump(config, f, indent=2)
|
|
337
|
+
|
|
338
|
+
return jsonify({
|
|
339
|
+
'success': True,
|
|
340
|
+
'message': f'QQBot 已{"启用" if enabled else "禁用"},需要重启 Gateway 才能生效'
|
|
341
|
+
})
|
|
342
|
+
except Exception as e:
|
|
343
|
+
return jsonify({'error': str(e)}), 500
|
|
344
|
+
|
|
345
|
+
@app.route('/api/netplan-apply', methods=['POST'])
|
|
346
|
+
def apply_netplan():
|
|
347
|
+
"""应用网络配置"""
|
|
348
|
+
try:
|
|
349
|
+
result = apply_network()
|
|
350
|
+
if result.returncode == 0:
|
|
351
|
+
return jsonify({'success': True, 'message': '网络配置已应用'})
|
|
352
|
+
else:
|
|
353
|
+
return jsonify({'error': result.stderr}), 500
|
|
354
|
+
except Exception as e:
|
|
355
|
+
return jsonify({'error': str(e)}), 500
|
|
356
|
+
|
|
357
|
+
@app.route('/api/adoremix-restart', methods=['POST'])
|
|
358
|
+
def restart_adoremix():
|
|
359
|
+
"""重启小播鼠服务"""
|
|
360
|
+
try:
|
|
361
|
+
result = subprocess.run(['systemctl', 'restart', 'adoremix'], capture_output=True, text=True, timeout=30)
|
|
362
|
+
if result.returncode == 0:
|
|
363
|
+
return jsonify({'success': True, 'message': '小播鼠服务已重启'})
|
|
364
|
+
else:
|
|
365
|
+
return jsonify({'error': result.stderr}), 500
|
|
366
|
+
except Exception as e:
|
|
367
|
+
return jsonify({'error': str(e)}), 500
|
|
368
|
+
|
|
369
|
+
@app.route('/api/openclaw-restart', methods=['POST'])
|
|
370
|
+
def restart_openclaw():
|
|
371
|
+
"""重启 OpenClaw Gateway"""
|
|
372
|
+
try:
|
|
373
|
+
result = subprocess.run(['openclaw', 'gateway', 'restart'], capture_output=True, text=True, timeout=300)
|
|
374
|
+
return jsonify({'success': True, 'message': 'OpenClaw Gateway 正在重启'})
|
|
375
|
+
except Exception as e:
|
|
376
|
+
return jsonify({'error': str(e)}), 500
|
|
377
|
+
|
|
378
|
+
@app.route('/api/models', methods=['GET'])
|
|
379
|
+
def get_models():
|
|
380
|
+
"""获取模型配置"""
|
|
381
|
+
try:
|
|
382
|
+
with open(OPENCLAW_CONFIG, 'r') as f:
|
|
383
|
+
config = json.load(f)
|
|
384
|
+
|
|
385
|
+
agents = config.get('agents', {}).get('defaults', {})
|
|
386
|
+
model_val = agents.get('model', '')
|
|
387
|
+
primary = model_val if isinstance(model_val, str) else model_val.get('primary', '')
|
|
388
|
+
models_config = agents.get('models', {})
|
|
389
|
+
|
|
390
|
+
# 获取各提供商的 API key
|
|
391
|
+
api_keys = {}
|
|
392
|
+
for model_id, model_cfg in models_config.items():
|
|
393
|
+
if isinstance(model_cfg, dict) and 'apiKey' in model_cfg:
|
|
394
|
+
api_keys[model_id] = model_cfg['apiKey']
|
|
395
|
+
|
|
396
|
+
return jsonify({
|
|
397
|
+
'primary': primary,
|
|
398
|
+
'models': models_config,
|
|
399
|
+
'apiKeys': api_keys,
|
|
400
|
+
'availableModels': POPULAR_MODELS
|
|
401
|
+
})
|
|
402
|
+
except Exception as e:
|
|
403
|
+
return jsonify({'error': str(e)}), 500
|
|
404
|
+
|
|
405
|
+
@app.route('/api/models', methods=['POST'])
|
|
406
|
+
def set_models():
|
|
407
|
+
"""保存模型配置"""
|
|
408
|
+
data = request.json
|
|
409
|
+
primary = data.get('primary', '')
|
|
410
|
+
model_configs = data.get('modelConfigs', {})
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
with open(OPENCLAW_CONFIG, 'r') as f:
|
|
414
|
+
config = json.load(f)
|
|
415
|
+
|
|
416
|
+
if 'agents' not in config:
|
|
417
|
+
config['agents'] = {}
|
|
418
|
+
if 'defaults' not in config['agents']:
|
|
419
|
+
config['agents']['defaults'] = {}
|
|
420
|
+
if 'models' not in config['agents']['defaults']:
|
|
421
|
+
config['agents']['defaults']['models'] = {}
|
|
422
|
+
|
|
423
|
+
# 设置主模型(直接存字符串)
|
|
424
|
+
config['agents']['defaults']['model'] = primary
|
|
425
|
+
|
|
426
|
+
# 确保主模型在 models 列表中
|
|
427
|
+
if primary not in config['agents']['defaults']['models']:
|
|
428
|
+
config['agents']['defaults']['models'][primary] = {}
|
|
429
|
+
|
|
430
|
+
# 保存模型配置 (apiKey + baseUrl)
|
|
431
|
+
for model_id, model_cfg in model_configs.items():
|
|
432
|
+
if model_id not in config['agents']['defaults']['models']:
|
|
433
|
+
config['agents']['defaults']['models'][model_id] = {}
|
|
434
|
+
|
|
435
|
+
if isinstance(model_cfg, dict):
|
|
436
|
+
if 'apiKey' in model_cfg:
|
|
437
|
+
config['agents']['defaults']['models'][model_id]['apiKey'] = model_cfg['apiKey']
|
|
438
|
+
if 'baseUrl' in model_cfg:
|
|
439
|
+
config['agents']['defaults']['models'][model_id]['baseUrl'] = model_cfg['baseUrl']
|
|
440
|
+
|
|
441
|
+
with open(OPENCLAW_CONFIG, 'w') as f:
|
|
442
|
+
json.dump(config, f, indent=2)
|
|
443
|
+
|
|
444
|
+
return jsonify({
|
|
445
|
+
'success': True,
|
|
446
|
+
'message': '模型配置已保存,需要重启 Gateway 才能生效'
|
|
447
|
+
})
|
|
448
|
+
except Exception as e:
|
|
449
|
+
return jsonify({'error': str(e)}), 500
|
|
450
|
+
|
|
451
|
+
# ==================== 实时日志查看 ====================
|
|
452
|
+
|
|
453
|
+
# 常用服务预设(优先列在前面)
|
|
454
|
+
LOG_SERVICES_PRESET = [
|
|
455
|
+
'adoremix',
|
|
456
|
+
'config-manager',
|
|
457
|
+
'openclaw-gateway',
|
|
458
|
+
'smbd',
|
|
459
|
+
'nmbd',
|
|
460
|
+
'ssh',
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
# 文件日志源(不是 systemd 服务,是应用自己的日志文件)
|
|
464
|
+
LOG_FILE_SOURCES = {
|
|
465
|
+
'adoremix-app': os.path.join(WORKDIR, 'var', 'app.log'),
|
|
466
|
+
'config-manager-out': '/var/log/config-manager.log', # 预留,可能不存在
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
@app.route('/api/logs', methods=['GET'])
|
|
470
|
+
def api_logs():
|
|
471
|
+
"""拉取 systemd journal 日志 或 文件日志"""
|
|
472
|
+
service = request.args.get('service', 'adoremix').strip()
|
|
473
|
+
try:
|
|
474
|
+
lines = int(request.args.get('lines', 200))
|
|
475
|
+
except ValueError:
|
|
476
|
+
lines = 200
|
|
477
|
+
lines = max(1, min(lines, 2000))
|
|
478
|
+
|
|
479
|
+
# 时间范围过滤:since 形如 "5min" / "30min" / "1h" / "2h" / "today" / "all" / ISO timestamp
|
|
480
|
+
since = request.args.get('since', 'all').strip()
|
|
481
|
+
|
|
482
|
+
if not service:
|
|
483
|
+
return jsonify({'error': '缺少 service 参数', 'lines': []}), 400
|
|
484
|
+
|
|
485
|
+
# 验证服务名(防注入:只允许字母数字下划线短横线点)
|
|
486
|
+
if not re.match(r'^[A-Za-z0-9._-]+$', service):
|
|
487
|
+
return jsonify({'error': '非法服务名', 'lines': []}), 400
|
|
488
|
+
|
|
489
|
+
# === 文件源 ===
|
|
490
|
+
if service in LOG_FILE_SOURCES:
|
|
491
|
+
file_path = LOG_FILE_SOURCES[service]
|
|
492
|
+
try:
|
|
493
|
+
if not os.path.exists(file_path):
|
|
494
|
+
return jsonify({
|
|
495
|
+
'service': service,
|
|
496
|
+
'source': 'file',
|
|
497
|
+
'path': file_path,
|
|
498
|
+
'lines': [],
|
|
499
|
+
'count': 0,
|
|
500
|
+
'timestamp': datetime.now(timezone.utc).isoformat(),
|
|
501
|
+
'warning': f'文件不存在: {file_path}'
|
|
502
|
+
})
|
|
503
|
+
# tail 取最后 N 行
|
|
504
|
+
result = subprocess.run(
|
|
505
|
+
['tail', '-n', str(lines), file_path],
|
|
506
|
+
capture_output=True, text=True, timeout=5
|
|
507
|
+
)
|
|
508
|
+
log_lines = result.stdout.splitlines() if result.stdout else []
|
|
509
|
+
return jsonify({
|
|
510
|
+
'service': service,
|
|
511
|
+
'source': 'file',
|
|
512
|
+
'path': file_path,
|
|
513
|
+
'lines': log_lines,
|
|
514
|
+
'count': len(log_lines),
|
|
515
|
+
'timestamp': datetime.now(timezone.utc).isoformat()
|
|
516
|
+
})
|
|
517
|
+
except subprocess.TimeoutExpired:
|
|
518
|
+
return jsonify({'error': 'tail timeout (>5s)', 'lines': []}), 500
|
|
519
|
+
except Exception as e:
|
|
520
|
+
return jsonify({'error': str(e), 'lines': []}), 500
|
|
521
|
+
|
|
522
|
+
# === journalctl 源 ===
|
|
523
|
+
# 映射快捷时间
|
|
524
|
+
since_map = {
|
|
525
|
+
'5min': '5 min ago',
|
|
526
|
+
'10min': '10 min ago',
|
|
527
|
+
'30min': '30 min ago',
|
|
528
|
+
'1h': '1 hour ago',
|
|
529
|
+
'2h': '2 hours ago',
|
|
530
|
+
'6h': '6 hours ago',
|
|
531
|
+
'12h': '12 hours ago',
|
|
532
|
+
'today': 'today',
|
|
533
|
+
'yesterday': 'yesterday',
|
|
534
|
+
}
|
|
535
|
+
since_value = since_map.get(since, since)
|
|
536
|
+
use_since = since_value and since_value != 'all'
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
cmd = ['journalctl', '-u', service, '--no-pager', '-n', str(lines)]
|
|
540
|
+
if use_since:
|
|
541
|
+
cmd += ['--since', since_value]
|
|
542
|
+
|
|
543
|
+
result = subprocess.run(
|
|
544
|
+
cmd,
|
|
545
|
+
capture_output=True,
|
|
546
|
+
text=True,
|
|
547
|
+
timeout=10
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
log_lines = result.stdout.splitlines() if result.stdout else []
|
|
551
|
+
|
|
552
|
+
return jsonify({
|
|
553
|
+
'service': service,
|
|
554
|
+
'source': 'journalctl',
|
|
555
|
+
'since': since,
|
|
556
|
+
'lines': log_lines,
|
|
557
|
+
'count': len(log_lines),
|
|
558
|
+
'timestamp': datetime.now(timezone.utc).isoformat()
|
|
559
|
+
})
|
|
560
|
+
except subprocess.TimeoutExpired:
|
|
561
|
+
return jsonify({'error': 'journalctl timeout (>10s)', 'lines': []}), 500
|
|
562
|
+
except FileNotFoundError:
|
|
563
|
+
return jsonify({'error': 'journalctl 未安装', 'lines': []}), 500
|
|
564
|
+
except Exception as e:
|
|
565
|
+
return jsonify({'error': str(e), 'lines': []}), 500
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@app.route('/api/logs/services', methods=['GET'])
|
|
569
|
+
def api_logs_services():
|
|
570
|
+
"""列出常用服务(用于下拉菜单)"""
|
|
571
|
+
services = list(LOG_SERVICES_PRESET)
|
|
572
|
+
try:
|
|
573
|
+
# 额外获取当前 running 的服务
|
|
574
|
+
result = subprocess.run(
|
|
575
|
+
['systemctl', 'list-units', '--type=service', '--no-pager', '--no-legend', '--state=running'],
|
|
576
|
+
capture_output=True, text=True, timeout=5
|
|
577
|
+
)
|
|
578
|
+
for line in result.stdout.splitlines():
|
|
579
|
+
parts = line.split()
|
|
580
|
+
if parts and parts[0].endswith('.service'):
|
|
581
|
+
name = parts[0][:-len('.service')]
|
|
582
|
+
if name not in services:
|
|
583
|
+
services.append(name)
|
|
584
|
+
except Exception:
|
|
585
|
+
pass
|
|
586
|
+
return jsonify({'services': services})
|
|
587
|
+
|
|
588
|
+
if __name__ == '__main__':
|
|
589
|
+
app.run(host='::', port=9877, debug=False)
|