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 +11 -0
- prismind/core/__init__.py +10 -0
- prismind/core/api_adapter.py +213 -0
- prismind/core/cache.py +55 -0
- prismind/core/config.py +113 -0
- prismind/core/engine.py +306 -0
- prismind/core/model_profile.py +172 -0
- prismind/core/multimodal.py +115 -0
- prismind/core/prompts.py +241 -0
- prismind/core/reasoning_control.py +54 -0
- prismind/core/session.py +53 -0
- prismind/core/tools.py +49 -0
- prismind/entrypoints/__init__.py +0 -0
- prismind/entrypoints/cli.py +117 -0
- prismind/entrypoints/sdk.py +82 -0
- prismind/entrypoints/web_server.py +209 -0
- prismind/prompts/default.yaml +10 -0
- prismind/server.py +57 -0
- prismind/setup.py +36 -0
- prismind-0.2.0.dist-info/METADATA +133 -0
- prismind-0.2.0.dist-info/RECORD +25 -0
- prismind-0.2.0.dist-info/WHEEL +5 -0
- prismind-0.2.0.dist-info/entry_points.txt +3 -0
- prismind-0.2.0.dist-info/licenses/LICENSE +21 -0
- prismind-0.2.0.dist-info/top_level.txt +1 -0
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
|
prismind/core/config.py
ADDED
|
@@ -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
|