vox-code 2.0.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.
Files changed (88) hide show
  1. vox_code-2.0.0.dist-info/METADATA +258 -0
  2. vox_code-2.0.0.dist-info/RECORD +88 -0
  3. vox_code-2.0.0.dist-info/WHEEL +4 -0
  4. vox_code-2.0.0.dist-info/entry_points.txt +3 -0
  5. voxcli/__init__.py +3 -0
  6. voxcli/__main__.py +5 -0
  7. voxcli/agent/__init__.py +12 -0
  8. voxcli/agent/agent.py +449 -0
  9. voxcli/agent/agent_budget.py +133 -0
  10. voxcli/agent/agent_orchestrator.py +414 -0
  11. voxcli/agent/plan_execute_agent.py +514 -0
  12. voxcli/agent/roles.py +80 -0
  13. voxcli/agent/sub_agent.py +351 -0
  14. voxcli/catalog.py +477 -0
  15. voxcli/chat.py +91 -0
  16. voxcli/cli/__init__.py +4 -0
  17. voxcli/cli/main.py +452 -0
  18. voxcli/cli/parser.py +71 -0
  19. voxcli/config.py +518 -0
  20. voxcli/gui/__main__.py +3 -0
  21. voxcli/gui/main.py +22 -0
  22. voxcli/gui/pet/__init__.py +5 -0
  23. voxcli/gui/pet/base.py +62 -0
  24. voxcli/gui/pet/coordinator.py +888 -0
  25. voxcli/gui/pet/data.py +430 -0
  26. voxcli/gui/pet/widgets.py +683 -0
  27. voxcli/gui/pet/windows.py +2298 -0
  28. voxcli/gui/pet/workers.py +54 -0
  29. voxcli/gui/pet_app.py +7 -0
  30. voxcli/hitl/__init__.py +11 -0
  31. voxcli/hitl/handler.py +11 -0
  32. voxcli/hitl/policy.py +32 -0
  33. voxcli/hitl/request.py +13 -0
  34. voxcli/hitl/result.py +11 -0
  35. voxcli/hitl/terminal_handler.py +64 -0
  36. voxcli/hitl/tool_registry.py +64 -0
  37. voxcli/llm/base.py +93 -0
  38. voxcli/llm/factory.py +178 -0
  39. voxcli/llm/ollama_client.py +137 -0
  40. voxcli/llm/openai_compatible.py +249 -0
  41. voxcli/memory/base.py +16 -0
  42. voxcli/memory/budget.py +53 -0
  43. voxcli/memory/compressor.py +198 -0
  44. voxcli/memory/entry.py +36 -0
  45. voxcli/memory/long_term.py +126 -0
  46. voxcli/memory/manager.py +101 -0
  47. voxcli/memory/retriever.py +72 -0
  48. voxcli/memory/short_term.py +84 -0
  49. voxcli/memory/tokenizer.py +21 -0
  50. voxcli/plan/__init__.py +5 -0
  51. voxcli/plan/execution_plan.py +225 -0
  52. voxcli/plan/planner.py +198 -0
  53. voxcli/plan/task.py +123 -0
  54. voxcli/policy/audit_log.py +111 -0
  55. voxcli/policy/command_guard.py +34 -0
  56. voxcli/policy/exception.py +5 -0
  57. voxcli/policy/path_guard.py +32 -0
  58. voxcli/prompting/__init__.py +7 -0
  59. voxcli/prompting/presenter.py +154 -0
  60. voxcli/rag/__init__.py +16 -0
  61. voxcli/rag/analyzer.py +89 -0
  62. voxcli/rag/chunk.py +17 -0
  63. voxcli/rag/chunker.py +137 -0
  64. voxcli/rag/embedding.py +75 -0
  65. voxcli/rag/formatter.py +40 -0
  66. voxcli/rag/index.py +96 -0
  67. voxcli/rag/relation.py +14 -0
  68. voxcli/rag/retriever.py +58 -0
  69. voxcli/rag/store.py +155 -0
  70. voxcli/rag/tokenizer.py +26 -0
  71. voxcli/runtime/__init__.py +6 -0
  72. voxcli/runtime/session_controller.py +386 -0
  73. voxcli/tool/__init__.py +3 -0
  74. voxcli/tool/tool_registry.py +433 -0
  75. voxcli/util/animation.py +219 -0
  76. voxcli/util/ansi.py +82 -0
  77. voxcli/util/markdown.py +98 -0
  78. voxcli/web/__init__.py +17 -0
  79. voxcli/web/base.py +20 -0
  80. voxcli/web/extractor.py +77 -0
  81. voxcli/web/factory.py +38 -0
  82. voxcli/web/fetch_result.py +27 -0
  83. voxcli/web/fetcher.py +42 -0
  84. voxcli/web/network_policy.py +49 -0
  85. voxcli/web/result.py +23 -0
  86. voxcli/web/searxng.py +55 -0
  87. voxcli/web/serpapi.py +53 -0
  88. voxcli/web/zhipu.py +55 -0
@@ -0,0 +1,54 @@
1
+ """Background workers for the desktop pet UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import QThread, Signal
6
+
7
+ from ...chat import GuiChatSubmission
8
+ from ...config import GuiModelConfig
9
+ from ...llm.base import Message
10
+ from ...llm.factory import create_from_provider_config
11
+ from ...runtime import SessionController
12
+
13
+
14
+ class SessionWorker(QThread):
15
+ completed = Signal(object)
16
+ failed = Signal(str)
17
+
18
+ def __init__(self, controller: SessionController, line: str | GuiChatSubmission):
19
+ super().__init__()
20
+ self._controller = controller
21
+ self._line = line
22
+
23
+ def run(self):
24
+ try:
25
+ reply = self._controller.submit(self._line)
26
+ self.completed.emit(reply)
27
+ except Exception as exc:
28
+ self.failed.emit(str(exc))
29
+
30
+
31
+ class ModelConnectionTestWorker(QThread):
32
+ completed = Signal(str)
33
+ failed = Signal(str)
34
+
35
+ def __init__(self, config: GuiModelConfig):
36
+ super().__init__()
37
+ self._config = config
38
+
39
+ def run(self):
40
+ try:
41
+ missing = self._config.validate()
42
+ if missing:
43
+ raise ValueError(f"缺少字段: {', '.join(missing)}")
44
+ client = create_from_provider_config(
45
+ self._config.provider,
46
+ self._config.provider_config(),
47
+ )
48
+ if client is None:
49
+ raise RuntimeError(f"无法创建 provider={self._config.provider} 的客户端")
50
+ response = client.chat([Message.user("Reply with OK only.")])
51
+ summary = (response.content or "").strip() or "OK"
52
+ self.completed.emit(f"连接成功: {summary[:48]}")
53
+ except Exception as exc:
54
+ self.failed.emit(str(exc))
voxcli/gui/pet_app.py ADDED
@@ -0,0 +1,7 @@
1
+ """Backward-compatible desktop pet entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .pet import PetCoordinator, run_pet_app
6
+
7
+ __all__ = ["PetCoordinator", "run_pet_app"]
@@ -0,0 +1,11 @@
1
+ from .request import ApprovalRequest
2
+ from .result import ApprovalResult
3
+ from .policy import ApprovalPolicy
4
+ from .handler import HitlHandler
5
+ from .terminal_handler import TerminalHitlHandler
6
+ from .tool_registry import HitlToolRegistry
7
+
8
+ __all__ = [
9
+ "ApprovalRequest", "ApprovalResult", "ApprovalPolicy",
10
+ "HitlHandler", "TerminalHitlHandler", "HitlToolRegistry",
11
+ ]
voxcli/hitl/handler.py ADDED
@@ -0,0 +1,11 @@
1
+ """HITL 处理器接口"""
2
+
3
+ from typing import Protocol
4
+
5
+ from .request import ApprovalRequest
6
+ from .result import ApprovalResult
7
+
8
+
9
+ class HitlHandler(Protocol):
10
+ def request_approval(self, req: ApprovalRequest) -> ApprovalResult:
11
+ ...
voxcli/hitl/policy.py ADDED
@@ -0,0 +1,32 @@
1
+ """HITL 审批策略 - 定义哪些工具需要人工审批"""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class ApprovalLevel(Enum):
7
+ ALWAYS = "ALWAYS" # 总是需要审批
8
+ DANGEROUS = "DANGEROUS" # 危险操作需要审批
9
+ NEVER = "NEVER" # 不需要审批
10
+
11
+
12
+ # 危险工具列表(与 AuditLog 保持一致)
13
+ _DANGEROUS_TOOLS = {"write_file", "execute_command", "create_project"}
14
+
15
+
16
+ class ApprovalPolicy:
17
+ def __init__(self, mode: str = "auto"):
18
+ self._mode = mode # "auto", "always", "never"
19
+
20
+ def check(self, tool_name: str) -> ApprovalLevel:
21
+ if self._mode == "always":
22
+ return ApprovalLevel.ALWAYS
23
+ if self._mode == "never":
24
+ return ApprovalLevel.NEVER
25
+ return ApprovalLevel.DANGEROUS if tool_name in _DANGEROUS_TOOLS else ApprovalLevel.NEVER
26
+
27
+ def set_mode(self, mode: str):
28
+ self._mode = mode
29
+
30
+ @property
31
+ def mode(self) -> str:
32
+ return self._mode
voxcli/hitl/request.py ADDED
@@ -0,0 +1,13 @@
1
+ """HITL 审批请求"""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, Optional
5
+
6
+
7
+ @dataclass
8
+ class ApprovalRequest:
9
+ tool_name: str
10
+ arguments: Dict[str, str]
11
+ reason: str
12
+ request_id: str = ""
13
+ metadata: Dict[str, str] = field(default_factory=dict)
voxcli/hitl/result.py ADDED
@@ -0,0 +1,11 @@
1
+ """HITL 审批结果"""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class ApprovalResult:
9
+ approved: bool
10
+ modified_args: Optional[dict] = None
11
+ feedback: Optional[str] = None
@@ -0,0 +1,64 @@
1
+ """终端 HITL 处理器 - 在终端请求用户审批"""
2
+
3
+ import json
4
+ import sys
5
+ from typing import Optional, Set
6
+
7
+ from ..util.ansi import heading, subtle, emphasis
8
+ from .request import ApprovalRequest
9
+ from .result import ApprovalResult
10
+
11
+
12
+ class TerminalHitlHandler:
13
+ def __init__(self):
14
+ self._approve_all = False
15
+
16
+ def request_approval(self, req: ApprovalRequest) -> ApprovalResult:
17
+ if self._approve_all:
18
+ return ApprovalResult(approved=True)
19
+
20
+ print()
21
+ print(heading("🪪 人工审批"))
22
+ print(f" 工具: {emphasis(req.tool_name)}")
23
+ print(f" 参数: {subtle(json.dumps(req.arguments, ensure_ascii=False))}")
24
+ print(f" 原因: {req.reason}")
25
+ print()
26
+
27
+ while True:
28
+ try:
29
+ line = input(" 操作 [a]批准 [A]全部批准 [r]拒绝 [s]跳过 [m]修改 → ").strip().lower()
30
+ except (EOFError, KeyboardInterrupt):
31
+ print()
32
+ return ApprovalResult(approved=False, feedback="用户中断输入")
33
+
34
+ if line in ("a", ""):
35
+ return ApprovalResult(approved=True)
36
+ if line == "a":
37
+ self._approve_all = True
38
+ return ApprovalResult(approved=True)
39
+ if line == "r":
40
+ return ApprovalResult(approved=False, feedback="用户拒绝")
41
+ if line == "s":
42
+ return ApprovalResult(approved=False, feedback="跳过")
43
+ if line == "m":
44
+ return self._handle_modify(req)
45
+ print(" 无效选择,请重新输入")
46
+
47
+ def _handle_modify(self, req: ApprovalRequest) -> ApprovalResult:
48
+ print(f" 输入修改后的参数 (JSON),空行取消修改:")
49
+ try:
50
+ modified = input(" > ").strip()
51
+ except (EOFError, KeyboardInterrupt):
52
+ return ApprovalResult(approved=False, feedback="用户中断输入")
53
+
54
+ if not modified:
55
+ return ApprovalResult(approved=False, feedback="取消修改")
56
+
57
+ try:
58
+ new_args = json.loads(modified)
59
+ if not isinstance(new_args, dict):
60
+ raise ValueError
61
+ return ApprovalResult(approved=True, modified_args=new_args)
62
+ except (json.JSONDecodeError, ValueError):
63
+ print(" JSON 格式无效")
64
+ return ApprovalResult(approved=False, feedback="JSON 格式无效")
@@ -0,0 +1,64 @@
1
+ """HITL 工具注册表包装器 - 在工具执行前插入审批环节"""
2
+
3
+ import json
4
+ from typing import List, Optional
5
+
6
+ from ..tool import ToolRegistry, ToolInvocation, ToolExecutionResult
7
+ from .policy import ApprovalPolicy, ApprovalLevel
8
+ from .terminal_handler import TerminalHitlHandler
9
+ from .request import ApprovalRequest
10
+
11
+
12
+ class HitlToolRegistry:
13
+ def __init__(self, tool_registry: ToolRegistry,
14
+ policy: Optional[ApprovalPolicy] = None,
15
+ handler: Optional[TerminalHitlHandler] = None):
16
+ self._registry = tool_registry
17
+ self._policy = policy or ApprovalPolicy()
18
+ self._handler = handler or TerminalHitlHandler()
19
+
20
+ def set_mode(self, mode: str):
21
+ self._policy.set_mode(mode)
22
+
23
+ def execute_tools(self, invocations: List[ToolInvocation]) -> List[ToolExecutionResult]:
24
+ approved_invocations: List[ToolInvocation] = []
25
+
26
+ for inv in invocations:
27
+ level = self._policy.check(inv.name)
28
+ if level == ApprovalLevel.NEVER:
29
+ approved_invocations.append(inv)
30
+ continue
31
+
32
+ req = ApprovalRequest(
33
+ tool_name=inv.name,
34
+ arguments=self._parse_args(inv.arguments_json),
35
+ reason=f"{level.value} 级别操作",
36
+ request_id=inv.id,
37
+ )
38
+ result = self._handler.request_approval(req)
39
+
40
+ if not result.approved:
41
+ approved_invocations.append(ToolInvocation(
42
+ inv.id, inv.name,
43
+ f'{{"error": "HITL拒绝了", "reason": "{result.feedback or "用户拒绝"}"}}'
44
+ ))
45
+ continue
46
+
47
+ if result.modified_args is not None:
48
+ approved_invocations.append(ToolInvocation(
49
+ inv.id, inv.name, json.dumps(result.modified_args)
50
+ ))
51
+ else:
52
+ approved_invocations.append(inv)
53
+
54
+ return self._registry.execute_tools(approved_invocations)
55
+
56
+ def __getattr__(self, name: str):
57
+ return getattr(self._registry, name)
58
+
59
+ @staticmethod
60
+ def _parse_args(json_str: str) -> dict:
61
+ try:
62
+ return json.loads(json_str)
63
+ except json.JSONDecodeError:
64
+ return {}
voxcli/llm/base.py ADDED
@@ -0,0 +1,93 @@
1
+ """LLM 客户端接口与消息类型"""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional, Protocol, Sequence
5
+
6
+ from ..chat import ChatAttachment
7
+
8
+
9
+ @dataclass
10
+ class ToolCall:
11
+ id: str
12
+ name: str
13
+ arguments: str
14
+
15
+
16
+ @dataclass
17
+ class ToolDef:
18
+ name: str
19
+ description: str
20
+ parameters: dict
21
+
22
+
23
+ @dataclass
24
+ class Message:
25
+ role: str # system, user, assistant, tool
26
+ content: Optional[str] = None
27
+ attachments: tuple[ChatAttachment, ...] = field(default_factory=tuple)
28
+ reasoning_content: Optional[str] = None
29
+ tool_calls: Optional[list[ToolCall]] = None
30
+ tool_call_id: Optional[str] = None
31
+
32
+ @classmethod
33
+ def system(cls, content: str) -> "Message":
34
+ return cls(role="system", content=content)
35
+
36
+ @classmethod
37
+ def user(
38
+ cls,
39
+ content: str,
40
+ attachments: Optional[Sequence[ChatAttachment]] = None,
41
+ ) -> "Message":
42
+ return cls(role="user", content=content, attachments=tuple(attachments or ()))
43
+
44
+ @classmethod
45
+ def assistant(cls, content: str = "", reasoning_content: Optional[str] = None,
46
+ tool_calls: Optional[list[ToolCall]] = None) -> "Message":
47
+ return cls(role="assistant", content=content, reasoning_content=reasoning_content,
48
+ tool_calls=tool_calls)
49
+
50
+ @classmethod
51
+ def tool(cls, tool_call_id: str, content: str) -> "Message":
52
+ return cls(role="tool", content=content, tool_call_id=tool_call_id)
53
+
54
+
55
+ @dataclass
56
+ class ChatResponse:
57
+ role: str = "assistant"
58
+ content: Optional[str] = None
59
+ reasoning_content: Optional[str] = None
60
+ tool_calls: Optional[list[ToolCall]] = None
61
+ input_tokens: int = 0
62
+ output_tokens: int = 0
63
+
64
+ @property
65
+ def has_tool_calls(self) -> bool:
66
+ return bool(self.tool_calls)
67
+
68
+
69
+ class StreamListener(Protocol):
70
+ def on_reasoning_delta(self, delta: str): ...
71
+ def on_content_delta(self, delta: str): ...
72
+
73
+
74
+ class StreamListenerNoOp:
75
+ def on_reasoning_delta(self, delta: str): pass
76
+ def on_content_delta(self, delta: str): pass
77
+
78
+
79
+ STREAM_LISTENER_NOOP = StreamListenerNoOp()
80
+
81
+
82
+ class LlmClient(Protocol):
83
+ def chat(self, messages: list[Message], tools: Optional[list[ToolDef]] = None,
84
+ listener: StreamListener = STREAM_LISTENER_NOOP) -> ChatResponse: ...
85
+
86
+ @property
87
+ def model_name(self) -> str: ...
88
+
89
+ @property
90
+ def provider_name(self) -> str: ...
91
+
92
+ @property
93
+ def supports_image_inputs(self) -> bool: ...
voxcli/llm/factory.py ADDED
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ """LLM 客户端工厂 - 从环境变量读取模型配置"""
4
+
5
+ import os
6
+ import logging
7
+ from typing import Optional
8
+
9
+ from ..config import ProviderConfig, pai_config
10
+ from .base import LlmClient
11
+ from .openai_compatible import OpenAiCompatibleClient
12
+ from .ollama_client import OllamaClient
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # 默认模型映射
17
+ _DEFAULT_MODELS = {
18
+ "glm": "glm-5.1",
19
+ "deepseek": "deepseek-chat",
20
+ "qwen": "qwen-plus",
21
+ "ollama": "qwen2.5:7b",
22
+ "codex": "gpt-5-codex",
23
+ "claude-code": "claude-sonnet-4-20250514",
24
+ }
25
+
26
+ # base_url 映射
27
+ _DEFAULT_URLS = {
28
+ "glm": "https://open.bigmodel.cn/api/paas/v4/chat/completions",
29
+ "deepseek": "https://api.deepseek.com/chat/completions",
30
+ "qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
31
+ "ollama": "http://localhost:11434",
32
+ "codex": "",
33
+ "claude-code": "",
34
+ }
35
+
36
+ # env key 映射
37
+ _ENV_KEYS = {
38
+ "glm": (("GLM_API_KEY",), ("GLM_MODEL",), ("GLM_BASE_URL",)),
39
+ "deepseek": (("DEEPSEEK_API_KEY",), ("DEEPSEEK_MODEL",), ("DEEPSEEK_BASE_URL",)),
40
+ "qwen": (
41
+ ("QWEN_API_KEY", "DASHSCOPE_API_KEY"),
42
+ ("QWEN_MODEL", "DASHSCOPE_MODEL"),
43
+ ("QWEN_BASE_URL", "DASHSCOPE_BASE_URL"),
44
+ ),
45
+ "ollama": (None, ("OLLAMA_MODEL",), ("OLLAMA_BASE_URL",)),
46
+ "codex": (None, None, None),
47
+ "claude-code": (None, None, None),
48
+ }
49
+
50
+
51
+ def _env(keys: str | tuple[str, ...] | None, default: str = "") -> str:
52
+ if keys is None:
53
+ return default
54
+ if isinstance(keys, str):
55
+ keys = (keys,)
56
+ for key in keys:
57
+ value = os.environ.get(key, "").strip()
58
+ if value:
59
+ return value
60
+ return default
61
+
62
+
63
+ def default_model_for(provider: str) -> str:
64
+ return _DEFAULT_MODELS.get(provider.lower().strip(), "")
65
+
66
+
67
+ def default_base_url_for(provider: str) -> str:
68
+ return _DEFAULT_URLS.get(provider.lower().strip(), "")
69
+
70
+
71
+ def _config_value(provider: str, field: str) -> str:
72
+ provider_config = pai_config.providers.get(provider.lower())
73
+ if provider_config is None:
74
+ return ""
75
+ return provider_config.get(field, "")
76
+
77
+
78
+ def _normalize_base_url(provider: str, base_url: str) -> str:
79
+ normalized = (base_url or "").strip().rstrip("/")
80
+ if not normalized:
81
+ return normalized
82
+ if provider == "qwen" and normalized.endswith("/compatible-mode/v1"):
83
+ return normalized + "/chat/completions"
84
+ return normalized
85
+
86
+
87
+ def _build_client(provider: str, model: str, base_url: str, api_key: str) -> Optional[LlmClient]:
88
+ if provider == "ollama":
89
+ if not model:
90
+ logger.warning("No model for ollama")
91
+ return None
92
+ return OllamaClient(model=model, base_url=base_url or _DEFAULT_URLS["ollama"])
93
+
94
+ if not api_key:
95
+ logger.warning("No API key for %s", provider)
96
+ return None
97
+
98
+ if not model:
99
+ logger.warning("No model for %s", provider)
100
+ return None
101
+
102
+ return OpenAiCompatibleClient(
103
+ api_key=api_key,
104
+ model=model,
105
+ base_url=base_url,
106
+ provider_name=provider,
107
+ )
108
+
109
+
110
+ def create(provider: str, model_name: Optional[str] = None) -> Optional[LlmClient]:
111
+ """根据 provider 名称创建 LLM 客户端,优先读取环境变量,其次读取全局配置。"""
112
+ provider = provider.lower().strip()
113
+ keys = _ENV_KEYS.get(provider)
114
+ if keys is None:
115
+ logger.warning("Unknown provider: %s", provider)
116
+ return None
117
+
118
+ api_key_key, model_key, url_key = keys
119
+
120
+ # 模型名:优先参数 > env > 全局配置 > 默认值
121
+ model = model_name or _env(model_key)
122
+ if not model:
123
+ model = _config_value(provider, "model")
124
+ if not model:
125
+ model = _DEFAULT_MODELS.get(provider, "")
126
+
127
+ # base_url:优先 env > 全局配置 > 默认值
128
+ base_url = _env(url_key)
129
+ if not base_url:
130
+ base_url = _config_value(provider, "base_url")
131
+ if not base_url:
132
+ base_url = _DEFAULT_URLS.get(provider, "")
133
+ base_url = _normalize_base_url(provider, base_url)
134
+
135
+ # OpenAI 兼容:需要 api_key
136
+ api_key = _env(api_key_key)
137
+ if not api_key:
138
+ api_key = _config_value(provider, "api_key")
139
+
140
+ return _build_client(provider, model, base_url, api_key)
141
+
142
+
143
+ def create_from_provider_config(provider: str, config: ProviderConfig) -> Optional[LlmClient]:
144
+ """根据显式 provider 配置创建客户端,不读取全局配置。"""
145
+ normalized = provider.lower().strip()
146
+ if normalized not in _ENV_KEYS:
147
+ logger.warning("Unknown provider: %s", provider)
148
+ return None
149
+ model = config.model.strip() or default_model_for(normalized)
150
+ base_url = _normalize_base_url(
151
+ normalized,
152
+ config.base_url.strip() or _DEFAULT_URLS.get(normalized, ""),
153
+ )
154
+ api_key = config.api_key.strip()
155
+ return _build_client(normalized, model, base_url, api_key)
156
+
157
+
158
+ def create_from_config() -> Optional[LlmClient]:
159
+ """依次尝试 active preset → default provider → common fallbacks."""
160
+ preset = pai_config.get_model_preset(pai_config.active_model_preset)
161
+ if preset is not None:
162
+ client = create(preset.provider, preset.model)
163
+ if client is not None:
164
+ return client
165
+
166
+ default = pai_config.default_provider_name
167
+ if default:
168
+ client = create(default)
169
+ if client is not None:
170
+ return client
171
+
172
+ # 回退遍历
173
+ for provider in ("glm", "deepseek", "qwen", "ollama"):
174
+ client = create(provider)
175
+ if client is not None:
176
+ return client
177
+
178
+ return None
@@ -0,0 +1,137 @@
1
+ """Ollama 本地模型客户端"""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Optional
6
+
7
+ import httpx
8
+
9
+ from .base import (
10
+ LlmClient, Message, ChatResponse, ToolCall, ToolDef,
11
+ StreamListener, STREAM_LISTENER_NOOP,
12
+ )
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class OllamaClient(LlmClient):
18
+ def __init__(self, model: str, base_url: str = "http://localhost:11434"):
19
+ self._model = model
20
+ self._base_url = base_url.rstrip("/")
21
+ self._http = httpx.Client(timeout=httpx.Timeout(300.0, connect=10.0))
22
+
23
+ @property
24
+ def model_name(self) -> str:
25
+ return self._model
26
+
27
+ @property
28
+ def provider_name(self) -> str:
29
+ return "ollama"
30
+
31
+ @property
32
+ def supports_image_inputs(self) -> bool:
33
+ return False
34
+
35
+ def _build_request(self, messages: list[Message],
36
+ tools: Optional[list[ToolDef]] = None) -> dict:
37
+ body: dict = {
38
+ "model": self._model,
39
+ "stream": True,
40
+ "messages": [],
41
+ }
42
+ for msg in messages:
43
+ if msg.attachments:
44
+ raise RuntimeError("当前 Ollama GUI 会话暂不支持图片输入,请切换到 OpenAI Compatible 模型。")
45
+ m: dict = {"role": msg.role}
46
+ if msg.content is not None:
47
+ m["content"] = msg.content
48
+ if msg.tool_calls:
49
+ m["tool_calls"] = [
50
+ {
51
+ "id": tc.id,
52
+ "type": "function",
53
+ "function": {"name": tc.name, "arguments": tc.arguments},
54
+ }
55
+ for tc in msg.tool_calls
56
+ ]
57
+ if msg.tool_call_id:
58
+ m["tool_call_id"] = msg.tool_call_id
59
+ body["messages"].append(m)
60
+ if tools:
61
+ body["tools"] = [
62
+ {
63
+ "type": "function",
64
+ "function": {
65
+ "name": t.name,
66
+ "description": t.description,
67
+ "parameters": t.parameters,
68
+ },
69
+ }
70
+ for t in tools
71
+ ]
72
+ return body
73
+
74
+ def chat(self, messages: list[Message], tools: Optional[list[ToolDef]] = None,
75
+ listener: StreamListener = STREAM_LISTENER_NOOP) -> ChatResponse:
76
+ body = self._build_request(messages, tools)
77
+ resp = self._http.post(f"{self._base_url}/api/chat", json=body)
78
+ resp.raise_for_status()
79
+
80
+ content_parts: list[str] = []
81
+ tool_calls_acc: dict[int, dict] = {}
82
+
83
+ for line in resp.iter_lines():
84
+ line = line.strip()
85
+ if not line:
86
+ continue
87
+ try:
88
+ chunk = json.loads(line)
89
+ except json.JSONDecodeError:
90
+ continue
91
+
92
+ if chunk.get("done"):
93
+ break
94
+
95
+ delta = chunk.get("message", {})
96
+ if delta.get("content"):
97
+ content_parts.append(delta["content"])
98
+ listener.on_content_delta(delta["content"])
99
+ if delta.get("tool_calls"):
100
+ self._merge_ollama_tool_calls(tool_calls_acc, delta["tool_calls"])
101
+
102
+ content = "".join(content_parts) or None
103
+ tool_calls = self._build_tool_calls(tool_calls_acc) or None
104
+
105
+ return ChatResponse(
106
+ role="assistant",
107
+ content=content,
108
+ tool_calls=tool_calls,
109
+ )
110
+
111
+ @staticmethod
112
+ def _merge_ollama_tool_calls(acc: dict[int, dict], tool_calls: list[dict]):
113
+ for tc in tool_calls:
114
+ idx = len(acc)
115
+ if idx not in acc:
116
+ acc[idx] = {"id": f"call_{idx}", "name": "", "arguments": ""}
117
+ fn = tc.get("function", {})
118
+ if fn.get("name"):
119
+ acc[idx]["name"] = fn["name"]
120
+ if fn.get("arguments"):
121
+ acc[idx]["arguments"] = json.dumps(fn["arguments"], ensure_ascii=False)
122
+
123
+ @staticmethod
124
+ def _build_tool_calls(acc: dict[int, dict]) -> list[ToolCall]:
125
+ if not acc:
126
+ return []
127
+ result = []
128
+ for idx in sorted(acc.keys()):
129
+ entry = acc[idx]
130
+ if not entry["id"]:
131
+ continue
132
+ result.append(ToolCall(
133
+ id=entry["id"],
134
+ name=entry["name"],
135
+ arguments=entry["arguments"],
136
+ ))
137
+ return result