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.
- vox_code-2.0.0.dist-info/METADATA +258 -0
- vox_code-2.0.0.dist-info/RECORD +88 -0
- vox_code-2.0.0.dist-info/WHEEL +4 -0
- vox_code-2.0.0.dist-info/entry_points.txt +3 -0
- voxcli/__init__.py +3 -0
- voxcli/__main__.py +5 -0
- voxcli/agent/__init__.py +12 -0
- voxcli/agent/agent.py +449 -0
- voxcli/agent/agent_budget.py +133 -0
- voxcli/agent/agent_orchestrator.py +414 -0
- voxcli/agent/plan_execute_agent.py +514 -0
- voxcli/agent/roles.py +80 -0
- voxcli/agent/sub_agent.py +351 -0
- voxcli/catalog.py +477 -0
- voxcli/chat.py +91 -0
- voxcli/cli/__init__.py +4 -0
- voxcli/cli/main.py +452 -0
- voxcli/cli/parser.py +71 -0
- voxcli/config.py +518 -0
- voxcli/gui/__main__.py +3 -0
- voxcli/gui/main.py +22 -0
- voxcli/gui/pet/__init__.py +5 -0
- voxcli/gui/pet/base.py +62 -0
- voxcli/gui/pet/coordinator.py +888 -0
- voxcli/gui/pet/data.py +430 -0
- voxcli/gui/pet/widgets.py +683 -0
- voxcli/gui/pet/windows.py +2298 -0
- voxcli/gui/pet/workers.py +54 -0
- voxcli/gui/pet_app.py +7 -0
- voxcli/hitl/__init__.py +11 -0
- voxcli/hitl/handler.py +11 -0
- voxcli/hitl/policy.py +32 -0
- voxcli/hitl/request.py +13 -0
- voxcli/hitl/result.py +11 -0
- voxcli/hitl/terminal_handler.py +64 -0
- voxcli/hitl/tool_registry.py +64 -0
- voxcli/llm/base.py +93 -0
- voxcli/llm/factory.py +178 -0
- voxcli/llm/ollama_client.py +137 -0
- voxcli/llm/openai_compatible.py +249 -0
- voxcli/memory/base.py +16 -0
- voxcli/memory/budget.py +53 -0
- voxcli/memory/compressor.py +198 -0
- voxcli/memory/entry.py +36 -0
- voxcli/memory/long_term.py +126 -0
- voxcli/memory/manager.py +101 -0
- voxcli/memory/retriever.py +72 -0
- voxcli/memory/short_term.py +84 -0
- voxcli/memory/tokenizer.py +21 -0
- voxcli/plan/__init__.py +5 -0
- voxcli/plan/execution_plan.py +225 -0
- voxcli/plan/planner.py +198 -0
- voxcli/plan/task.py +123 -0
- voxcli/policy/audit_log.py +111 -0
- voxcli/policy/command_guard.py +34 -0
- voxcli/policy/exception.py +5 -0
- voxcli/policy/path_guard.py +32 -0
- voxcli/prompting/__init__.py +7 -0
- voxcli/prompting/presenter.py +154 -0
- voxcli/rag/__init__.py +16 -0
- voxcli/rag/analyzer.py +89 -0
- voxcli/rag/chunk.py +17 -0
- voxcli/rag/chunker.py +137 -0
- voxcli/rag/embedding.py +75 -0
- voxcli/rag/formatter.py +40 -0
- voxcli/rag/index.py +96 -0
- voxcli/rag/relation.py +14 -0
- voxcli/rag/retriever.py +58 -0
- voxcli/rag/store.py +155 -0
- voxcli/rag/tokenizer.py +26 -0
- voxcli/runtime/__init__.py +6 -0
- voxcli/runtime/session_controller.py +386 -0
- voxcli/tool/__init__.py +3 -0
- voxcli/tool/tool_registry.py +433 -0
- voxcli/util/animation.py +219 -0
- voxcli/util/ansi.py +82 -0
- voxcli/util/markdown.py +98 -0
- voxcli/web/__init__.py +17 -0
- voxcli/web/base.py +20 -0
- voxcli/web/extractor.py +77 -0
- voxcli/web/factory.py +38 -0
- voxcli/web/fetch_result.py +27 -0
- voxcli/web/fetcher.py +42 -0
- voxcli/web/network_policy.py +49 -0
- voxcli/web/result.py +23 -0
- voxcli/web/searxng.py +55 -0
- voxcli/web/serpapi.py +53 -0
- 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
voxcli/hitl/__init__.py
ADDED
|
@@ -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
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,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
|