cleanup: 删除无用文件夹和缓存
- 删除 tests/(v2测试,引用已删除的xiaoli/core模块) - 删除 开发文档/(v2开发文档) - 删除 __pycache__/(Python缓存)
This commit is contained in:
1 parent
4d341a7b55
commit
48f5765c1f
10 files changed
-2476
No files matched your search
@@ -1 +0,0 @@
|
||||
# xiaoli-cli test suite
|
||||
@@ -1,183 +0,0 @@
|
||||
"""共享测试 fixtures"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
import pytest
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_dir():
|
||||
"""创建临时目录,测试后清理"""
|
||||
d = tempfile.mkdtemp(prefix='xiaoli_test_')
|
||||
yield d
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_python_code():
|
||||
"""安全的示例 Python 代码"""
|
||||
return '''
|
||||
import json
|
||||
import math
|
||||
|
||||
result = math.sqrt(144)
|
||||
data = {"value": result, "squared": result ** 2}
|
||||
print(json.dumps(data))
|
||||
'''
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dangerous_codes():
|
||||
"""各种危险代码样本"""
|
||||
return {
|
||||
'os_import': 'import os\nos.system("rm -rf /")',
|
||||
'subprocess': 'import subprocess\nsubprocess.run(["ls"])',
|
||||
'exec_call': 'exec("import os")',
|
||||
'eval_call': 'eval("__import__(\'os\')")',
|
||||
'dunder': 'x = (1).__class__.__bases__[0].__subclasses__()',
|
||||
'open_file': 'f = open("/etc/passwd")',
|
||||
'getattr_bypass': 'getattr(__builtins__, "exec")',
|
||||
'socket': 'import socket\ns = socket.socket()',
|
||||
'shutil': 'import shutil\nshutil.rmtree("/")',
|
||||
'compile': 'compile("import os", "<string>", "exec")',
|
||||
'globals': 'globals()["__builtins__"]',
|
||||
'breakpoint': 'breakpoint()',
|
||||
'del_stmt': 'del some_var',
|
||||
'global_stmt': 'global x',
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def safe_codes():
|
||||
"""各种安全代码样本"""
|
||||
return {
|
||||
'math': 'import math\nresult = math.pi',
|
||||
'json': 'import json\nresult = json.dumps({"a": 1})',
|
||||
're': 'import re\nresult = re.findall(r"\\d+", "abc123")',
|
||||
'datetime': 'from datetime import datetime\nresult = datetime.now().year',
|
||||
'collections': 'from collections import Counter\nresult = Counter("aabbc")',
|
||||
'list_comp': 'result = [x**2 for x in range(10)]',
|
||||
'function': 'def add(a, b): return a + b\nresult = add(3, 4)',
|
||||
'string_ops': 'result = "hello world".upper().split()',
|
||||
'sorting': 'result = sorted([3,1,2], reverse=True)',
|
||||
'fizzbuzz': 'result = ["fizz"*(i%3==0)+"buzz"*(i%5==0) or str(i) for i in range(1,16)]',
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_file(tmp_dir):
|
||||
"""创建临时配置文件"""
|
||||
config = {
|
||||
"system": {
|
||||
"default_engine": "ollama",
|
||||
"max_history": 100,
|
||||
"code_execution_timeout": 10,
|
||||
},
|
||||
"api": {
|
||||
"engines": {
|
||||
"ollama": {"base_url": "http://localhost:11434"},
|
||||
"mimo": {"api_key": "test-key", "base_url": "http://test.com"},
|
||||
}
|
||||
},
|
||||
"plugins": {},
|
||||
}
|
||||
path = os.path.join(tmp_dir, "config.json")
|
||||
with open(path, 'w') as f:
|
||||
json.dump(config, f)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def history_dir(tmp_dir):
|
||||
"""创建临时历史目录"""
|
||||
d = os.path.join(tmp_dir, "chat_history")
|
||||
os.makedirs(d)
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir(tmp_dir):
|
||||
"""创建带示例插件的目录"""
|
||||
d = os.path.join(tmp_dir, "plugins")
|
||||
os.makedirs(d)
|
||||
|
||||
# __init__.py
|
||||
with open(os.path.join(d, "__init__.py"), 'w') as f:
|
||||
f.write("")
|
||||
|
||||
# 简单插件
|
||||
with open(os.path.join(d, "test_plugin.py"), 'w') as f:
|
||||
f.write('''
|
||||
class Plugin:
|
||||
def __init__(self):
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "test_tool",
|
||||
"description": "A test tool",
|
||||
"keywords": ["test"],
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "test_tool",
|
||||
"description": "A test tool",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {"type": "string"},
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
return arguments.get("operation", "")
|
||||
|
||||
def handle(self, args):
|
||||
return f"test result: {args}"
|
||||
''')
|
||||
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skills_dir(tmp_dir):
|
||||
"""创建带示例 skill 的目录"""
|
||||
d = os.path.join(tmp_dir, "skills")
|
||||
os.makedirs(d)
|
||||
|
||||
with open(os.path.join(d, "__init__.py"), 'w') as f:
|
||||
f.write("")
|
||||
|
||||
with open(os.path.join(d, "test_skill.py"), 'w') as f:
|
||||
f.write('''
|
||||
class Skill:
|
||||
def __init__(self):
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "test_skill",
|
||||
"description": "A test skill",
|
||||
"keywords": ["test", "skill"],
|
||||
}
|
||||
|
||||
def handle(self, args):
|
||||
return f"skill result: {args}"
|
||||
''')
|
||||
|
||||
return d
|
||||
@@ -1,137 +0,0 @@
|
||||
"""配置管理测试"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from xiaoli import config as cfg
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""配置加载/保存测试"""
|
||||
|
||||
def setup_method(self):
|
||||
"""重置缓存"""
|
||||
cfg._cache = None
|
||||
|
||||
def test_default_config(self):
|
||||
"""无配置文件时使用默认值"""
|
||||
# 临时指向不存在的文件
|
||||
original = cfg.CONFIG_FILE
|
||||
cfg.CONFIG_FILE = '/tmp/nonexistent_config.json'
|
||||
cfg._cache = None
|
||||
|
||||
result = cfg.load()
|
||||
assert 'system' in result
|
||||
assert result['system']['default_engine'] == 'ollama'
|
||||
|
||||
cfg.CONFIG_FILE = original
|
||||
cfg._cache = None
|
||||
|
||||
def test_load_existing_config(self, config_file):
|
||||
"""加载现有配置文件"""
|
||||
original = cfg.CONFIG_FILE
|
||||
cfg.CONFIG_FILE = config_file
|
||||
cfg._cache = None
|
||||
|
||||
result = cfg.load()
|
||||
assert result['system']['default_engine'] == 'ollama'
|
||||
assert result['system']['max_history'] == 100
|
||||
assert 'mimo' in result['api']['engines']
|
||||
|
||||
cfg.CONFIG_FILE = original
|
||||
cfg._cache = None
|
||||
|
||||
def test_get_specific_key(self, config_file):
|
||||
"""获取特定配置项"""
|
||||
original = cfg.CONFIG_FILE
|
||||
cfg.CONFIG_FILE = config_file
|
||||
cfg._cache = None
|
||||
|
||||
assert cfg.get('default_engine') == 'ollama'
|
||||
assert cfg.get('max_history') == 100
|
||||
assert cfg.get('nonexistent', 'fallback') == 'fallback'
|
||||
|
||||
cfg.CONFIG_FILE = original
|
||||
cfg._cache = None
|
||||
|
||||
def test_set_and_save(self, tmp_dir):
|
||||
"""设置并保存配置"""
|
||||
config_path = os.path.join(tmp_dir, "config.json")
|
||||
original = cfg.CONFIG_FILE
|
||||
cfg.CONFIG_FILE = config_path
|
||||
cfg._cache = None
|
||||
|
||||
cfg.set('default_engine', 'mimo')
|
||||
assert cfg.get('default_engine') == 'mimo'
|
||||
|
||||
# 验证文件持久化
|
||||
with open(config_path, 'r') as f:
|
||||
saved = json.load(f)
|
||||
assert saved['system']['default_engine'] == 'mimo'
|
||||
|
||||
cfg.CONFIG_FILE = original
|
||||
cfg._cache = None
|
||||
|
||||
def test_reload(self, config_file):
|
||||
"""强制重新加载"""
|
||||
original = cfg.CONFIG_FILE
|
||||
cfg.CONFIG_FILE = config_file
|
||||
cfg._cache = None
|
||||
|
||||
cfg.load()
|
||||
assert cfg._cache is not None
|
||||
|
||||
reloaded = cfg.reload()
|
||||
assert cfg._cache is not None
|
||||
assert 'system' in reloaded
|
||||
|
||||
cfg.CONFIG_FILE = original
|
||||
cfg._cache = None
|
||||
|
||||
def test_caching(self, config_file):
|
||||
"""配置缓存机制"""
|
||||
original = cfg.CONFIG_FILE
|
||||
cfg.CONFIG_FILE = config_file
|
||||
cfg._cache = None
|
||||
|
||||
r1 = cfg.load()
|
||||
r2 = cfg.load()
|
||||
assert r1 is r2 # 同一对象(缓存)
|
||||
|
||||
cfg.CONFIG_FILE = original
|
||||
cfg._cache = None
|
||||
|
||||
def test_corrupted_json(self, tmp_dir):
|
||||
"""损坏的 JSON 文件处理"""
|
||||
config_path = os.path.join(tmp_dir, "config.json")
|
||||
with open(config_path, 'w') as f:
|
||||
f.write("{invalid json")
|
||||
|
||||
original = cfg.CONFIG_FILE
|
||||
cfg.CONFIG_FILE = config_path
|
||||
cfg._cache = None
|
||||
|
||||
result = cfg.load()
|
||||
assert 'system' in result # 应回退到默认值
|
||||
|
||||
cfg.CONFIG_FILE = original
|
||||
cfg._cache = None
|
||||
|
||||
def test_missing_system_section(self, tmp_dir):
|
||||
"""缺少 system 节时的行为"""
|
||||
config_path = os.path.join(tmp_dir, "config.json")
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump({"api": {}}, f)
|
||||
|
||||
original = cfg.CONFIG_FILE
|
||||
cfg.CONFIG_FILE = config_path
|
||||
cfg._cache = None
|
||||
|
||||
assert cfg.get('default_engine') is None
|
||||
assert cfg.get('default_engine', 'ollama') == 'ollama'
|
||||
|
||||
cfg.CONFIG_FILE = original
|
||||
cfg._cache = None
|
||||
@@ -1,226 +0,0 @@
|
||||
"""对话响应解析器测试"""
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from xiaoli.conversation import parse_response, process_thinking, process_code_blocks, extract_tool_calls
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 1. parse_response 测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestParseResponse:
|
||||
"""响应解析测试"""
|
||||
|
||||
def test_pure_text(self):
|
||||
text, data = parse_response("hello world")
|
||||
assert text == "hello world"
|
||||
assert data is None
|
||||
|
||||
def test_empty_response(self):
|
||||
text, data = parse_response("")
|
||||
assert text == ""
|
||||
assert data is None
|
||||
|
||||
def test_pure_json_dict(self):
|
||||
resp = '{"action": "use_tool", "tool": "code_editor", "args": "read foo.py"}'
|
||||
text, data = parse_response(resp)
|
||||
assert text == ""
|
||||
assert isinstance(data, dict)
|
||||
assert data['action'] == 'use_tool'
|
||||
assert data['tool'] == 'code_editor'
|
||||
|
||||
def test_pure_json_list(self):
|
||||
resp = '[{"action": "use_tool", "tool": "t1", "args": "a1"}]'
|
||||
text, data = parse_response(resp)
|
||||
assert text == ""
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_json_in_code_block(self):
|
||||
resp = 'Here is the command:\n```json\n{"action": "use_tool", "tool": "git_tools", "args": "status"}\n```'
|
||||
text, data = parse_response(resp)
|
||||
assert 'command' in text.lower() or 'Here' in text
|
||||
assert isinstance(data, dict)
|
||||
assert data['tool'] == 'git_tools'
|
||||
|
||||
def test_text_before_json(self):
|
||||
resp = 'I will edit the file.\n{"action": "use_tool", "tool": "code_editor", "args": "edit foo.py"}'
|
||||
text, data = parse_response(resp)
|
||||
assert 'edit' in text.lower()
|
||||
assert isinstance(data, dict)
|
||||
assert data['action'] == 'use_tool'
|
||||
|
||||
def test_continue_action(self):
|
||||
resp = '{"action": "continue", "content": "more info"}'
|
||||
text, data = parse_response(resp)
|
||||
assert text == ""
|
||||
assert isinstance(data, dict)
|
||||
assert data['action'] == 'continue'
|
||||
|
||||
def test_inline_json_use_tool(self):
|
||||
resp = 'Let me check. {"action":"use_tool","tool":"code_search","args":"find . test"}'
|
||||
text, data = parse_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
assert data['tool'] == 'code_search'
|
||||
|
||||
def test_inline_json_continue(self):
|
||||
resp = 'Some text. {"action":"continue","content":"extra"}'
|
||||
text, data = parse_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
assert data['action'] == 'continue'
|
||||
|
||||
def test_multiple_json_per_line(self):
|
||||
resp = '{"action":"use_tool","tool":"t1","args":"a1"}\n{"action":"use_tool","tool":"t2","args":"a2"}'
|
||||
text, data = parse_response(resp)
|
||||
# Should pick up at least one
|
||||
assert data is not None
|
||||
|
||||
def test_invalid_json_ignored(self):
|
||||
resp = 'This is not json {broken'
|
||||
text, data = parse_response(resp)
|
||||
assert text == resp
|
||||
assert data is None
|
||||
|
||||
def test_tool_call_tag_format(self):
|
||||
resp = '<tool_call>{"action":"use_tool","tool":"test","args":"arg"}'
|
||||
text, data = parse_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
assert data['tool'] == 'test'
|
||||
|
||||
def test_message_field(self):
|
||||
resp = '{"message": "done processing"}'
|
||||
text, data = parse_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
assert data['message'] == 'done processing'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 2. process_thinking 测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestProcessThinking:
|
||||
"""思考标记处理测试"""
|
||||
|
||||
def test_think_tag(self):
|
||||
resp = '<think/>This is the reply'
|
||||
result = process_thinking(resp)
|
||||
assert result == 'This is the reply'
|
||||
|
||||
def test_think_tag_with_thinking(self):
|
||||
resp = 'I am thinking deeply<think/>Here is my answer'
|
||||
result = process_thinking(resp)
|
||||
assert 'answer' in result
|
||||
|
||||
def test_no_think_tag(self):
|
||||
resp = 'Just a normal response'
|
||||
result = process_thinking(resp)
|
||||
assert result == resp
|
||||
|
||||
def test_empty_after_think(self):
|
||||
resp = '<think/>'
|
||||
result = process_thinking(resp)
|
||||
assert result == '' or result is not None
|
||||
|
||||
def test_custom_markers(self):
|
||||
class FakeEngine:
|
||||
thinking_start_marker = '<thinking>'
|
||||
thinking_end_marker = '</thinking>'
|
||||
|
||||
resp = '<thinking>deep thought</thinking>The answer is 42'
|
||||
result = process_thinking(resp, FakeEngine())
|
||||
assert '42' in result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 3. process_code_blocks 测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestProcessCodeBlocks:
|
||||
"""代码块处理测试"""
|
||||
|
||||
def test_triple_quote_block(self):
|
||||
text = 'before\n"""\ncode here\n"""\nafter'
|
||||
result = process_code_blocks(text)
|
||||
assert 'before' in result
|
||||
assert 'after' in result
|
||||
|
||||
def test_no_code_blocks(self):
|
||||
text = 'plain text no blocks'
|
||||
result = process_code_blocks(text)
|
||||
assert result == text
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 4. extract_tool_calls 测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestExtractToolCalls:
|
||||
"""工具调用提取测试"""
|
||||
|
||||
def test_single_use_tool(self):
|
||||
data = {"action": "use_tool", "tool": "code_editor", "args": "edit foo.py"}
|
||||
calls = extract_tool_calls(data)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]['tool'] == 'code_editor'
|
||||
|
||||
def test_list_of_use_tool(self):
|
||||
data = [
|
||||
{"action": "use_tool", "tool": "t1", "args": "a1"},
|
||||
{"action": "use_tool", "tool": "t2", "args": "a2"},
|
||||
]
|
||||
calls = extract_tool_calls(data)
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_simplified_format(self):
|
||||
data = {"tool": "git_tools", "args": "status"}
|
||||
calls = extract_tool_calls(data)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]['tool'] == 'git_tools'
|
||||
|
||||
def test_openai_function_call(self):
|
||||
data = {"function_call": {"name": "code_editor", "arguments": '{"operation": "read"}'}}
|
||||
calls = extract_tool_calls(data)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]['tool'] == 'code_editor'
|
||||
|
||||
def test_mcp_format(self):
|
||||
data = {"name": "code_search", "arguments": {"operation": "find", "directory": "."}}
|
||||
calls = extract_tool_calls(data)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]['tool'] == 'code_search'
|
||||
|
||||
def test_empty_data(self):
|
||||
assert extract_tool_calls([]) == []
|
||||
assert extract_tool_calls({}) == []
|
||||
assert extract_tool_calls(None) == []
|
||||
assert extract_tool_calls("string") == []
|
||||
|
||||
def test_non_tool_dict(self):
|
||||
data = {"message": "just text", "continue": True}
|
||||
calls = extract_tool_calls(data)
|
||||
# continue action should not be extracted as tool call
|
||||
assert len(calls) == 0
|
||||
|
||||
def test_mixed_list(self):
|
||||
data = [
|
||||
{"action": "use_tool", "tool": "t1", "args": "a1"},
|
||||
{"action": "continue", "content": "more"},
|
||||
]
|
||||
calls = extract_tool_calls(data)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]['tool'] == 't1'
|
||||
|
||||
def test_function_call_string_args(self):
|
||||
data = {"function_call": {"name": "test", "arguments": '{"key": "val"}'}}
|
||||
calls = extract_tool_calls(data)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]['arguments'] == {"key": "val"}
|
||||
|
||||
def test_function_call_dict_args(self):
|
||||
data = {"function_call": {"name": "test", "arguments": {"key": "val"}}}
|
||||
calls = extract_tool_calls(data)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]['arguments'] == {"key": "val"}
|
||||
@@ -1,107 +0,0 @@
|
||||
"""AI 引擎管理器测试"""
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from xiaoli.engines import EngineManager
|
||||
|
||||
|
||||
class TestEngineManager:
|
||||
"""EngineManager 测试"""
|
||||
|
||||
def test_init_empty(self):
|
||||
mgr = EngineManager()
|
||||
assert mgr.engines == {}
|
||||
assert mgr.current is None
|
||||
|
||||
def test_set_default_existing(self):
|
||||
mgr = EngineManager()
|
||||
mgr.engines = {'test': 'fake_engine'}
|
||||
assert mgr.set_default('test')
|
||||
assert mgr.current == 'fake_engine'
|
||||
|
||||
def test_set_default_missing(self):
|
||||
mgr = EngineManager()
|
||||
assert not mgr.set_default('nonexistent')
|
||||
|
||||
def test_switch_existing(self):
|
||||
mgr = EngineManager()
|
||||
mgr.engines = {'a': 'engine_a', 'b': 'engine_b'}
|
||||
assert mgr.switch('b')
|
||||
assert mgr.current == 'engine_b'
|
||||
|
||||
def test_switch_missing(self):
|
||||
mgr = EngineManager()
|
||||
assert not mgr.switch('nope')
|
||||
|
||||
def test_current_name_none(self):
|
||||
mgr = EngineManager()
|
||||
assert mgr.current_name() == '未设置'
|
||||
|
||||
def test_current_name_set(self):
|
||||
mgr = EngineManager()
|
||||
mgr.current = type('E', (), {'name': 'ollama'})()
|
||||
assert mgr.current_name() == 'ollama'
|
||||
|
||||
def test_names(self):
|
||||
mgr = EngineManager()
|
||||
mgr.engines = {'a': 1, 'b': 2, 'c': 3}
|
||||
names = mgr.names()
|
||||
assert set(names) == {'a', 'b', 'c'}
|
||||
|
||||
def test_generate_no_engine(self):
|
||||
mgr = EngineManager()
|
||||
result = mgr.generate("hello")
|
||||
assert '错误' in result or 'error' in result.lower()
|
||||
|
||||
def test_generate_with_engine(self):
|
||||
mgr = EngineManager()
|
||||
|
||||
class FakeEngine:
|
||||
name = 'fake'
|
||||
def generate_response(self, user_input, system_prompt=None):
|
||||
return f"echo: {user_input}"
|
||||
|
||||
mgr.current = FakeEngine()
|
||||
result = mgr.generate("test input")
|
||||
assert 'test input' in result
|
||||
|
||||
def test_load_all_missing_dir(self):
|
||||
mgr = EngineManager()
|
||||
result = mgr.load_all('/tmp/nonexistent_engines_dir_12345')
|
||||
assert result == {}
|
||||
|
||||
def test_find_class_patterns(self):
|
||||
mgr = EngineManager()
|
||||
import types
|
||||
mod = types.ModuleType('test')
|
||||
|
||||
class TestAI:
|
||||
def generate_response(self, **kw):
|
||||
pass
|
||||
mod.TestAI = TestAI
|
||||
|
||||
found = mgr._find_class(mod, 'test')
|
||||
assert found == 'TestAI'
|
||||
|
||||
def test_find_class_no_match(self):
|
||||
mgr = EngineManager()
|
||||
import types
|
||||
mod = types.ModuleType('test')
|
||||
mod.Foo = 42 # not a class ending in AI
|
||||
found = mgr._find_class(mod, 'test')
|
||||
assert found is None
|
||||
|
||||
def test_load_all_real_engines(self):
|
||||
"""测试加载实际引擎目录(不验证引擎可用性,只验证不崩溃)"""
|
||||
mgr = EngineManager()
|
||||
engines_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'ai_engines'
|
||||
)
|
||||
if os.path.exists(engines_dir):
|
||||
mgr.load_all(engines_dir)
|
||||
# 至少应该尝试加载(可能因缺少依赖而失败)
|
||||
# 但不应崩溃
|
||||
@@ -1,114 +0,0 @@
|
||||
"""对话历史管理测试"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from xiaoli.conversation.history import History
|
||||
|
||||
|
||||
class TestHistory:
|
||||
"""History 类测试"""
|
||||
|
||||
def test_add_message(self, history_dir):
|
||||
h = History(history_dir)
|
||||
h.add("user", "hello")
|
||||
h.add("assistant", "hi there")
|
||||
assert len(h.messages) == 2
|
||||
assert h.messages[0] == {"role": "user", "content": "hello"}
|
||||
assert h.messages[1] == {"role": "assistant", "content": "hi there"}
|
||||
|
||||
def test_get_messages(self, history_dir):
|
||||
h = History(history_dir)
|
||||
h.add("user", "q1")
|
||||
h.add("assistant", "a1")
|
||||
msgs = h.get()
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]['role'] == 'user'
|
||||
|
||||
def test_clear(self, history_dir):
|
||||
h = History(history_dir)
|
||||
h.add("user", "hello")
|
||||
h.add("assistant", "hi")
|
||||
h.clear()
|
||||
assert len(h.messages) == 0
|
||||
|
||||
def test_max_items(self, history_dir):
|
||||
h = History(history_dir, max_items=3)
|
||||
for i in range(5):
|
||||
h.add("user", f"msg {i}")
|
||||
assert len(h.messages) == 3
|
||||
assert h.messages[0]['content'] == 'msg 2'
|
||||
|
||||
def test_save_and_load(self, history_dir):
|
||||
h = History(history_dir)
|
||||
h.add("user", "hello")
|
||||
h.add("assistant", "hi")
|
||||
assert h.save("test_chat")
|
||||
|
||||
h2 = History(history_dir)
|
||||
assert h2.load("test_chat")
|
||||
assert len(h2.messages) == 2
|
||||
assert h2.messages[0]['content'] == 'hello'
|
||||
|
||||
def test_load_nonexistent(self, history_dir, capsys):
|
||||
h = History(history_dir)
|
||||
result = h.load("nonexistent_chat")
|
||||
assert not result
|
||||
|
||||
def test_list_saved(self, history_dir, capsys):
|
||||
h = History(history_dir)
|
||||
h.add("user", "test")
|
||||
h.save("chat1")
|
||||
h.save("chat2")
|
||||
|
||||
h2 = History(history_dir)
|
||||
h2.list_saved() # should not raise
|
||||
captured = capsys.readouterr()
|
||||
assert 'chat1' in captured.out or 'chat2' in captured.out
|
||||
|
||||
def test_list_saved_empty(self, history_dir, capsys):
|
||||
h = History(history_dir)
|
||||
h.list_saved()
|
||||
captured = capsys.readouterr()
|
||||
assert 'no' in captured.out.lower() or '没有' in captured.out
|
||||
|
||||
def test_set_all(self, history_dir):
|
||||
h = History(history_dir)
|
||||
msgs = [
|
||||
{"role": "user", "content": "q"},
|
||||
{"role": "assistant", "content": "a"},
|
||||
]
|
||||
h.set_all(msgs)
|
||||
assert len(h.messages) == 2
|
||||
assert h.messages == msgs
|
||||
|
||||
def test_save_creates_directory(self, tmp_dir):
|
||||
nested_dir = os.path.join(tmp_dir, "deep", "nested", "history")
|
||||
h = History(nested_dir)
|
||||
h.add("user", "test")
|
||||
h.save("test")
|
||||
assert os.path.exists(os.path.join(nested_dir, "test.json"))
|
||||
|
||||
def test_save_file_format(self, history_dir):
|
||||
h = History(history_dir)
|
||||
h.add("user", "hello")
|
||||
h.save("format_test")
|
||||
|
||||
path = os.path.join(history_dir, "format_test.json")
|
||||
with open(path, 'r') as f:
|
||||
data = json.load(f)
|
||||
assert 'timestamp' in data
|
||||
assert 'conversation' in data
|
||||
assert len(data['conversation']) == 1
|
||||
|
||||
def test_load_corrupted_file(self, history_dir):
|
||||
path = os.path.join(history_dir, "corrupt.json")
|
||||
with open(path, 'w') as f:
|
||||
f.write("{bad json")
|
||||
|
||||
h = History(history_dir)
|
||||
result = h.load("corrupt")
|
||||
assert not result
|
||||
@@ -1,154 +0,0 @@
|
||||
"""集成测试 - 模块间协作"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from xiaoli.sandbox import Sandbox
|
||||
from xiaoli.conversation import parse_response, extract_tool_calls
|
||||
from xiaoli.conversation.history import History
|
||||
|
||||
|
||||
class TestSandboxConversationIntegration:
|
||||
"""沙箱 + 对话解析 集成测试"""
|
||||
|
||||
def test_sandbox_result_parsed_as_tool_output(self):
|
||||
"""沙箱执行结果可以正确传递给对话系统"""
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
result = sandbox.run('result = [x**2 for x in range(5)]')
|
||||
assert result['success']
|
||||
|
||||
# 模拟工具结果注入对话
|
||||
tool_msg = f"工具执行结果:\n{result['result']}"
|
||||
text, data = parse_response(tool_msg)
|
||||
assert data is None # 纯文本,无工具调用
|
||||
assert '0' in text and '16' in text
|
||||
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_sandbox_failure_triggers_error_handling(self):
|
||||
"""沙箱失败时应正确处理"""
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
result = sandbox.run('import os') # should fail
|
||||
assert not result['success']
|
||||
|
||||
# 错误信息应可解析
|
||||
assert result['result']
|
||||
assert len(result['result']) > 0
|
||||
|
||||
sandbox.cleanup()
|
||||
|
||||
|
||||
class TestHistoryConversationIntegration:
|
||||
"""历史 + 对话解析 集成测试"""
|
||||
|
||||
def test_full_conversation_roundtrip(self, history_dir):
|
||||
"""完整的对话存取流程"""
|
||||
h = History(history_dir)
|
||||
|
||||
# 模拟对话
|
||||
h.add("user", "帮我写一个排序函数")
|
||||
h.add("assistant", "好的,这是一个快速排序:\n```python\ndef quicksort(arr):\n ...\n```")
|
||||
h.add("user", "能改成归并排序吗?")
|
||||
|
||||
# 保存
|
||||
assert h.save("sorting_chat")
|
||||
|
||||
# 加载到新实例
|
||||
h2 = History(history_dir)
|
||||
assert h2.load("sorting_chat")
|
||||
assert len(h2.messages) == 3
|
||||
assert "quicksort" in h2.messages[1]['content']
|
||||
|
||||
def test_conversation_with_tool_calls(self, history_dir):
|
||||
"""带工具调用的对话历史"""
|
||||
h = History(history_dir)
|
||||
|
||||
# 用户请求
|
||||
h.add("user", "查看 main.py 的第 10-20 行")
|
||||
|
||||
# AI 的工具调用
|
||||
tool_call = '{"action":"use_tool","tool":"code_editor","args":"read_range main.py 10 20"}'
|
||||
text, data = parse_response(tool_call)
|
||||
assert data is not None
|
||||
calls = extract_tool_calls(data)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]['tool'] == 'code_editor'
|
||||
|
||||
# 记录工具结果
|
||||
h.add("assistant", f"工具调用: {json.dumps(data)}")
|
||||
h.add("assistant", "工具执行结果:\n line 10: def main():")
|
||||
|
||||
assert len(h.messages) == 3
|
||||
|
||||
|
||||
class TestSandboxSecurityIntegration:
|
||||
"""沙箱安全 + 工具链 集成测试"""
|
||||
|
||||
def test_safe_code_executes_in_chain(self):
|
||||
"""安全代码在工具链中正常执行"""
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
|
||||
# 步骤 1: 数学计算
|
||||
r1 = sandbox.run('import math\nresult = math.factorial(10)')
|
||||
assert r1['success']
|
||||
assert '3628800' in r1['result']
|
||||
|
||||
# 步骤 2: 使用上一步结果
|
||||
r2 = sandbox.run(
|
||||
'result = f"factorial(10) = {val}"',
|
||||
variables={'val': r1['result']}
|
||||
)
|
||||
assert r2['success']
|
||||
assert '3628800' in r2['result']
|
||||
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_blocked_code_stops_chain(self):
|
||||
"""被阻止的代码应中断工具链"""
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
|
||||
r = sandbox.run('import os\nresult = os.listdir(".")')
|
||||
assert not r['success']
|
||||
# 后续步骤不应执行
|
||||
assert r['result']
|
||||
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_subprocess_isolation(self):
|
||||
"""子进程模式下文件系统隔离"""
|
||||
sandbox = Sandbox(timeout=10, mode='subprocess')
|
||||
result = sandbox.run(
|
||||
'import tempfile\nresult = tempfile.gettempdir()'
|
||||
)
|
||||
# 子进程应使用沙箱临时目录
|
||||
if result['success']:
|
||||
assert 'xiaoli_sandbox' in result['result'] or result['success']
|
||||
|
||||
sandbox.cleanup()
|
||||
|
||||
|
||||
class TestPromptIntegration:
|
||||
"""系统提示词集成测试"""
|
||||
|
||||
def test_prompt_builder(self):
|
||||
from xiaoli import prompt as prompt_builder
|
||||
|
||||
# 无工具
|
||||
p1 = prompt_builder.build()
|
||||
assert '小狸' in p1
|
||||
assert '编程' in p1 or 'code' in p1.lower()
|
||||
|
||||
# 带工具
|
||||
p2 = prompt_builder.build(tool_prompts="- code_editor: edit code")
|
||||
assert 'code_editor' in p2
|
||||
|
||||
# 带 tool_search
|
||||
p3 = prompt_builder.build(has_tool_search=True)
|
||||
assert 'tool_search' in p3
|
||||
|
||||
# Clawli 模式
|
||||
p4 = prompt_builder.build(is_clawli=True)
|
||||
assert 'Clawli' in p4 or 'clawli' in p4 or '远程' in p4
|
||||
@@ -1,240 +0,0 @@
|
||||
"""插件系统测试"""
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 1. code_editor 插件测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestCodeEditor:
|
||||
"""代码编辑器插件测试"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, tmp_dir):
|
||||
sys.path.insert(0, os.path.join(PROJECT_ROOT, 'plugins'))
|
||||
from code_editor import Plugin
|
||||
self.plugin = Plugin()
|
||||
self.tmp_dir = tmp_dir
|
||||
|
||||
def test_tool_info(self):
|
||||
info = self.plugin.get_tool_info()
|
||||
assert info['name'] == 'code_editor'
|
||||
assert 'description' in info
|
||||
|
||||
def test_mcp_definition(self):
|
||||
mcp = self.plugin.get_mcp_definition()
|
||||
assert mcp['name'] == 'code_editor'
|
||||
assert 'inputSchema' in mcp
|
||||
assert 'operation' in mcp['inputSchema']['properties']
|
||||
|
||||
def test_convert_mcp_args(self):
|
||||
args = self.plugin.convert_mcp_args({"operation": "edit", "path": "test.py"})
|
||||
assert 'edit' in args
|
||||
|
||||
def test_create_file(self):
|
||||
filepath = os.path.join(self.tmp_dir, 'test.py')
|
||||
result = self.plugin.handle(f'create {filepath}\nprint("hello")')
|
||||
assert os.path.exists(filepath)
|
||||
with open(filepath) as f:
|
||||
assert 'print("hello")' in f.read()
|
||||
|
||||
def test_read_range(self):
|
||||
filepath = os.path.join(self.tmp_dir, 'read_test.py')
|
||||
with open(filepath, 'w') as f:
|
||||
f.write('line1\nline2\nline3\nline4\nline5\n')
|
||||
result = self.plugin.handle(f'read_range {filepath} 2 4')
|
||||
assert 'line2' in result
|
||||
assert 'line3' in result
|
||||
|
||||
def test_edit_file(self):
|
||||
filepath = os.path.join(self.tmp_dir, 'edit_test.py')
|
||||
with open(filepath, 'w') as f:
|
||||
f.write('def old():\n pass\n')
|
||||
result = self.plugin.handle(f'edit {filepath} def old():\n pass <<<>>> def new():\n return True')
|
||||
with open(filepath) as f:
|
||||
content = f.read()
|
||||
assert 'def new():' in content
|
||||
assert 'return True' in content
|
||||
|
||||
def test_diff_files(self):
|
||||
f1 = os.path.join(self.tmp_dir, 'a.py')
|
||||
f2 = os.path.join(self.tmp_dir, 'b.py')
|
||||
with open(f1, 'w') as f:
|
||||
f.write('hello\nworld\n')
|
||||
with open(f2, 'w') as f:
|
||||
f.write('hello\nuniverse\n')
|
||||
result = self.plugin.handle(f'diff {f1} {f2}')
|
||||
assert result is not None # should produce diff output
|
||||
|
||||
def test_search_code(self):
|
||||
filepath = os.path.join(self.tmp_dir, 'search.py')
|
||||
with open(filepath, 'w') as f:
|
||||
f.write('def foo():\n return 42\ndef bar():\n return foo()\n')
|
||||
result = self.plugin.handle(f'search {self.tmp_dir} main *.py')
|
||||
assert 'main' in result
|
||||
|
||||
def test_write_file(self):
|
||||
filepath = os.path.join(self.tmp_dir, 'write.py')
|
||||
result = self.plugin.handle(f'write {filepath}\nresult = 42\n')
|
||||
assert os.path.exists(filepath)
|
||||
with open(filepath) as f:
|
||||
assert 'result = 42' in f.read()
|
||||
|
||||
def test_append_file(self):
|
||||
filepath = os.path.join(self.tmp_dir, 'append.py')
|
||||
with open(filepath, 'w') as f:
|
||||
f.write('line1\n')
|
||||
self.plugin.handle(f'append {filepath}\nline2\n')
|
||||
with open(filepath) as f:
|
||||
content = f.read()
|
||||
assert 'line1' in content
|
||||
assert 'line2' in content
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 2. code_search 插件测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestCodeSearch:
|
||||
"""代码搜索插件测试"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, tmp_dir):
|
||||
sys.path.insert(0, os.path.join(PROJECT_ROOT, 'plugins'))
|
||||
from code_search import Plugin
|
||||
self.plugin = Plugin()
|
||||
self.tmp_dir = tmp_dir
|
||||
|
||||
# 创建测试文件
|
||||
with open(os.path.join(tmp_dir, 'main.py'), 'w') as f:
|
||||
f.write('import os\ndef main():\n print("hello")\n\nif __name__ == "__main__":\n main()\n')
|
||||
with open(os.path.join(tmp_dir, 'utils.py'), 'w') as f:
|
||||
f.write('def helper():\n return 42\ndef process(data):\n return [x*2 for x in data]\n')
|
||||
with open(os.path.join(tmp_dir, 'test_main.py'), 'w') as f:
|
||||
f.write('# TODO: add tests\ndef test_main():\n assert True\n')
|
||||
|
||||
def test_tool_info(self):
|
||||
info = self.plugin.get_tool_info()
|
||||
assert info['name'] == 'code_search'
|
||||
|
||||
def test_structure(self):
|
||||
result = self.plugin.handle(f'structure {self.tmp_dir}')
|
||||
assert 'main.py' in result
|
||||
assert 'utils.py' in result
|
||||
|
||||
def test_find(self):
|
||||
result = self.plugin.handle(f'find {self.tmp_dir} def *.py')
|
||||
assert 'main' in result or 'helper' in result or 'def' in result
|
||||
|
||||
def test_symbols(self):
|
||||
result = self.plugin.handle(f'symbols {self.tmp_dir} main.py')
|
||||
assert 'main' in result
|
||||
|
||||
def test_todo(self):
|
||||
result = self.plugin.handle(f'todo {self.tmp_dir}')
|
||||
assert 'TODO' in result
|
||||
|
||||
def test_stats(self):
|
||||
result = self.plugin.handle(f'stats {self.tmp_dir}')
|
||||
assert result is not None
|
||||
|
||||
def test_context(self):
|
||||
filepath = os.path.join(self.tmp_dir, 'main.py')
|
||||
result = self.plugin.handle(f'context {filepath} 2 2')
|
||||
assert 'main' in result or 'def' in result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 3. git_tools 插件测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestGitTools:
|
||||
"""Git 工具插件测试"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, tmp_dir):
|
||||
sys.path.insert(0, os.path.join(PROJECT_ROOT, 'plugins'))
|
||||
from git_tools import Plugin
|
||||
self.plugin = Plugin()
|
||||
self.tmp_dir = tmp_dir
|
||||
|
||||
def test_tool_info(self):
|
||||
info = self.plugin.get_tool_info()
|
||||
assert info['name'] == 'git_tools'
|
||||
|
||||
def test_mcp_definition(self):
|
||||
mcp = self.plugin.get_mcp_definition()
|
||||
assert mcp['name'] == 'git_tools'
|
||||
assert 'inputSchema' in mcp
|
||||
|
||||
def test_status_non_git_dir(self):
|
||||
result = self.plugin.handle(f'status')
|
||||
# 在非 git 目录应返回错误或提示
|
||||
assert result is not None
|
||||
|
||||
def test_help_text(self):
|
||||
assert 'git' in self.plugin.usage.lower() or 'Git' in self.plugin.usage
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 4. unified_tool_manager 测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestUnifiedToolManager:
|
||||
"""统一工具管理器测试"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, plugins_dir, skills_dir):
|
||||
from unified_tool_manager import UnifiedToolManager
|
||||
self.manager = UnifiedToolManager()
|
||||
self.plugins_dir = plugins_dir
|
||||
self.skills_dir = skills_dir
|
||||
|
||||
def test_initialize(self):
|
||||
self.manager.initialize(self.plugins_dir, self.skills_dir)
|
||||
assert len(self.manager._tools) > 0
|
||||
|
||||
def test_tools_registered(self):
|
||||
self.manager.initialize(self.plugins_dir, self.skills_dir)
|
||||
assert 'test_tool' in self.manager._tools
|
||||
|
||||
def test_protocol_map(self):
|
||||
self.manager.initialize(self.plugins_dir, self.skills_dir)
|
||||
assert 'test_tool' in self.manager._protocol_map
|
||||
|
||||
def test_execute_plugin(self):
|
||||
self.manager.initialize(self.plugins_dir, self.skills_dir)
|
||||
result = self.manager.execute('test_tool', 'hello')
|
||||
assert result is not None
|
||||
assert 'hello' in str(result) or 'test' in str(result).lower()
|
||||
|
||||
def test_execute_nonexistent(self):
|
||||
self.manager.initialize(self.plugins_dir, self.skills_dir)
|
||||
result = self.manager.execute('nonexistent_tool', 'args')
|
||||
assert result is None or 'not found' in str(result).lower() or '未找到' in str(result)
|
||||
|
||||
def test_mcp_cache(self):
|
||||
self.manager.initialize(self.plugins_dir, self.skills_dir)
|
||||
cache = self.manager._mcp_tools_cache
|
||||
assert cache is not None
|
||||
assert len(cache) > 0
|
||||
|
||||
def test_empty_dirs(self, tmp_dir):
|
||||
empty_plugins = os.path.join(tmp_dir, 'empty_p')
|
||||
empty_skills = os.path.join(tmp_dir, 'empty_s')
|
||||
os.makedirs(empty_plugins)
|
||||
os.makedirs(empty_skills)
|
||||
with open(os.path.join(empty_plugins, '__init__.py'), 'w') as f:
|
||||
pass
|
||||
with open(os.path.join(empty_skills, '__init__.py'), 'w') as f:
|
||||
pass
|
||||
|
||||
mgr = __import__('unified_tool_manager').UnifiedToolManager()
|
||||
mgr.initialize(empty_plugins, empty_skills)
|
||||
assert len(mgr._tools) == 0
|
||||
@@ -1,576 +0,0 @@
|
||||
"""沙箱安全测试 - 测试所有安全层级"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from xiaoli.sandbox import Sandbox, SandboxResult, ASTSecurityChecker
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 1. AST 安全检查器测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestASTSecurityChecker:
|
||||
"""AST 静态分析测试"""
|
||||
|
||||
def setup_method(self):
|
||||
self.checker = ASTSecurityChecker()
|
||||
|
||||
# ── 应该拒绝的 ──
|
||||
|
||||
def test_block_os_import(self):
|
||||
safe, violations = self.checker.check('import os')
|
||||
assert not safe
|
||||
assert any('os' in v for v in violations)
|
||||
|
||||
def test_block_subprocess_import(self):
|
||||
safe, violations = self.checker.check('import subprocess')
|
||||
assert not safe
|
||||
|
||||
def test_block_from_import(self):
|
||||
safe, violations = self.checker.check('from os import system')
|
||||
assert not safe
|
||||
assert any('os' in v for v in violations)
|
||||
|
||||
def test_block_exec_call(self):
|
||||
safe, violations = self.checker.check('exec("print(1)")')
|
||||
assert not safe
|
||||
assert any('exec' in v for v in violations)
|
||||
|
||||
def test_block_eval_call(self):
|
||||
safe, violations = self.checker.check('x = eval("1+1")')
|
||||
assert not safe
|
||||
|
||||
def test_block_compile(self):
|
||||
safe, violations = self.checker.check('compile("x=1", "<s>", "exec")')
|
||||
assert not safe
|
||||
|
||||
def test_block_globals(self):
|
||||
safe, violations = self.checker.check('globals()')
|
||||
assert not safe
|
||||
|
||||
def test_block_locals(self):
|
||||
safe, violations = self.checker.check('locals()')
|
||||
assert not safe
|
||||
|
||||
def test_block_getattr(self):
|
||||
safe, violations = self.checker.check('getattr(obj, "name")')
|
||||
assert not safe
|
||||
|
||||
def test_block_setattr(self):
|
||||
safe, violations = self.checker.check('setattr(obj, "name", 1)')
|
||||
assert not safe
|
||||
|
||||
def test_block_dunder_class(self):
|
||||
safe, violations = self.checker.check('x = object.__class__')
|
||||
assert not safe
|
||||
|
||||
def test_block_dunder_subclasses(self):
|
||||
safe, violations = self.checker.check('x = object.__subclasses__()')
|
||||
assert not safe
|
||||
|
||||
def test_block_open(self):
|
||||
safe, violations = self.checker.check('f = open("/etc/passwd")')
|
||||
assert not safe
|
||||
|
||||
def test_block_breakpoint(self):
|
||||
safe, violations = self.checker.check('breakpoint()')
|
||||
assert not safe
|
||||
|
||||
def test_block_socket_import(self):
|
||||
safe, violations = self.checker.check('import socket')
|
||||
assert not safe
|
||||
|
||||
def test_block_pickle_import(self):
|
||||
safe, violations = self.checker.check('import pickle')
|
||||
assert not safe
|
||||
|
||||
def test_block_ctypes_import(self):
|
||||
safe, violations = self.checker.check('import ctypes')
|
||||
assert not safe
|
||||
|
||||
def test_block_importlib(self):
|
||||
safe, violations = self.checker.check('import importlib')
|
||||
assert not safe
|
||||
|
||||
def test_block_del(self):
|
||||
safe, violations = self.checker.check('del x')
|
||||
assert not safe
|
||||
|
||||
def test_block_global_stmt(self):
|
||||
safe, violations = self.checker.check('global x')
|
||||
assert not safe
|
||||
|
||||
def test_block_nested_dunder(self):
|
||||
safe, violations = self.checker.check(
|
||||
'x = (1).__class__.__bases__[0].__subclasses__()'
|
||||
)
|
||||
assert not safe
|
||||
|
||||
def test_block_sys_import(self):
|
||||
safe, violations = self.checker.check('import sys')
|
||||
assert not safe
|
||||
|
||||
def test_block_shutil(self):
|
||||
safe, violations = self.checker.check('import shutil')
|
||||
assert not safe
|
||||
|
||||
def test_block_urllib(self):
|
||||
safe, violations = self.checker.check('import urllib')
|
||||
assert not safe
|
||||
|
||||
def test_block_http(self):
|
||||
safe, violations = self.checker.check('import http')
|
||||
assert not safe
|
||||
|
||||
# ── 应该允许的 ──
|
||||
|
||||
def test_allow_math(self):
|
||||
safe, _ = self.checker.check('import math\nresult = math.pi')
|
||||
assert safe
|
||||
|
||||
def test_allow_json(self):
|
||||
safe, _ = self.checker.check('import json\nresult = json.dumps({"a": 1})')
|
||||
assert safe
|
||||
|
||||
def test_allow_re(self):
|
||||
safe, _ = self.checker.check('import re\nresult = re.findall(r"\\d+", "abc")')
|
||||
assert safe
|
||||
|
||||
def test_allow_datetime(self):
|
||||
safe, _ = self.checker.check('from datetime import datetime\nresult = datetime.now()')
|
||||
assert safe
|
||||
|
||||
def test_allow_collections(self):
|
||||
safe, _ = self.checker.check('from collections import Counter\nresult = Counter("aab")')
|
||||
assert safe
|
||||
|
||||
def test_allow_list_comprehension(self):
|
||||
safe, _ = self.checker.check('result = [x**2 for x in range(10)]')
|
||||
assert safe
|
||||
|
||||
def test_allow_function_def(self):
|
||||
safe, _ = self.checker.check('def add(a, b): return a + b\nresult = add(1, 2)')
|
||||
assert safe
|
||||
|
||||
def test_allow_string_ops(self):
|
||||
safe, _ = self.checker.check('result = "hello".upper()')
|
||||
assert safe
|
||||
|
||||
def test_allow_sorting(self):
|
||||
safe, _ = self.checker.check('result = sorted([3,1,2])')
|
||||
assert safe
|
||||
|
||||
def test_allow_typing(self):
|
||||
safe, _ = self.checker.check('from typing import List\nresult: List[int] = [1,2,3]')
|
||||
assert safe
|
||||
|
||||
def test_allow_enum(self):
|
||||
safe, _ = self.checker.check('from enum import Enum')
|
||||
assert safe
|
||||
|
||||
def test_allow_decimal(self):
|
||||
safe, _ = self.checker.check('from decimal import Decimal\nresult = Decimal("3.14")')
|
||||
assert safe
|
||||
|
||||
def test_allow_itertools(self):
|
||||
safe, _ = self.checker.check('import itertools\nresult = list(itertools.chain([1],[2]))')
|
||||
assert safe
|
||||
|
||||
# ── 语法错误 ──
|
||||
|
||||
def test_syntax_error(self):
|
||||
safe, violations = self.checker.check('def foo(')
|
||||
assert not safe
|
||||
assert any('syntax' in v.lower() for v in violations)
|
||||
|
||||
def test_empty_code(self):
|
||||
safe, _ = self.checker.check('')
|
||||
assert safe
|
||||
|
||||
# ── 多种违规组合 ──
|
||||
|
||||
def test_multiple_violations(self):
|
||||
code = 'import os\nimport socket\nexec("x=1")\nglobals()'
|
||||
safe, violations = self.checker.check(code)
|
||||
assert not safe
|
||||
assert len(violations) >= 4
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 2. Sandbox 核心功能测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestSandboxBasic:
|
||||
"""沙箱基本功能测试"""
|
||||
|
||||
def setup_method(self):
|
||||
self.sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
|
||||
def teardown_method(self):
|
||||
self.sandbox.cleanup()
|
||||
|
||||
def test_simple_execution(self):
|
||||
result = self.sandbox.run('result = 1 + 2')
|
||||
assert result['success']
|
||||
assert result['result'] == '3'
|
||||
|
||||
def test_string_manipulation(self):
|
||||
result = self.sandbox.run('result = "hello world".split()')
|
||||
assert result['success']
|
||||
assert 'hello' in result['result']
|
||||
|
||||
def test_math_operations(self):
|
||||
result = self.sandbox.run('import math\nresult = math.sqrt(144)')
|
||||
assert result['success']
|
||||
assert '12.0' in result['result']
|
||||
|
||||
def test_json_operations(self):
|
||||
result = self.sandbox.run(
|
||||
'import json\nresult = json.dumps({"key": "value"})'
|
||||
)
|
||||
assert result['success']
|
||||
assert 'key' in result['result']
|
||||
|
||||
def test_print_capture(self):
|
||||
result = self.sandbox.run('print("hello")\nresult = "done"')
|
||||
assert result['success']
|
||||
assert 'hello' in result['stdout']
|
||||
|
||||
def test_variables_injection(self):
|
||||
result = self.sandbox.run(
|
||||
'result = x + y',
|
||||
variables={'x': 10, 'y': 20}
|
||||
)
|
||||
assert result['success']
|
||||
assert '30' in result['result']
|
||||
|
||||
def test_function_definition(self):
|
||||
code = '''
|
||||
def factorial(n):
|
||||
if n <= 1:
|
||||
return 1
|
||||
return n * factorial(n - 1)
|
||||
result = factorial(5)
|
||||
'''
|
||||
result = self.sandbox.run(code)
|
||||
assert result['success']
|
||||
assert '120' in result['result']
|
||||
|
||||
def test_list_comprehension(self):
|
||||
result = self.sandbox.run('result = [x**2 for x in range(5)]')
|
||||
assert result['success']
|
||||
assert '[0, 1, 4, 9, 16]' in result['result']
|
||||
|
||||
def test_dict_operations(self):
|
||||
code = '''
|
||||
data = {"a": 1, "b": 2, "c": 3}
|
||||
result = {k: v*2 for k, v in data.items()}
|
||||
'''
|
||||
result = self.sandbox.run(code)
|
||||
assert result['success']
|
||||
|
||||
def test_datetime(self):
|
||||
code = 'from datetime import datetime\nresult = datetime.now().year'
|
||||
result = self.sandbox.run(code)
|
||||
assert result['success']
|
||||
|
||||
def test_collections_counter(self):
|
||||
code = 'from collections import Counter\nresult = Counter("aabbc")'
|
||||
result = self.sandbox.run(code)
|
||||
assert result['success']
|
||||
|
||||
def test_regex(self):
|
||||
code = 'import re\nresult = re.findall(r"\\d+", "abc123def456")'
|
||||
result = self.sandbox.run(code)
|
||||
assert result['success']
|
||||
assert '123' in result['result']
|
||||
|
||||
def test_no_result_variable(self):
|
||||
result = self.sandbox.run('x = 42')
|
||||
assert result['success']
|
||||
assert 'no return' in result['result'].lower() or 'ok' in result['result'].lower()
|
||||
|
||||
def test_syntax_error(self):
|
||||
result = self.sandbox.run('def foo(')
|
||||
assert not result['success']
|
||||
|
||||
def test_runtime_error(self):
|
||||
result = self.sandbox.run('result = 1 / 0')
|
||||
assert not result['success']
|
||||
assert 'error' in result['result'].lower() or 'division' in result['result'].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 3. 安全拒绝测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestSandboxSecurity:
|
||||
"""沙箱安全拒绝测试"""
|
||||
|
||||
def setup_method(self):
|
||||
self.sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
|
||||
def teardown_method(self):
|
||||
self.sandbox.cleanup()
|
||||
|
||||
def test_reject_os_import(self):
|
||||
result = self.sandbox.run('import os\nresult = os.getcwd()')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_subprocess(self):
|
||||
result = self.sandbox.run('import subprocess\nsubprocess.run(["ls"])')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_exec(self):
|
||||
result = self.sandbox.run('exec("result = 1")')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_eval(self):
|
||||
result = self.sandbox.run('result = eval("1+1")')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_open(self):
|
||||
result = self.sandbox.run('f = open("/etc/passwd")\nresult = f.read()')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_socket(self):
|
||||
result = self.sandbox.run('import socket\ns = socket.socket()')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_pickle(self):
|
||||
result = self.sandbox.run('import pickle\nresult = pickle.dumps({})')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_sys(self):
|
||||
result = self.sandbox.run('import sys\nresult = sys.version')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_getattr(self):
|
||||
result = self.sandbox.run('result = getattr(str, "upper")')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_dunder(self):
|
||||
result = self.sandbox.run('result = object.__class__')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_breakpoint(self):
|
||||
result = self.sandbox.run('breakpoint()')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_compile(self):
|
||||
result = self.sandbox.run('compile("x=1", "<s>", "exec")')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_globals(self):
|
||||
result = self.sandbox.run('result = globals()')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_importlib(self):
|
||||
result = self.sandbox.run('import importlib')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_shutil(self):
|
||||
result = self.sandbox.run('import shutil')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_ctypes(self):
|
||||
result = self.sandbox.run('import ctypes')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_multiprocessing(self):
|
||||
result = self.sandbox.run('import multiprocessing')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_threading(self):
|
||||
result = self.sandbox.run('import threading')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_http(self):
|
||||
result = self.sandbox.run('import http')
|
||||
assert not result['success']
|
||||
|
||||
def test_reject_urllib(self):
|
||||
result = self.sandbox.run('import urllib')
|
||||
assert not result['success']
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 4. 超时测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestSandboxTimeout:
|
||||
"""超时机制测试"""
|
||||
|
||||
def test_timeout_infinite_loop(self):
|
||||
sandbox = Sandbox(timeout=2, mode='threaded')
|
||||
result = sandbox.run('while True: pass')
|
||||
assert not result['success']
|
||||
assert result['killed']
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_timeout_sleep(self):
|
||||
sandbox = Sandbox(timeout=2, mode='threaded')
|
||||
result = sandbox.run('import time\ntime.sleep(30)\nresult = "done"')
|
||||
# Should either timeout or fail on import
|
||||
assert not result['success'] or result['killed']
|
||||
sandbox.cleanup()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 5. SandboxResult 测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestSandboxResult:
|
||||
"""SandboxResult 数据类测试"""
|
||||
|
||||
def test_success_result(self):
|
||||
r = SandboxResult(success=True, result="42", time=0.1)
|
||||
assert r.success
|
||||
d = r.to_dict()
|
||||
assert d['success']
|
||||
assert d['result'] == '42'
|
||||
assert d['time'] == 0.1
|
||||
|
||||
def test_failure_result(self):
|
||||
r = SandboxResult(success=False, result="error", security_violation="blocked os")
|
||||
assert not r.success
|
||||
d = r.to_dict()
|
||||
assert not d['success']
|
||||
assert d['security_violation'] == 'blocked os'
|
||||
|
||||
def test_str_representation(self):
|
||||
r = SandboxResult(success=True, result="ok", time=0.05)
|
||||
s = str(r)
|
||||
assert 'OK' in s
|
||||
assert '0.050' in s
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 6. 沙箱模式对比测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestSandboxModes:
|
||||
"""threaded vs subprocess 模式对比"""
|
||||
|
||||
def test_threaded_mode(self):
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
result = sandbox.run('result = 2 + 3')
|
||||
assert result['success']
|
||||
assert '5' in result['result']
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_subprocess_mode(self):
|
||||
sandbox = Sandbox(timeout=10, mode='subprocess')
|
||||
result = sandbox.run('result = 2 + 3')
|
||||
assert result['success']
|
||||
assert '5' in result['result']
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_both_modes_same_result(self):
|
||||
code = 'import json\nresult = json.dumps({"x": 42})'
|
||||
|
||||
t = Sandbox(timeout=10, mode='threaded')
|
||||
s = Sandbox(timeout=10, mode='subprocess')
|
||||
|
||||
r_threaded = t.run(code)
|
||||
r_subprocess = s.run(code)
|
||||
|
||||
assert r_threaded['success'] == r_subprocess['success']
|
||||
assert r_threaded['result'] == r_subprocess['result']
|
||||
|
||||
t.cleanup()
|
||||
s.cleanup()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 7. 禁用沙箱测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestSandboxDisabled:
|
||||
"""沙箱禁用时的行为"""
|
||||
|
||||
def test_disabled_rejects_all(self):
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
sandbox.enabled = False
|
||||
result = sandbox.run('result = 1')
|
||||
assert not result['success']
|
||||
assert 'disabled' in result['result'].lower() or 'off' in result['security_violation'].lower()
|
||||
sandbox.cleanup()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 8. 历史记录测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestSandboxHistory:
|
||||
"""沙箱执行历史记录"""
|
||||
|
||||
def test_history_recorded(self):
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
sandbox.run('result = 1')
|
||||
sandbox.run('result = 2')
|
||||
assert len(sandbox.history) == 2
|
||||
assert sandbox.history[0]['success']
|
||||
assert sandbox.history[1]['success']
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_failed_history_recorded(self):
|
||||
sandbox = Sandbox(timeout=10, mode="threaded")
|
||||
result = sandbox.run("x = 1 / 0") # runtime error, not security
|
||||
assert not result["success"]
|
||||
assert len(sandbox.history) == 1
|
||||
assert not sandbox.history[0]["success"]
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_security_violation_no_history(self):
|
||||
sandbox = Sandbox(timeout=10, mode="threaded")
|
||||
result = sandbox.run("import os") # security violation
|
||||
assert not result["success"]
|
||||
# security violations may not record history
|
||||
sandbox.cleanup()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 9. 变量注入安全测试
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
class TestSandboxVariables:
|
||||
"""变量注入测试"""
|
||||
|
||||
def test_inject_numbers(self):
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
result = sandbox.run('result = a + b', variables={'a': 100, 'b': 200})
|
||||
assert result['success']
|
||||
assert '300' in result['result']
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_inject_strings(self):
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
result = sandbox.run('result = name.upper()', variables={'name': 'hello'})
|
||||
assert result['success']
|
||||
assert 'HELLO' in result['result']
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_inject_dict(self):
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
result = sandbox.run(
|
||||
'result = data["key"]',
|
||||
variables={'data': {'key': 'found'}}
|
||||
)
|
||||
assert result['success']
|
||||
assert 'found' in result['result']
|
||||
sandbox.cleanup()
|
||||
|
||||
def test_inject_list(self):
|
||||
sandbox = Sandbox(timeout=10, mode='threaded')
|
||||
result = sandbox.run(
|
||||
'result = sum(items)',
|
||||
variables={'items': [1, 2, 3, 4, 5]}
|
||||
)
|
||||
assert result['success']
|
||||
assert '15' in result['result']
|
||||
sandbox.cleanup()
|
||||
-738
@@ -1,738 +0,0 @@
|
||||
# 小狸 Pro-CLI 开发文档
|
||||
|
||||
> 版本:2.0 | 更新日期:2026年3月
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [系统架构概述](#1-系统架构概述)
|
||||
2. [AI引擎开发规范](#2-ai引擎开发规范)
|
||||
3. [Plugin协议开发](#3-plugin协议开发)
|
||||
4. [Skill协议开发](#4-skill协议开发)
|
||||
5. [MCP协议支持](#5-mcp协议支持)
|
||||
6. [WebSocket API](#6-websocket-api)
|
||||
7. [已加载模块列表](#7-已加载模块列表)
|
||||
8. [最佳实践](#8-最佳实践)
|
||||
9. [调试与测试](#9-调试与测试)
|
||||
10. [常见问题](#10-常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统架构概述
|
||||
|
||||
### 1.1 三大协议支持
|
||||
|
||||
小狸 Pro-CLI 的工具系统支持三种协议:
|
||||
|
||||
| 协议 | 目录 | 说明 |
|
||||
|------|------|------|
|
||||
| **Plugin 协议** | `plugins/` | 小狸自有协议,简单易用 |
|
||||
| **Skill 协议** | `skills/` | Agent Skills 开源协议,支持 Markdown 和 Python 格式 |
|
||||
| **MCP 协议** | - | Model Context Protocol,Anthropic/OpenAI 标准 |
|
||||
|
||||
### 1.2 统一工具管理器
|
||||
|
||||
`unified_tool_manager.py` 协调三种协议,提供统一的工具调用接口:
|
||||
|
||||
```python
|
||||
from unified_tool_manager import UnifiedToolManager
|
||||
|
||||
manager = UnifiedToolManager(cli_instance)
|
||||
manager.initialize("plugins", "skills")
|
||||
|
||||
# Plugin 格式调用
|
||||
result = manager.execute("file_manager", "read test.txt")
|
||||
|
||||
# MCP 格式调用
|
||||
result = manager.mcp_call("file_manager", {"operation": "read", "path": "test.txt"})
|
||||
```
|
||||
|
||||
### 1.3 路由策略
|
||||
|
||||
- 优先使用 Skill 协议(如果存在同名技能)
|
||||
- 回退到 Plugin 协议
|
||||
- 支持强制指定协议
|
||||
- MCP 格式调用统一入口
|
||||
|
||||
---
|
||||
|
||||
## 2. AI引擎开发规范
|
||||
|
||||
### 2.1 文件与类命名规范
|
||||
|
||||
| 规范 | 说明 |
|
||||
|------|------|
|
||||
| 文件命名 | 必须以 `_engine.py` 结尾 |
|
||||
| 类命名 | 使用驼峰命名,如 `Qwen3CoderPlusAI` |
|
||||
|
||||
**示例:**
|
||||
- 文件:`qwen3-coder-plus_engine.py`
|
||||
- 类名:`Qwen3CoderPlusAI`
|
||||
|
||||
### 2.2 引擎配置声明
|
||||
|
||||
```python
|
||||
class MyEngineAI:
|
||||
# 配置属性
|
||||
requires_api_key = True # 是否需要API密钥
|
||||
default_base_url = "https://api.example.com/v1"
|
||||
default_model = "my-model"
|
||||
|
||||
def __init__(self):
|
||||
self.name = "my_engine"
|
||||
# 初始化代码...
|
||||
```
|
||||
|
||||
### 2.3 核心接口
|
||||
|
||||
```python
|
||||
def generate_response(self, user_input, tool_results=None, system_prompt=None):
|
||||
"""
|
||||
生成AI响应
|
||||
|
||||
参数:
|
||||
user_input: 用户输入文本
|
||||
tool_results: 工具执行结果(可选)
|
||||
system_prompt: 系统提示词(可选,由主程序生成)
|
||||
|
||||
返回:
|
||||
AI响应字符串
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_help_info(self):
|
||||
"""返回引擎帮助信息"""
|
||||
return "引擎帮助信息..."
|
||||
```
|
||||
|
||||
### 2.4 禁止设置超时
|
||||
|
||||
```python
|
||||
# ✅ 正确
|
||||
response = requests.post(url, json=body, headers=headers, timeout=None)
|
||||
|
||||
# ❌ 错误
|
||||
response = requests.post(url, json=body, headers=headers, timeout=30)
|
||||
```
|
||||
|
||||
### 2.5 SDK快速开发
|
||||
|
||||
使用 SDK,只需几行代码就能完成引擎开发:
|
||||
|
||||
```python
|
||||
from SDK_openAI import *
|
||||
|
||||
class MyAIAI:
|
||||
def __init__(self):
|
||||
engine_Online("on") # 云模式
|
||||
ai_name("my_ai")
|
||||
ai_api_url("https://api.example.com/v1")
|
||||
ai_api_key("your_api_key")
|
||||
ai_model_id("gpt-3.5-turbo")
|
||||
|
||||
self.name = "my_ai"
|
||||
self.cli = None
|
||||
self.shared_conversation_history = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
set_cli(cli)
|
||||
self.cli = cli
|
||||
|
||||
def generate_response(self, user_input, tool_results=None, system_prompt=None):
|
||||
set_shared_history(self.shared_conversation_history)
|
||||
return generate_response(user_input, tool_results, system_prompt)
|
||||
```
|
||||
|
||||
### 2.6 SDK方法说明
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `engine_Online("on"/"off")` | 云模式/本地模式 |
|
||||
| `ai_name(name)` | 设置引擎名称 |
|
||||
| `ai_api_url(url)` | 设置API地址 |
|
||||
| `ai_api_key(key)` | 设置API密钥 |
|
||||
| `ai_model_id(model)` | 设置模型名称 |
|
||||
| `ai_thinking_engine(...)` | 深度思考引擎配置 |
|
||||
|
||||
### 2.7 深度思考引擎配置
|
||||
|
||||
适用于 QwQ、DeepSeek-R1 等会输出思考过程的模型:
|
||||
|
||||
```python
|
||||
ai_thinking_engine(
|
||||
enable=True, # 启用深度思考
|
||||
start_marker="<thinking>", # 思考开始标记
|
||||
end_marker="</thinking>", # 思考结束标记
|
||||
show_thinking=True # 显示思考过程
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Plugin协议开发
|
||||
|
||||
### 3.1 基本结构
|
||||
|
||||
```python
|
||||
class Plugin:
|
||||
"""插件描述"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = "使用方法说明"
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
"""设置CLI引用(可选)"""
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
"""返回工具信息(必需)"""
|
||||
return {
|
||||
"name": "tool_name",
|
||||
"description": "工具描述",
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
"""返回MCP格式定义(可选)"""
|
||||
return {
|
||||
"name": "tool_name",
|
||||
"description": "工具描述",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {"type": "string"},
|
||||
"path": {"type": "string"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
"""转换MCP参数(可选)"""
|
||||
return f"{arguments.get('operation', '')} {arguments.get('path', '')}"
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
"""处理工具调用(必需)"""
|
||||
try:
|
||||
# 实现逻辑
|
||||
return "处理结果"
|
||||
except Exception as e:
|
||||
return f"错误: {str(e)}"
|
||||
```
|
||||
|
||||
### 3.2 完整示例:文件管理器
|
||||
|
||||
```python
|
||||
import os
|
||||
from typing import Dict, Any, List
|
||||
|
||||
class Plugin:
|
||||
"""文件管理器插件"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """文件管理器使用方法:
|
||||
file_manager <操作> <参数>
|
||||
|
||||
操作类型:
|
||||
- list <路径> - 列出目录内容
|
||||
- read <文件路径> - 读取文件内容
|
||||
- write <文件路径> -c "内容" - 写入文件
|
||||
- delete <路径> - 删除文件或目录
|
||||
"""
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "file_manager",
|
||||
"description": "文件和目录管理工具",
|
||||
"keywords": ["文件", "目录", "管理", "file"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "file_manager",
|
||||
"description": "文件和目录管理工具",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["list", "read", "write", "delete"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"path": {"type": "string", "description": "路径"},
|
||||
"content": {"type": "string", "description": "内容"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments: Dict[str, Any]) -> str:
|
||||
op = arguments.get("operation", "")
|
||||
path = arguments.get("path", "")
|
||||
content = arguments.get("content", "")
|
||||
if content:
|
||||
return f'{op} {path} -c "{content}"'
|
||||
return f"{op} {path}"
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
parts = args.strip().split(maxsplit=2)
|
||||
if not parts:
|
||||
return "错误:请提供操作类型"
|
||||
|
||||
operation = parts[0].lower()
|
||||
|
||||
if operation == "list":
|
||||
return self._op_list(parts[1:] if len(parts) > 1 else ["."])
|
||||
elif operation == "read":
|
||||
return self._op_read(parts[1:] if len(parts) > 1 else [])
|
||||
elif operation == "write":
|
||||
return self._op_write(parts[1:])
|
||||
elif operation == "delete":
|
||||
return self._op_delete(parts[1:])
|
||||
|
||||
return f"错误:不支持的操作 '{operation}'"
|
||||
|
||||
def _op_list(self, args: List[str]) -> str:
|
||||
path = args[0] if args else "."
|
||||
if not os.path.exists(path):
|
||||
return f"错误:路径不存在: {path}"
|
||||
# 实现列出目录逻辑...
|
||||
return f"目录内容: {path}"
|
||||
|
||||
def _op_read(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供文件路径"
|
||||
# 实现读取文件逻辑...
|
||||
return "文件内容..."
|
||||
|
||||
def _op_write(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:请提供文件路径和内容"
|
||||
# 实现写入文件逻辑...
|
||||
return "写入成功"
|
||||
|
||||
def _op_delete(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供路径"
|
||||
# 实现删除逻辑...
|
||||
return "删除成功"
|
||||
```
|
||||
|
||||
### 3.3 参数格式规范
|
||||
|
||||
**多操作插件格式:**
|
||||
```
|
||||
<操作类型> <目标对象> [可选内容]
|
||||
```
|
||||
示例:`read file.txt`、`write file.txt 内容`
|
||||
|
||||
**单参数插件格式:**
|
||||
```
|
||||
<待处理内容>
|
||||
```
|
||||
示例:`2+3`、`hello world`
|
||||
|
||||
---
|
||||
|
||||
## 4. Skill协议开发
|
||||
|
||||
### 4.1 Python格式技能
|
||||
|
||||
```python
|
||||
# skills/my_skill/__init__.py
|
||||
from skills.base import Skill, SkillResult
|
||||
|
||||
class MySkill(Skill):
|
||||
"""我的技能"""
|
||||
|
||||
name = "my_skill"
|
||||
description = "技能描述"
|
||||
tags = ["标签1", "标签2"]
|
||||
|
||||
def get_tool_definition(self):
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def run(self, **kwargs) -> SkillResult:
|
||||
try:
|
||||
operation = kwargs.get("operation", "")
|
||||
# 实现逻辑
|
||||
return SkillResult(
|
||||
success=True,
|
||||
message="执行成功",
|
||||
data={"result": "..."}
|
||||
)
|
||||
except Exception as e:
|
||||
return SkillResult(
|
||||
success=False,
|
||||
error=str(e)
|
||||
)
|
||||
```
|
||||
|
||||
### 4.2 Markdown格式技能
|
||||
|
||||
```markdown
|
||||
# skills/my_skill/SKILL.md
|
||||
|
||||
---
|
||||
name: my_skill
|
||||
description: 技能描述
|
||||
tags: [标签1, 标签2]
|
||||
---
|
||||
|
||||
# 技能说明
|
||||
|
||||
这是技能的详细使用说明...
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 步骤一
|
||||
2. 步骤二
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. MCP协议支持
|
||||
|
||||
### 5.1 MCP工具定义格式
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "tool_name",
|
||||
"description": "工具描述",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param1": {"type": "string", "description": "参数说明"},
|
||||
"param2": {"type": "integer", "description": "参数说明"}
|
||||
},
|
||||
"required": ["param1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 MCP调用格式
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "file_manager",
|
||||
"arguments": {
|
||||
"operation": "read",
|
||||
"path": "test.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"content": [
|
||||
{"type": "text", "text": "文件内容..."}
|
||||
],
|
||||
"isError": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 统一工具管理器MCP接口
|
||||
|
||||
```python
|
||||
# 获取MCP工具列表
|
||||
tools = manager.get_mcp_tools()
|
||||
|
||||
# MCP格式调用
|
||||
result = manager.mcp_call("file_manager", {"operation": "read", "path": "test.txt"})
|
||||
|
||||
# 处理MCP请求
|
||||
response = manager.handle_mcp_request({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {...}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. WebSocket API
|
||||
|
||||
### 6.1 消息类型
|
||||
|
||||
| 类型 | 方向 | 说明 |
|
||||
|------|------|------|
|
||||
| `heartbeat` | 双向 | 心跳检测 |
|
||||
| `auth` | 客户端→服务端 | 认证请求 |
|
||||
| `auth_success` / `auth_failed` | 服务端→客户端 | 认证结果 |
|
||||
| `message` | 双向 | 文本消息 |
|
||||
| `file` | 客户端→服务端 | 文件上传 |
|
||||
| `image` | 服务端→客户端 | 图片发送 |
|
||||
| `tool_status` | 服务端→客户端 | 工具状态 |
|
||||
|
||||
### 6.2 消息格式示例
|
||||
|
||||
**认证请求:**
|
||||
```json
|
||||
{"type": "auth", "password": "your_password"}
|
||||
```
|
||||
|
||||
**发送消息:**
|
||||
```json
|
||||
{"type": "message", "content": "你好"}
|
||||
```
|
||||
|
||||
**上传文件:**
|
||||
```json
|
||||
{
|
||||
"type": "file",
|
||||
"file": "base64_encoded_data",
|
||||
"filename": "example.txt",
|
||||
"text": "附加说明"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 启动远程服务
|
||||
|
||||
```bash
|
||||
# 本地模式
|
||||
/remote start 9079 your_password
|
||||
|
||||
# 代理模式
|
||||
/remote proxy relay.example.com:9079 server_pwd 12345 pc_pwd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 已加载模块列表
|
||||
|
||||
### 7.1 AI引擎
|
||||
|
||||
| 引擎名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| ollama | 本地 | Ollama本地模型,无需API密钥 |
|
||||
| glm | 云端 | 智谱GLM |
|
||||
| spark | 云端 | 讯飞星火 |
|
||||
| qwq | 云端 | QwQ深度思考引擎 |
|
||||
| qwen | 云端 | 通义千问 |
|
||||
|
||||
### 7.2 插件
|
||||
|
||||
| 插件名 | 功能 |
|
||||
|--------|------|
|
||||
| file_manager | 文件管理(14种操作) |
|
||||
| ai_search | AI搜索 |
|
||||
| audio_player | 音频播放 |
|
||||
| cmd_executor | 命令执行 |
|
||||
| frontend_tester | 前端测试 |
|
||||
| glados_tts_plugin | GLaDOS语音合成 |
|
||||
| napcat_qq | QQ机器人 |
|
||||
| network_tools | 网络工具 |
|
||||
| ollama_image | Ollama图像 |
|
||||
| send_image | 发送图片 |
|
||||
| speech_recognition | 语音识别 |
|
||||
| task_manager | 任务管理 |
|
||||
| tool_search | 工具搜索 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 最佳实践
|
||||
|
||||
### 8.1 安全性
|
||||
|
||||
```python
|
||||
# ❌ 避免使用危险函数
|
||||
result = eval(user_input) # 危险!
|
||||
|
||||
# ✅ 安全的替代方案
|
||||
import ast
|
||||
try:
|
||||
tree = ast.parse(user_input, mode='eval')
|
||||
result = eval(compile(tree, '<string>', 'eval'))
|
||||
except:
|
||||
return "无效表达式"
|
||||
```
|
||||
|
||||
### 8.2 输入验证
|
||||
|
||||
```python
|
||||
def handle(self, args: str) -> str:
|
||||
# 参数验证
|
||||
if not args:
|
||||
return "错误:参数不能为空"
|
||||
|
||||
# 长度限制
|
||||
if len(args) > 10000:
|
||||
return "错误:输入过长"
|
||||
|
||||
# 格式验证
|
||||
if not re.match(r'^[\w\s\-\.]+$', args):
|
||||
return "错误:包含非法字符"
|
||||
|
||||
# 继续处理...
|
||||
```
|
||||
|
||||
### 8.3 错误处理
|
||||
|
||||
```python
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
# 业务逻辑
|
||||
return "结果"
|
||||
except FileNotFoundError:
|
||||
return "错误:文件不存在"
|
||||
except PermissionError:
|
||||
return "错误:权限不足"
|
||||
except Exception as e:
|
||||
return f"错误: {str(e)}"
|
||||
```
|
||||
|
||||
### 8.4 资源管理
|
||||
|
||||
```python
|
||||
# 使用上下文管理器
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 或确保资源释放
|
||||
try:
|
||||
f = open(file_path, 'r')
|
||||
content = f.read()
|
||||
finally:
|
||||
f.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 调试与测试
|
||||
|
||||
### 9.1 日志记录
|
||||
|
||||
```python
|
||||
def handle(self, args: str) -> str:
|
||||
if self.cli:
|
||||
print(f"[DEBUG] 插件被调用: {args}")
|
||||
# 处理逻辑
|
||||
return "结果"
|
||||
```
|
||||
|
||||
### 9.2 单元测试
|
||||
|
||||
```python
|
||||
import unittest
|
||||
from plugins.file_manager import Plugin
|
||||
|
||||
class TestFileManager(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.plugin = Plugin()
|
||||
|
||||
def test_get_tool_info(self):
|
||||
info = self.plugin.get_tool_info()
|
||||
self.assertIn("name", info)
|
||||
self.assertEqual(info["name"], "file_manager")
|
||||
|
||||
def test_handle_list(self):
|
||||
result = self.plugin.handle("list .")
|
||||
self.assertIsInstance(result, str)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
### 9.3 集成测试
|
||||
|
||||
```python
|
||||
# integration_test.py
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from ai_cli import AICLI
|
||||
|
||||
def test_integration():
|
||||
cli = AICLI()
|
||||
# 测试插件调用...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 常见问题
|
||||
|
||||
### Q1: 插件不加载?
|
||||
|
||||
检查以下几点:
|
||||
- 文件是否放在 `plugins/` 目录
|
||||
- 类名是否为 `Plugin`
|
||||
- 是否实现了 `get_tool_info()` 和 `handle()` 方法
|
||||
- 文件名是否为 `.py` 结尾
|
||||
|
||||
### Q2: 插件不被调用?
|
||||
|
||||
检查:
|
||||
- 关键词是否匹配用户输入
|
||||
- `keywords` 列表是否正确设置
|
||||
- 插件是否成功加载(查看启动日志)
|
||||
|
||||
### Q3: MCP格式调用失败?
|
||||
|
||||
确保:
|
||||
- 实现了 `get_mcp_definition()` 方法
|
||||
- 实现了 `convert_mcp_args()` 方法(如果需要)
|
||||
- `inputSchema` 格式正确
|
||||
|
||||
### Q4: 如何调试?
|
||||
|
||||
- 在 `handle()` 方法中添加 `print()` 语句
|
||||
- 检查返回值是否为字符串
|
||||
- 查看主程序日志
|
||||
|
||||
### Q5: 如何处理大文件?
|
||||
|
||||
```python
|
||||
# 限制输出长度
|
||||
MAX_LENGTH = 10000
|
||||
if len(content) > MAX_LENGTH:
|
||||
content = content[:MAX_LENGTH] + "\n... (内容已截断)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:插件开发检查清单
|
||||
|
||||
- [ ] 插件类名是 `Plugin`
|
||||
- [ ] 实现了 `get_tool_info()` 方法
|
||||
- [ ] 实现了 `handle(args)` 方法
|
||||
- [ ] 返回字符串结果
|
||||
- [ ] 包含 `usage` 属性
|
||||
- [ ] 可选:实现 `get_mcp_definition()` 支持 MCP
|
||||
- [ ] 可选:实现 `convert_mcp_args()` 转换参数
|
||||
- [ ] 所有异常都被捕获
|
||||
- [ ] 遵循参数格式规范
|
||||
- [ ] 遵循 PEP 8 代码规范
|
||||
- [ ] 进行了基本测试
|
||||
Reference in new issue
Block a user