fr-cli 2.1.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fr_cli-2.1.0/fr_cli.egg-info → fr_cli-2.2.0}/PKG-INFO +4 -3
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/__init__.py +1 -1
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/conf/config.py +23 -3
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/core/core.py +49 -12
- fr_cli-2.2.0/fr_cli/core/llm.py +181 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/core/stream.py +38 -38
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/main.py +1 -1
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/repl/commands.py +20 -3
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/ui/ui.py +4 -3
- {fr_cli-2.1.0 → fr_cli-2.2.0/fr_cli.egg-info}/PKG-INFO +4 -3
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli.egg-info/SOURCES.txt +1 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli.egg-info/requires.txt +1 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/pyproject.toml +4 -3
- {fr_cli-2.1.0 → fr_cli-2.2.0}/LICENSE +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/MANIFEST.in +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/README.md +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/README.md +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/WEAPON.MD +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/addon/plugin.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/__init__.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/builtins/__init__.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/builtins/_utils.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/builtins/db.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/builtins/local.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/builtins/rag.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/builtins/rag_watcher_daemon.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/builtins/remote.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/builtins/spider.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/client.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/executor.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/generator.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/manager.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/master.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/master_prompt.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/remote.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/server.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/agent/workflow.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/breakthrough/update.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/command/__init__.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/command/executor.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/command/registry.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/command/security.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/conf/wizard.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/core/chat.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/core/intent.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/core/recommender.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/core/sysmon.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/core/thinking.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/gatekeeper/__init__.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/gatekeeper/daemon.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/gatekeeper/manager.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/lang/i18n.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/memory/context.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/memory/history.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/memory/session.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/repl/__init__.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/security/security.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/weapon/cron.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/weapon/dataframe.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/weapon/disk.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/weapon/fs.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/weapon/launcher.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/weapon/loader.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/weapon/mail.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/weapon/mcp.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/weapon/vision.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli/weapon/web.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli.egg-info/dependency_links.txt +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli.egg-info/entry_points.txt +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/fr_cli.egg-info/top_level.txt +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/setup.cfg +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_agent_client.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_agent_server.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_ai_save_file_with_verify.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_all.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_auto_session.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_builtins.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_dataframe.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_gatekeeper.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_integration.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_intent_classification.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_launcher.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_master_agent.py +0 -0
- {fr_cli-2.1.0 → fr_cli-2.2.0}/tests/test_structured_tools.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fr-cli
|
|
3
|
-
Version: 2.
|
|
4
|
-
Summary: 凡人打字机 -
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: 凡人打字机 - 支持多模型(Zhipu/DeepSeek/Kimi/Qwen/StepFun/MiniMax/Spark)的终极全能终端工具
|
|
5
5
|
Author: FANREN CLI Author
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/yourname/fr-cli
|
|
8
8
|
Project-URL: Issues, https://github.com/yourname/fr-cli/issues
|
|
9
|
-
Keywords: zhipuai,
|
|
9
|
+
Keywords: zhipuai,deepseek,kimi,qwen,cli,ai,terminal,chatbot
|
|
10
10
|
Classifier: Development Status :: 4 - Beta
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
@@ -24,6 +24,7 @@ Requires-Python: >=3.8
|
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
License-File: LICENSE
|
|
26
26
|
Requires-Dist: zhipuai>=2.0.0
|
|
27
|
+
Requires-Dist: openai>=1.0.0
|
|
27
28
|
Requires-Dist: requests>=2.28.0
|
|
28
29
|
Requires-Dist: mcp>=1.6.0
|
|
29
30
|
Provides-Extra: data
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import shutil
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from fr_cli.ui.ui import YELLOW, RED, GREEN, RESET
|
|
9
|
+
from fr_cli.ui.ui import YELLOW, RED, GREEN, RESET, DIM
|
|
10
10
|
|
|
11
11
|
CONFIG_FILE = Path.home() / ".zhipu_cli_config.json"
|
|
12
12
|
CONFIG_BACKUP = Path.home() / ".zhipu_cli_config.json.bak"
|
|
@@ -17,6 +17,7 @@ DEFAULT_LIMIT = 20000
|
|
|
17
17
|
def _default_config():
|
|
18
18
|
"""返回默认配置字典"""
|
|
19
19
|
return {
|
|
20
|
+
"provider": "zhipu",
|
|
20
21
|
"key": "",
|
|
21
22
|
"model": "glm-4-flash",
|
|
22
23
|
"limit": DEFAULT_LIMIT,
|
|
@@ -28,6 +29,7 @@ def _default_config():
|
|
|
28
29
|
"disk": {},
|
|
29
30
|
"thinking_mode": "direct",
|
|
30
31
|
"mcp": {"servers": []},
|
|
32
|
+
"providers": {},
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
|
|
@@ -111,11 +113,29 @@ def init_config():
|
|
|
111
113
|
save_config(c)
|
|
112
114
|
print(f"{GREEN}✅ 默认洞府已开辟: {DEFAULT_WORKSPACE}{RESET}")
|
|
113
115
|
|
|
114
|
-
|
|
116
|
+
# 向后兼容:无 provider 字段的旧配置自动补全
|
|
117
|
+
if "provider" not in c:
|
|
118
|
+
c["provider"] = "zhipu"
|
|
119
|
+
save_config(c)
|
|
120
|
+
|
|
121
|
+
provider = c.get("provider", "zhipu")
|
|
122
|
+
providers_cfg = c.get("providers", {})
|
|
123
|
+
pcfg = providers_cfg.get(provider, {})
|
|
124
|
+
|
|
125
|
+
# 检查当前提供商是否已配置 key
|
|
126
|
+
has_key = bool(pcfg.get("key", c.get("key", "")))
|
|
127
|
+
|
|
128
|
+
if not has_key:
|
|
115
129
|
print(f"\n{YELLOW}⚠️ API Key Required{RESET}")
|
|
116
|
-
|
|
130
|
+
from fr_cli.core.llm import list_providers
|
|
131
|
+
providers = list_providers()
|
|
132
|
+
print(f"{DIM}当前道统: {provider}{RESET}")
|
|
133
|
+
print(f"{DIM}支持道统: {', '.join([p['id'] for p in providers])}{RESET}")
|
|
134
|
+
k = input(f"👉 Enter API Key for [{provider}]: ").strip()
|
|
117
135
|
if k:
|
|
118
136
|
c["key"] = k
|
|
137
|
+
providers_cfg.setdefault(provider, {})["key"] = k
|
|
138
|
+
c["providers"] = providers_cfg
|
|
119
139
|
ok = save_config(c)
|
|
120
140
|
if ok:
|
|
121
141
|
print(f"{GREEN}✅ API Key 已保存至: {CONFIG_FILE}{RESET}")
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
全局状态管理容器 (AppState)
|
|
3
3
|
统一管理配置、子系统实例、运行时状态,实现依赖注入。
|
|
4
4
|
"""
|
|
5
|
-
from zhipuai import ZhipuAI
|
|
6
5
|
from fr_cli.weapon.fs import VFS
|
|
7
6
|
from fr_cli.weapon.mail import MailClient
|
|
8
7
|
from fr_cli.weapon.web import WebRaider
|
|
@@ -12,6 +11,7 @@ from fr_cli.command.security import SecurityManager
|
|
|
12
11
|
from fr_cli.command.executor import CommandExecutor
|
|
13
12
|
from fr_cli.weapon.loader import load_weapon_md
|
|
14
13
|
from fr_cli.weapon.mcp import MCPManager
|
|
14
|
+
from fr_cli.core.llm import create_llm_client, list_providers, get_provider_info, resolve_provider_model
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class AppState:
|
|
@@ -20,15 +20,16 @@ class AppState:
|
|
|
20
20
|
def __init__(self, cfg):
|
|
21
21
|
self.cfg = cfg
|
|
22
22
|
self.lang = cfg.get("lang", "zh")
|
|
23
|
-
self.model_name = cfg.get("model", "glm-4-flash")
|
|
24
23
|
self.limit = cfg.get("limit", 4096)
|
|
25
|
-
self.api_key = cfg.get("key", "")
|
|
26
24
|
self.sn = cfg.get("session_name", "")
|
|
27
25
|
self.aliases = cfg.get("aliases", {})
|
|
28
26
|
self.thinking_mode = cfg.get("thinking_mode", "direct")
|
|
29
27
|
|
|
28
|
+
# LLM 客户端统一初始化(万法归一)
|
|
29
|
+
self.client, self.provider, self.model_name = create_llm_client(cfg)
|
|
30
|
+
self.api_key = self.client.api_key
|
|
31
|
+
|
|
30
32
|
# 核心子系统实例化
|
|
31
|
-
self.client = ZhipuAI(api_key=self.api_key)
|
|
32
33
|
self.vfs = VFS(cfg.get("allowed_dirs", []))
|
|
33
34
|
self.plugins = init_plugins()
|
|
34
35
|
self.mail_c = MailClient(cfg.get("mail", {}))
|
|
@@ -63,25 +64,61 @@ class AppState:
|
|
|
63
64
|
self.gatekeeper = GatekeeperManager()
|
|
64
65
|
|
|
65
66
|
def reinit_client(self):
|
|
66
|
-
"""API Key
|
|
67
|
-
self.
|
|
68
|
-
self.
|
|
67
|
+
"""API Key、提供商或模型变更后重铸客户端"""
|
|
68
|
+
self.client, self.provider, self.model_name = create_llm_client(self.cfg)
|
|
69
|
+
self.api_key = self.client.api_key
|
|
69
70
|
|
|
70
71
|
def save_cfg(self):
|
|
71
72
|
"""持久化当前配置"""
|
|
72
73
|
from fr_cli.conf.config import save_config
|
|
73
74
|
save_config(self.cfg)
|
|
74
75
|
|
|
75
|
-
def
|
|
76
|
-
"""
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
def update_provider(self, provider_id):
|
|
77
|
+
"""切换 LLM 提供商(召唤新的道统)"""
|
|
78
|
+
info = get_provider_info(provider_id)
|
|
79
|
+
if not info:
|
|
80
|
+
return False
|
|
81
|
+
self.cfg["provider"] = provider_id
|
|
82
|
+
# 如果新提供商没有设置过模型,使用其默认模型
|
|
83
|
+
providers_cfg = self.cfg.setdefault("providers", {})
|
|
84
|
+
if provider_id not in providers_cfg or not providers_cfg[provider_id].get("model"):
|
|
85
|
+
self.cfg["model"] = info["default_model"]
|
|
86
|
+
self.model_name = info["default_model"]
|
|
87
|
+
else:
|
|
88
|
+
self.model_name = providers_cfg[provider_id].get("model", info["default_model"])
|
|
89
|
+
self.cfg["model"] = self.model_name
|
|
90
|
+
self.save_cfg()
|
|
91
|
+
self.reinit_client()
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
def update_model(self, arg):
|
|
95
|
+
"""
|
|
96
|
+
切换法器模型
|
|
97
|
+
支持格式:
|
|
98
|
+
- "deepseek-chat" 仅切换模型(保持当前提供商)
|
|
99
|
+
- "deepseek:deepseek-chat" 同时切换提供商和模型
|
|
100
|
+
"""
|
|
101
|
+
new_provider, new_model = resolve_provider_model(arg)
|
|
102
|
+
if new_provider and new_provider != self.provider:
|
|
103
|
+
# 切换提供商 + 模型
|
|
104
|
+
if not self.update_provider(new_provider):
|
|
105
|
+
return False
|
|
106
|
+
self.cfg["model"] = new_model
|
|
107
|
+
self.model_name = new_model
|
|
108
|
+
# 同步到 providers 配置中当前提供商的 model
|
|
109
|
+
providers_cfg = self.cfg.setdefault("providers", {})
|
|
110
|
+
pcfg = providers_cfg.setdefault(self.provider, {})
|
|
111
|
+
pcfg["model"] = new_model
|
|
79
112
|
self.save_cfg()
|
|
80
113
|
self.reinit_client()
|
|
114
|
+
return True
|
|
81
115
|
|
|
82
116
|
def update_key(self, key):
|
|
83
|
-
"""重铸 API
|
|
117
|
+
"""重铸 API 密钥(针对当前提供商)"""
|
|
84
118
|
self.cfg["key"] = key
|
|
119
|
+
providers_cfg = self.cfg.setdefault("providers", {})
|
|
120
|
+
pcfg = providers_cfg.setdefault(self.provider, {})
|
|
121
|
+
pcfg["key"] = key
|
|
85
122
|
self.save_cfg()
|
|
86
123
|
self.reinit_client()
|
|
87
124
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM 统一召唤接口 —— 万法归一
|
|
3
|
+
|
|
4
|
+
为各大模型提供商提供统一的流式对话接口,
|
|
5
|
+
使主程序无需关心底层 SDK 差异。
|
|
6
|
+
"""
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Iterator, Optional, Dict, Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseLLMClient(ABC):
|
|
12
|
+
"""大模型客户端抽象基类"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, api_key: str, **kwargs):
|
|
15
|
+
self.api_key = api_key
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def stream_chat(self, model: str, messages: list, max_tokens: int = 4096) -> Iterator[dict]:
|
|
19
|
+
"""
|
|
20
|
+
流式对话,yield 每个 token 块
|
|
21
|
+
格式: {"content": str, "usage": dict or None}
|
|
22
|
+
"""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ZhipuLLMClient(BaseLLMClient):
|
|
27
|
+
"""智谱 AI 客户端 (zhipuai SDK)"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, api_key: str, **kwargs):
|
|
30
|
+
super().__init__(api_key, **kwargs)
|
|
31
|
+
from zhipuai import ZhipuAI
|
|
32
|
+
self._client = ZhipuAI(api_key=api_key)
|
|
33
|
+
|
|
34
|
+
def stream_chat(self, model: str, messages: list, max_tokens: int = 4096) -> Iterator[dict]:
|
|
35
|
+
response = self._client.chat.completions.create(
|
|
36
|
+
model=model,
|
|
37
|
+
messages=messages,
|
|
38
|
+
stream=True,
|
|
39
|
+
max_tokens=max_tokens,
|
|
40
|
+
)
|
|
41
|
+
for chunk in response:
|
|
42
|
+
content = ""
|
|
43
|
+
usage = None
|
|
44
|
+
if chunk.choices and chunk.choices[0].delta:
|
|
45
|
+
content = chunk.choices[0].delta.content or ""
|
|
46
|
+
if hasattr(chunk, 'usage') and chunk.usage:
|
|
47
|
+
usage = chunk.usage.model_dump() if hasattr(chunk.usage, 'model_dump') else vars(chunk.usage)
|
|
48
|
+
yield {"content": content, "usage": usage}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OpenAICompatibleClient(BaseLLMClient):
|
|
52
|
+
"""
|
|
53
|
+
OpenAI 兼容格式客户端
|
|
54
|
+
覆盖:DeepSeek / Kimi(Moonshot) / 通义千问(Qwen) / StepFun / MiniMax / 讯飞星火(Spark)
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, **kwargs):
|
|
58
|
+
super().__init__(api_key, **kwargs)
|
|
59
|
+
from openai import OpenAI
|
|
60
|
+
self._client = OpenAI(api_key=api_key, base_url=base_url)
|
|
61
|
+
|
|
62
|
+
def stream_chat(self, model: str, messages: list, max_tokens: int = 4096) -> Iterator[dict]:
|
|
63
|
+
response = self._client.chat.completions.create(
|
|
64
|
+
model=model,
|
|
65
|
+
messages=messages,
|
|
66
|
+
stream=True,
|
|
67
|
+
max_tokens=max_tokens,
|
|
68
|
+
)
|
|
69
|
+
for chunk in response:
|
|
70
|
+
content = ""
|
|
71
|
+
usage = None
|
|
72
|
+
if chunk.choices and chunk.choices[0].delta:
|
|
73
|
+
content = chunk.choices[0].delta.content or ""
|
|
74
|
+
if hasattr(chunk, 'usage') and chunk.usage:
|
|
75
|
+
usage = chunk.usage.model_dump() if hasattr(chunk.usage, 'model_dump') else vars(chunk.usage)
|
|
76
|
+
yield {"content": content, "usage": usage}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# 提供商配置表
|
|
80
|
+
_PROVIDERS: Dict[str, Dict[str, Any]] = {
|
|
81
|
+
"zhipu": {
|
|
82
|
+
"name": "智谱AI (Zhipu)",
|
|
83
|
+
"default_model": "glm-4-flash",
|
|
84
|
+
"client_class": ZhipuLLMClient,
|
|
85
|
+
"base_url": None,
|
|
86
|
+
},
|
|
87
|
+
"deepseek": {
|
|
88
|
+
"name": "DeepSeek",
|
|
89
|
+
"default_model": "deepseek-chat",
|
|
90
|
+
"client_class": OpenAICompatibleClient,
|
|
91
|
+
"base_url": "https://api.deepseek.com",
|
|
92
|
+
},
|
|
93
|
+
"kimi": {
|
|
94
|
+
"name": "Kimi (Moonshot)",
|
|
95
|
+
"default_model": "moonshot-v1-8k",
|
|
96
|
+
"client_class": OpenAICompatibleClient,
|
|
97
|
+
"base_url": "https://api.moonshot.cn/v1",
|
|
98
|
+
},
|
|
99
|
+
"qwen": {
|
|
100
|
+
"name": "通义千问 (Qwen)",
|
|
101
|
+
"default_model": "qwen-turbo",
|
|
102
|
+
"client_class": OpenAICompatibleClient,
|
|
103
|
+
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
104
|
+
},
|
|
105
|
+
"stepfun": {
|
|
106
|
+
"name": "阶跃星辰 (StepFun)",
|
|
107
|
+
"default_model": "step-1-8k",
|
|
108
|
+
"client_class": OpenAICompatibleClient,
|
|
109
|
+
"base_url": "https://api.stepfun.com/v1",
|
|
110
|
+
},
|
|
111
|
+
"minimax": {
|
|
112
|
+
"name": "MiniMax",
|
|
113
|
+
"default_model": "abab6.5-chat",
|
|
114
|
+
"client_class": OpenAICompatibleClient,
|
|
115
|
+
"base_url": "https://api.minimax.chat/v1",
|
|
116
|
+
},
|
|
117
|
+
"spark": {
|
|
118
|
+
"name": "讯飞星火 (Spark)",
|
|
119
|
+
"default_model": "generalv3.5",
|
|
120
|
+
"client_class": OpenAICompatibleClient,
|
|
121
|
+
"base_url": "https://spark-api-open.xf-yun.com/v1",
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def create_llm_client(cfg: dict):
|
|
127
|
+
"""
|
|
128
|
+
根据配置创建对应的 LLM 客户端
|
|
129
|
+
|
|
130
|
+
cfg 格式支持:
|
|
131
|
+
新版: {"provider": "deepseek", "providers": {"deepseek": {"key": "xxx", "model": "..."}}}
|
|
132
|
+
旧版: {"key": "xxx", "model": "glm-4-flash"} (自动识别为 zhipu)
|
|
133
|
+
|
|
134
|
+
返回: (client_instance, provider_id, model_name)
|
|
135
|
+
"""
|
|
136
|
+
provider = cfg.get("provider", "zhipu")
|
|
137
|
+
providers_cfg = cfg.get("providers", {})
|
|
138
|
+
|
|
139
|
+
# 获取当前提供商配置
|
|
140
|
+
pcfg = providers_cfg.get(provider, {})
|
|
141
|
+
|
|
142
|
+
# 向后兼容:如果 providers 中没有当前 provider,从顶层读取 key/model
|
|
143
|
+
api_key = pcfg.get("key", cfg.get("key", ""))
|
|
144
|
+
default_model = _PROVIDERS.get(provider, _PROVIDERS["zhipu"])["default_model"]
|
|
145
|
+
model = pcfg.get("model", cfg.get("model", default_model))
|
|
146
|
+
|
|
147
|
+
info = _PROVIDERS.get(provider, _PROVIDERS["zhipu"])
|
|
148
|
+
client_class = info["client_class"]
|
|
149
|
+
base_url = info.get("base_url")
|
|
150
|
+
|
|
151
|
+
kwargs = {"api_key": api_key}
|
|
152
|
+
if base_url:
|
|
153
|
+
kwargs["base_url"] = base_url
|
|
154
|
+
|
|
155
|
+
return client_class(**kwargs), provider, model
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def list_providers():
|
|
159
|
+
"""返回所有支持的提供商列表"""
|
|
160
|
+
return [
|
|
161
|
+
{"id": k, "name": v["name"], "default_model": v["default_model"]}
|
|
162
|
+
for k, v in _PROVIDERS.items()
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_provider_info(provider_id: str):
|
|
167
|
+
"""获取指定提供商信息"""
|
|
168
|
+
return _PROVIDERS.get(provider_id)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_provider_model(arg: str) -> tuple:
|
|
172
|
+
"""
|
|
173
|
+
解析用户输入的模型参数
|
|
174
|
+
支持格式:
|
|
175
|
+
- "deepseek:deepseek-chat" → ("deepseek", "deepseek-chat")
|
|
176
|
+
- "deepseek-chat" → (None, "deepseek-chat") (仅模型名,保持当前提供商)
|
|
177
|
+
"""
|
|
178
|
+
if ":" in arg:
|
|
179
|
+
parts = arg.split(":", 1)
|
|
180
|
+
return parts[0].strip(), parts[1].strip()
|
|
181
|
+
return None, arg.strip()
|
|
@@ -6,78 +6,78 @@ import time
|
|
|
6
6
|
from fr_cli.ui.ui import RESET, DIM, CYAN, RED, CODE_BG, CODE_FG
|
|
7
7
|
from fr_cli.lang.i18n import T
|
|
8
8
|
|
|
9
|
+
|
|
9
10
|
def stream_cnt(client, model, messages, lang, custom_prefix=None, max_tokens=None, silent=False):
|
|
10
11
|
"""
|
|
11
|
-
|
|
12
|
+
流式调用 LLM 并实时打印,带有简易代码块高亮状态机
|
|
13
|
+
:param client: LLM 客户端实例 (BaseLLMClient 子类)
|
|
12
14
|
:param silent: 如果为True,则不输出到终端,仅返回文本
|
|
13
15
|
:return: tuple (完整回复文本 str, 使用情况 dict, 响应时间 float)
|
|
14
16
|
"""
|
|
15
17
|
if not silent:
|
|
16
18
|
p = custom_prefix or f"{CYAN}{T('prompt_ai', lang)}{RESET} "
|
|
17
19
|
sys.stdout.write(p); sys.stdout.flush()
|
|
18
|
-
|
|
20
|
+
|
|
19
21
|
start_time = time.time()
|
|
20
22
|
full_text = ""
|
|
21
23
|
in_code = False
|
|
22
24
|
usage = {}
|
|
23
|
-
|
|
25
|
+
|
|
24
26
|
try:
|
|
25
27
|
# 验证API密钥
|
|
26
28
|
if not client.api_key or len(client.api_key) < 10:
|
|
27
29
|
print(f"{RED}[错误] API密钥未配置或格式不正确{RESET}")
|
|
28
30
|
return "[错误] 请先配置有效的API密钥", {}, 0.0
|
|
29
|
-
|
|
30
|
-
response = client.
|
|
31
|
+
|
|
32
|
+
response = client.stream_chat(
|
|
31
33
|
model=model,
|
|
32
34
|
messages=messages,
|
|
33
|
-
stream=True,
|
|
34
35
|
max_tokens=max_tokens if max_tokens else 4096
|
|
35
36
|
)
|
|
36
|
-
|
|
37
|
+
|
|
37
38
|
for chunk in response:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
in_code = not in_code
|
|
48
|
-
if not silent:
|
|
49
|
-
if in_code:
|
|
50
|
-
sys.stdout.write(f"{CODE_BG}{CODE_FG}")
|
|
51
|
-
else:
|
|
52
|
-
sys.stdout.write(f"{RESET}")
|
|
39
|
+
txt = chunk.get("content", "")
|
|
40
|
+
if txt:
|
|
41
|
+
full_text += txt
|
|
42
|
+
# 简易状态机:检测 ``` 切换代码背景
|
|
43
|
+
if "```" in txt:
|
|
44
|
+
parts = txt.split("```")
|
|
45
|
+
for i, part in enumerate(parts):
|
|
46
|
+
if i > 0: # 遇到了一个 ```
|
|
47
|
+
in_code = not in_code
|
|
53
48
|
if not silent:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
if in_code:
|
|
50
|
+
sys.stdout.write(f"{CODE_BG}{CODE_FG}")
|
|
51
|
+
else:
|
|
52
|
+
sys.stdout.write(f"{RESET}")
|
|
57
53
|
if not silent:
|
|
58
|
-
|
|
59
|
-
sys.stdout.write(f"{CODE_BG}{CODE_FG}{txt}{CODE_FG}")
|
|
60
|
-
else:
|
|
61
|
-
sys.stdout.write(txt)
|
|
54
|
+
sys.stdout.write(part)
|
|
62
55
|
sys.stdout.flush()
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
56
|
+
else:
|
|
57
|
+
if not silent:
|
|
58
|
+
if in_code:
|
|
59
|
+
sys.stdout.write(f"{CODE_BG}{CODE_FG}{txt}{CODE_FG}")
|
|
60
|
+
else:
|
|
61
|
+
sys.stdout.write(txt)
|
|
62
|
+
sys.stdout.flush()
|
|
63
|
+
|
|
64
|
+
if chunk.get("usage"):
|
|
65
|
+
usage = chunk["usage"]
|
|
66
|
+
|
|
67
67
|
except Exception as e:
|
|
68
68
|
sys.stdout.write(f"\n{DIM}{str(e)[:50]}{RESET}")
|
|
69
69
|
sys.stdout.flush()
|
|
70
|
-
|
|
70
|
+
|
|
71
71
|
if not silent:
|
|
72
72
|
sys.stdout.write(RESET)
|
|
73
73
|
sys.stdout.write("\n")
|
|
74
74
|
sys.stdout.flush()
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
end_time = time.time()
|
|
77
77
|
response_time = end_time - start_time
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
# 如果没有收到任何内容,返回提示信息
|
|
80
80
|
if not full_text:
|
|
81
81
|
return "[错误] 无法获取AI回复,请检查API密钥配置", usage, response_time
|
|
82
|
-
|
|
83
|
-
return full_text, usage, response_time
|
|
82
|
+
|
|
83
|
+
return full_text, usage, response_time
|
|
@@ -181,7 +181,7 @@ def main():
|
|
|
181
181
|
state.context_summary = load_context(state.sn)
|
|
182
182
|
|
|
183
183
|
# 启动动画
|
|
184
|
-
print_banner(state.model_name, state.limit, cfg.get("allowed_dirs", [""]), state.sn, state.lang)
|
|
184
|
+
print_banner(state.model_name, state.limit, cfg.get("allowed_dirs", [""]), state.sn, state.lang, state.provider)
|
|
185
185
|
|
|
186
186
|
# ================= 主循环 =================
|
|
187
187
|
while True:
|
|
@@ -100,8 +100,23 @@ def _cmd_help(state, parts):
|
|
|
100
100
|
def _cmd_model(state, parts):
|
|
101
101
|
arg1 = parts[1] if len(parts) > 1 else ""
|
|
102
102
|
if arg1:
|
|
103
|
-
state.update_model(arg1)
|
|
104
|
-
|
|
103
|
+
ok = state.update_model(arg1)
|
|
104
|
+
if ok:
|
|
105
|
+
print(f"{GREEN}✅ 已切换: [{state.provider}] {state.model_name}{RESET}")
|
|
106
|
+
else:
|
|
107
|
+
print(f"{RED}❌ 无效的提供商或模型: {arg1}{RESET}")
|
|
108
|
+
else:
|
|
109
|
+
# 显示当前道统与可用模型
|
|
110
|
+
from fr_cli.core.llm import list_providers, get_provider_info
|
|
111
|
+
print(f"{CYAN}🧠 当前道统: [{state.provider}] {state.model_name}{RESET}")
|
|
112
|
+
print(f"\n{DIM}可用道统与默认模型:{RESET}")
|
|
113
|
+
for p in list_providers():
|
|
114
|
+
marker = " 👈 当前" if p["id"] == state.provider else ""
|
|
115
|
+
print(f" {CYAN}{p['id']}{RESET} — {p['name']}{DIM} (默认: {p['default_model']}){RESET}{marker}")
|
|
116
|
+
print(f"\n{DIM}用法:{RESET}")
|
|
117
|
+
print(f" /model <模型名> — 切换当前道统下的模型")
|
|
118
|
+
print(f" /model <道统>:<模型名> — 同时切换道统和模型")
|
|
119
|
+
print(f" 示例: /model deepseek:deepseek-chat")
|
|
105
120
|
return False
|
|
106
121
|
|
|
107
122
|
|
|
@@ -109,7 +124,9 @@ def _cmd_key(state, parts):
|
|
|
109
124
|
arg1 = parts[1] if len(parts) > 1 else ""
|
|
110
125
|
if arg1:
|
|
111
126
|
state.update_key(arg1)
|
|
112
|
-
print(f"{GREEN}{
|
|
127
|
+
print(f"{GREEN}✅ [{state.provider}] API Key 已更新{RESET}")
|
|
128
|
+
else:
|
|
129
|
+
print(f"{YELLOW}⚠️ 用法: /key <API密钥> (为当前道统 [{state.provider}] 设置密钥){RESET}")
|
|
113
130
|
return False
|
|
114
131
|
|
|
115
132
|
|
|
@@ -48,7 +48,7 @@ def get_display_width(text):
|
|
|
48
48
|
width += 1
|
|
49
49
|
return width
|
|
50
50
|
|
|
51
|
-
def print_banner(mn, tl, ad, sn, l):
|
|
51
|
+
def print_banner(mn, tl, ad, sn, l, provider="zhipu"):
|
|
52
52
|
"""打印启动时的小乌龟从左向右爬行动画"""
|
|
53
53
|
|
|
54
54
|
# 乌龟身体(6行)
|
|
@@ -106,10 +106,11 @@ def print_banner(mn, tl, ad, sn, l):
|
|
|
106
106
|
uf = (l == "zh")
|
|
107
107
|
ds = f"{GREEN}{ad}{RESET}" if ad else f"{RED}{('未开放洞府' if uf else 'No dir')}{RESET}"
|
|
108
108
|
ss = f"{MAGENTA}{sn}{RESET}" if sn else f"{DIM}{'全新轮回' if uf else 'New'}{RESET}"
|
|
109
|
+
pv = f" {'🏛️ 道统' if uf else 'Provider'}: {CYAN}{provider}{RESET}"
|
|
109
110
|
i1 = f" {'🔮 模型' if uf else 'Model'}: {GREEN}{BOLD}{mn}{RESET} | {'🛡️ 上限' if uf else 'Limit'}: {YELLOW}{tl}{RESET}"
|
|
110
111
|
i2 = f" {'📂 洞府' if uf else 'Dir'}: {ds} | {'⏳ 轮回' if uf else 'Sess'}: {ss}"
|
|
111
|
-
bl = max(get_display_width(i1), get_display_width(i2)) + 4
|
|
112
|
-
print(f"{MAGENTA}┌{'─'*bl}┐{RESET}\n{MAGENTA}│{RESET}{i1}{' '*(bl-get_display_width(i1)-2)}{MAGENTA}│{RESET}\n{MAGENTA}│{RESET}{i2}{' '*(bl-get_display_width(i2)-2)}{MAGENTA}│{RESET}\n{MAGENTA}└{'─'*bl}┘{RESET}\n")
|
|
112
|
+
bl = max(get_display_width(pv), get_display_width(i1), get_display_width(i2)) + 4
|
|
113
|
+
print(f"{MAGENTA}┌{'─'*bl}┐{RESET}\n{MAGENTA}│{RESET}{pv}{' '*(bl-get_display_width(pv)-2)}{MAGENTA}│{RESET}\n{MAGENTA}│{RESET}{i1}{' '*(bl-get_display_width(i1)-2)}{MAGENTA}│{RESET}\n{MAGENTA}│{RESET}{i2}{' '*(bl-get_display_width(i2)-2)}{MAGENTA}│{RESET}\n{MAGENTA}└{'─'*bl}┘{RESET}\n")
|
|
113
114
|
|
|
114
115
|
def print_bye():
|
|
115
116
|
"""打印退出动画"""
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fr-cli
|
|
3
|
-
Version: 2.
|
|
4
|
-
Summary: 凡人打字机 -
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: 凡人打字机 - 支持多模型(Zhipu/DeepSeek/Kimi/Qwen/StepFun/MiniMax/Spark)的终极全能终端工具
|
|
5
5
|
Author: FANREN CLI Author
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/yourname/fr-cli
|
|
8
8
|
Project-URL: Issues, https://github.com/yourname/fr-cli/issues
|
|
9
|
-
Keywords: zhipuai,
|
|
9
|
+
Keywords: zhipuai,deepseek,kimi,qwen,cli,ai,terminal,chatbot
|
|
10
10
|
Classifier: Development Status :: 4 - Beta
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
@@ -24,6 +24,7 @@ Requires-Python: >=3.8
|
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
License-File: LICENSE
|
|
26
26
|
Requires-Dist: zhipuai>=2.0.0
|
|
27
|
+
Requires-Dist: openai>=1.0.0
|
|
27
28
|
Requires-Dist: requests>=2.28.0
|
|
28
29
|
Requires-Dist: mcp>=1.6.0
|
|
29
30
|
Provides-Extra: data
|
|
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fr-cli"
|
|
7
|
-
version = "2.
|
|
8
|
-
description = "凡人打字机 -
|
|
7
|
+
version = "2.2.0"
|
|
8
|
+
description = "凡人打字机 - 支持多模型(Zhipu/DeepSeek/Kimi/Qwen/StepFun/MiniMax/Spark)的终极全能终端工具"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
authors = [
|
|
13
13
|
{name = "FANREN CLI Author"},
|
|
14
14
|
]
|
|
15
|
-
keywords = ["zhipuai", "
|
|
15
|
+
keywords = ["zhipuai", "deepseek", "kimi", "qwen", "cli", "ai", "terminal", "chatbot"]
|
|
16
16
|
classifiers = [
|
|
17
17
|
"Development Status :: 4 - Beta",
|
|
18
18
|
"Environment :: Console",
|
|
@@ -30,6 +30,7 @@ classifiers = [
|
|
|
30
30
|
]
|
|
31
31
|
dependencies = [
|
|
32
32
|
"zhipuai>=2.0.0",
|
|
33
|
+
"openai>=1.0.0",
|
|
33
34
|
"requests>=2.28.0",
|
|
34
35
|
"mcp>=1.6.0",
|
|
35
36
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|