prismind 0.2.0__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.
prismind/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from .entrypoints.sdk import chat, chat_stream, chat_async, set_mode, set_config, clear_cache, clear_history, list_tools
2
+ from .core.multimodal import detect_vision_type, ImageLoader
3
+ from .core.config import load_config, save_config
4
+
5
+ __version__ = "0.2.0"
6
+ __all__ = [
7
+ "chat", "chat_stream", "chat_async", "set_mode", "set_config",
8
+ "clear_cache", "clear_history", "list_tools",
9
+ "detect_vision_type", "ImageLoader",
10
+ "load_config", "save_config",
11
+ ]
@@ -0,0 +1,10 @@
1
+ from .engine import Engine
2
+ from .prompts import PromptLoader, BUILTIN_PROMPTS, QTYPES, pick_prisms, get_prism_prompt, PRISM_TEMPLATES
3
+ from .reasoning_control import ReasonMode, ReasoningController, DEEP_WARNING
4
+ from .model_profile import ModelProfile, detect_model_profile
5
+ from .cache import Cache
6
+ from .session import SessionManager
7
+ from .tools import ToolRegistry
8
+ from .api_adapter import APIAdapter
9
+ from .multimodal import detect_vision_type, ImageLoader, prune_images, VISION_PROMPTS
10
+ from .config import load_config, save_config
@@ -0,0 +1,213 @@
1
+ """API适配器:统一OpenAI格式 + 多模态差异化构建"""
2
+ import os, time, json, base64, hashlib, sys, requests
3
+
4
+
5
+ class APIAdapter:
6
+ def __init__(self):
7
+ self._session = requests.Session()
8
+ self._session.headers.update({"Connection": "keep-alive"})
9
+
10
+ def call(self, msgs: list, model: str, ak: str, ab: str,
11
+ mt: int, temp: float, to: int, stream: bool = False,
12
+ images: list = None):
13
+ """统一调用入口,自动识别供应商"""
14
+ provider = self._detect_provider(ab)
15
+ has_img = bool(images)
16
+
17
+ try:
18
+ if provider == "ollama":
19
+ return self._call_ollama(msgs, model, ak, ab, mt, temp, to, stream, images)
20
+ if provider == "claude":
21
+ return self._call_claude(msgs, model, ak, ab, mt, temp, to, stream, images)
22
+ if provider == "gemini":
23
+ return self._call_gemini(msgs, model, ak, ab, mt, temp, to, stream, images)
24
+ return self._call_openai(msgs, model, ak, ab, mt, temp, to, stream, images, provider)
25
+ except Exception as e:
26
+ print(f"[API 错误] {model}@{ab}: {e}", file=sys.stderr)
27
+ return None
28
+
29
+ # ---- Ollama ----
30
+ def _call_ollama(self, msgs, model, ak, ab, mt, temp, to, stream, images):
31
+ text = msgs[-1]["content"]
32
+ img_data = []
33
+ if images:
34
+ for img in images:
35
+ if img.get("data"): img_data.append(img["data"])
36
+ elif img.get("url"):
37
+ d, _ = self._download_img(img["url"])
38
+ if d: img_data.append(d["data"])
39
+ msgs[-1] = {"role": "user", "content": text}
40
+ py = {"model": model, "messages": msgs, "stream": stream,
41
+ "options": {"num_predict": mt, "temperature": temp}}
42
+ if img_data: py["images"] = img_data
43
+ r = self._session.post(ab.rstrip("/") + "/api/chat", json=py,
44
+ headers={"Content-Type": "application/json"}, timeout=to, stream=stream)
45
+ if stream:
46
+ return self._stream_ollama(r)
47
+ if r.status_code != 200: return None
48
+ # Ollama 有时返回 NDJSON(每行一个 JSON),兼容两种格式
49
+ body = r.text.strip()
50
+ try:
51
+ return json.loads(body).get("message", {}).get("content", "").strip()
52
+ except json.JSONDecodeError:
53
+ # NDJSON 格式:逐行解析取最后一行的 content
54
+ lines = [l.strip() for l in body.split("\n") if l.strip()]
55
+ for line in reversed(lines):
56
+ try:
57
+ d = json.loads(line)
58
+ if d.get("done"):
59
+ return d.get("message", {}).get("content", "") or ""
60
+ except: continue
61
+ return ""
62
+
63
+ def _stream_ollama(self, r):
64
+ def gen():
65
+ try:
66
+ for l in r.iter_lines():
67
+ if not l: continue
68
+ try:
69
+ d = json.loads(l.decode())
70
+ if d.get("done"): break
71
+ yield d.get("message", {}).get("content", "")
72
+ except: continue
73
+ finally: pass
74
+ return gen()
75
+
76
+ # ---- Claude ----
77
+ def _call_claude(self, msgs, model, ak, ab, mt, temp, to, stream, images):
78
+ sy, ot = "", []
79
+ for m in msgs:
80
+ if m["role"] == "system": sy = m["content"]
81
+ else:
82
+ if images and m["role"] == "user" and m == msgs[-1]:
83
+ content = self._build_claude_content(m["content"], images)
84
+ ot.append({"role": "user", "content": content})
85
+ else:
86
+ ot.append({"role": m["role"], "content": m["content"]})
87
+ py = {"model": model, "max_tokens": mt, "messages": ot, "stream": stream}
88
+ if sy: py["system"] = sy
89
+ r = self._session.post(ab.rstrip("/") + "/v1/messages", json=py,
90
+ headers={"x-api-key": ak, "anthropic-version": "2023-06-01",
91
+ "Content-Type": "application/json"}, timeout=to, stream=stream)
92
+ if stream:
93
+ def gen():
94
+ try:
95
+ for l in r.iter_lines():
96
+ if not l: continue
97
+ d = json.loads(l.decode().lstrip("data: "))
98
+ if d.get("type") == "content_block_delta": yield d["delta"]["text"]
99
+ finally: pass
100
+ return gen()
101
+ return r.json()["content"][0]["text"].strip()
102
+
103
+ def _build_claude_content(self, text, images):
104
+ parts = [{"type": "text", "text": text}]
105
+ for img in images:
106
+ if img.get("data"):
107
+ parts.append({"type": "image",
108
+ "source": {"type": "base64", "media_type": img.get("mime", "image/jpeg"),
109
+ "data": img["data"]}})
110
+ elif img.get("url"):
111
+ d, _ = self._download_img(img["url"])
112
+ if d:
113
+ parts.append({"type": "image",
114
+ "source": {"type": "base64", "media_type": d["mime"], "data": d["data"]}})
115
+ return parts
116
+
117
+ # ---- Gemini ----
118
+ def _call_gemini(self, msgs, model, ak, ab, mt, temp, to, stream, images):
119
+ contents = []
120
+ for m in msgs:
121
+ role = "model" if m["role"] == "assistant" else "user"
122
+ if images and m["role"] == "user" and m == msgs[-1]:
123
+ parts = self._build_openai_content(m["content"], images)
124
+ gp = []
125
+ for p in parts:
126
+ if p["type"] == "text": gp.append({"text": p["text"]})
127
+ elif p["type"] == "image_url":
128
+ u = p["image_url"]["url"]
129
+ if u.startswith("data:"):
130
+ _, b64 = u.split(",", 1)
131
+ gp.append({"inline_data": {"mime_type": u.split(";")[0].split(":")[1], "data": b64}})
132
+ else:
133
+ gp.append({"text": f"[图片: {u[:80]}]"})
134
+ contents.append({"role": role, "parts": gp})
135
+ else:
136
+ contents.append({"role": role, "parts": [{"text": m.get("content", "")}]})
137
+ py = {"contents": contents, "generationConfig": {"maxOutputTokens": mt, "temperature": temp}}
138
+ r = self._session.post(ab.rstrip("/") + "/v1beta/models/" + model + ":generateContent",
139
+ json=py, headers={"Content-Type": "application/json"}, timeout=to)
140
+ if r.status_code == 200:
141
+ try: return r.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
142
+ except: return None
143
+ return None
144
+
145
+ # ---- OpenAI 兼容 ----
146
+ def _call_openai(self, msgs, model, ak, ab, mt, temp, to, stream, images, provider):
147
+ if images:
148
+ oa = []
149
+ for m in msgs:
150
+ if m["role"] == "user" and m == msgs[-1]:
151
+ oa.append({"role": "user", "content": self._build_openai_content(m["content"], images)})
152
+ else:
153
+ oa.append(m)
154
+ else:
155
+ oa = msgs
156
+ py = {"model": model, "messages": oa, "max_tokens": mt, "temperature": temp, "stream": stream}
157
+ if provider == "ernie":
158
+ py = {"model": model, "messages": oa, "max_output_tokens": mt, "temperature": temp, "stream": stream}
159
+ r = self._session.post(ab.rstrip("/") + "/chat/completions", json=py,
160
+ headers={"Authorization": f"Bearer {ak}", "Content-Type": "application/json"},
161
+ timeout=to, stream=stream)
162
+ if stream:
163
+ def gen():
164
+ try:
165
+ for l in r.iter_lines():
166
+ if not l: continue
167
+ d = l.decode().strip()
168
+ if not d.startswith("data:"): continue
169
+ d = d[5:].strip()
170
+ if d == "[DONE]": break
171
+ try: yield json.loads(d)["choices"][0]["delta"].get("content", "")
172
+ except: pass
173
+ finally: pass
174
+ return gen()
175
+ if r.status_code == 200:
176
+ return r.json()["choices"][0]["message"]["content"].strip()
177
+ return None
178
+
179
+ def _build_openai_content(self, text, images):
180
+ parts = [{"type": "text", "text": text}]
181
+ for img in images:
182
+ if img.get("url"):
183
+ parts.append({"type": "image_url", "image_url": {"url": img["url"]}})
184
+ elif img.get("data"):
185
+ mime = img.get("mime", "image/jpeg")
186
+ parts.append({"type": "image_url",
187
+ "image_url": {"url": f"data:{mime};base64,{img['data']}"}})
188
+ return parts
189
+
190
+ def _download_img(self, url, timeout=10):
191
+ try:
192
+ r = requests.get(url, timeout=timeout)
193
+ if r.status_code != 200: return None, "下载失败"
194
+ ct = r.headers.get("content-type", "").lower()
195
+ valid = {"image/jpeg", "image/png", "image/gif", "image/webp"}
196
+ if ct not in valid:
197
+ ext = os.path.splitext(url.split("?")[0])[1].lower()
198
+ m = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
199
+ ".gif": "image/gif", ".webp": "image/webp"}
200
+ ct = m.get(ext, "image/jpeg")
201
+ return {"data": base64.b64encode(r.content).decode(), "mime": ct}, ""
202
+ except Exception as e:
203
+ return None, str(e)
204
+
205
+ def _detect_provider(self, ab: str) -> str:
206
+ b = ab.lower()
207
+ if "11434" in b: return "ollama"
208
+ if "anthropic" in b: return "claude"
209
+ if "googleapis" in b or "generativelanguage" in b: return "gemini"
210
+ if "baidu" in b or "ernie" in b: return "ernie"
211
+ if "dashscope" in b or "qwen" in b: return "dashscope"
212
+ if "bigmodel" in b or "zhipu" in b: return "glm"
213
+ return "openai"
prismind/core/cache.py ADDED
@@ -0,0 +1,55 @@
1
+ """本地缓存(内存LRU + 可选json持久化)"""
2
+ import os, json, time, threading
3
+ from collections import OrderedDict
4
+
5
+
6
+ class Cache:
7
+ def __init__(self, max_size: int = 200, ttl: int = 3600, persist_path: str = None):
8
+ self._d = OrderedDict()
9
+ self._lock = threading.Lock()
10
+ self.max_size = max_size
11
+ self.ttl = ttl
12
+ self.persist_path = persist_path
13
+ if persist_path and os.path.exists(persist_path):
14
+ self._load()
15
+
16
+ def get(self, key: str):
17
+ with self._lock:
18
+ if key not in self._d: return None
19
+ v, t = self._d[key]
20
+ if time.time() - t > self.ttl:
21
+ del self._d[key]
22
+ return None
23
+ self._d.move_to_end(key)
24
+ return v
25
+
26
+ def set(self, key: str, value: str):
27
+ with self._lock:
28
+ self._d[key] = (value[:10000], time.time())
29
+ self._d.move_to_end(key)
30
+ while len(self._d) > self.max_size:
31
+ self._d.popitem(last=False)
32
+ self._save()
33
+
34
+ def clear(self):
35
+ with self._lock:
36
+ self._d.clear()
37
+ self._save()
38
+
39
+ def _save(self):
40
+ """持久化到磁盘"""
41
+ if not self.persist_path: return
42
+ try:
43
+ os.makedirs(os.path.dirname(self.persist_path), exist_ok=True)
44
+ with self._lock:
45
+ with open(self.persist_path, "w", encoding="utf-8") as f:
46
+ json.dump({k: v for k, v in self._d.items()}, f, ensure_ascii=False)
47
+ except: pass
48
+
49
+ def _load(self):
50
+ try:
51
+ with open(self.persist_path) as f:
52
+ for k, v in json.load(f).items():
53
+ if time.time() - v[1] < self.ttl:
54
+ self._d[k] = v
55
+ except: pass
@@ -0,0 +1,113 @@
1
+ """
2
+ 配置文件加载:支持 persfuse.json / .env / 环境变量
3
+ 优先级:环境变量 > .env > persfuse.json > 默认值
4
+ """
5
+ import os, json, platform
6
+
7
+
8
+ DEFAULT_CONFIG = {
9
+ "model": "deepseek-chat",
10
+ "ak": "",
11
+ "ab": "https://api.deepseek.com/v1",
12
+ "mode": "standard",
13
+ }
14
+
15
+
16
+ def _load_dotenv(path: str = None) -> dict:
17
+ """简易 .env 解析器(不依赖 python-dotenv)"""
18
+ if path is None:
19
+ path = os.path.join(os.getcwd(), ".env")
20
+ if not os.path.exists(path):
21
+ return {}
22
+ result = {}
23
+ try:
24
+ with open(path, encoding="utf-8") as f:
25
+ for line in f:
26
+ line = line.strip()
27
+ if not line or line.startswith("#"):
28
+ continue
29
+ if "=" not in line:
30
+ continue
31
+ key, _, val = line.partition("=")
32
+ key, val = key.strip(), val.strip().strip("\"'").strip()
33
+ if key.startswith("PRISMIND_"):
34
+ env_key = key[len("PRISMIND_"):].lower()
35
+ result[env_key] = val
36
+ except Exception:
37
+ pass
38
+ return result
39
+
40
+
41
+ def _find_config_file() -> str:
42
+ """搜索配置文件:当前目录 -> 用户主目录"""
43
+ candidates = [
44
+ os.path.join(os.getcwd(), "persfuse.json"),
45
+ os.path.join(os.getcwd(), ".persfuse.json"),
46
+ ]
47
+ home = os.path.expanduser("~")
48
+ if platform.system() == "Windows":
49
+ candidates.append(os.path.join(home, "persfuse.json"))
50
+ candidates.append(os.path.join(os.getenv("APPDATA", ""), "persfuse", "config.json"))
51
+ else:
52
+ candidates.append(os.path.join(home, ".config", "persfuse", "config.json"))
53
+ candidates.append(os.path.join(home, ".persfuse.json"))
54
+ for path in candidates:
55
+ if os.path.exists(path):
56
+ return path
57
+ return ""
58
+
59
+
60
+ def _load_json(path: str) -> dict:
61
+ try:
62
+ with open(path, encoding="utf-8") as f:
63
+ return json.load(f)
64
+ except Exception:
65
+ return {}
66
+
67
+
68
+ def load_config(overrides: dict = None) -> dict:
69
+ """加载配置,返回值合并了环境变量和文件的设置"""
70
+ cfg = dict(DEFAULT_CONFIG)
71
+
72
+ # 1. .env 文件(在配置文件之前,低于环境变量)
73
+ dotenv_cfg = _load_dotenv()
74
+ cfg.update({k: v for k, v in dotenv_cfg.items() if v})
75
+
76
+ # 2. 配置文件 (persfuse.json)
77
+ path = _find_config_file()
78
+ if path:
79
+ file_cfg = _load_json(path)
80
+ cfg.update(file_cfg)
81
+
82
+ # 3. 环境变量覆盖(最高)
83
+ env_map = {
84
+ "PRISMIND_MODEL": "model",
85
+ "PRISMIND_AK": "ak",
86
+ "PRISMIND_AB": "ab",
87
+ "PRISMIND_MODE": "mode",
88
+ }
89
+ for env_key, cfg_key in env_map.items():
90
+ val = os.environ.get(env_key)
91
+ if val:
92
+ cfg[cfg_key] = val
93
+
94
+ # 4. 运行时覆盖(最高优先级)
95
+ if overrides:
96
+ for k in ("model", "ak", "ab", "mode"):
97
+ if k in overrides and overrides[k]:
98
+ cfg[k] = overrides[k]
99
+
100
+ return cfg
101
+
102
+
103
+ def save_config(cfg: dict, path: str = None) -> str:
104
+ """保存配置到文件"""
105
+ if not path:
106
+ if platform.system() == "Windows":
107
+ path = os.path.join(os.getenv("APPDATA", os.path.expanduser("~")), "persfuse", "config.json")
108
+ else:
109
+ path = os.path.join(os.path.expanduser("~"), ".config", "persfuse", "config.json")
110
+ os.makedirs(os.path.dirname(path), exist_ok=True)
111
+ with open(path, "w", encoding="utf-8") as f:
112
+ json.dump(cfg, f, ensure_ascii=False, indent=2)
113
+ return path