aria-code 4.1.3__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.
- agents/__init__.py +32 -0
- agents/base.py +190 -0
- agents/deep/__init__.py +37 -0
- agents/deep/calibration_loop.py +144 -0
- agents/deep/critic.py +125 -0
- agents/deep/deepen.py +193 -0
- agents/deep/models.py +149 -0
- agents/deep/pipeline.py +164 -0
- agents/deep/quant_fusion.py +192 -0
- agents/deep/themes.py +95 -0
- agents/deep/tiers.py +106 -0
- agents/financial/__init__.py +10 -0
- agents/financial/catalyst.py +279 -0
- agents/financial/debate.py +145 -0
- agents/financial/earnings.py +303 -0
- agents/financial/fundamental.py +159 -0
- agents/financial/macro.py +99 -0
- agents/financial/news.py +207 -0
- agents/financial/risk.py +132 -0
- agents/financial/sector.py +279 -0
- agents/financial/synthesis.py +274 -0
- agents/financial/technical.py +258 -0
- agents/portfolio_agent.py +333 -0
- agents/realty/__init__.py +62 -0
- agents/realty/asset_diagnosis.py +150 -0
- agents/realty/business_match.py +165 -0
- agents/realty/cashflow_verify.py +208 -0
- agents/realty/contract_rules.py +209 -0
- agents/realty/energy_anomaly.py +188 -0
- agents/realty/exit_settlement.py +207 -0
- agents/realty/fulfillment_risk.py +205 -0
- agents/realty/ops_optimize.py +159 -0
- agents/realty/revenue_share.py +214 -0
- agents/registry.py +144 -0
- agents/sports/__init__.py +0 -0
- agents/sports/football_agent.py +169 -0
- agents/team.py +289 -0
- aliyun_data_client.py +660 -0
- apps/README.md +12 -0
- apps/__init__.py +2 -0
- apps/channels/README.md +15 -0
- apps/cli/README.md +13 -0
- apps/cli/__init__.py +2 -0
- apps/cli/bootstrap.py +99 -0
- apps/cli/codegen_paths.py +29 -0
- apps/cli/commands/__init__.py +16 -0
- apps/cli/commands/analysis_cmds.py +288 -0
- apps/cli/commands/backtest_cmds.py +1887 -0
- apps/cli/commands/broker_cmds.py +1154 -0
- apps/cli/commands/business_workflow_cmds.py +289 -0
- apps/cli/commands/catalog.py +84 -0
- apps/cli/commands/data_cmds.py +405 -0
- apps/cli/commands/diagnostic_cmds.py +179 -0
- apps/cli/commands/diagnostic_ops_cmds.py +696 -0
- apps/cli/commands/finance_render.py +12 -0
- apps/cli/commands/market.py +399 -0
- apps/cli/commands/market_cmds.py +1276 -0
- apps/cli/commands/market_context.py +425 -0
- apps/cli/commands/market_render.py +7 -0
- apps/cli/commands/model_cmds.py +1579 -0
- apps/cli/commands/ops_cmds.py +668 -0
- apps/cli/commands/portfolio_cmds.py +962 -0
- apps/cli/commands/report.py +377 -0
- apps/cli/commands/scaffold_templates.py +617 -0
- apps/cli/commands/session_cmds.py +179 -0
- apps/cli/commands/session_ux_cmds.py +280 -0
- apps/cli/commands/team.py +588 -0
- apps/cli/commands/team_render.py +8 -0
- apps/cli/commands/ui_cmds.py +358 -0
- apps/cli/commands/workflow_cmds.py +279 -0
- apps/cli/commands/workspace_cmds.py +1414 -0
- apps/cli/config_paths.py +70 -0
- apps/cli/config_store.py +61 -0
- apps/cli/deterministic.py +122 -0
- apps/cli/direct.py +48 -0
- apps/cli/github_app_auth.py +135 -0
- apps/cli/handlers/__init__.py +11 -0
- apps/cli/handlers/broker_handlers.py +122 -0
- apps/cli/handlers/chart_handlers.py +1309 -0
- apps/cli/handlers/market_handlers.py +2509 -0
- apps/cli/handlers/realty_handlers.py +114 -0
- apps/cli/handlers/strategy_advice.py +82 -0
- apps/cli/hooks.py +180 -0
- apps/cli/i18n.py +284 -0
- apps/cli/intent.py +136 -0
- apps/cli/intent_router.py +217 -0
- apps/cli/lifecycle_hooks.py +48 -0
- apps/cli/main.py +29 -0
- apps/cli/market_metadata.py +135 -0
- apps/cli/market_universe.py +265 -0
- apps/cli/message_processing.py +257 -0
- apps/cli/plan_mode.py +139 -0
- apps/cli/plotly_html.py +15 -0
- apps/cli/prediction_feedback.py +202 -0
- apps/cli/preflight.py +497 -0
- apps/cli/project_aria.py +60 -0
- apps/cli/prompts/__init__.py +0 -0
- apps/cli/prompts/coding.py +658 -0
- apps/cli/prompts/system_prompts.py +531 -0
- apps/cli/prompts/ui.py +434 -0
- apps/cli/providers/__init__.py +1 -0
- apps/cli/providers/base.py +271 -0
- apps/cli/providers/chat_routing.py +80 -0
- apps/cli/providers/llm/__init__.py +1 -0
- apps/cli/providers/llm/ollama_stream.py +1170 -0
- apps/cli/providers/llm/sse_stream.py +216 -0
- apps/cli/providers/runtime_bridge.py +185 -0
- apps/cli/runtime_consumer.py +489 -0
- apps/cli/session_export.py +87 -0
- apps/cli/session_jsonl.py +207 -0
- apps/cli/session_store.py +112 -0
- apps/cli/todo_tracker.py +190 -0
- apps/cli/tools/__init__.py +40 -0
- apps/cli/tools/context.py +46 -0
- apps/cli/tools/file_tools.py +112 -0
- apps/cli/tools/market_tools.py +549 -0
- apps/cli/tools/notebook_tools.py +111 -0
- apps/cli/tools/system_tools.py +669 -0
- apps/cli/tools/write_tools.py +715 -0
- apps/cli/tradingview_bridge.py +434 -0
- apps/cli/update_check.py +152 -0
- apps/cli/utils/__init__.py +0 -0
- apps/cli/utils/market_detect.py +1578 -0
- apps/daemon/README.md +14 -0
- apps/vscode/README.md +115 -0
- apps/vscode/package.json +70 -0
- aria_cli.py +11636 -0
- aria_code-4.1.3.dist-info/METADATA +952 -0
- aria_code-4.1.3.dist-info/RECORD +284 -0
- aria_code-4.1.3.dist-info/WHEEL +5 -0
- aria_code-4.1.3.dist-info/entry_points.txt +2 -0
- aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
- aria_code-4.1.3.dist-info/top_level.txt +50 -0
- aria_daemon.py +1295 -0
- aria_feishu_bot.py +1359 -0
- aria_relay_client.py +182 -0
- aria_relay_server.py +405 -0
- aria_telegram_bot.py +202 -0
- ariarc.py +328 -0
- artifacts.py +491 -0
- backtest_report.py +472 -0
- brokers/__init__.py +72 -0
- brokers/base.py +207 -0
- brokers/capabilities.py +264 -0
- brokers/cn/__init__.py +10 -0
- brokers/cn/easytrader_broker.py +193 -0
- brokers/cn/futu_broker.py +194 -0
- brokers/cn/longbridge_broker.py +190 -0
- brokers/cn/tiger_broker.py +196 -0
- brokers/cn/xtquant_broker.py +175 -0
- brokers/config.py +364 -0
- brokers/intl/__init__.py +5 -0
- brokers/intl/alpaca_broker.py +183 -0
- brokers/intl/ibkr_broker.py +215 -0
- brokers/intl/webull_broker.py +156 -0
- brokers/paper_broker.py +259 -0
- brokers/planning.py +296 -0
- brokers/registry.py +181 -0
- brokers/trading.py +237 -0
- change_store.py +127 -0
- command_safety.py +19 -0
- computer_use_tools.py +504 -0
- dashboard_generator.py +578 -0
- data_analysis_tools.py +808 -0
- data_cleaner.py +483 -0
- data_service.py +481 -0
- datasources/__init__.py +23 -0
- datasources/base.py +166 -0
- datasources/router.py +221 -0
- datasources/sources/__init__.py +15 -0
- datasources/sources/akshare_source.py +269 -0
- datasources/sources/alpha_vantage_source.py +202 -0
- datasources/sources/edgar_source.py +218 -0
- datasources/sources/finnhub_source.py +197 -0
- datasources/sources/fred_source.py +219 -0
- datasources/sources/tushare_source.py +141 -0
- datasources/sources/web_scraper_source.py +278 -0
- datasources/sources/world_bank_source.py +205 -0
- datasources/sources/yfinance_source.py +152 -0
- demo_player.py +204 -0
- doctor.py +508 -0
- file_analysis_tools.py +734 -0
- finance_formulas.py +389 -0
- football_data_client.py +1670 -0
- intent_classifier.py +358 -0
- local_finance_tools.py +3221 -0
- local_llm_provider.py +552 -0
- macro_tools.py +368 -0
- market_data_client.py +1899 -0
- mcp_client.py +506 -0
- memory_manager.py +245 -0
- model_capability.py +416 -0
- notification_tools.py +248 -0
- packages/__init__.py +23 -0
- packages/aria_agents/__init__.py +5 -0
- packages/aria_agents/manifest.py +69 -0
- packages/aria_core/__init__.py +34 -0
- packages/aria_core/architecture.py +192 -0
- packages/aria_core/export.py +124 -0
- packages/aria_core/manifest.py +65 -0
- packages/aria_infra/__init__.py +15 -0
- packages/aria_infra/arthera.py +52 -0
- packages/aria_infra/doctor.py +246 -0
- packages/aria_infra/product.py +37 -0
- packages/aria_mcp/__init__.py +25 -0
- packages/aria_mcp/bridge.py +38 -0
- packages/aria_mcp/config.py +97 -0
- packages/aria_mcp/tools.py +61 -0
- packages/aria_sdk/__init__.py +19 -0
- packages/aria_sdk/client.py +396 -0
- packages/aria_sdk/providers.py +70 -0
- packages/aria_sdk/streaming.py +73 -0
- packages/aria_sdk/types.py +86 -0
- packages/aria_services/__init__.py +55 -0
- packages/aria_services/context.py +258 -0
- packages/aria_services/data.py +11 -0
- packages/aria_services/provider_health.py +189 -0
- packages/aria_services/registry.py +213 -0
- packages/aria_services/usage.py +138 -0
- packages/aria_skills/__init__.py +5 -0
- packages/aria_skills/registry.py +59 -0
- packages/aria_tools/__init__.py +5 -0
- packages/aria_tools/registry.py +128 -0
- packages/quant_engine/__init__.py +6 -0
- packages/quant_engine/sports/__init__.py +72 -0
- packages/quant_engine/sports/calibrator.py +353 -0
- packages/quant_engine/sports/dixon_coles.py +234 -0
- packages/quant_engine/sports/elo.py +299 -0
- packages/quant_engine/sports/form.py +188 -0
- packages/quant_engine/sports/h2h.py +195 -0
- packages/quant_engine/sports/ml_model.py +354 -0
- packages/quant_engine/sports/predictor.py +311 -0
- packages/quant_engine/sports/tracker.py +664 -0
- packages/quant_engine/stochastic/__init__.py +27 -0
- packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- packages/quant_engine/stochastic/ito_calculus.py +477 -0
- packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- packages/quant_engine/stochastic/options_pricing.py +573 -0
- packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- plan_utils.py +194 -0
- plugin_loader.py +328 -0
- portfolio_ledger.py +262 -0
- privacy/__init__.py +5 -0
- privacy/feedback.py +123 -0
- project_tools.py +525 -0
- providers/__init__.py +30 -0
- providers/llm/__init__.py +19 -0
- providers/llm/anthropic.py +184 -0
- providers/llm/base.py +139 -0
- providers/llm/ollama.py +128 -0
- providers/llm/openai_compat.py +282 -0
- providers/llm/registry.py +358 -0
- realty_data_tools.py +659 -0
- report_generator.py +1314 -0
- runtime/__init__.py +103 -0
- runtime/agent_loop.py +1183 -0
- runtime/approval.py +51 -0
- runtime/events.py +102 -0
- runtime/gateway.py +128 -0
- runtime/lsp.py +346 -0
- runtime/subagent.py +258 -0
- runtime/tool_executor.py +104 -0
- runtime/tool_policy.py +106 -0
- safety/__init__.py +21 -0
- safety/permissions.py +275 -0
- setup_wizard.py +653 -0
- strategy_vault.py +420 -0
- ui/__init__.py +100 -0
- ui/banner.py +310 -0
- ui/completer.py +391 -0
- ui/console.py +271 -0
- ui/image_render.py +243 -0
- ui/input_box.py +376 -0
- ui/picker.py +195 -0
- ui/render/__init__.py +11 -0
- ui/render/finance.py +1480 -0
- ui/render/market.py +225 -0
- ui/render/output.py +681 -0
- ui/render/team.py +346 -0
- ui/robot.py +235 -0
- workspace/__init__.py +6 -0
- workspace/files.py +170 -0
- workspace/verify.py +113 -0
|
@@ -0,0 +1,1579 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ModelCommandsMixin — Model/config commands: model, apikey, providers, cloud, config, tools, skills.
|
|
3
|
+
|
|
4
|
+
Extracted from aria_cli.py. Methods' __globals__ are rebound to aria_cli's namespace
|
|
5
|
+
by _rebind_mixin_globals() called at module load time.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ModelCommandsMixin:
|
|
11
|
+
"""Mixin: Model/config commands: model, apikey, providers, cloud, config, tools, skills."""
|
|
12
|
+
|
|
13
|
+
async def cmd_model(self, args: str):
|
|
14
|
+
name = args.strip()
|
|
15
|
+
|
|
16
|
+
# ── "provider/model" format (Open Interpreter style) ─────────────────
|
|
17
|
+
# Examples: /model deepseek/deepseek-chat /model ollama/qwen2.5:7b
|
|
18
|
+
# /model openai/gpt-4.5 /model openai/o3 /model openai/o4-mini
|
|
19
|
+
if "/" in name and not name.startswith("http"):
|
|
20
|
+
_prov, _mod = name.split("/", 1)
|
|
21
|
+
_prov = _prov.strip().lower()
|
|
22
|
+
_mod = _mod.strip()
|
|
23
|
+
_local_backends = {"ollama", "lmstudio", "vllm", "llamacpp", "jan", "custom"}
|
|
24
|
+
if _prov not in _local_backends:
|
|
25
|
+
# Cloud provider — check API key
|
|
26
|
+
_key = _get_provider_key(_prov)
|
|
27
|
+
if not _key:
|
|
28
|
+
msg = (f"⚠ {_prov} API key 未配置。"
|
|
29
|
+
f"运行: /apikey set {_prov} <key>")
|
|
30
|
+
console.print(f"[yellow]{msg}[/yellow]") if HAS_RICH else print(msg)
|
|
31
|
+
return
|
|
32
|
+
self.terminal.config["local_provider"] = _prov
|
|
33
|
+
self.terminal.config["model"] = _mod
|
|
34
|
+
save_config(self.terminal.config)
|
|
35
|
+
msg = f"✓ 已切换到 {_prov}/{_mod}"
|
|
36
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
# Direct selection by number: /model 1 /model 2 … (Codex style)
|
|
40
|
+
if name.isdigit():
|
|
41
|
+
idx = int(name) - 1
|
|
42
|
+
keys = list(MODELS.keys())
|
|
43
|
+
if 0 <= idx < len(keys):
|
|
44
|
+
self._set_model(keys[idx])
|
|
45
|
+
else:
|
|
46
|
+
console.print(f"[dim]No model #{name}[/dim]" if HAS_RICH else f"No model #{name}")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
# Direct selection by key (case-insensitive): /model qwen7b
|
|
50
|
+
if name.lower() in MODELS:
|
|
51
|
+
self._set_model(name.lower())
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Direct selection by alias: /model st / s / p / coder
|
|
55
|
+
if name.lower() in MODEL_ALIASES:
|
|
56
|
+
self._set_model(MODEL_ALIASES[name.lower()])
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Direct selection by full Ollama model ID: /model qwen2.5-coder:1.5b
|
|
60
|
+
if name and ":" in name:
|
|
61
|
+
self._set_model_by_id(name)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# ── Interactive picker (Codex style: numbered list + descriptions) ────
|
|
65
|
+
ollama_url = self.terminal.config.get("ollama_url", "http://localhost:11434")
|
|
66
|
+
current_id = self.terminal.config.get("model", "qwen2.5:7b")
|
|
67
|
+
try:
|
|
68
|
+
from apps.cli.i18n import t as _i18nt
|
|
69
|
+
_lang = self.terminal.config.get("ui_lang", "en") or "en"
|
|
70
|
+
_i18n = lambda k: _i18nt(k, lang=_lang)
|
|
71
|
+
except Exception:
|
|
72
|
+
_lang = "en"
|
|
73
|
+
_i18n = lambda k: k
|
|
74
|
+
|
|
75
|
+
rich_models, ollama_err = detect_ollama_models_rich(ollama_url)
|
|
76
|
+
installed_names = {m["name"] for m in rich_models}
|
|
77
|
+
aria_ids = {m["id"] for m in MODELS.values()}
|
|
78
|
+
|
|
79
|
+
# ── Build picker title (one line, shown inside arrow_select header) ──
|
|
80
|
+
_sel_model = _i18n("select_model")
|
|
81
|
+
_installed = _i18n("installed")
|
|
82
|
+
if ollama_err:
|
|
83
|
+
_picker_title = f"{_sel_model} [Ollama: {ollama_err[:40]}]"
|
|
84
|
+
else:
|
|
85
|
+
n_installed = sum(1 for m in MODELS.values() if m["id"] in installed_names)
|
|
86
|
+
_picker_title = f"{_sel_model} {n_installed}/{len(MODELS)} {_installed} · /model <id> or number"
|
|
87
|
+
|
|
88
|
+
def _status_tag(mid: str, badge: str) -> str:
|
|
89
|
+
"""Return short status: ● installed / ○ not installed / ☁ cloud"""
|
|
90
|
+
if badge == "Cloud":
|
|
91
|
+
return "☁"
|
|
92
|
+
return "●" if mid in installed_names else "○"
|
|
93
|
+
|
|
94
|
+
# Get terminal width for safe label truncation
|
|
95
|
+
try:
|
|
96
|
+
_term_cols = os.get_terminal_size().columns
|
|
97
|
+
except Exception:
|
|
98
|
+
_term_cols = 80
|
|
99
|
+
|
|
100
|
+
def _cjk_width(s: str) -> int:
|
|
101
|
+
"""Display-column width (CJK = 2 cols each)."""
|
|
102
|
+
w = 0
|
|
103
|
+
for ch in s:
|
|
104
|
+
cp = ord(ch)
|
|
105
|
+
w += 2 if (0x2E80 <= cp <= 0xA4CF or 0xAC00 <= cp <= 0xD7AF or
|
|
106
|
+
0xFF01 <= cp <= 0xFF60 or 0x3000 <= cp <= 0x303F) else 1
|
|
107
|
+
return w
|
|
108
|
+
|
|
109
|
+
def _cjk_truncate(s: str, max_cols: int) -> str:
|
|
110
|
+
"""Truncate s so its display width ≤ max_cols, adding … if cut."""
|
|
111
|
+
w, out = 0, ""
|
|
112
|
+
for ch in s:
|
|
113
|
+
cw = 2 if (0x2E80 <= ord(ch) <= 0xA4CF or
|
|
114
|
+
0xAC00 <= ord(ch) <= 0xD7AF or
|
|
115
|
+
0xFF01 <= ord(ch) <= 0xFF60 or
|
|
116
|
+
0x3000 <= ord(ch) <= 0x303F) else 1
|
|
117
|
+
if w + cw > max_cols:
|
|
118
|
+
return out + "…"
|
|
119
|
+
out += ch
|
|
120
|
+
w += cw
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
def _short_desc(m: dict) -> str:
|
|
124
|
+
"""Single-line description with right-aligned meta tags."""
|
|
125
|
+
desc = m.get("description", "")
|
|
126
|
+
badge = m.get("badge", "")
|
|
127
|
+
extras = []
|
|
128
|
+
if _HAS_MODEL_CAP:
|
|
129
|
+
cap = get_model_capability(m["id"])
|
|
130
|
+
extras.append(f"ctx={cap.context_window//1024}K")
|
|
131
|
+
if cap.tool_calls: extras.append("tools✓")
|
|
132
|
+
if cap.thinking: extras.append("think")
|
|
133
|
+
else:
|
|
134
|
+
extras.append(f"ctx={m.get('num_ctx', 8192)//1024}K")
|
|
135
|
+
if badge in ("Fast", "Code", "Think", "Cloud"):
|
|
136
|
+
extras.insert(0, badge)
|
|
137
|
+
meta = " " + " · ".join(extras) if extras else ""
|
|
138
|
+
# prefix " N. ☁ ModelName " ≈ 24 cols; give description 60% of remaining
|
|
139
|
+
_prefix_cols = 24
|
|
140
|
+
_avail = max(30, _term_cols - _prefix_cols - len(meta) - 2)
|
|
141
|
+
_desc_budget = max(20, _avail * 3 // 4) # 75% of available → description
|
|
142
|
+
desc = _cjk_truncate(desc, _desc_budget)
|
|
143
|
+
return f"{desc}{meta}"
|
|
144
|
+
|
|
145
|
+
# Build option list (Codex: numbered, no separators within Aria section)
|
|
146
|
+
options: list = [] # (label_str, desc_str) for _arrow_select
|
|
147
|
+
all_ids: list = []
|
|
148
|
+
|
|
149
|
+
# ── Print numbered list only in non-interactive (-p) mode ────────────
|
|
150
|
+
# In interactive TTY mode the arrow picker below already shows all items.
|
|
151
|
+
# Printing twice causes the visual duplication seen in the session log.
|
|
152
|
+
_is_tty = sys.stdin.isatty()
|
|
153
|
+
idx_counter = 1
|
|
154
|
+
if not _is_tty:
|
|
155
|
+
# Non-interactive (-p mode): show static numbered list then return.
|
|
156
|
+
# The arrow picker cannot run without a TTY.
|
|
157
|
+
community_list = [cm for cm in rich_models if cm["name"] not in aria_ids]
|
|
158
|
+
for key, m in MODELS.items():
|
|
159
|
+
mid = m["id"]
|
|
160
|
+
is_cur = mid == current_id
|
|
161
|
+
status = _status_tag(mid, m.get("badge", ""))
|
|
162
|
+
cur_tag = " (current)" if is_cur else ""
|
|
163
|
+
desc = _short_desc(m)
|
|
164
|
+
line = f" {idx_counter}. {status} {m['name']:<14s} {desc}{cur_tag}"
|
|
165
|
+
console.print(line) if HAS_RICH else print(line)
|
|
166
|
+
idx_counter += 1
|
|
167
|
+
if community_list:
|
|
168
|
+
console.print() if HAS_RICH else print()
|
|
169
|
+
lbl = " Community (Ollama)"
|
|
170
|
+
console.print(f"[dim]{lbl}[/dim]") if HAS_RICH else print(lbl)
|
|
171
|
+
for cm in community_list:
|
|
172
|
+
mid = cm["name"]
|
|
173
|
+
is_cur = mid == current_id
|
|
174
|
+
cur_tag = " (current)" if is_cur else ""
|
|
175
|
+
line = f" {idx_counter}. ● {mid}{cur_tag}"
|
|
176
|
+
console.print(line) if HAS_RICH else print(line)
|
|
177
|
+
idx_counter += 1
|
|
178
|
+
console.print() if HAS_RICH else print()
|
|
179
|
+
console.print(" [dim]Use /model <id> to switch. E.g. /model deepseek/deepseek-chat[/dim]") if HAS_RICH else print(" Use /model <id> to switch.")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# ── Build compact options for _arrow_select ────────────────────────
|
|
183
|
+
# In TTY mode: include short description (static list is suppressed above).
|
|
184
|
+
# In non-TTY: descriptions already shown in static list, keep labels short.
|
|
185
|
+
num = 1
|
|
186
|
+
for key, m in MODELS.items():
|
|
187
|
+
mid = m["id"]
|
|
188
|
+
status = _status_tag(mid, m.get("badge", ""))
|
|
189
|
+
is_cur = " ◀" if mid == current_id else ""
|
|
190
|
+
if _is_tty:
|
|
191
|
+
desc_part = f" {_short_desc(m)}"
|
|
192
|
+
else:
|
|
193
|
+
desc_part = ""
|
|
194
|
+
label = f" {num}. {status} {m['name']}{is_cur}{desc_part}"
|
|
195
|
+
options.append((label, ""))
|
|
196
|
+
all_ids.append(mid)
|
|
197
|
+
num += 1
|
|
198
|
+
|
|
199
|
+
community = [cm for cm in rich_models if cm["name"] not in aria_ids]
|
|
200
|
+
if community:
|
|
201
|
+
_comm_label = _i18n("community_models")
|
|
202
|
+
options.append((f" ── {_comm_label} ──", ""))
|
|
203
|
+
all_ids.append(None)
|
|
204
|
+
for cm in community:
|
|
205
|
+
mid = cm["name"]
|
|
206
|
+
is_cur = " ◀" if mid == current_id else ""
|
|
207
|
+
options.append((f" {num}. ● {mid}{is_cur}", ""))
|
|
208
|
+
all_ids.append(mid)
|
|
209
|
+
num += 1
|
|
210
|
+
|
|
211
|
+
if ollama_err and not rich_models:
|
|
212
|
+
_unreach = _i18n("ollama_unreachable")
|
|
213
|
+
options.append((f" ── {_unreach} ──────────", ""))
|
|
214
|
+
all_ids.append(None)
|
|
215
|
+
|
|
216
|
+
# ── Run thread-based arrow picker (short labels = no line wrap) ────
|
|
217
|
+
current_idx = next((i for i, mid in enumerate(all_ids) if mid == current_id), 0)
|
|
218
|
+
|
|
219
|
+
while True:
|
|
220
|
+
choice = await _run_picker_in_thread(
|
|
221
|
+
options, current_idx,
|
|
222
|
+
_picker_title,
|
|
223
|
+
max_visible=len(options),
|
|
224
|
+
)
|
|
225
|
+
if choice < 0:
|
|
226
|
+
_msg = _i18n("cancelled")
|
|
227
|
+
console.print(f"[dim]{_msg}[/dim]" if HAS_RICH else _msg)
|
|
228
|
+
return
|
|
229
|
+
if all_ids[choice] is None:
|
|
230
|
+
current_idx = min(choice + 1, len(options) - 1)
|
|
231
|
+
continue
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
self._set_model_by_id(all_ids[choice])
|
|
235
|
+
|
|
236
|
+
def _set_model(self, key: str):
|
|
237
|
+
"""Set model by MODELS key."""
|
|
238
|
+
m = MODELS[key]
|
|
239
|
+
self._set_model_by_id(m["id"])
|
|
240
|
+
|
|
241
|
+
def _set_model_by_id(self, model_id: str):
|
|
242
|
+
"""Set model by Ollama model ID (works for both built-in and community models)."""
|
|
243
|
+
self.terminal.config["model"] = model_id
|
|
244
|
+
self.terminal._actual_model = None # reset: new config model, no known fallback yet
|
|
245
|
+
save_config(self.terminal.config)
|
|
246
|
+
# Pretty label
|
|
247
|
+
for m in MODELS.values():
|
|
248
|
+
if m["id"] == model_id:
|
|
249
|
+
if HAS_RICH:
|
|
250
|
+
console.print(f"[bold]Model:[/bold] [bold]{m['name']} {m['version']}[/bold] "
|
|
251
|
+
f"[dim]{m['tag']}[/dim]")
|
|
252
|
+
else:
|
|
253
|
+
print(f"Model: {m['name']} {m['version']} ({m['tag']})")
|
|
254
|
+
return
|
|
255
|
+
# Community / unknown model
|
|
256
|
+
if HAS_RICH:
|
|
257
|
+
console.print(f"[bold]Model:[/bold] [bold]{model_id}[/bold] [dim](local)[/dim]")
|
|
258
|
+
else:
|
|
259
|
+
print(f"Model: {model_id} (local)")
|
|
260
|
+
|
|
261
|
+
def cmd_thinking(self, args: str):
|
|
262
|
+
mode = args.strip().lower()
|
|
263
|
+
|
|
264
|
+
# Direct set: /thinking on
|
|
265
|
+
if mode in ("on", "thinking"):
|
|
266
|
+
self.terminal.config["thinking_mode"] = "thinking"
|
|
267
|
+
elif mode in ("off", "instant"):
|
|
268
|
+
self.terminal.config["thinking_mode"] = "instant"
|
|
269
|
+
elif mode == "auto":
|
|
270
|
+
self.terminal.config["thinking_mode"] = "auto"
|
|
271
|
+
elif mode:
|
|
272
|
+
# Unknown mode, show picker
|
|
273
|
+
pass
|
|
274
|
+
else:
|
|
275
|
+
# Interactive picker
|
|
276
|
+
current = self.terminal.config.get("thinking_mode", "auto")
|
|
277
|
+
mode_keys = list(THINKING_MODES.keys())
|
|
278
|
+
current_idx = mode_keys.index(current) if current in mode_keys else 0
|
|
279
|
+
options = [(info["label"], info["description"]) for info in THINKING_MODES.values()]
|
|
280
|
+
choice = _arrow_select(options, selected=current_idx, title="Thinking Mode")
|
|
281
|
+
if 0 <= choice < len(mode_keys):
|
|
282
|
+
self.terminal.config["thinking_mode"] = mode_keys[choice]
|
|
283
|
+
else:
|
|
284
|
+
if HAS_RICH:
|
|
285
|
+
console.print("[dim]No change[/dim]")
|
|
286
|
+
else:
|
|
287
|
+
print("No change")
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
save_config(self.terminal.config)
|
|
291
|
+
result = self.terminal.config["thinking_mode"]
|
|
292
|
+
info = THINKING_MODES.get(result, {})
|
|
293
|
+
if HAS_RICH:
|
|
294
|
+
console.print(f"[green]Thinking: {info.get('label', result)}[/green] [dim]{info.get('description', '')}[/dim]")
|
|
295
|
+
else:
|
|
296
|
+
print(f"Thinking: {result}")
|
|
297
|
+
|
|
298
|
+
def cmd_skills(self, args: str):
|
|
299
|
+
"""List all available skills grouped by category."""
|
|
300
|
+
categories = {}
|
|
301
|
+
for s in SKILLS:
|
|
302
|
+
cat = s["category"]
|
|
303
|
+
if cat not in categories:
|
|
304
|
+
categories[cat] = []
|
|
305
|
+
categories[cat].append(s)
|
|
306
|
+
|
|
307
|
+
cat_labels = {
|
|
308
|
+
"research": "Research",
|
|
309
|
+
"analysis": "Analysis",
|
|
310
|
+
"strategy": "Strategy",
|
|
311
|
+
"risk": "Risk Management",
|
|
312
|
+
"quant": "Quantitative",
|
|
313
|
+
"crypto": "Crypto",
|
|
314
|
+
"tools": "Tools",
|
|
315
|
+
"code": "Code Generation",
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if HAS_RICH:
|
|
319
|
+
console.print()
|
|
320
|
+
for cat, skills in categories.items():
|
|
321
|
+
label = cat_labels.get(cat, cat.title())
|
|
322
|
+
console.print(f" [bold]{label}[/bold]")
|
|
323
|
+
for s in skills:
|
|
324
|
+
args_hint = f" [dim]{s.get('args', '')}[/dim]" if s.get("args") else ""
|
|
325
|
+
console.print(f" [bold]{s['command']:20s}[/bold][dim]{s['description']}[/dim]{args_hint}")
|
|
326
|
+
console.print()
|
|
327
|
+
|
|
328
|
+
console.print("[dim] Type a skill command to execute, e.g. /deep-analysis AAPL[/dim]\n")
|
|
329
|
+
else:
|
|
330
|
+
print("\nSkills:")
|
|
331
|
+
for cat, skills in categories.items():
|
|
332
|
+
label = cat_labels.get(cat, cat.title())
|
|
333
|
+
print(f"\n [{label}]")
|
|
334
|
+
for s in skills:
|
|
335
|
+
print(f" {s['command']:20s} {s['description']}")
|
|
336
|
+
|
|
337
|
+
async def _execute_skill(self, skill: dict, args: str):
|
|
338
|
+
"""Execute a skill by expanding its prompt template and sending to AI."""
|
|
339
|
+
parts = args.strip().upper().split() if args.strip() else []
|
|
340
|
+
cmd = skill["command"]
|
|
341
|
+
|
|
342
|
+
# Skill invocation header — matches the ⏺ tool-call rhythm
|
|
343
|
+
_skill_name = skill.get("name") or cmd.lstrip("/")
|
|
344
|
+
_arg_hint = f" [dim]{args.strip()}[/dim]" if args.strip() else ""
|
|
345
|
+
if HAS_RICH:
|
|
346
|
+
console.print(f"\n [#C08050]⏺[/#C08050] [bold]技能 · {_skill_name}[/bold]{_arg_hint}")
|
|
347
|
+
else:
|
|
348
|
+
print(f"\n ⏺ 技能 · {_skill_name} {args.strip()}")
|
|
349
|
+
|
|
350
|
+
# Build the prompt from template
|
|
351
|
+
template = skill["prompt"]
|
|
352
|
+
|
|
353
|
+
if cmd == "/deep-analysis":
|
|
354
|
+
symbol = parts[0] if parts else "AAPL"
|
|
355
|
+
prompt = template.format(symbol=symbol)
|
|
356
|
+
|
|
357
|
+
elif cmd == "/trade-idea":
|
|
358
|
+
context = f" in {' '.join(parts)}" if parts else " in the US market"
|
|
359
|
+
prompt = template.format(context=context)
|
|
360
|
+
|
|
361
|
+
elif cmd == "/risk-report":
|
|
362
|
+
if parts:
|
|
363
|
+
symbols = ", ".join(parts)
|
|
364
|
+
else:
|
|
365
|
+
symbols = ", ".join(self.terminal.config.get("watchlist", ["AAPL", "MSFT", "GOOGL"]))
|
|
366
|
+
prompt = template.format(symbols=symbols)
|
|
367
|
+
|
|
368
|
+
elif cmd == "/factor-screen":
|
|
369
|
+
factor = " ".join(parts).lower() if parts else "momentum"
|
|
370
|
+
prompt = template.format(factor=factor)
|
|
371
|
+
|
|
372
|
+
elif cmd == "/backtest-report":
|
|
373
|
+
strategy = parts[0].lower() if len(parts) > 0 else "momentum"
|
|
374
|
+
symbol = parts[1] if len(parts) > 1 else "SPY"
|
|
375
|
+
start = parts[2] if len(parts) > 2 else "2023-01-01"
|
|
376
|
+
end = parts[3] if len(parts) > 3 else "2025-01-01"
|
|
377
|
+
prompt = template.format(strategy=strategy, symbol=symbol, start=start, end=end)
|
|
378
|
+
|
|
379
|
+
elif cmd == "/morning-brief":
|
|
380
|
+
extra = f"\nFocus on: {' '.join(parts)}" if parts else ""
|
|
381
|
+
prompt = template.format(extra=extra)
|
|
382
|
+
|
|
383
|
+
elif cmd == "/macro-outlook":
|
|
384
|
+
context = f" for {' '.join(parts)}" if parts else " for the US and global economy"
|
|
385
|
+
prompt = template.format(context=context)
|
|
386
|
+
|
|
387
|
+
elif cmd == "/crypto-scan":
|
|
388
|
+
extra = f"\nFocus on: {' '.join(parts)}" if parts else ""
|
|
389
|
+
prompt = template.format(extra=extra)
|
|
390
|
+
|
|
391
|
+
elif cmd == "/watchlist-scan":
|
|
392
|
+
symbols = ", ".join(self.terminal.config.get("watchlist", ["AAPL", "MSFT", "GOOGL"]))
|
|
393
|
+
prompt = template.format(symbols=symbols)
|
|
394
|
+
|
|
395
|
+
elif cmd == "/sector-rotation":
|
|
396
|
+
prompt = template
|
|
397
|
+
|
|
398
|
+
elif cmd == "/gen-strategy":
|
|
399
|
+
strategy = parts[0].lower() if len(parts) > 0 else "momentum"
|
|
400
|
+
symbol = parts[1] if len(parts) > 1 else "SPY"
|
|
401
|
+
prompt = template.format(strategy=strategy, symbol=symbol)
|
|
402
|
+
|
|
403
|
+
elif cmd == "/gen-analysis":
|
|
404
|
+
topic = " ".join(parts[:2]).lower() if parts else "technical analysis"
|
|
405
|
+
symbols = ", ".join(parts[2:]) if len(parts) > 2 else "SPY"
|
|
406
|
+
prompt = template.format(topic=topic, symbols=symbols)
|
|
407
|
+
|
|
408
|
+
elif cmd == "/gen-bot":
|
|
409
|
+
exchange = parts[0].lower() if len(parts) > 0 else "binance"
|
|
410
|
+
strategy = " ".join(parts[1:]).lower() if len(parts) > 1 else "grid trading"
|
|
411
|
+
prompt = template.format(exchange=exchange, strategy=strategy)
|
|
412
|
+
|
|
413
|
+
else:
|
|
414
|
+
prompt = template
|
|
415
|
+
|
|
416
|
+
# Show skill activation
|
|
417
|
+
if HAS_RICH:
|
|
418
|
+
tools = ", ".join(skill.get("tools_hint", [])[:3])
|
|
419
|
+
console.print(f"[bold]Skill:[/bold] [bold]{skill['name']}[/bold] [dim]tools: {tools}[/dim]")
|
|
420
|
+
else:
|
|
421
|
+
print(f"Skill: {skill['name']}")
|
|
422
|
+
|
|
423
|
+
await self.terminal.send_message(prompt)
|
|
424
|
+
|
|
425
|
+
def cmd_tools(self, args: str):
|
|
426
|
+
if HAS_RICH:
|
|
427
|
+
console.print()
|
|
428
|
+
console.print(" [bold]Local Tools[/bold] [dim](Code Agent)[/dim]")
|
|
429
|
+
for i, (name, (_, desc)) in enumerate(LOCAL_TOOLS.items(), 1):
|
|
430
|
+
console.print(f" [bold]{name:28s}[/bold][dim]{desc}[/dim]")
|
|
431
|
+
console.print()
|
|
432
|
+
|
|
433
|
+
console.print(f" [bold]Remote Tools[/bold] [dim]({len(ARIA_TOOLS)})[/dim]")
|
|
434
|
+
for i, (name, desc) in enumerate(ARIA_TOOLS, 1):
|
|
435
|
+
console.print(f" [bold]{name:28s}[/bold][dim]{desc}[/dim]")
|
|
436
|
+
console.print()
|
|
437
|
+
else:
|
|
438
|
+
print("\nLocal Tools (Code Agent):")
|
|
439
|
+
for i, (name, (_, desc)) in enumerate(LOCAL_TOOLS.items(), 1):
|
|
440
|
+
print(f" {i:2d}. {name:30s} {desc}")
|
|
441
|
+
print("\nRemote Aria Tools (22):")
|
|
442
|
+
for i, (name, desc) in enumerate(ARIA_TOOLS, 1):
|
|
443
|
+
print(f" {i:2d}. {name:30s} {desc}")
|
|
444
|
+
|
|
445
|
+
async def cmd_apikey(self, args: str):
|
|
446
|
+
"""Manage Cloud API keys.
|
|
447
|
+
|
|
448
|
+
Usage:
|
|
449
|
+
/apikey — 交互式向导:选择 provider → 输入 key → 测试连接
|
|
450
|
+
/apikey set <p> <k> — 直接保存
|
|
451
|
+
/apikey list — 列出所有已配置 key
|
|
452
|
+
/apikey remove <p> — 删除 key
|
|
453
|
+
/apikey test <p> — 测试连接
|
|
454
|
+
"""
|
|
455
|
+
parts = args.strip().split()
|
|
456
|
+
sub = parts[0].lower() if parts else ""
|
|
457
|
+
|
|
458
|
+
# ── 无参数 or "add" → 交互式向导 ─────────────────────────────────────
|
|
459
|
+
if not sub or sub in ("add", "wizard"):
|
|
460
|
+
await self._cmd_apikey_wizard()
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
pjson = _load_providers_json() # dict of {provider: {api_key, base_url, ...}}
|
|
464
|
+
|
|
465
|
+
if sub == "set-url":
|
|
466
|
+
# /apikey set-url <provider> <base_url>
|
|
467
|
+
# 允许自定义端点(中转代理、国内镜像等),示例:
|
|
468
|
+
# /apikey set-url openai https://my-proxy.com
|
|
469
|
+
# /apikey set-url siliconflow https://api.siliconflow.cn
|
|
470
|
+
if len(parts) < 3:
|
|
471
|
+
msg = "Usage: /apikey set-url <provider> <base_url>"
|
|
472
|
+
console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
|
|
473
|
+
return
|
|
474
|
+
provider = parts[1].lower()
|
|
475
|
+
url = parts[2].rstrip("/")
|
|
476
|
+
entry = pjson.get(provider, {})
|
|
477
|
+
entry["base_url"] = url
|
|
478
|
+
pjson[provider] = entry
|
|
479
|
+
_save_providers_json(pjson)
|
|
480
|
+
msg = f"✓ {provider.capitalize()} base_url 已更新: {url}"
|
|
481
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
if sub == "set":
|
|
485
|
+
if len(parts) < 3:
|
|
486
|
+
msg = ("Usage: /apikey set <provider> <key> (e.g. /apikey set deepseek sk-...)\n"
|
|
487
|
+
" /apikey set-url <provider> <base_url> (自定义代理端点)")
|
|
488
|
+
console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
|
|
489
|
+
return
|
|
490
|
+
provider = parts[1].lower()
|
|
491
|
+
key = parts[2]
|
|
492
|
+
_all_known = set(_PROVIDER_KEY_MAP) | set(_DATA_KEY_MAP) | set(_PROVIDER_BASE_URLS)
|
|
493
|
+
if provider not in _all_known:
|
|
494
|
+
known_llm = ", ".join(sorted(_PROVIDER_KEY_MAP.keys()))
|
|
495
|
+
known_data = ", ".join(sorted(_DATA_KEY_MAP.keys()))
|
|
496
|
+
msg = (f"Unknown provider '{provider}'.\n"
|
|
497
|
+
f" LLM providers: {known_llm}\n"
|
|
498
|
+
f" Data services: {known_data}")
|
|
499
|
+
console.print(f"[yellow]{msg}[/yellow]") if HAS_RICH else print(msg)
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
# ── Data service key ──────────────────────────────────────────────
|
|
503
|
+
if provider in _DATA_KEY_MAP:
|
|
504
|
+
_save_data_key(provider, key)
|
|
505
|
+
env_var = _DATA_KEY_MAP[provider]
|
|
506
|
+
os.environ[env_var] = key # take effect immediately
|
|
507
|
+
masked = key[:6] + "****" + key[-4:] if len(key) > 10 else "****"
|
|
508
|
+
signup = _DATA_SIGNUP_URLS.get(provider, "")
|
|
509
|
+
msg = f"✓ {provider.capitalize()} 数据服务 key 已保存 ({masked})"
|
|
510
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
# ── LLM provider key (original logic) ────────────────────────────
|
|
514
|
+
# Persist to providers.json
|
|
515
|
+
entry = pjson.get(provider, {})
|
|
516
|
+
entry["api_key"] = key
|
|
517
|
+
if provider in _PROVIDER_BASE_URLS:
|
|
518
|
+
entry.setdefault("base_url", _PROVIDER_BASE_URLS[provider])
|
|
519
|
+
pjson[provider] = entry
|
|
520
|
+
_save_providers_json(pjson)
|
|
521
|
+
# Also set in current process env so it works immediately
|
|
522
|
+
env_var = _PROVIDER_KEY_MAP.get(provider)
|
|
523
|
+
if env_var:
|
|
524
|
+
os.environ[env_var] = key
|
|
525
|
+
masked = key[:6] + "****" + key[-4:] if len(key) > 10 else "****"
|
|
526
|
+
msg = f"✓ {provider.capitalize()} API key 已保存 ({masked})"
|
|
527
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
528
|
+
|
|
529
|
+
elif sub == "list":
|
|
530
|
+
_LLM_ORDER = [
|
|
531
|
+
# 国际
|
|
532
|
+
"deepseek", "anthropic", "openai", "google", "xai",
|
|
533
|
+
"groq", "mistral", "cohere", "perplexity", "together",
|
|
534
|
+
# 国内
|
|
535
|
+
"siliconflow", "dashscope", "moonshot", "zhipu",
|
|
536
|
+
"baidu", "bytedance", "minimax", "stepfun", "01ai",
|
|
537
|
+
]
|
|
538
|
+
_DATA_ORDER = ["finnhub", "alphavantage", "twelvedata", "polygon",
|
|
539
|
+
"fmp", "newsapi", "coingecko", "tavily", "brave"]
|
|
540
|
+
data_configured = _load_data_keys()
|
|
541
|
+
|
|
542
|
+
if HAS_RICH:
|
|
543
|
+
from rich.table import Table
|
|
544
|
+
from rich import box as _rbox
|
|
545
|
+
console.print()
|
|
546
|
+
console.print(" [bold]🤖 LLM 服务 Keys[/bold] [dim]— /apikey 进入向导[/dim]")
|
|
547
|
+
console.print()
|
|
548
|
+
for prov in _LLM_ORDER:
|
|
549
|
+
env_var = _PROVIDER_KEY_MAP.get(prov, "")
|
|
550
|
+
key_val = os.getenv(env_var or "") or pjson.get(prov, {}).get("api_key", "")
|
|
551
|
+
desc = _PROVIDER_DESC.get(prov, "")
|
|
552
|
+
if key_val:
|
|
553
|
+
masked = key_val[:6] + "****" + key_val[-4:] if len(key_val) > 10 else "****"
|
|
554
|
+
console.print(f" [green]●[/green] [green]{prov:<14}[/green] {masked} [dim]{desc}[/dim]")
|
|
555
|
+
else:
|
|
556
|
+
console.print(f" [dim]○ {prov:<14} 未配置 {desc}[/dim]")
|
|
557
|
+
console.print()
|
|
558
|
+
console.print(" [bold]📊 数据服务 Keys[/bold] [dim]— 后端离线时直连数据源[/dim]")
|
|
559
|
+
console.print()
|
|
560
|
+
for svc in _DATA_ORDER:
|
|
561
|
+
key_val = data_configured.get(svc, "")
|
|
562
|
+
desc = _PROVIDER_DESC.get(svc, "")
|
|
563
|
+
if key_val:
|
|
564
|
+
masked = key_val[:6] + "****" + key_val[-4:] if len(key_val) > 10 else "****"
|
|
565
|
+
console.print(f" [green]●[/green] [green]{svc:<14}[/green] {masked} [dim]{desc}[/dim]")
|
|
566
|
+
else:
|
|
567
|
+
console.print(f" [dim]○ {svc:<14} 未配置 {desc}[/dim]")
|
|
568
|
+
console.print()
|
|
569
|
+
console.print(" [dim]提示: /apikey 进入交互向导 · /apikey test <provider> 测试连接[/dim]")
|
|
570
|
+
console.print()
|
|
571
|
+
else:
|
|
572
|
+
print("\n LLM Providers:")
|
|
573
|
+
for prov in _LLM_ORDER:
|
|
574
|
+
env_var = _PROVIDER_KEY_MAP.get(prov, "")
|
|
575
|
+
key_val = os.getenv(env_var or "") or pjson.get(prov, {}).get("api_key", "")
|
|
576
|
+
status = key_val[:6] + "****" if key_val else "未配置"
|
|
577
|
+
print(f" {prov:14s} {status}")
|
|
578
|
+
print("\n Data Services:")
|
|
579
|
+
for svc in _DATA_ORDER:
|
|
580
|
+
key_val = data_configured.get(svc, "")
|
|
581
|
+
status = key_val[:6] + "****" if key_val else "未配置"
|
|
582
|
+
print(f" {svc:16s} {status}")
|
|
583
|
+
|
|
584
|
+
elif sub == "remove":
|
|
585
|
+
if len(parts) < 2:
|
|
586
|
+
console.print("[dim]Usage: /apikey remove <provider>[/dim]") if HAS_RICH else print("Usage: /apikey remove <provider>")
|
|
587
|
+
return
|
|
588
|
+
provider = parts[1].lower()
|
|
589
|
+
# LLM section
|
|
590
|
+
if provider in pjson:
|
|
591
|
+
pjson[provider].pop("api_key", None)
|
|
592
|
+
if not pjson[provider]:
|
|
593
|
+
del pjson[provider]
|
|
594
|
+
_save_providers_json(pjson)
|
|
595
|
+
# Data section
|
|
596
|
+
if provider in _DATA_KEY_MAP:
|
|
597
|
+
try:
|
|
598
|
+
if PROVIDERS_FILE.exists():
|
|
599
|
+
raw = json.loads(PROVIDERS_FILE.read_text(encoding="utf-8"))
|
|
600
|
+
if provider in raw.get("data", {}):
|
|
601
|
+
del raw["data"][provider]
|
|
602
|
+
PROVIDERS_FILE.write_text(json.dumps(raw, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
603
|
+
except Exception as _e:
|
|
604
|
+
logger.debug("apikey delete from file failed: %s", _e)
|
|
605
|
+
# Clear from env
|
|
606
|
+
env_var = _PROVIDER_KEY_MAP.get(provider) or _DATA_KEY_MAP.get(provider)
|
|
607
|
+
if env_var and env_var in os.environ:
|
|
608
|
+
del os.environ[env_var]
|
|
609
|
+
msg = f"✓ {provider.capitalize()} key 已删除"
|
|
610
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
611
|
+
|
|
612
|
+
elif sub == "test":
|
|
613
|
+
if len(parts) < 2:
|
|
614
|
+
console.print("[dim]Usage: /apikey test <provider>[/dim]") if HAS_RICH else print("Usage: /apikey test <provider>")
|
|
615
|
+
return
|
|
616
|
+
provider = parts[1].lower()
|
|
617
|
+
key = _get_provider_key(provider) or _load_data_keys().get(provider, "")
|
|
618
|
+
if not key:
|
|
619
|
+
msg = f"⚠ {provider} API key 未配置,先运行 /apikey {provider}"
|
|
620
|
+
console.print(f"[yellow]{msg}[/yellow]") if HAS_RICH else print(msg)
|
|
621
|
+
return
|
|
622
|
+
console.print(f"[dim] 正在测试 {provider}…[/dim]") if HAS_RICH else print(f" 测试 {provider}…")
|
|
623
|
+
import asyncio as _aio
|
|
624
|
+
loop = _aio.get_event_loop()
|
|
625
|
+
ok, result_msg = await loop.run_in_executor(None, _test_api_key, provider, key)
|
|
626
|
+
color = "green" if ok else "yellow"
|
|
627
|
+
console.print(f" [{color}]{result_msg}[/{color}]") if HAS_RICH else print(f" {result_msg}")
|
|
628
|
+
|
|
629
|
+
else:
|
|
630
|
+
console.print("[dim]Usage: /apikey [set|list|remove|test] — 或直接 /apikey 进入向导[/dim]") if HAS_RICH else print("Usage: /apikey [set|list|remove|test]")
|
|
631
|
+
|
|
632
|
+
async def _cmd_apikey_wizard(self):
|
|
633
|
+
"""交互式 API Key 配置向导:选 provider → 查看指引 → 输入 key → 测试连接。"""
|
|
634
|
+
import getpass as _getpass
|
|
635
|
+
|
|
636
|
+
pjson = _load_providers_json()
|
|
637
|
+
data_cfg = _load_data_keys()
|
|
638
|
+
|
|
639
|
+
def _is_configured(name: str) -> bool:
|
|
640
|
+
env = _PROVIDER_KEY_MAP.get(name) or _DATA_KEY_MAP.get(name)
|
|
641
|
+
if env and os.getenv(env):
|
|
642
|
+
return True
|
|
643
|
+
if name in pjson and pjson[name].get("api_key"):
|
|
644
|
+
return True
|
|
645
|
+
if name in data_cfg:
|
|
646
|
+
return True
|
|
647
|
+
return False
|
|
648
|
+
|
|
649
|
+
# ── 分组构建 picker ───────────────────────────────────────────────────
|
|
650
|
+
_LLM_ORDER = [
|
|
651
|
+
# 国际
|
|
652
|
+
"deepseek", "anthropic", "openai", "google", "xai",
|
|
653
|
+
"groq", "mistral", "cohere", "perplexity", "together",
|
|
654
|
+
# 国内
|
|
655
|
+
"siliconflow", "dashscope", "moonshot", "zhipu",
|
|
656
|
+
"baidu", "bytedance", "minimax", "stepfun", "01ai",
|
|
657
|
+
]
|
|
658
|
+
_DATA_ORDER = ["finnhub", "alphavantage", "twelvedata", "polygon",
|
|
659
|
+
"fmp", "newsapi", "coingecko", "tavily", "brave"]
|
|
660
|
+
|
|
661
|
+
all_items = [] # (label, desc, key_name | None)
|
|
662
|
+
|
|
663
|
+
all_items.append(("─── 🤖 LLM 服务 (对话·分析·推理) ", "", None))
|
|
664
|
+
for k in _LLM_ORDER:
|
|
665
|
+
dot = "[green]●[/green]" if _is_configured(k) else "[dim]○[/dim]"
|
|
666
|
+
desc = _PROVIDER_DESC.get(k, "")
|
|
667
|
+
configured_tag = " ✓" if _is_configured(k) else ""
|
|
668
|
+
all_items.append((f" {k:<14}{configured_tag}", desc, k))
|
|
669
|
+
|
|
670
|
+
all_items.append(("─── 📊 数据服务 (行情·财报·新闻) ", "", None))
|
|
671
|
+
for k in _DATA_ORDER:
|
|
672
|
+
desc = _PROVIDER_DESC.get(k, "")
|
|
673
|
+
configured_tag = " ✓" if _is_configured(k) else ""
|
|
674
|
+
all_items.append((f" {k:<14}{configured_tag}", desc, k))
|
|
675
|
+
|
|
676
|
+
picker_opts = [(label, desc) for label, desc, _ in all_items]
|
|
677
|
+
sep_indices = {i for i, (_, _, key) in enumerate(all_items) if key is None}
|
|
678
|
+
key_at = {i: key for i, (_, _, key) in enumerate(all_items) if key}
|
|
679
|
+
|
|
680
|
+
# 默认选中第一个真实条目
|
|
681
|
+
first_real = next(i for i in range(len(all_items)) if i not in sep_indices)
|
|
682
|
+
selected = first_real
|
|
683
|
+
|
|
684
|
+
while True:
|
|
685
|
+
console.print() if HAS_RICH else None
|
|
686
|
+
|
|
687
|
+
if HAS_RICH:
|
|
688
|
+
from rich.panel import Panel as _Panel
|
|
689
|
+
from rich import box as _rbox
|
|
690
|
+
console.print(_Panel(
|
|
691
|
+
" ↑↓ 上下选择 · Enter 确认 · ESC/q 退出向导\n"
|
|
692
|
+
" [green]●[/green] 已配置 [dim]○[/dim] 未配置 ✓ 表示 key 已存在",
|
|
693
|
+
border_style="dim", box=_rbox.ROUNDED, padding=(0, 2),
|
|
694
|
+
))
|
|
695
|
+
|
|
696
|
+
idx = _arrow_select(picker_opts, selected=selected, title="选择要配置的 Provider", max_visible=20)
|
|
697
|
+
|
|
698
|
+
if idx < 0:
|
|
699
|
+
console.print("[dim]已退出向导[/dim]") if HAS_RICH else print("已退出")
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
if idx in sep_indices:
|
|
703
|
+
nxt = next((i for i in range(idx + 1, len(all_items)) if i not in sep_indices), first_real)
|
|
704
|
+
selected = nxt
|
|
705
|
+
continue
|
|
706
|
+
|
|
707
|
+
provider = key_at[idx]
|
|
708
|
+
selected = idx
|
|
709
|
+
|
|
710
|
+
# ── 显示获取指引 ──────────────────────────────────────────────────
|
|
711
|
+
guide = _PROVIDER_GUIDE.get(provider, "")
|
|
712
|
+
signup = _LLM_SIGNUP_URLS.get(provider) or _DATA_SIGNUP_URLS.get(provider, "")
|
|
713
|
+
if HAS_RICH:
|
|
714
|
+
from rich.panel import Panel as _Panel
|
|
715
|
+
from rich import box as _rbox
|
|
716
|
+
guide_body = guide
|
|
717
|
+
if signup:
|
|
718
|
+
guide_body += f"\n\n[bold cyan]🔗 {signup}[/bold cyan]"
|
|
719
|
+
current_key = _get_provider_key(provider)
|
|
720
|
+
if current_key:
|
|
721
|
+
masked = current_key[:6] + "****" + current_key[-4:] if len(current_key) > 10 else "****"
|
|
722
|
+
guide_body += f"\n\n[green]当前 key: {masked}[/green] (直接回车保留现有 key)"
|
|
723
|
+
console.print()
|
|
724
|
+
console.print(_Panel(
|
|
725
|
+
guide_body,
|
|
726
|
+
title=f"[bold]{provider.upper()} 配置指引[/bold]",
|
|
727
|
+
border_style="cyan", box=_rbox.ROUNDED, padding=(0, 2),
|
|
728
|
+
))
|
|
729
|
+
else:
|
|
730
|
+
print(f"\n=== {provider.upper()} ===")
|
|
731
|
+
print(guide)
|
|
732
|
+
if signup:
|
|
733
|
+
print(f"注册地址: {signup}")
|
|
734
|
+
|
|
735
|
+
# ── 输入 key ──────────────────────────────────────────────────────
|
|
736
|
+
prompt_str = f" 请输入 {provider} API Key (输入后不显示): "
|
|
737
|
+
try:
|
|
738
|
+
raw_key = _getpass.getpass(prompt_str)
|
|
739
|
+
except (KeyboardInterrupt, EOFError):
|
|
740
|
+
console.print("\n[dim]已跳过[/dim]") if HAS_RICH else print("\n已跳过")
|
|
741
|
+
continue
|
|
742
|
+
|
|
743
|
+
raw_key = raw_key.strip()
|
|
744
|
+
if not raw_key:
|
|
745
|
+
# 保留现有 key,直接回到 picker
|
|
746
|
+
msg = "未输入 key,保留现有配置"
|
|
747
|
+
console.print(f"[dim] {msg}[/dim]") if HAS_RICH else print(f" {msg}")
|
|
748
|
+
continue
|
|
749
|
+
|
|
750
|
+
# ── 保存 ──────────────────────────────────────────────────────────
|
|
751
|
+
if provider in _DATA_KEY_MAP:
|
|
752
|
+
_save_data_key(provider, raw_key)
|
|
753
|
+
env_var = _DATA_KEY_MAP[provider]
|
|
754
|
+
os.environ[env_var] = raw_key
|
|
755
|
+
else:
|
|
756
|
+
pjson_fresh = _load_providers_json()
|
|
757
|
+
entry = pjson_fresh.get(provider, {})
|
|
758
|
+
entry["api_key"] = raw_key
|
|
759
|
+
if provider in _PROVIDER_BASE_URLS:
|
|
760
|
+
entry.setdefault("base_url", _PROVIDER_BASE_URLS[provider])
|
|
761
|
+
pjson_fresh[provider] = entry
|
|
762
|
+
_save_providers_json(pjson_fresh)
|
|
763
|
+
env_var = _PROVIDER_KEY_MAP.get(provider)
|
|
764
|
+
if env_var:
|
|
765
|
+
os.environ[env_var] = raw_key
|
|
766
|
+
pjson = pjson_fresh
|
|
767
|
+
|
|
768
|
+
masked = raw_key[:6] + "****" + raw_key[-4:] if len(raw_key) > 10 else "****"
|
|
769
|
+
msg = f"✓ {provider} key 已保存 ({masked})"
|
|
770
|
+
console.print(f"[green] {msg}[/green]") if HAS_RICH else print(f" {msg}")
|
|
771
|
+
|
|
772
|
+
# ── 连接测试 ──────────────────────────────────────────────────────
|
|
773
|
+
print(f" 正在测试连接…", end="", flush=True)
|
|
774
|
+
import asyncio as _aio
|
|
775
|
+
loop = _aio.get_event_loop()
|
|
776
|
+
ok, result_msg = await loop.run_in_executor(None, _test_api_key, provider, raw_key)
|
|
777
|
+
print("\r", end="") # 清除"正在测试"那行
|
|
778
|
+
if HAS_RICH:
|
|
779
|
+
color = "green" if ok else "yellow"
|
|
780
|
+
console.print(f" [{color}]{result_msg}[/{color}]")
|
|
781
|
+
else:
|
|
782
|
+
print(f" {result_msg}")
|
|
783
|
+
|
|
784
|
+
# ── 继续配置其他 provider? ───────────────────────────────────────
|
|
785
|
+
console.print() if HAS_RICH else None
|
|
786
|
+
try:
|
|
787
|
+
again = input(" 继续配置其他 provider? (y/N) › ").strip().lower()
|
|
788
|
+
except (KeyboardInterrupt, EOFError):
|
|
789
|
+
again = "n"
|
|
790
|
+
if again not in ("y", "yes", "是"):
|
|
791
|
+
console.print("[dim] 向导已完成。输入 /apikey list 查看所有配置。[/dim]") if HAS_RICH else print("向导完成")
|
|
792
|
+
return
|
|
793
|
+
|
|
794
|
+
def cmd_providers(self, args: str):
|
|
795
|
+
"""Show all LLM providers: local backends + cloud API status (Open Interpreter style)."""
|
|
796
|
+
if HAS_RICH:
|
|
797
|
+
console.print()
|
|
798
|
+
|
|
799
|
+
# ── Section 1: Local backends ────────────────────────────────────────
|
|
800
|
+
try:
|
|
801
|
+
from local_llm_provider import probe_all_backends, BACKEND_DEFAULTS
|
|
802
|
+
results = probe_all_backends()
|
|
803
|
+
current_provider = self.terminal.config.get("local_provider", "ollama")
|
|
804
|
+
# Count Ollama models if online
|
|
805
|
+
_ollama_count = ""
|
|
806
|
+
if results.get("ollama"):
|
|
807
|
+
try:
|
|
808
|
+
_omodels, _ = detect_ollama_models_rich(
|
|
809
|
+
self.terminal.config.get("ollama_url", "http://localhost:11434"))
|
|
810
|
+
_ollama_count = f" [dim]{len(_omodels)} 个模型[/dim]" if _omodels else ""
|
|
811
|
+
except Exception:
|
|
812
|
+
pass
|
|
813
|
+
|
|
814
|
+
if HAS_RICH:
|
|
815
|
+
console.print(" [bold]本地 Backend[/bold]")
|
|
816
|
+
console.print()
|
|
817
|
+
else:
|
|
818
|
+
print(" == Local Backends ==")
|
|
819
|
+
|
|
820
|
+
for name, available in results.items():
|
|
821
|
+
info = BACKEND_DEFAULTS.get(name, {})
|
|
822
|
+
url = info.get("default_url", "")
|
|
823
|
+
color = "green" if available else "dim"
|
|
824
|
+
icon = "✅" if available else "○"
|
|
825
|
+
active = " ◀ active" if name == current_provider else ""
|
|
826
|
+
extra = _ollama_count if (name == "ollama" and available) else ""
|
|
827
|
+
if HAS_RICH:
|
|
828
|
+
console.print(
|
|
829
|
+
f" {icon} [{color}]{name:12s}[/{color}]"
|
|
830
|
+
f" [dim]{url:30s}[/dim]{extra}"
|
|
831
|
+
f"[green]{active}[/green]"
|
|
832
|
+
)
|
|
833
|
+
else:
|
|
834
|
+
status = "✓" if available else "✗"
|
|
835
|
+
print(f" {status} {name:12s} {url}{active}")
|
|
836
|
+
except ImportError:
|
|
837
|
+
pass
|
|
838
|
+
|
|
839
|
+
# ── Section 2: Cloud provider API keys ───────────────────────────────
|
|
840
|
+
pjson = _load_providers_json()
|
|
841
|
+
_CLOUD_LIST = [
|
|
842
|
+
# ── 国际云端 ────────────────────────────────────────────────
|
|
843
|
+
("deepseek", "DeepSeek", "deepseek/deepseek-chat"),
|
|
844
|
+
("anthropic", "Anthropic", "anthropic/claude-sonnet-4-6"),
|
|
845
|
+
("openai", "OpenAI", "openai/gpt-4.5"),
|
|
846
|
+
("google", "Google Gemini", "google/gemini-2.0-flash-exp"),
|
|
847
|
+
("xai", "xAI Grok", "xai/grok-3-fast"),
|
|
848
|
+
("groq", "Groq", "groq/llama-3.3-70b-versatile"),
|
|
849
|
+
("mistral", "Mistral", "mistral/mistral-large-latest"),
|
|
850
|
+
("cohere", "Cohere", "cohere/command-r-plus"),
|
|
851
|
+
("perplexity", "Perplexity", "perplexity/sonar-pro"),
|
|
852
|
+
("together", "Together", "together/meta-llama/Meta-Llama-3.1-70B"),
|
|
853
|
+
# ── 国内云端 ────────────────────────────────────────────────
|
|
854
|
+
("siliconflow", "SiliconFlow", "siliconflow/Qwen/Qwen2.5-7B-Instruct"),
|
|
855
|
+
("dashscope", "DashScope", "dashscope/qwen-max"),
|
|
856
|
+
("moonshot", "Moonshot Kimi", "moonshot/moonshot-v1-128k"),
|
|
857
|
+
("zhipu", "Zhipu GLM", "zhipu/glm-4-plus"),
|
|
858
|
+
("baidu", "Baidu ERNIE", "baidu/ernie-4.5-turbo-128k"),
|
|
859
|
+
("bytedance", "ByteDance", "bytedance/<endpoint-id>"),
|
|
860
|
+
("minimax", "MiniMax", "minimax/MiniMax-Text-01"),
|
|
861
|
+
("stepfun", "StepFun", "stepfun/step-2-16k"),
|
|
862
|
+
("01ai", "01.AI Yi", "01ai/yi-large"),
|
|
863
|
+
]
|
|
864
|
+
if HAS_RICH:
|
|
865
|
+
console.print()
|
|
866
|
+
console.print(" [bold]Cloud Provider API[/bold]")
|
|
867
|
+
console.print()
|
|
868
|
+
else:
|
|
869
|
+
print()
|
|
870
|
+
print(" == Cloud Providers ==")
|
|
871
|
+
|
|
872
|
+
for prov, label, example_model in _CLOUD_LIST:
|
|
873
|
+
env_var = _PROVIDER_KEY_MAP.get(prov, "")
|
|
874
|
+
key = (os.getenv(env_var, "") if env_var else "") or \
|
|
875
|
+
(pjson.get(prov, {}).get("api_key", "") if isinstance(pjson, dict) else "")
|
|
876
|
+
if key:
|
|
877
|
+
masked = key[:6] + "****" + key[-4:] if len(key) > 10 else "****"
|
|
878
|
+
if HAS_RICH:
|
|
879
|
+
console.print(f" 🔑 [green]{label:14s}[/green] [dim]{masked}[/dim]")
|
|
880
|
+
else:
|
|
881
|
+
print(f" ✓ {label:14s} {masked}")
|
|
882
|
+
else:
|
|
883
|
+
hint = f"/apikey set {prov} <key>"
|
|
884
|
+
if HAS_RICH:
|
|
885
|
+
console.print(f" ○ [dim]{label:14s} 未配置 → {hint}[/dim]")
|
|
886
|
+
else:
|
|
887
|
+
print(f" ✗ {label:14s} {hint}")
|
|
888
|
+
|
|
889
|
+
# ── Custom endpoint ──────────────────────────────────────────────────
|
|
890
|
+
custom_ep = self.terminal.config.get("custom_endpoint", "")
|
|
891
|
+
custom_m = self.terminal.config.get("custom_model", "")
|
|
892
|
+
if custom_ep:
|
|
893
|
+
if HAS_RICH:
|
|
894
|
+
console.print()
|
|
895
|
+
console.print(f" 🔧 [bold]Custom endpoint[/bold] [dim]{custom_ep}[/dim] model=[cyan]{custom_m or '?'}[/cyan]")
|
|
896
|
+
else:
|
|
897
|
+
print(f"\n Custom: {custom_ep} model={custom_m}")
|
|
898
|
+
|
|
899
|
+
# ── Data service keys section ─────────────────────────────────────────
|
|
900
|
+
_data_keys = _load_data_keys()
|
|
901
|
+
_DATA_DISPLAY = [
|
|
902
|
+
("finnhub", "Finnhub", "股票+新闻"),
|
|
903
|
+
("newsapi", "NewsAPI", "财经新闻"),
|
|
904
|
+
("brave", "Brave Search", "网页搜索"),
|
|
905
|
+
("alphavantage", "Alpha Vantage", "历史数据"),
|
|
906
|
+
("coingecko", "CoinGecko Pro", "加密数据"),
|
|
907
|
+
("twelvedata", "Twelve Data", "全球行情"),
|
|
908
|
+
]
|
|
909
|
+
if HAS_RICH:
|
|
910
|
+
console.print()
|
|
911
|
+
console.print(" [bold]📊 数据服务 API[/bold] [dim](后端离线时的本地数据源)[/dim]")
|
|
912
|
+
console.print()
|
|
913
|
+
else:
|
|
914
|
+
print("\n == Data Service APIs ==")
|
|
915
|
+
for svc, label, desc in _DATA_DISPLAY:
|
|
916
|
+
key_val = _data_keys.get(svc, "")
|
|
917
|
+
if key_val:
|
|
918
|
+
masked = key_val[:6] + "****" + key_val[-4:] if len(key_val) > 10 else "****"
|
|
919
|
+
signup = _DATA_SIGNUP_URLS.get(svc, "")
|
|
920
|
+
if HAS_RICH:
|
|
921
|
+
console.print(f" 🔑 [green]{label:18s}[/green] [dim]{masked} {desc}[/dim]")
|
|
922
|
+
else:
|
|
923
|
+
print(f" ✓ {label:18s} {masked}")
|
|
924
|
+
else:
|
|
925
|
+
hint = f"/apikey set {svc} <key>"
|
|
926
|
+
signup = _DATA_SIGNUP_URLS.get(svc, "")
|
|
927
|
+
if HAS_RICH:
|
|
928
|
+
console.print(f" ○ [dim]{label:18s} 未配置 → {hint}[/dim]")
|
|
929
|
+
else:
|
|
930
|
+
print(f" ✗ {label:18s} {hint}")
|
|
931
|
+
|
|
932
|
+
# ── Free data source registry (akshare / yfinance / tushare) ────────────
|
|
933
|
+
try:
|
|
934
|
+
from datasources.router import DataRouter as _DR
|
|
935
|
+
free_sources = _DR().list_sources()
|
|
936
|
+
except Exception:
|
|
937
|
+
free_sources = []
|
|
938
|
+
|
|
939
|
+
if free_sources:
|
|
940
|
+
if HAS_RICH:
|
|
941
|
+
console.print()
|
|
942
|
+
console.print(" [bold]免费行情数据源[/bold] [dim](datasources/router — no API key required)[/dim]")
|
|
943
|
+
console.print()
|
|
944
|
+
else:
|
|
945
|
+
print("\n == Free Market Data Sources ==")
|
|
946
|
+
for s in free_sources:
|
|
947
|
+
ok_icon = "[green]✓[/green]" if s["configured"] else "[dim]○[/dim]"
|
|
948
|
+
key_tag = " [dim](no key)[/dim]" if not s["needs_key"] else " [dim](API key)[/dim]"
|
|
949
|
+
mkts = ", ".join(s.get("markets", []))
|
|
950
|
+
if HAS_RICH:
|
|
951
|
+
console.print(
|
|
952
|
+
f" {ok_icon} [bold]{s['name']:12s}[/bold] "
|
|
953
|
+
f"[dim]{mkts:22s}[/dim]{key_tag}"
|
|
954
|
+
)
|
|
955
|
+
else:
|
|
956
|
+
ok = "✓" if s["configured"] else "○"
|
|
957
|
+
key = "(no key)" if not s["needs_key"] else "(key)"
|
|
958
|
+
print(f" {ok} {s['name']:12s} {mkts:22s} {key}")
|
|
959
|
+
if HAS_RICH:
|
|
960
|
+
console.print(" [dim]Config: ~/.aria/datasources.yaml[/dim]")
|
|
961
|
+
|
|
962
|
+
if HAS_RICH:
|
|
963
|
+
console.print()
|
|
964
|
+
console.print(" [dim]配置 LLM Key: /apikey set deepseek <key>[/dim]")
|
|
965
|
+
console.print(" [dim]配置数据 Key: /apikey set finnhub <key>[/dim]")
|
|
966
|
+
console.print(" [dim]切换模型: /model deepseek/deepseek-chat[/dim]")
|
|
967
|
+
console.print(" [dim]首次向导: /setup[/dim]")
|
|
968
|
+
console.print(" [dim]自定义端点: /config set custom_endpoint=http://...[/dim]")
|
|
969
|
+
console.print()
|
|
970
|
+
|
|
971
|
+
async def cmd_cloud(self, args: str):
|
|
972
|
+
"""
|
|
973
|
+
Manage Alibaba Cloud data service connection.
|
|
974
|
+
|
|
975
|
+
Usage:
|
|
976
|
+
/cloud status — show connection status & circuit breaker state
|
|
977
|
+
/cloud set <url> — set cloud_api_server URL (e.g. http://your-aliyun-ip:8000)
|
|
978
|
+
/cloud data <url> — set akshare_data_server URL (e.g. http://your-aliyun-ip:8002)
|
|
979
|
+
/cloud token <jwt-token> — set API token
|
|
980
|
+
/cloud health — live health-check both services
|
|
981
|
+
/cloud reset — reset circuit breakers
|
|
982
|
+
"""
|
|
983
|
+
try:
|
|
984
|
+
from aliyun_data_client import AliyunDataClient, save_cloud_config, summarize_cloud_health
|
|
985
|
+
except ImportError:
|
|
986
|
+
if HAS_RICH:
|
|
987
|
+
console.print(" [red]aliyun_data_client.py not found[/red]")
|
|
988
|
+
else:
|
|
989
|
+
print(" aliyun_data_client.py not found")
|
|
990
|
+
return
|
|
991
|
+
|
|
992
|
+
parts = args.strip().split(None, 2)
|
|
993
|
+
sub = parts[0].lower() if parts else "status"
|
|
994
|
+
|
|
995
|
+
if sub == "set" and len(parts) >= 2:
|
|
996
|
+
url = parts[1]
|
|
997
|
+
save_cloud_config(cloud_url=url)
|
|
998
|
+
AliyunDataClient.reset()
|
|
999
|
+
if HAS_RICH:
|
|
1000
|
+
console.print(f" [green]Cloud API URL set to: {url}[/green]")
|
|
1001
|
+
console.print(f" [dim]Saved to ~/.arthera/config.json[/dim]")
|
|
1002
|
+
return
|
|
1003
|
+
|
|
1004
|
+
if sub == "data" and len(parts) >= 2:
|
|
1005
|
+
url = parts[1]
|
|
1006
|
+
save_cloud_config(data_url=url)
|
|
1007
|
+
AliyunDataClient.reset()
|
|
1008
|
+
if HAS_RICH:
|
|
1009
|
+
console.print(f" [green]AKShare Data URL set to: {url}[/green]")
|
|
1010
|
+
console.print(f" [dim]Saved to ~/.arthera/config.json[/dim]")
|
|
1011
|
+
return
|
|
1012
|
+
|
|
1013
|
+
if sub == "token" and len(parts) >= 2:
|
|
1014
|
+
token = parts[1]
|
|
1015
|
+
save_cloud_config(api_token=token)
|
|
1016
|
+
AliyunDataClient.reset()
|
|
1017
|
+
if HAS_RICH:
|
|
1018
|
+
console.print(f" [green]API token saved (length {len(token)})[/green]")
|
|
1019
|
+
return
|
|
1020
|
+
|
|
1021
|
+
if sub == "reset":
|
|
1022
|
+
AliyunDataClient.reset()
|
|
1023
|
+
if HAS_RICH:
|
|
1024
|
+
console.print(" [green]Circuit breakers reset, config reloaded[/green]")
|
|
1025
|
+
return
|
|
1026
|
+
|
|
1027
|
+
client = AliyunDataClient.get()
|
|
1028
|
+
|
|
1029
|
+
if sub == "health":
|
|
1030
|
+
if HAS_RICH:
|
|
1031
|
+
console.print(" [dim]Checking health…[/dim]")
|
|
1032
|
+
with console.status("[dim]Checking cloud services…[/dim]", spinner="dots") if HAS_RICH else _null_ctx():
|
|
1033
|
+
cloud_h = await client.health_cloud()
|
|
1034
|
+
data_h = await client.health_data()
|
|
1035
|
+
st = client.status()
|
|
1036
|
+
summary = summarize_cloud_health(cloud_h, data_h, st)
|
|
1037
|
+
|
|
1038
|
+
def _svc_label(name: str, health: dict) -> str:
|
|
1039
|
+
status = str(health.get("status", "?"))
|
|
1040
|
+
ok = status in ("healthy", "ok", "ready", "online")
|
|
1041
|
+
color = "green" if ok else "red"
|
|
1042
|
+
icon = "✓" if ok else "✗"
|
|
1043
|
+
breaker = st.get("cloud_cb" if name == "cloud_api_server" else "data_cb", "?")
|
|
1044
|
+
return f" [{color}]●[/{color}] {name} {icon} {status} [dim]breaker={breaker}[/dim]"
|
|
1045
|
+
|
|
1046
|
+
def _print_health_detail(title: str, health: dict):
|
|
1047
|
+
if HAS_RICH:
|
|
1048
|
+
console.print()
|
|
1049
|
+
console.print(_svc_label(title, health))
|
|
1050
|
+
detail_keys = [
|
|
1051
|
+
(k, v) for k, v in health.items()
|
|
1052
|
+
if k not in {"status", "services", "cloud_url", "data_url"}
|
|
1053
|
+
]
|
|
1054
|
+
if detail_keys:
|
|
1055
|
+
for k, v in detail_keys:
|
|
1056
|
+
console.print(f" [dim]{k}: {v}[/dim]")
|
|
1057
|
+
services = health.get("services") or {}
|
|
1058
|
+
if services:
|
|
1059
|
+
for svc, svc_status in services.items():
|
|
1060
|
+
svc_ok = "online" in str(svc_status) or "ready" in str(svc_status)
|
|
1061
|
+
svc_icon = "✓" if svc_ok else "○"
|
|
1062
|
+
svc_color = "green" if svc_ok else "yellow"
|
|
1063
|
+
console.print(f" [dim]{svc_icon} {svc}: [{svc_color}]{svc_status}[/{svc_color}][/dim]")
|
|
1064
|
+
else:
|
|
1065
|
+
print(f" {title}: {health.get('status', '?')}")
|
|
1066
|
+
for k, v in health.items():
|
|
1067
|
+
if k not in {"status", "services", "cloud_url", "data_url"}:
|
|
1068
|
+
print(f" {k}: {v}")
|
|
1069
|
+
for svc, svc_status in (health.get("services") or {}).items():
|
|
1070
|
+
print(f" {svc}: {svc_status}")
|
|
1071
|
+
|
|
1072
|
+
if HAS_RICH:
|
|
1073
|
+
console.print()
|
|
1074
|
+
color = "green" if summary.status == "ok" else "yellow" if summary.status == "warn" else "red"
|
|
1075
|
+
console.print(f" [bold]Summary[/bold] [{color}]{summary.detail}[/{color}]")
|
|
1076
|
+
console.print(f" [dim]breaker_open={summary.breaker_open} token_set={summary.token_set}[/dim]")
|
|
1077
|
+
console.print(f" [dim]suggestion: {summary.suggestion}[/dim]")
|
|
1078
|
+
console.print(f" [dim]cloud_api_server: {client.cloud_url}[/dim]")
|
|
1079
|
+
console.print(f" [dim]akshare_data_server: {client.data_url}[/dim]")
|
|
1080
|
+
_print_health_detail("cloud_api_server", cloud_h)
|
|
1081
|
+
_print_health_detail("akshare_data_server", data_h)
|
|
1082
|
+
console.print()
|
|
1083
|
+
else:
|
|
1084
|
+
print(f" Summary: {summary.detail} ({summary.status})")
|
|
1085
|
+
print(f" breaker_open={summary.breaker_open} token_set={summary.token_set}")
|
|
1086
|
+
print(f" suggestion: {summary.suggestion}")
|
|
1087
|
+
_print_health_detail("cloud_api_server", cloud_h)
|
|
1088
|
+
_print_health_detail("akshare_data_server", data_h)
|
|
1089
|
+
return
|
|
1090
|
+
|
|
1091
|
+
# Default: /cloud status
|
|
1092
|
+
st = client.status()
|
|
1093
|
+
if HAS_RICH:
|
|
1094
|
+
console.print()
|
|
1095
|
+
console.print(" [bold]Alibaba Cloud Data Services[/bold]")
|
|
1096
|
+
console.print()
|
|
1097
|
+
health_summary = st.get("health_summary") or {}
|
|
1098
|
+
color = "green" if health_summary.get("status") == "ok" else "yellow" if health_summary.get("status") == "warn" else "red"
|
|
1099
|
+
if health_summary:
|
|
1100
|
+
console.print(f" [bold]Health[/bold] [{color}]{health_summary.get('detail', '')}[/{color}]")
|
|
1101
|
+
console.print(f" [dim]breaker_open={health_summary.get('breaker_open', 0)} token_set={health_summary.get('token_set', False)}[/dim]")
|
|
1102
|
+
_c = "green" if st["cloud_cb"] == "closed" else "red"
|
|
1103
|
+
_d = "green" if st["data_cb"] == "closed" else "red"
|
|
1104
|
+
console.print(f" [{_c}]●[/{_c}] cloud_api_server [dim]{st['cloud_url']}[/dim]"
|
|
1105
|
+
f" [{_c}]{st['cloud_cb']}[/{_c}]")
|
|
1106
|
+
console.print(f" [{_d}]●[/{_d}] akshare_data_server [dim]{st['data_url']}[/dim]"
|
|
1107
|
+
f" [{_d}]{st['data_cb']}[/{_d}]")
|
|
1108
|
+
tok_str = "[green]set[/green]" if st["has_token"] else "[dim]not set[/dim]"
|
|
1109
|
+
console.print(f" Auth token: {tok_str}")
|
|
1110
|
+
console.print()
|
|
1111
|
+
console.print(" [dim]Configure: /cloud set <url> /cloud data <url> /cloud token <jwt>[/dim]")
|
|
1112
|
+
console.print(" [dim]Health: /cloud health[/dim]")
|
|
1113
|
+
console.print()
|
|
1114
|
+
else:
|
|
1115
|
+
health_summary = st.get("health_summary") or {}
|
|
1116
|
+
if health_summary:
|
|
1117
|
+
print(f" Health: {health_summary.get('detail', '')} ({health_summary.get('status', '')})")
|
|
1118
|
+
print(f" Cloud: {st['cloud_url']} ({st['cloud_cb']})")
|
|
1119
|
+
print(f" Data: {st['data_url']} ({st['data_cb']})")
|
|
1120
|
+
print(f" Token: {'set' if st['has_token'] else 'not set'}")
|
|
1121
|
+
|
|
1122
|
+
def cmd_permissions(self, args: str):
|
|
1123
|
+
"""Tool permission policy manager.
|
|
1124
|
+
|
|
1125
|
+
/permissions — show current policy table
|
|
1126
|
+
/permissions allow <tool> — auto-approve this tool forever
|
|
1127
|
+
/permissions deny <tool> — permanently block this tool
|
|
1128
|
+
/permissions ask <tool> — always prompt before this tool
|
|
1129
|
+
/permissions reset — clear all custom rules
|
|
1130
|
+
/permissions remove <tool>— remove this tool from policy
|
|
1131
|
+
"""
|
|
1132
|
+
try:
|
|
1133
|
+
from runtime.tool_policy import (
|
|
1134
|
+
load_tool_policy, save_tool_policy,
|
|
1135
|
+
add_to_policy, remove_from_policy,
|
|
1136
|
+
)
|
|
1137
|
+
except ImportError as _e:
|
|
1138
|
+
console.print(f"[red]runtime.tool_policy not available: {_e}[/red]") if HAS_RICH else print(f"Error: {_e}")
|
|
1139
|
+
return
|
|
1140
|
+
|
|
1141
|
+
parts = args.strip().split(maxsplit=1)
|
|
1142
|
+
sub = parts[0].lower() if parts else "show"
|
|
1143
|
+
|
|
1144
|
+
if sub in ("allow", "deny", "ask"):
|
|
1145
|
+
if len(parts) < 2:
|
|
1146
|
+
msg = f"Usage: /permissions {sub} <tool_name>"
|
|
1147
|
+
console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
|
|
1148
|
+
return
|
|
1149
|
+
tool = parts[1].strip()
|
|
1150
|
+
add_to_policy(tool, sub)
|
|
1151
|
+
labels = {"allow": ("[green]✓ 白名单[/green]", "auto-approve"), "deny": ("[red]✗ 黑名单[/red]", "block"), "ask": ("[yellow]? 询问[/yellow]", "ask-always")}
|
|
1152
|
+
rich_label, plain_label = labels[sub]
|
|
1153
|
+
if HAS_RICH:
|
|
1154
|
+
console.print(f" {rich_label} [bold]{tool}[/bold] [dim]已更新[/dim]")
|
|
1155
|
+
else:
|
|
1156
|
+
print(f" {plain_label}: {tool}")
|
|
1157
|
+
return
|
|
1158
|
+
|
|
1159
|
+
if sub == "reset":
|
|
1160
|
+
save_tool_policy({"allowed": [], "denied": [], "ask_always": []})
|
|
1161
|
+
msg = "✓ 工具权限策略已重置"
|
|
1162
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
1163
|
+
return
|
|
1164
|
+
|
|
1165
|
+
if sub in ("remove", "rm"):
|
|
1166
|
+
if len(parts) < 2:
|
|
1167
|
+
msg = "Usage: /permissions remove <tool_name>"
|
|
1168
|
+
console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
|
|
1169
|
+
return
|
|
1170
|
+
tool = parts[1].strip()
|
|
1171
|
+
if remove_from_policy(tool):
|
|
1172
|
+
msg = f"✓ '{tool}' 已从策略中移除"
|
|
1173
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
1174
|
+
else:
|
|
1175
|
+
msg = f"'{tool}' 不在任何策略列表中"
|
|
1176
|
+
console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
|
|
1177
|
+
return
|
|
1178
|
+
|
|
1179
|
+
# Default: show policy table
|
|
1180
|
+
policy = load_tool_policy()
|
|
1181
|
+
allowed = policy.get("allowed", [])
|
|
1182
|
+
denied = policy.get("denied", [])
|
|
1183
|
+
ask_always = policy.get("ask_always", [])
|
|
1184
|
+
|
|
1185
|
+
if HAS_RICH:
|
|
1186
|
+
from rich.table import Table
|
|
1187
|
+
from rich import box as _rbox
|
|
1188
|
+
console.print()
|
|
1189
|
+
console.print(" [bold]Tool Permissions[/bold] [dim](~/.arthera/tool_policy.json)[/dim]")
|
|
1190
|
+
console.print()
|
|
1191
|
+
t = Table(box=_rbox.SIMPLE, padding=(0, 1), show_header=True)
|
|
1192
|
+
t.add_column("Tool", style="bold", min_width=22)
|
|
1193
|
+
t.add_column("Policy", min_width=12)
|
|
1194
|
+
t.add_column("Effect", style="dim")
|
|
1195
|
+
for tool in sorted(allowed):
|
|
1196
|
+
t.add_row(tool, "[green]✓ allow[/green]", "auto-approve, never prompt")
|
|
1197
|
+
for tool in sorted(denied):
|
|
1198
|
+
t.add_row(tool, "[red]✗ deny[/red]", "always reject, never run")
|
|
1199
|
+
for tool in sorted(ask_always):
|
|
1200
|
+
t.add_row(tool, "[yellow]? ask[/yellow]", "always prompt even in auto mode")
|
|
1201
|
+
if not (allowed or denied or ask_always):
|
|
1202
|
+
t.add_row("[dim]— no custom rules —[/dim]", "", "")
|
|
1203
|
+
console.print(t)
|
|
1204
|
+
console.print(" [dim]/permissions allow <tool> — 白名单(自动批准)[/dim]")
|
|
1205
|
+
console.print(" [dim]/permissions deny <tool> — 黑名单(始终拒绝)[/dim]")
|
|
1206
|
+
console.print(" [dim]/permissions ask <tool> — 始终询问[/dim]")
|
|
1207
|
+
console.print(" [dim]/permissions remove <tool> — 移除规则[/dim]")
|
|
1208
|
+
console.print(" [dim]/permissions reset — 清空所有规则[/dim]")
|
|
1209
|
+
console.print()
|
|
1210
|
+
else:
|
|
1211
|
+
print("\n Tool Permissions (~/.arthera/tool_policy.json)")
|
|
1212
|
+
print(f" Allow: {', '.join(allowed) or 'none'}")
|
|
1213
|
+
print(f" Deny: {', '.join(denied) or 'none'}")
|
|
1214
|
+
print(f" Ask: {', '.join(ask_always) or 'none'}")
|
|
1215
|
+
print()
|
|
1216
|
+
print(" Usage: /permissions allow|deny|ask|remove|reset <tool>")
|
|
1217
|
+
|
|
1218
|
+
def cmd_config(self, args: str):
|
|
1219
|
+
"""Show or set CLI configuration."""
|
|
1220
|
+
from apps.cli.config_paths import config_snapshot
|
|
1221
|
+
parts = args.strip().split(maxsplit=1)
|
|
1222
|
+
if not parts or parts[0] == "show":
|
|
1223
|
+
# Show current config
|
|
1224
|
+
cfg = self.terminal.config
|
|
1225
|
+
if HAS_RICH:
|
|
1226
|
+
console.print()
|
|
1227
|
+
console.print("[bold]Configuration[/bold]")
|
|
1228
|
+
console.print()
|
|
1229
|
+
snap = config_snapshot()
|
|
1230
|
+
for key in ("api_url", "ollama_url", "model", "thinking_mode",
|
|
1231
|
+
"command_policy", "permission_mode", "network_enabled",
|
|
1232
|
+
"write_policy", "lsp_autocheck", "input_style", "input_theme",
|
|
1233
|
+
"response_footer", "auto_compact_context",
|
|
1234
|
+
"auto_compact_threshold", "auto_save_sessions"):
|
|
1235
|
+
val = cfg.get(key, "-")
|
|
1236
|
+
console.print(f" [dim]{key:<24s}[/dim]{val}")
|
|
1237
|
+
console.print(f" [dim]{'config_dir':<24s}[/dim]{snap['config_dir']}")
|
|
1238
|
+
console.print(f" [dim]{'config_file':<24s}[/dim]{snap['config_file']}")
|
|
1239
|
+
console.print(f" [dim]{'sessions_dir':<24s}[/dim]{snap['sessions_dir']}")
|
|
1240
|
+
console.print(f" [dim]{'user_output_root':<24s}[/dim]{snap['user_output_root']}")
|
|
1241
|
+
# Show notification/search config from resolved config.json
|
|
1242
|
+
try:
|
|
1243
|
+
import json as _j
|
|
1244
|
+
_ncfg_path = Path(snap["config_file"])
|
|
1245
|
+
_ncfg = _j.loads(_ncfg_path.read_text()) if _ncfg_path.exists() else {}
|
|
1246
|
+
if _wh := _ncfg.get("notify_webhook"):
|
|
1247
|
+
console.print(f" [dim]{'notify_webhook':<24s}[/dim]{_wh[:50]}{'…' if len(_wh)>50 else ''}")
|
|
1248
|
+
except Exception:
|
|
1249
|
+
pass
|
|
1250
|
+
import os as _os_show
|
|
1251
|
+
if _os_show.getenv("BRAVE_SEARCH_API_KEY"):
|
|
1252
|
+
console.print(f" [dim]{'brave_key':<24s}[/dim][green]已配置[/green]")
|
|
1253
|
+
else:
|
|
1254
|
+
console.print(f" [dim]{'brave_key':<24s}[/dim][dim]未设置 — /config set brave_key=BSAAxxx[/dim]")
|
|
1255
|
+
# Security check: warn if providers.json has plaintext api_key
|
|
1256
|
+
_pf = Path(snap["providers_file"])
|
|
1257
|
+
if _pf.exists():
|
|
1258
|
+
try:
|
|
1259
|
+
_pd = _j.loads(_pf.read_text())
|
|
1260
|
+
_has_plain = any(
|
|
1261
|
+
v.get("api_key") for v in _pd.values()
|
|
1262
|
+
if isinstance(v, dict) and v.get("api_key")
|
|
1263
|
+
and not str(v["api_key"]).startswith("${")
|
|
1264
|
+
)
|
|
1265
|
+
if _has_plain:
|
|
1266
|
+
console.print()
|
|
1267
|
+
console.print(
|
|
1268
|
+
" [yellow]⚠ ~/.arthera/providers.json 含明文 API Key[/yellow]\n"
|
|
1269
|
+
" [dim] 建议迁移到环境变量: export OPENAI_API_KEY=sk-...[/dim]\n"
|
|
1270
|
+
" [dim] 然后删除 providers.json 中的 api_key 字段[/dim]"
|
|
1271
|
+
)
|
|
1272
|
+
except Exception:
|
|
1273
|
+
pass
|
|
1274
|
+
console.print()
|
|
1275
|
+
else:
|
|
1276
|
+
for key in ("api_url", "ollama_url", "model", "thinking_mode",
|
|
1277
|
+
"command_policy", "permission_mode", "network_enabled",
|
|
1278
|
+
"write_policy", "lsp_autocheck", "input_style", "input_theme",
|
|
1279
|
+
"response_footer", "auto_compact_context",
|
|
1280
|
+
"auto_compact_threshold"):
|
|
1281
|
+
print(f" {key}: {cfg.get(key, '-')}")
|
|
1282
|
+
elif len(parts) == 2 and parts[0] == "set":
|
|
1283
|
+
# Parse key=value
|
|
1284
|
+
kv = parts[1].split("=", 1)
|
|
1285
|
+
if len(kv) == 2:
|
|
1286
|
+
key, val = kv[0].strip(), kv[1].strip()
|
|
1287
|
+
# Validate known config keys
|
|
1288
|
+
if key == "command_policy":
|
|
1289
|
+
if val not in {"safe", "balanced", "full"}:
|
|
1290
|
+
msg = "command_policy must be one of: safe | balanced | full"
|
|
1291
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1292
|
+
return
|
|
1293
|
+
elif key == "permission_mode":
|
|
1294
|
+
if val not in {"read-only", "workspace-write", "full-access"}:
|
|
1295
|
+
msg = "permission_mode must be one of: read-only | workspace-write | full-access"
|
|
1296
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1297
|
+
return
|
|
1298
|
+
elif key in {"network_enabled", "data_sharing", "feedback_upload"}:
|
|
1299
|
+
if val.lower() in {"true", "1", "yes", "on"}:
|
|
1300
|
+
val = True
|
|
1301
|
+
elif val.lower() in {"false", "0", "no", "off"}:
|
|
1302
|
+
val = False
|
|
1303
|
+
else:
|
|
1304
|
+
msg = f"{key} must be: true | false"
|
|
1305
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1306
|
+
return
|
|
1307
|
+
elif key == "thinking_mode":
|
|
1308
|
+
if val not in {"auto", "instant", "thinking"}:
|
|
1309
|
+
msg = "thinking_mode must be one of: auto | instant | thinking"
|
|
1310
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1311
|
+
return
|
|
1312
|
+
elif key == "model":
|
|
1313
|
+
resolved = MODEL_ALIASES.get(val) or (val if val in MODELS else None)
|
|
1314
|
+
if not resolved:
|
|
1315
|
+
valid = ", ".join(sorted(MODEL_ALIASES.keys()))
|
|
1316
|
+
msg = f"Unknown model '{val}'. Valid: {valid}"
|
|
1317
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1318
|
+
return
|
|
1319
|
+
val = MODELS[resolved]["id"]
|
|
1320
|
+
elif key == "auto_save_sessions":
|
|
1321
|
+
if val.lower() in {"true", "1", "yes", "on"}:
|
|
1322
|
+
val = True
|
|
1323
|
+
elif val.lower() in {"false", "0", "no", "off"}:
|
|
1324
|
+
val = False
|
|
1325
|
+
else:
|
|
1326
|
+
msg = "auto_save_sessions must be: true | false"
|
|
1327
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1328
|
+
return
|
|
1329
|
+
elif key == "auto_compact_context":
|
|
1330
|
+
if val.lower() in {"true", "1", "yes", "on"}:
|
|
1331
|
+
val = True
|
|
1332
|
+
elif val.lower() in {"false", "0", "no", "off"}:
|
|
1333
|
+
val = False
|
|
1334
|
+
else:
|
|
1335
|
+
msg = "auto_compact_context must be: true | false"
|
|
1336
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1337
|
+
return
|
|
1338
|
+
elif key == "lsp_autocheck":
|
|
1339
|
+
if val.lower() in {"true", "1", "yes", "on"}:
|
|
1340
|
+
val = True
|
|
1341
|
+
elif val.lower() in {"false", "0", "no", "off"}:
|
|
1342
|
+
val = False
|
|
1343
|
+
else:
|
|
1344
|
+
msg = "lsp_autocheck must be: true | false"
|
|
1345
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1346
|
+
return
|
|
1347
|
+
elif key == "auto_compact_threshold":
|
|
1348
|
+
try:
|
|
1349
|
+
val = float(val)
|
|
1350
|
+
except Exception:
|
|
1351
|
+
msg = "auto_compact_threshold must be a number between 0.50 and 0.95"
|
|
1352
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1353
|
+
return
|
|
1354
|
+
if not 0.50 <= val <= 0.95:
|
|
1355
|
+
msg = "auto_compact_threshold must be between 0.50 and 0.95"
|
|
1356
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1357
|
+
return
|
|
1358
|
+
elif key == "write_policy":
|
|
1359
|
+
if val not in {"desktop_only", "confirm_outside", "always_confirm"}:
|
|
1360
|
+
msg = "write_policy must be: desktop_only | confirm_outside | always_confirm"
|
|
1361
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1362
|
+
return
|
|
1363
|
+
elif key == "local_mode":
|
|
1364
|
+
if val.lower() in {"true", "1", "yes", "on"}:
|
|
1365
|
+
val = True
|
|
1366
|
+
elif val.lower() in {"false", "0", "no", "off"}:
|
|
1367
|
+
val = False
|
|
1368
|
+
else:
|
|
1369
|
+
msg = "local_mode must be: true | false"
|
|
1370
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1371
|
+
return
|
|
1372
|
+
elif key == "banner":
|
|
1373
|
+
if val not in {"full", "compact", "off"}:
|
|
1374
|
+
msg = "banner must be: full | compact | off"
|
|
1375
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1376
|
+
return
|
|
1377
|
+
elif key == "input_style":
|
|
1378
|
+
if val not in {"panel", "box", "plain"}:
|
|
1379
|
+
msg = "input_style must be: panel | box | plain"
|
|
1380
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1381
|
+
return
|
|
1382
|
+
elif key == "input_theme":
|
|
1383
|
+
if val not in {"auto", "dark", "light"}:
|
|
1384
|
+
msg = "input_theme must be: auto | dark | light"
|
|
1385
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1386
|
+
return
|
|
1387
|
+
elif key == "response_footer":
|
|
1388
|
+
if val not in {"compact", "full", "off"}:
|
|
1389
|
+
msg = "response_footer must be: compact | full | off"
|
|
1390
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1391
|
+
return
|
|
1392
|
+
elif key == "ui_lang":
|
|
1393
|
+
if val not in {"zh", "en", "ja", "ko", "auto"}:
|
|
1394
|
+
msg = "ui_lang must be: zh | en | auto (auto = detect from OS locale)"
|
|
1395
|
+
console.print(f"[red]{msg}[/red]" if HAS_RICH else msg)
|
|
1396
|
+
return
|
|
1397
|
+
if val == "auto":
|
|
1398
|
+
try:
|
|
1399
|
+
from apps.cli.i18n import detect_system_lang as _dsl
|
|
1400
|
+
val = _dsl()
|
|
1401
|
+
except Exception:
|
|
1402
|
+
val = "en"
|
|
1403
|
+
msg = f"✓ UI 语言已设为 {val} (重启生效)" if val == "zh" else f"✓ UI language set to {val} (takes effect on restart)"
|
|
1404
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
1405
|
+
self.terminal.config[key] = val
|
|
1406
|
+
save_config(self.terminal.config)
|
|
1407
|
+
return
|
|
1408
|
+
elif key == "notify_webhook":
|
|
1409
|
+
# /config set notify_webhook=https://qyapi.weixin.qq.com/...
|
|
1410
|
+
# 写入 ~/.arthera/config.json(notification_tools 直接读取)
|
|
1411
|
+
try:
|
|
1412
|
+
_ncfg_path = Path.home() / ".arthera" / "config.json"
|
|
1413
|
+
_ncfg = json.loads(_ncfg_path.read_text()) if _ncfg_path.exists() else {}
|
|
1414
|
+
_ncfg["notify_webhook"] = val
|
|
1415
|
+
_ncfg_path.write_text(json.dumps(_ncfg, indent=2, ensure_ascii=False))
|
|
1416
|
+
except Exception as _e:
|
|
1417
|
+
logger.debug("notify_webhook save failed: %s", _e)
|
|
1418
|
+
msg = f"✓ 通知 Webhook 已设为 {val[:60]}…" if len(val) > 60 else f"✓ 通知 Webhook 已设为 {val}"
|
|
1419
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
1420
|
+
return
|
|
1421
|
+
elif key == "brave_key":
|
|
1422
|
+
# /config set brave_key=BSAAxxx → 写入 ~/.aria/.env
|
|
1423
|
+
_env_path = Path.home() / ".aria" / ".env"
|
|
1424
|
+
_env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1425
|
+
existing = _env_path.read_text() if _env_path.exists() else ""
|
|
1426
|
+
import re as _re_cfg
|
|
1427
|
+
if "BRAVE_SEARCH_API_KEY" in existing:
|
|
1428
|
+
existing = _re_cfg.sub(r"BRAVE_SEARCH_API_KEY=.*", f"BRAVE_SEARCH_API_KEY={val}", existing)
|
|
1429
|
+
else:
|
|
1430
|
+
existing = existing.rstrip("\n") + f"\nBRAVE_SEARCH_API_KEY={val}\n"
|
|
1431
|
+
_env_path.write_text(existing)
|
|
1432
|
+
_env_path.chmod(0o600)
|
|
1433
|
+
import os as _os_cfg
|
|
1434
|
+
_os_cfg.environ["BRAVE_SEARCH_API_KEY"] = val
|
|
1435
|
+
msg = "✓ Brave Search API key 已保存到~/.aria/.env (生效于当前会话)"
|
|
1436
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
1437
|
+
return
|
|
1438
|
+
elif key == "custom_endpoint":
|
|
1439
|
+
# /config set custom_endpoint=http://my-litellm:4000/v1
|
|
1440
|
+
# Automatically sets local_provider=custom
|
|
1441
|
+
self.terminal.config["local_provider"] = "custom"
|
|
1442
|
+
self.terminal.config["custom_endpoint"] = val
|
|
1443
|
+
_sync_write_policy(self.terminal.config)
|
|
1444
|
+
save_config(self.terminal.config)
|
|
1445
|
+
msg = f"✓ 自定义 endpoint 设为 {val} (local_provider=custom)"
|
|
1446
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
1447
|
+
return
|
|
1448
|
+
elif key == "custom_model":
|
|
1449
|
+
# /config set custom_model=gpt-4o
|
|
1450
|
+
self.terminal.config["custom_model"] = val
|
|
1451
|
+
if self.terminal.config.get("local_provider") == "custom":
|
|
1452
|
+
self.terminal.config["model"] = val
|
|
1453
|
+
_sync_write_policy(self.terminal.config)
|
|
1454
|
+
save_config(self.terminal.config)
|
|
1455
|
+
console.print(f" [dim]custom_model[/dim] = {val}" if HAS_RICH else f" custom_model = {val}")
|
|
1456
|
+
return
|
|
1457
|
+
self.terminal.config[key] = val
|
|
1458
|
+
_sync_write_policy(self.terminal.config)
|
|
1459
|
+
save_config(self.terminal.config)
|
|
1460
|
+
console.print(f" [dim]{key}[/dim] = {val}" if HAS_RICH else f" {key} = {val}")
|
|
1461
|
+
else:
|
|
1462
|
+
console.print("[dim]Usage: /config set key=value[/dim]" if HAS_RICH
|
|
1463
|
+
else "Usage: /config set key=value")
|
|
1464
|
+
elif parts[0] == "reload":
|
|
1465
|
+
fresh = load_config()
|
|
1466
|
+
self.terminal.config.update(fresh)
|
|
1467
|
+
msg = f"Config reloaded from {config_snapshot()['config_file']}"
|
|
1468
|
+
console.print(f"[dim]{msg}[/dim]" if HAS_RICH else msg)
|
|
1469
|
+
|
|
1470
|
+
elif parts[0] == "allow":
|
|
1471
|
+
# /config allow <tool_name> — permanently auto-approve this tool
|
|
1472
|
+
if len(parts) < 2:
|
|
1473
|
+
msg = "Usage: /config allow <tool_name> (e.g. /config allow read_file)"
|
|
1474
|
+
console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
|
|
1475
|
+
return
|
|
1476
|
+
tool = parts[1].strip()
|
|
1477
|
+
try:
|
|
1478
|
+
from runtime.tool_policy import add_to_policy
|
|
1479
|
+
add_to_policy(tool, "allow")
|
|
1480
|
+
msg = f"✓ 工具 '{tool}' 加入永久白名单(始终自动批准,无需确认)"
|
|
1481
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
1482
|
+
except Exception as _e:
|
|
1483
|
+
console.print(f"[red]Error: {_e}[/red]") if HAS_RICH else print(f"Error: {_e}")
|
|
1484
|
+
|
|
1485
|
+
elif parts[0] == "deny":
|
|
1486
|
+
# /config deny <tool_name> — permanently block this tool
|
|
1487
|
+
if len(parts) < 2:
|
|
1488
|
+
msg = "Usage: /config deny <tool_name> (e.g. /config deny run_command)"
|
|
1489
|
+
console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
|
|
1490
|
+
return
|
|
1491
|
+
tool = parts[1].strip()
|
|
1492
|
+
try:
|
|
1493
|
+
from runtime.tool_policy import add_to_policy
|
|
1494
|
+
add_to_policy(tool, "deny")
|
|
1495
|
+
msg = f"✓ 工具 '{tool}' 加入黑名单(始终拒绝,不会执行)"
|
|
1496
|
+
console.print(f"[red]{msg}[/red]") if HAS_RICH else print(msg)
|
|
1497
|
+
except Exception as _e:
|
|
1498
|
+
console.print(f"[red]Error: {_e}[/red]") if HAS_RICH else print(f"Error: {_e}")
|
|
1499
|
+
|
|
1500
|
+
elif parts[0] == "ask":
|
|
1501
|
+
# /config ask <tool_name> — always prompt before this tool
|
|
1502
|
+
if len(parts) < 2:
|
|
1503
|
+
msg = "Usage: /config ask <tool_name> (e.g. /config ask write_file)"
|
|
1504
|
+
console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
|
|
1505
|
+
return
|
|
1506
|
+
tool = parts[1].strip()
|
|
1507
|
+
try:
|
|
1508
|
+
from runtime.tool_policy import add_to_policy
|
|
1509
|
+
add_to_policy(tool, "ask")
|
|
1510
|
+
msg = f"✓ 工具 '{tool}' 设为始终询问(每次执行前都弹出确认)"
|
|
1511
|
+
console.print(f"[yellow]{msg}[/yellow]") if HAS_RICH else print(msg)
|
|
1512
|
+
except Exception as _e:
|
|
1513
|
+
console.print(f"[red]Error: {_e}[/red]") if HAS_RICH else print(f"Error: {_e}")
|
|
1514
|
+
|
|
1515
|
+
elif parts[0] == "policy":
|
|
1516
|
+
# /config policy — show all policy settings
|
|
1517
|
+
# /config policy reset — reset to defaults
|
|
1518
|
+
sub = parts[1].lower() if len(parts) > 1 else "show"
|
|
1519
|
+
try:
|
|
1520
|
+
from runtime.tool_policy import load_tool_policy, save_tool_policy, remove_from_policy
|
|
1521
|
+
if sub == "reset":
|
|
1522
|
+
save_tool_policy({"allowed": [], "denied": [], "ask_always": []})
|
|
1523
|
+
msg = "✓ 工具权限策略已重置为默认值"
|
|
1524
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
1525
|
+
elif sub in ("remove", "rm") and len(parts) > 2:
|
|
1526
|
+
tool = parts[2].strip()
|
|
1527
|
+
removed = remove_from_policy(tool)
|
|
1528
|
+
if removed:
|
|
1529
|
+
msg = f"✓ '{tool}' 已从策略中移除"
|
|
1530
|
+
console.print(f"[green]{msg}[/green]") if HAS_RICH else print(msg)
|
|
1531
|
+
else:
|
|
1532
|
+
msg = f"'{tool}' 不在任何策略列表中"
|
|
1533
|
+
console.print(f"[dim]{msg}[/dim]") if HAS_RICH else print(msg)
|
|
1534
|
+
else:
|
|
1535
|
+
policy = load_tool_policy()
|
|
1536
|
+
if HAS_RICH:
|
|
1537
|
+
console.print()
|
|
1538
|
+
console.print(" [bold]工具权限策略[/bold] [dim]~/.arthera/tool_policy.json[/dim]")
|
|
1539
|
+
console.print()
|
|
1540
|
+
allowed = policy.get("allowed", [])
|
|
1541
|
+
denied = policy.get("denied", [])
|
|
1542
|
+
asked = policy.get("ask_always", [])
|
|
1543
|
+
if allowed:
|
|
1544
|
+
console.print(f" [green]✓ 白名单(自动允许):[/green] {', '.join(allowed)}")
|
|
1545
|
+
else:
|
|
1546
|
+
console.print(" [dim]✓ 白名单: 空[/dim]")
|
|
1547
|
+
if denied:
|
|
1548
|
+
console.print(f" [red]✗ 黑名单(始终拒绝):[/red] {', '.join(denied)}")
|
|
1549
|
+
else:
|
|
1550
|
+
console.print(" [dim]✗ 黑名单: 空[/dim]")
|
|
1551
|
+
if asked:
|
|
1552
|
+
console.print(f" [yellow]? 始终询问:[/yellow] {', '.join(asked)}")
|
|
1553
|
+
else:
|
|
1554
|
+
console.print(" [dim]? 始终询问: 空[/dim]")
|
|
1555
|
+
console.print()
|
|
1556
|
+
console.print(" [dim]/config allow <tool> — 加入白名单[/dim]")
|
|
1557
|
+
console.print(" [dim]/config deny <tool> — 加入黑名单[/dim]")
|
|
1558
|
+
console.print(" [dim]/config ask <tool> — 设为始终询问[/dim]")
|
|
1559
|
+
console.print(" [dim]/config policy remove <tool> — 移除策略[/dim]")
|
|
1560
|
+
console.print(" [dim]/config policy reset — 清空所有策略[/dim]")
|
|
1561
|
+
console.print()
|
|
1562
|
+
else:
|
|
1563
|
+
print("\n Tool Policy:")
|
|
1564
|
+
print(f" Allowed: {', '.join(policy.get('allowed', [])) or 'none'}")
|
|
1565
|
+
print(f" Denied: {', '.join(policy.get('denied', [])) or 'none'}")
|
|
1566
|
+
print(f" Ask-always: {', '.join(policy.get('ask_always', [])) or 'none'}")
|
|
1567
|
+
except Exception as _e:
|
|
1568
|
+
console.print(f"[red]Error: {_e}[/red]") if HAS_RICH else print(f"Error: {_e}")
|
|
1569
|
+
|
|
1570
|
+
else:
|
|
1571
|
+
console.print(
|
|
1572
|
+
"[dim]Usage: /config [show] | /config set key=value | /config reload\n"
|
|
1573
|
+
" /config allow <tool> | /config deny <tool> | /config ask <tool>\n"
|
|
1574
|
+
" /config policy [reset|remove <tool>][/dim]"
|
|
1575
|
+
if HAS_RICH else
|
|
1576
|
+
"Usage: /config [show] | /config set key=value | /config reload\n"
|
|
1577
|
+
" /config allow <tool> | /config deny <tool> | /config ask <tool>\n"
|
|
1578
|
+
" /config policy [reset|remove <tool>]"
|
|
1579
|
+
)
|