chcode 0.1.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.
chcode/prompts.py ADDED
@@ -0,0 +1,640 @@
1
+ """
2
+ 统一交互层 — 所有用户交互都通过此模块
3
+
4
+ 用 questionary 实现下拉列表、确认框、文本输入等。
5
+ 在 async 上下文中用 asyncio.to_thread 包装同步的 questionary 调用。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ from typing import Any
13
+
14
+ import questionary
15
+ from rich.console import Console
16
+
17
+ console = Console()
18
+
19
+
20
+ async def select(
21
+ message: str,
22
+ choices: list[str],
23
+ default: str | None = None,
24
+ ) -> str | None:
25
+ """下拉单选"""
26
+
27
+ def _ask():
28
+ return questionary.select(
29
+ message=message,
30
+ choices=choices,
31
+ default=default,
32
+ ).ask()
33
+
34
+ return await asyncio.to_thread(_ask)
35
+
36
+
37
+ async def confirm(message: str, default: bool = True) -> bool:
38
+ """确认框"""
39
+
40
+ def _ask():
41
+ return questionary.confirm(
42
+ message=message,
43
+ default=default,
44
+ ).ask()
45
+
46
+ return await asyncio.to_thread(_ask)
47
+
48
+
49
+ async def checkbox(message: str, choices: list[str]) -> list[str]:
50
+ """多选框"""
51
+
52
+ def _ask():
53
+ return questionary.checkbox(message=message, choices=choices).ask()
54
+
55
+ return await asyncio.to_thread(_ask) or []
56
+
57
+
58
+ async def text(message: str, default: str = "") -> str:
59
+ """文本输入"""
60
+
61
+ def _ask():
62
+ return questionary.text(
63
+ message=message,
64
+ default=default,
65
+ ).ask()
66
+
67
+ return await asyncio.to_thread(_ask)
68
+
69
+
70
+ async def password(message: str) -> str:
71
+ """密码输入(隐藏回显)"""
72
+
73
+ def _ask():
74
+ return questionary.password(
75
+ message=message,
76
+ ).ask()
77
+
78
+ return await asyncio.to_thread(_ask)
79
+
80
+
81
+ async def select_or_custom(
82
+ message: str,
83
+ preset_choices: list[str],
84
+ custom_label: str = "自定义输入...",
85
+ custom_prompt: str = "请输入: ",
86
+ default: str | None = None,
87
+ ) -> str:
88
+ """下拉选择 + 自定义输入。末尾有「自定义输入...」选项。"""
89
+ choices = list(preset_choices) + [custom_label]
90
+ result = await select(message, choices, default=default)
91
+ if result == custom_label:
92
+ return await text(custom_prompt)
93
+ return result
94
+
95
+
96
+ # ─── 模型配置表单专用 ──────────────────────────────────────────
97
+
98
+ BASE_URL_PRESETS = [
99
+ "https://api.openai.com/v1",
100
+ "https://api-inference.modelscope.cn/v1",
101
+ "https://open.bigmodel.cn/api/paas/v4",
102
+ "https://api.deepseek.com/v1",
103
+ "https://dashscope.aliyuncs.com/compatible-mode/v1",
104
+ "https://api.longcat.chat/openai/v1",
105
+ ]
106
+
107
+ MODELSCOPE_BASE_URL = "https://api-inference.modelscope.cn/v1"
108
+
109
+ MODELSCOPE_PRESETS = [
110
+ {
111
+ "model": "ZhipuAI/GLM-5",
112
+ "base_url": MODELSCOPE_BASE_URL,
113
+ "temperature": 1.0,
114
+ "top_p": 0.95,
115
+ "stream_usage": True,
116
+ },
117
+ {
118
+ "model": "Qwen/Qwen3-235B-A22B-Thinking-2507",
119
+ "base_url": MODELSCOPE_BASE_URL,
120
+ "temperature": 0.6,
121
+ "top_p": 0.95,
122
+ "stream_usage": True,
123
+ "extra_body": {"top_k": 20},
124
+ },
125
+ {
126
+ "model": "Qwen/Qwen3-235B-A22B-Instruct-2507",
127
+ "base_url": MODELSCOPE_BASE_URL,
128
+ "temperature": 0.7,
129
+ "top_p": 0.8,
130
+ "stream_usage": True,
131
+ "extra_body": {"top_k": 20},
132
+ },
133
+ {
134
+ "model": "Qwen/Qwen3.5-397B-A17B",
135
+ "base_url": MODELSCOPE_BASE_URL,
136
+ "temperature": 0.6,
137
+ "top_p": 0.95,
138
+ "stream_usage": True,
139
+ "extra_body": {"top_k": 20, "repetition_penalty": 1.0},
140
+ },
141
+ {
142
+ "model": "deepseek-ai/DeepSeek-V3.2",
143
+ "base_url": MODELSCOPE_BASE_URL,
144
+ "temperature": 1.0,
145
+ "top_p": 0.95,
146
+ "stream_usage": True,
147
+ },
148
+ {
149
+ "model": "MiniMax/MiniMax-M2.5",
150
+ "base_url": MODELSCOPE_BASE_URL,
151
+ "temperature": 1.0,
152
+ "top_p": 0.95,
153
+ "stream_usage": True,
154
+ "extra_body": {"top_k": 40},
155
+ },
156
+ {
157
+ "model": "moonshotai/Kimi-K2.5",
158
+ "base_url": MODELSCOPE_BASE_URL,
159
+ "temperature": 1.0,
160
+ "top_p": 0.95,
161
+ "stream_usage": True,
162
+ },
163
+ {
164
+ "model": "ZhipuAI/GLM-5.1",
165
+ "base_url": MODELSCOPE_BASE_URL,
166
+ "temperature": 1.0,
167
+ "top_p": 0.95,
168
+ "stream_usage": True,
169
+ },
170
+ {
171
+ "model": "Qwen/Qwen3-Coder-480B-A35B-Instruct",
172
+ "base_url": MODELSCOPE_BASE_URL,
173
+ "temperature": 0.7,
174
+ "top_p": 0.8,
175
+ "stream_usage": True,
176
+ "extra_body": {"top_k": 20, "repetition_penalty": 1.05},
177
+ },
178
+ {
179
+ "model": "XiaomiMiMo/MiMo-V2-Flash",
180
+ "base_url": MODELSCOPE_BASE_URL,
181
+ "temperature": 0.3,
182
+ "top_p": 0.95,
183
+ "stream_usage": True,
184
+ },
185
+ {
186
+ "model": "deepseek-ai/DeepSeek-R1-0528",
187
+ "base_url": MODELSCOPE_BASE_URL,
188
+ "temperature": 0.6,
189
+ "top_p": 0.95,
190
+ "stream_usage": True,
191
+ },
192
+ {
193
+ "model": "Qwen/Qwen3-Next-80B-A3B-Thinking",
194
+ "base_url": MODELSCOPE_BASE_URL,
195
+ "temperature": 0.6,
196
+ "top_p": 0.95,
197
+ "stream_usage": True,
198
+ "extra_body": {"top_k": 20},
199
+ },
200
+ # {
201
+ # "model": "MiniMax/MiniMax-M2.7",
202
+ # "base_url": MODELSCOPE_BASE_URL,
203
+ # "temperature": 1.0,
204
+ # "top_p": 0.95,
205
+ # "stream_usage": True,
206
+ # "extra_body": {"top_k": 40},
207
+ # },
208
+ {
209
+ "model": "deepseek-ai/DeepSeek-V4-Pro",
210
+ "base_url": MODELSCOPE_BASE_URL,
211
+ "temperature": 1.0,
212
+ "top_p": 1.0,
213
+ "stream_usage": True,
214
+ },
215
+ {
216
+ "model": "deepseek-ai/DeepSeek-V4-Flash",
217
+ "base_url": MODELSCOPE_BASE_URL,
218
+ "temperature": 1.0,
219
+ "top_p": 1.0,
220
+ "stream_usage": True,
221
+ },
222
+ ]
223
+
224
+ API_KEY_ENV_VARS = [
225
+ ("BIGMODEL_API_KEY", "智谱 GLM"),
226
+ ("ModelScopeToken", "ModelScope"),
227
+ ("OPENAI_API_KEY", "OpenAI"),
228
+ ("DEEPSEEK_API_KEY", "DeepSeek"),
229
+ ("DASHSCOPE_API_KEY", "通义千问"),
230
+ ("ANTHROPIC_API_KEY", "Anthropic Claude"),
231
+ ("LONGCAT_API_KEY", "LongCat"),
232
+ ]
233
+
234
+ TEMPERATURE_PRESETS = ["0", "0.3", "0.5", "0.6", "0.7", "1.0", "1.5", "2.0"]
235
+ TOP_P_PRESETS = ["0.5", "0.7", "0.9", "0.95", "1.0"]
236
+ TOP_K_PRESETS = ["1", "5", "10", "20", "40", "50"]
237
+ MAX_COMPLETION_TOKENS_PRESETS = ["32768", "65536", "122880", "204800"]
238
+ FREQ_PENALTY_PRESETS = ["0", "0.2", "0.5", "1.0", "1.5", "2.0"]
239
+ PRESENCE_PENALTY_PRESETS = ["0", "0.2", "0.5", "1.0", "1.5", "2.0"]
240
+
241
+ SKIP_LABEL = "跳过 (不设置)"
242
+
243
+
244
+ class _SkipSentinel:
245
+ """哨兵对象,区分「跳过此字段」和「用户取消整个表单」。"""
246
+
247
+ _instance = None
248
+
249
+ def __new__(cls):
250
+ if cls._instance is None:
251
+ cls._instance = super().__new__(cls)
252
+ return cls._instance
253
+
254
+ def __repr__(self):
255
+ return "SKIP"
256
+
257
+
258
+ _SKIP = _SkipSentinel()
259
+
260
+
261
+ async def _ask_hyperparam(
262
+ message: str,
263
+ preset_choices: list[str],
264
+ existing_value: str | None = None,
265
+ custom_prompt: str = "请输入: ",
266
+ ) -> Any:
267
+ """单个超参输入,支持「跳过」。返回值 / _SKIP / None(取消)。"""
268
+ choices = [SKIP_LABEL] + list(preset_choices) + ["自定义输入..."]
269
+
270
+ default = None
271
+ if existing_value is not None and existing_value in preset_choices:
272
+ default = existing_value
273
+
274
+ result = await select(message, choices, default=default)
275
+ if result is None:
276
+ return None
277
+ if result == SKIP_LABEL:
278
+ return _SKIP
279
+ if result == "自定义输入...":
280
+ raw = await text(custom_prompt)
281
+ if raw is None or raw.strip() == "":
282
+ return _SKIP
283
+ return raw.strip()
284
+ return result
285
+
286
+
287
+ async def model_config_form(
288
+ existing_config: dict | None = None,
289
+ ) -> dict | None:
290
+ """
291
+ 模型配置表单 — 全部用下拉列表 + 文本输入
292
+
293
+ Args:
294
+ existing_config: 现有配置(编辑模式)
295
+
296
+ Returns:
297
+ 配置字典,用户取消返回 None
298
+ """
299
+ import os
300
+
301
+ cfg = dict(existing_config) if existing_config else {}
302
+
303
+ # ─── 必填字段 ───
304
+ is_editing = bool(cfg)
305
+ KEEP_LABEL = "保持当前值"
306
+
307
+ # ── 模型名称 ──
308
+ model_name = cfg.get("model", "")
309
+ if not model_name:
310
+ model_name = await text("输入模型名称: ")
311
+ if not model_name or not model_name.strip():
312
+ return None
313
+ model_name = model_name.strip()
314
+
315
+ # ── Base URL ──
316
+ base_url = cfg.get("base_url", "")
317
+ if is_editing and base_url:
318
+ _keep_url = f"{KEEP_LABEL} ({base_url})"
319
+ _url_choices = [_keep_url] + list(BASE_URL_PRESETS) + ["自定义输入..."]
320
+ result = await select("选择 API Base URL:", _url_choices, default=_keep_url)
321
+ if result is None:
322
+ return None
323
+ if result == _keep_url:
324
+ pass # base_url unchanged
325
+ elif result == "自定义输入...":
326
+ base_url = await text("输入 Base URL: ")
327
+ if not base_url or not base_url.strip():
328
+ return None
329
+ else:
330
+ base_url = result
331
+ else:
332
+ _url_choices = ["魔搭 (ModelScope)"] + list(BASE_URL_PRESETS) + ["自定义输入..."]
333
+ result = await select("选择 API Base URL:", _url_choices)
334
+ if result is None:
335
+ return None
336
+ if result == "魔搭 (ModelScope)":
337
+ base_url = MODELSCOPE_BASE_URL
338
+ elif result == "自定义输入...":
339
+ base_url = await text("输入 Base URL: ")
340
+ if not base_url or not base_url.strip():
341
+ return None
342
+ else:
343
+ base_url = result
344
+
345
+ # API Key — 先展示环境变量快捷选择
346
+ existing_api_key = cfg.get("api_key", "")
347
+
348
+ env_choices = [
349
+ f"{var} ({desc})" for var, desc in API_KEY_ENV_VARS if os.getenv(var)
350
+ ]
351
+
352
+ if is_editing:
353
+ _masked = (
354
+ existing_api_key[:6] + "****" + existing_api_key[-4:]
355
+ if len(existing_api_key) > 10
356
+ else "****"
357
+ )
358
+ env_choices.insert(0, f"保持当前 Key ({_masked})")
359
+
360
+ env_choices.append("手动输入 API Key...")
361
+ if env_choices:
362
+ result = await select("选择 API Key 来源:", env_choices)
363
+ if result is None:
364
+ return None
365
+ if result.startswith("保持当前 Key"):
366
+ api_key = existing_api_key
367
+ elif result == "手动输入 API Key...":
368
+ api_key = await password("输入 API Key: ")
369
+ else:
370
+ var_name = result.split(" (")[0]
371
+ api_key = os.getenv(var_name, "")
372
+ else:
373
+ api_key = await password("输入 API Key: ")
374
+
375
+ if not api_key:
376
+ console.print("[red]API Key 不能为空[/red]")
377
+ return None
378
+
379
+ config: dict[str, Any] = {
380
+ "model": model_name,
381
+ "base_url": base_url,
382
+ "api_key": api_key,
383
+ "stream_usage": True,
384
+ }
385
+
386
+ # 编辑旧配置时清理已移除的 max_tokens 字段
387
+ cfg.pop("max_tokens", None)
388
+
389
+ # ─── 超参(可选) ───
390
+ want_hyperparams = await confirm("配置超参数?", default=False)
391
+ if want_hyperparams:
392
+ # temperature
393
+ t_val = str(cfg["temperature"]) if "temperature" in cfg else None
394
+ result = await _ask_hyperparam(
395
+ "Temperature:",
396
+ TEMPERATURE_PRESETS,
397
+ existing_value=t_val,
398
+ custom_prompt="输入 temperature: ",
399
+ )
400
+ if result is None:
401
+ return None
402
+ if result is not _SKIP:
403
+ config["temperature"] = float(result)
404
+ else:
405
+ config.pop("temperature", None)
406
+
407
+ # top_p
408
+ tp_val = str(cfg["top_p"]) if "top_p" in cfg else None
409
+ result = await _ask_hyperparam(
410
+ "Top P:",
411
+ TOP_P_PRESETS,
412
+ existing_value=tp_val,
413
+ custom_prompt="输入 top_p: ",
414
+ )
415
+ if result is None:
416
+ return None
417
+ if result is not _SKIP:
418
+ config["top_p"] = float(result)
419
+ else:
420
+ config.pop("top_p", None)
421
+
422
+ # top_k → extra_body
423
+ existing_extra = cfg.get("extra_body", {})
424
+ tk_val = (
425
+ str(existing_extra["top_k"])
426
+ if isinstance(existing_extra, dict) and "top_k" in existing_extra
427
+ else None
428
+ )
429
+ result = await _ask_hyperparam(
430
+ "Top K:",
431
+ TOP_K_PRESETS,
432
+ existing_value=tk_val,
433
+ custom_prompt="输入 top_k: ",
434
+ )
435
+ if result is None:
436
+ return None
437
+ if result is not _SKIP:
438
+ # 合并到已有的 extra_body(可能已有 max_completion_tokens)
439
+ _eb = dict(existing_extra) if isinstance(existing_extra, dict) else {}
440
+ _eb["top_k"] = int(result)
441
+ config["extra_body"] = _eb
442
+ else:
443
+ # 跳过 top_k,但仍保留 extra_body 中的其他字段(如 max_completion_tokens)
444
+ if isinstance(existing_extra, dict):
445
+ _eb = {k: v for k, v in existing_extra.items() if k != "top_k"}
446
+ if _eb:
447
+ config["extra_body"] = _eb
448
+
449
+ # max_completion_tokens → extra_body
450
+ _eb = config.get("extra_body", {})
451
+ mct_val = (
452
+ str(_eb["max_completion_tokens"])
453
+ if isinstance(_eb, dict) and "max_completion_tokens" in _eb
454
+ else None
455
+ )
456
+ result = await _ask_hyperparam(
457
+ "Max Completion Tokens:",
458
+ MAX_COMPLETION_TOKENS_PRESETS,
459
+ existing_value=mct_val,
460
+ custom_prompt="输入 max_completion_tokens: ",
461
+ )
462
+ if result is None:
463
+ return None
464
+ if result is not _SKIP:
465
+ _eb = dict(_eb) if isinstance(_eb, dict) else {}
466
+ _eb["max_completion_tokens"] = int(result)
467
+ config["extra_body"] = _eb
468
+ else:
469
+ if isinstance(_eb, dict):
470
+ _eb = {k: v for k, v in _eb.items() if k != "max_completion_tokens"}
471
+ if _eb:
472
+ config["extra_body"] = _eb
473
+ else:
474
+ config.pop("extra_body", None)
475
+
476
+ # stop_sequences
477
+ ss_val = None
478
+ if "stop_sequences" in cfg:
479
+ v = cfg["stop_sequences"]
480
+ ss_val = ", ".join(str(x) for x in v) if isinstance(v, list) else str(v)
481
+ result = await _ask_hyperparam(
482
+ "Stop Sequences:",
483
+ [], # 无预设,只有自定义
484
+ existing_value=ss_val,
485
+ custom_prompt="输入停止序列 (逗号分隔): ",
486
+ )
487
+ if result is None:
488
+ return None
489
+ if result is not _SKIP:
490
+ config["stop_sequences"] = [
491
+ s.strip() for s in str(result).split(",") if s.strip()
492
+ ]
493
+ else:
494
+ config.pop("stop_sequences", None)
495
+
496
+ # frequency_penalty
497
+ fp_val = str(cfg["frequency_penalty"]) if "frequency_penalty" in cfg else None
498
+ result = await _ask_hyperparam(
499
+ "Frequency Penalty:",
500
+ FREQ_PENALTY_PRESETS,
501
+ existing_value=fp_val,
502
+ custom_prompt="输入 frequency_penalty: ",
503
+ )
504
+ if result is None:
505
+ return None
506
+ if result is not _SKIP:
507
+ config["frequency_penalty"] = float(result)
508
+ else:
509
+ config.pop("frequency_penalty", None)
510
+
511
+ # presence_penalty
512
+ pp_val = str(cfg["presence_penalty"]) if "presence_penalty" in cfg else None
513
+ result = await _ask_hyperparam(
514
+ "Presence Penalty:",
515
+ PRESENCE_PENALTY_PRESETS,
516
+ existing_value=pp_val,
517
+ custom_prompt="输入 presence_penalty: ",
518
+ )
519
+ if result is None:
520
+ return None
521
+ if result is not _SKIP:
522
+ config["presence_penalty"] = float(result)
523
+ else:
524
+ config.pop("presence_penalty", None)
525
+
526
+ # max_retries - 固定为 4(失败 5 次后自动切换备用模型),不可配置
527
+ config["max_retries"] = 4
528
+
529
+ return config
530
+
531
+
532
+ async def configure_modelscope() -> dict | None:
533
+ """魔搭快捷配置 — 只需 API Key,自动生成 12 个预定义模型。"""
534
+ # 收集 API Key
535
+ env_choices = [
536
+ f"{var} ({desc})"
537
+ for var, desc in API_KEY_ENV_VARS
538
+ if var == "ModelScopeToken" and os.getenv(var)
539
+ ]
540
+ if env_choices:
541
+ result = await select(
542
+ "检测到 ModelScope Token,是否使用?", env_choices + ["手动输入..."]
543
+ )
544
+ if result is None:
545
+ return None
546
+ if result == "手动输入...":
547
+ api_key = await password("输入 ModelScope API Key: ")
548
+ else:
549
+ # 提取 env var 名
550
+ var_name = result.split(" (")[0]
551
+ api_key = os.getenv(var_name, "")
552
+ else:
553
+ api_key = await password("输入 ModelScope API Key: ")
554
+
555
+ if not api_key or not api_key.strip():
556
+ return None
557
+ api_key = api_key.strip()
558
+
559
+ # 用预设模型 + api_key 构建配置
560
+ default_cfg = dict(MODELSCOPE_PRESETS[0])
561
+ default_cfg["api_key"] = api_key
562
+
563
+ fallback = {}
564
+ for preset in MODELSCOPE_PRESETS[1:]:
565
+ cfg = dict(preset)
566
+ cfg["api_key"] = api_key
567
+ fallback[cfg["model"]] = cfg
568
+
569
+ return {"default": default_cfg, "fallback": fallback}
570
+
571
+
572
+ LONGCAT_BASE_URL = "https://api.longcat.chat/openai/v1"
573
+
574
+ LONGCAT_PRESETS = [
575
+ {
576
+ "model": "LongCat-2.0-Preview",
577
+ "base_url": LONGCAT_BASE_URL,
578
+ "temperature": 1.0,
579
+ "top_p": 0.95,
580
+ "stream_usage": True,
581
+ },
582
+ {
583
+ "model": "LongCat-Flash-Chat",
584
+ "base_url": LONGCAT_BASE_URL,
585
+ "temperature": 1.0,
586
+ "top_p": 0.95,
587
+ "stream_usage": True,
588
+ },
589
+ {
590
+ "model": "LongCat-Flash-Thinking",
591
+ "base_url": LONGCAT_BASE_URL,
592
+ "temperature": 0.6,
593
+ "top_p": 0.95,
594
+ "stream_usage": True,
595
+ },
596
+ {
597
+ "model": "LongCat-Flash-Lite",
598
+ "base_url": LONGCAT_BASE_URL,
599
+ "temperature": 1.0,
600
+ "top_p": 0.95,
601
+ "stream_usage": True,
602
+ },
603
+ ]
604
+
605
+
606
+ async def configure_longcat() -> dict | None:
607
+ """LongCat 快捷配置 — 只需 API Key,自动生成 4 个预定义模型。"""
608
+ env_choices = [
609
+ f"{var} ({desc})"
610
+ for var, desc in API_KEY_ENV_VARS
611
+ if var == "LONGCAT_API_KEY" and os.getenv(var)
612
+ ]
613
+ if env_choices:
614
+ result = await select(
615
+ "检测到 LongCat API Key,是否使用?", env_choices + ["手动输入..."]
616
+ )
617
+ if result is None:
618
+ return None
619
+ if result == "手动输入...":
620
+ api_key = await password("输入 LongCat API Key: ")
621
+ else:
622
+ var_name = result.split(" (")[0]
623
+ api_key = os.getenv(var_name, "")
624
+ else:
625
+ api_key = await password("输入 LongCat API Key: ")
626
+
627
+ if not api_key or not api_key.strip():
628
+ return None
629
+ api_key = api_key.strip()
630
+
631
+ default_cfg = dict(LONGCAT_PRESETS[0])
632
+ default_cfg["api_key"] = api_key
633
+
634
+ fallback = {}
635
+ for preset in LONGCAT_PRESETS[1:]:
636
+ cfg = dict(preset)
637
+ cfg["api_key"] = api_key
638
+ fallback[cfg["model"]] = cfg
639
+
640
+ return {"default": default_cfg, "fallback": fallback}