Merge branch 'main' into main

This commit is contained in:
mozhuanzuojing
2026-02-23 17:22:20 +08:00
committed by GitHub
25 changed files with 702 additions and 263 deletions
+55 -21
View File
@@ -141,17 +141,12 @@ jobs:
--add-data="skills:skills" \
--add-data="memory:memory" \
--add-data="resources:resources" \
--collect-data litellm \
--collect-data dateparser \
--collect-data tiktoken_ext \
--collect-data litellm \
--collect-data croniter \
--collect-data trafilatura \
--collect-data lxml \
--collect-data croniter \
--collect-all greenlet \
--collect-all cryptography \
--collect-all tiktoken \
--collect-all pydantic \
--collect-all pydantic_core \
--hidden-import=tiktoken_ext.openai_public \
--hidden-import=tiktoken_ext \
--hidden-import=greenlet \
@@ -159,6 +154,8 @@ jobs:
--hidden-import=backend \
--hidden-import=backend.app \
--hidden-import=backend.database \
--hidden-import=backend.utils.paths \
--hidden-import=backend.utils.logger \
--hidden-import=backend.api.auth \
--hidden-import=backend.api.chat \
--hidden-import=backend.api.channels \
@@ -171,12 +168,14 @@ jobs:
--hidden-import=backend.api.audio \
--hidden-import=backend.api.queue \
--hidden-import=backend.api.cron \
--hidden-import=backend.api.personalities \
--hidden-import=backend.models.message \
--hidden-import=backend.models.session \
--hidden-import=backend.models.setting \
--hidden-import=backend.models.task \
--hidden-import=backend.models.cron_job \
--hidden-import=backend.models.tool_conversation \
--hidden-import=backend.models.personality \
--hidden-import=uvicorn \
--hidden-import=uvicorn.logging \
--hidden-import=uvicorn.loops \
@@ -191,6 +190,22 @@ jobs:
--hidden-import=fastapi \
--hidden-import=sqlalchemy.ext.asyncio \
--hidden-import=aiosqlite \
--exclude-module=playwright \
--exclude-module=pandas \
--exclude-module=numpy \
--exclude-module=PyQt5 \
--exclude-module=PyQt6 \
--exclude-module=PySide2 \
--exclude-module=PySide6 \
--exclude-module=tkinter \
--exclude-module=matplotlib \
--exclude-module=scipy \
--exclude-module=PIL.ImageQt \
--exclude-module=transformers \
--exclude-module=torch \
--exclude-module=tensorflow \
--exclude-module=psycopg2 \
--exclude-module=asyncpg \
start_desktop.py
- name: 构建可执行文件 (Windows)
@@ -205,24 +220,21 @@ jobs:
--add-data="skills;skills" `
--add-data="memory;memory" `
--add-data="resources;resources" `
--collect-data litellm `
--collect-data dateparser `
--collect-data tiktoken_ext `
--collect-data litellm `
--collect-data croniter `
--collect-data trafilatura `
--collect-data lxml `
--collect-data croniter `
--collect-all greenlet `
--collect-all cryptography `
--collect-all tiktoken `
--collect-all pydantic `
--collect-all pydantic_core `
--collect-all pythonnet `
--collect-all clr_loader `
--hidden-import=tiktoken_ext.openai_public `
--hidden-import=tiktoken_ext `
--hidden-import=greenlet `
--hidden-import=greenlet._greenlet `
--hidden-import=backend `
--hidden-import=backend.app `
--hidden-import=backend.database `
--hidden-import=backend.utils.paths `
--hidden-import=backend.utils.logger `
--hidden-import=backend.api.auth `
--hidden-import=backend.api.chat `
--hidden-import=backend.api.channels `
@@ -235,12 +247,14 @@ jobs:
--hidden-import=backend.api.audio `
--hidden-import=backend.api.queue `
--hidden-import=backend.api.cron `
--hidden-import=backend.api.personalities `
--hidden-import=backend.models.message `
--hidden-import=backend.models.session `
--hidden-import=backend.models.setting `
--hidden-import=backend.models.task `
--hidden-import=backend.models.cron_job `
--hidden-import=backend.models.tool_conversation `
--hidden-import=backend.models.personality `
--hidden-import=uvicorn `
--hidden-import=uvicorn.logging `
--hidden-import=uvicorn.loops `
@@ -258,13 +272,33 @@ jobs:
--hidden-import=webview `
--hidden-import=webview.platforms.edgechromium `
--hidden-import=webview.platforms.winforms `
--hidden-import=pythonnet `
--hidden-import=clr `
--hidden-import=clr_loader `
--exclude-module=playwright `
--exclude-module=pandas `
--exclude-module=numpy `
--exclude-module=PyQt5 `
--exclude-module=PyQt6 `
--exclude-module=PySide2 `
--exclude-module=PySide6 `
--exclude-module=tkinter `
--exclude-module=matplotlib `
--exclude-module=scipy `
--exclude-module=PIL.ImageQt `
--exclude-module=transformers `
--exclude-module=torch `
--exclude-module=tensorflow `
--exclude-module=psycopg2 `
--exclude-module=asyncpg `
start_desktop.py
- name: 打包发布文件 (Linux/macOS)
if: runner.os != 'Windows'
- name: 打包发布文件 (macOS)
if: runner.os == 'macOS'
run: |
cd dist
tar -czf ${{ matrix.artifact-name }}.tar.gz CountBot.app/
ls -lh *.tar.gz
- name: 打包发布文件 (Linux)
if: runner.os == 'Linux'
run: |
cd dist
tar -czf ${{ matrix.artifact-name }}.tar.gz CountBot/
+82 -15
View File
@@ -5,6 +5,7 @@ import json
import uuid
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
@@ -21,6 +22,7 @@ from backend.modules.config.loader import config_loader
from backend.modules.providers.litellm_provider import LiteLLMProvider
from backend.modules.session.manager import SessionManager
from backend.modules.tools.registry import ToolRegistry
from backend.utils.paths import WORKSPACE_DIR
router = APIRouter(prefix="/api/chat", tags=["chat"])
@@ -70,6 +72,18 @@ class SessionResponse(BaseModel):
summary_updated_at: str | None = None
class ToolCallResponse(BaseModel):
"""工具调用响应"""
id: str
name: str
arguments: dict[str, Any]
result: str | None = None
error: str | None = None
status: str = "success"
duration: int | None = None
class MessageResponse(BaseModel):
"""消息响应"""
@@ -78,6 +92,7 @@ class MessageResponse(BaseModel):
role: str
content: str
created_at: str
tool_calls: list[ToolCallResponse] = Field(default_factory=list, description="工具调用记录")
# ============================================================================
@@ -107,7 +122,7 @@ async def get_agent_loop(db: AsyncSession = Depends(get_db)) -> AgentLoop:
# 获取工作空间路径
from pathlib import Path
workspace = Path(config.workspace.path) if config.workspace.path else Path.cwd()
workspace = Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
workspace.mkdir(parents=True, exist_ok=True)
# 初始化 LLM Provider
@@ -300,7 +315,7 @@ async def _maybe_auto_summarize(
return
config = config_loader.config
workspace = Path(config.workspace.path) if config.workspace.path else Path.cwd()
workspace = Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
memory_dir = workspace / "memory"
memory_dir.mkdir(parents=True, exist_ok=True)
memory = MemoryStore(memory_dir)
@@ -389,7 +404,7 @@ async def send_message(
if max_history > 0:
try:
from pathlib import Path as _Path
_workspace = _Path(config.workspace.path) if config.workspace.path else _Path.cwd()
_workspace = _Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
_memory_dir = _workspace / "memory"
_memory_dir.mkdir(parents=True, exist_ok=True)
_overflow_memory = MemoryStore(_memory_dir)
@@ -688,7 +703,7 @@ async def get_session_messages(
db: AsyncSession = Depends(get_db),
) -> list[MessageResponse]:
"""
获取会话的消息列表
获取会话的消息列表(包含工具调用记录)
Args:
session_id: 会话 ID
@@ -697,12 +712,15 @@ async def get_session_messages(
db: 数据库会话
Returns:
list[MessageResponse]: 消息列表
list[MessageResponse]: 消息列表(包含关联的工具调用)
Raises:
HTTPException: 会话不存在
"""
try:
from sqlalchemy import select
from backend.models.tool_conversation import ToolConversation
session_manager = SessionManager(db)
# 验证会话是否存在
@@ -720,16 +738,65 @@ async def get_session_messages(
offset=offset,
)
return [
MessageResponse(
id=msg.id,
session_id=msg.session_id,
role=msg.role,
content=msg.content,
created_at=msg.created_at.isoformat(),
# 获取该会话的所有工具调用记录
tool_calls_query = select(ToolConversation).where(
ToolConversation.session_id == session_id
).order_by(ToolConversation.timestamp.asc())
tool_calls_result = await db.execute(tool_calls_query)
all_tool_calls = tool_calls_result.scalars().all()
# 按 message_id 分组工具调用
tool_calls_by_message: dict[int, list[ToolConversation]] = {}
for tc in all_tool_calls:
if tc.message_id is not None:
if tc.message_id not in tool_calls_by_message:
tool_calls_by_message[tc.message_id] = []
tool_calls_by_message[tc.message_id].append(tc)
# 构建响应,包含工具调用
response_messages = []
for msg in messages:
# 获取该消息关联的工具调用
msg_tool_calls = tool_calls_by_message.get(msg.id, [])
# 转换工具调用为响应格式
tool_call_responses = []
for tc in msg_tool_calls:
try:
arguments = json.loads(tc.arguments) if tc.arguments else {}
except json.JSONDecodeError:
arguments = {}
# 确定状态
status_value = "success"
if tc.error:
status_value = "error"
tool_call_responses.append(
ToolCallResponse(
id=tc.id,
name=tc.tool_name,
arguments=arguments,
result=tc.result,
error=tc.error,
status=status_value,
duration=tc.duration_ms,
)
)
response_messages.append(
MessageResponse(
id=msg.id,
session_id=msg.session_id,
role=msg.role,
content=msg.content,
created_at=msg.created_at.isoformat(),
tool_calls=tool_call_responses,
)
)
for msg in messages
]
return response_messages
except HTTPException:
raise
@@ -1132,7 +1199,7 @@ async def summarize_session_to_memory(
# 6. 写入记忆
config = config_loader.config
workspace = Path(config.workspace.path) if config.workspace.path else Path.cwd()
workspace = Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
memory_dir = workspace / "memory"
memory_dir.mkdir(parents=True, exist_ok=True)
memory = MemoryStore(memory_dir)
+5 -1
View File
@@ -49,9 +49,13 @@ class SearchResponse(BaseModel):
total: int
from backend.modules.config.loader import config_loader
from backend.utils.paths import WORKSPACE_DIR
def get_memory_store() -> MemoryStore:
config = config_loader.config
workspace = Path(config.workspace.path) if config.workspace.path else Path.cwd()
workspace = Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
memory_dir = workspace / "memory"
memory_dir.mkdir(parents=True, exist_ok=True)
return MemoryStore(memory_dir)
+6 -2
View File
@@ -103,10 +103,14 @@ class DeleteSkillResponse(BaseModel):
# ============================================================================
from backend.modules.config.loader import config_loader
from backend.utils.paths import WORKSPACE_DIR
def get_skills_loader() -> SkillsLoader:
"""获取技能加载器实例"""
config = config_loader.config
workspace = Path(config.workspace.path) if config.workspace.path else Path.cwd()
workspace = Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
workspace = workspace.resolve() # 确保是绝对路径
skills_dir = workspace / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
@@ -255,7 +259,7 @@ async def toggle_skill(name: str, request: ToggleSkillRequest) -> ToggleSkillRes
# 持久化到配置文件
config = config_loader.config
workspace = Path(config.workspace.path) if config.workspace.path else Path.cwd()
workspace = Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
workspace = workspace.resolve() # 确保是绝对路径
config_file = workspace / ".skills_config.json"
+2 -1
View File
@@ -74,9 +74,10 @@ def get_tool_registry() -> ToolRegistry:
from backend.modules.tools.setup import register_all_tools
from backend.api.chat import get_global_subagent_manager
from backend.api.channels import get_channel_manager
from backend.utils.paths import WORKSPACE_DIR
config = config_loader.config
workspace = Path(config.workspace.path) if config.workspace.path else Path.cwd()
workspace = Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
workspace.mkdir(parents=True, exist_ok=True)
# 获取全局 SubagentManager
+10 -2
View File
@@ -24,6 +24,7 @@ def _create_shared_components(config):
from backend.modules.agent.skills import SkillsLoader
from backend.modules.agent.subagent import SubagentManager
from backend.modules.tools.setup import register_all_tools
from backend.utils.paths import WORKSPACE_DIR
logger.info("Getting provider metadata...")
provider_id = config.model.provider
@@ -38,7 +39,12 @@ def _create_shared_components(config):
)
logger.info("Setting up workspace...")
workspace = Path(config.workspace.path) if config.workspace.path else Path.cwd()
# 使用统一的工作区路径,如果配置中指定了路径则使用配置的
if config.workspace.path:
workspace = Path(config.workspace.path)
else:
workspace = WORKSPACE_DIR # 使用统一路径管理的默认工作区
workspace.mkdir(parents=True, exist_ok=True)
logger.info("Creating LiteLLM provider...")
provider = LiteLLMProvider(
@@ -479,7 +485,9 @@ async def health_check():
# 挂载前端静态文件
frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
from backend.utils.paths import APPLICATION_ROOT
frontend_dist = APPLICATION_ROOT / "frontend" / "dist"
if frontend_dist.exists():
from fastapi.responses import FileResponse
import mimetypes
+2 -3
View File
@@ -6,9 +6,8 @@ from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
# 数据目录
DATA_DIR = Path(__file__).parent.parent / "data"
DATA_DIR.mkdir(exist_ok=True)
# 使用统一路径管理
from backend.utils.paths import DATA_DIR
# 数据库文件路径
DATABASE_PATH = DATA_DIR / "countbot.db"
+3 -1
View File
@@ -55,7 +55,9 @@ class ContextBuilder:
{skills_summary}
**使用方法**: 当用户需求匹配某个技能时使用 read_file 读取 skills/<技能名>/SKILL.md 获取详细说明""")
**使用方法**:
- 单个技能: read_file(path='skills/<技能名>/SKILL.md')
- 批量读取推荐节省工具调用: read_file(paths=['skills/weather/SKILL.md', 'skills/email/SKILL.md'])""")
except Exception as e:
logger.warning(f"Failed to load skills: {e}")
+1 -1
View File
@@ -385,7 +385,7 @@ class AgentLoop:
logger.debug(f"Executing tool: {tool_name}")
try:
result = await self.tools.execute(tool_name, arguments)
result = await self.tools.execute(tool_name, arguments, auto_record=False)
return result
except Exception as e:
+3 -3
View File
@@ -8,10 +8,10 @@ from pathlib import Path
from typing import Any
from loguru import logger
from backend.utils.paths import APPLICATION_ROOT
# 默认内置技能目录(相对于此文件)
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent.parent.parent / "skills"
# 默认内置技能目录
BUILTIN_SKILLS_DIR = APPLICATION_ROOT / "skills"
class Skill:
+6 -1
View File
@@ -9,7 +9,6 @@ from sqlalchemy import select
from backend.database import AsyncSessionLocal
from backend.models.setting import Setting
from backend.modules.config.schema import AppConfig
from backend.modules.config.security import security_manager
class ConfigLoader:
@@ -119,6 +118,9 @@ class ConfigLoader:
def _decrypt_api_keys(self) -> None:
"""解密所有 provider 的 API 密钥"""
from backend.modules.config.security import get_security_manager
security_manager = get_security_manager()
for provider_name, provider_config in self.config.providers.items():
if provider_config.api_key:
try:
@@ -131,6 +133,9 @@ class ConfigLoader:
def _encrypt_api_keys_in_dict(self, config_dict: dict[str, Any]) -> dict[str, Any]:
"""加密配置字典中的 API 密钥"""
from backend.modules.config.security import get_security_manager
security_manager = get_security_manager()
if "providers" in config_dict:
for provider_name, provider_data in config_dict["providers"].items():
if isinstance(provider_data, dict) and provider_data.get("api_key"):
+32 -6
View File
@@ -1,20 +1,31 @@
"""安全管理器"""
"""安全管理器 - cryptography 可选依赖"""
from pathlib import Path
from cryptography.fernet import Fernet
from loguru import logger
# 尝试导入 cryptography如果不可用则优雅降级
try:
from cryptography.fernet import Fernet
CRYPTOGRAPHY_AVAILABLE = True
except ImportError:
logger.warning("cryptography 未安装,加密功能将被禁用")
CRYPTOGRAPHY_AVAILABLE = False
Fernet = None
class SecurityManager:
"""安全管理器"""
def __init__(self, key_file: Path) -> None:
self.key_file = key_file
self.cipher = self._load_or_create_cipher()
self.cipher = self._load_or_create_cipher() if CRYPTOGRAPHY_AVAILABLE else None
def _load_or_create_cipher(self) -> Fernet:
def _load_or_create_cipher(self):
"""加载或创建加密密钥"""
if not CRYPTOGRAPHY_AVAILABLE:
return None
if self.key_file.exists():
key = self.key_file.read_bytes()
logger.info(f"加载加密密钥: {self.key_file}")
@@ -29,12 +40,18 @@ class SecurityManager:
"""加密数据"""
if not data:
return ""
if not CRYPTOGRAPHY_AVAILABLE or not self.cipher:
logger.debug("cryptography 不可用,返回原始数据")
return data
return self.cipher.encrypt(data.encode()).decode()
def decrypt(self, data: str) -> str:
"""解密数据"""
if not data:
return ""
if not CRYPTOGRAPHY_AVAILABLE or not self.cipher:
logger.debug("cryptography 不可用,返回原始数据")
return data
try:
return self.cipher.decrypt(data.encode()).decode()
except Exception as e:
@@ -42,5 +59,14 @@ class SecurityManager:
return ""
DATA_DIR = Path(__file__).parent.parent.parent.parent / "data"
security_manager = SecurityManager(DATA_DIR / ".secret_key")
# 延迟初始化 security_manager避免模块导入时就触发依赖检查
_security_manager = None
def get_security_manager():
"""获取 SecurityManager 单例"""
global _security_manager
if _security_manager is None:
from backend.utils.paths import DATA_DIR
_security_manager = SecurityManager(DATA_DIR / ".secret_key")
return _security_manager
+121 -9
View File
@@ -68,10 +68,13 @@ class ReadFileTool(Tool):
def description(self) -> str:
return (
"Read file contents with line numbers. "
"Supports reading specific line ranges with start_line/end_line (1-based, inclusive). "
"Examples: read full file → read_file(path='a.py'); "
"read lines 10-20 → read_file(path='a.py', start_line=10, end_line=20); "
"read from line 50 → read_file(path='a.py', start_line=50)"
"Supports single file or batch mode (multiple files in one call). "
"Line ranges: start_line/end_line (1-based, inclusive). "
"Examples:\n"
"- Single: read_file(path='a.py')\n"
"- Range: read_file(path='a.py', start_line=10, end_line=20)\n"
"- Batch: read_file(paths=['a.py', 'b.py', 'skills/weather/SKILL.md'])\n"
"Batch mode is more efficient for multiple files (saves tool calls)."
)
@property
@@ -81,30 +84,71 @@ class ReadFileTool(Tool):
"properties": {
"path": {
"type": "string",
"description": "Path to the file (relative to workspace or absolute)",
"description": "Path to a single file (relative to workspace or absolute). Use this OR 'paths', not both.",
},
"paths": {
"type": "array",
"items": {"type": "string"},
"description": "List of file paths for batch reading (more efficient than multiple calls). Use this OR 'path', not both.",
},
"start_line": {
"type": "integer",
"description": "Start line number (1-based, inclusive). Omit to start from beginning.",
"description": "Start line number (1-based, inclusive). Only works in single file mode. Omit to start from beginning.",
},
"end_line": {
"type": "integer",
"description": "End line number (1-based, inclusive). Omit to read to end.",
"description": "End line number (1-based, inclusive). Only works in single file mode. Omit to read to end.",
},
"show_line_numbers": {
"type": "boolean",
"description": "Show line numbers in output (default: true)",
},
},
"required": ["path"],
"oneOf": [
{"required": ["path"]},
{"required": ["paths"]}
],
}
async def execute(self, **kwargs: Any) -> str:
path_str = kwargs.get("path", "")
path_str = kwargs.get("path")
paths_list = kwargs.get("paths")
start_line = kwargs.get("start_line")
end_line = kwargs.get("end_line")
show_line_numbers = kwargs.get("show_line_numbers", True)
# 参数验证:必须提供 path 或 paths 之一
if not path_str and not paths_list:
return "Error: Either 'path' or 'paths' parameter is required"
if path_str and paths_list:
return "Error: Provide either 'path' or 'paths', not both"
# 批量模式:读取多个文件
if paths_list:
if not isinstance(paths_list, list):
return "Error: 'paths' must be an array of strings"
if not paths_list:
return "Error: 'paths' array is empty"
# 批量模式不支持行范围参数
if start_line is not None or end_line is not None:
return "Error: Line range parameters (start_line/end_line) are not supported in batch mode"
return await self._read_multiple_files(paths_list, show_line_numbers)
# 单文件模式:保持原有逻辑
return await self._read_single_file(path_str, start_line, end_line, show_line_numbers)
async def _read_single_file(
self,
path_str: str,
start_line: int | None,
end_line: int | None,
show_line_numbers: bool
) -> str:
"""读取单个文件(原有逻辑)"""
if not path_str:
return "Error: Path parameter is required"
@@ -166,6 +210,74 @@ class ReadFileTool(Tool):
logger.error(f"Unexpected error reading file '{path_str}': {ex}")
return f"Error reading file: {str(ex)}"
async def _read_multiple_files(self, paths_list: list[str], show_line_numbers: bool) -> str:
"""批量读取多个文件"""
results = []
success_count = 0
error_count = 0
for path_str in paths_list:
try:
# 禁用技能检查
if self.skills_loader:
file_path_check = Path(path_str)
if not file_path_check.is_absolute():
file_path_check = (self.validator.workspace / path_str).resolve()
else:
file_path_check = file_path_check.resolve()
if file_path_check.name == "SKILL.md":
skill_name = file_path_check.parent.name
skill = self.skills_loader.get_skill(skill_name)
if skill and not skill.enabled:
logger.warning(f"Blocked read of disabled skill: {skill_name}")
results.append(f"[File: {path_str}]\nError: Skill '{skill_name}' is disabled. Enable it first.")
error_count += 1
continue
file_path = self.validator.validate_path(path_str)
if not file_path.exists():
results.append(f"[File: {path_str}]\nError: File not found")
error_count += 1
continue
if not file_path.is_file():
results.append(f"[File: {path_str}]\nError: Not a file")
error_count += 1
continue
content = file_path.read_text(encoding="utf-8")
lines = content.splitlines()
total = len(lines)
# 格式化输出
if show_line_numbers:
w = len(str(total))
output_lines = [f"{i + 1:>{w}}| {line}" for i, line in enumerate(lines)]
else:
output_lines = lines
header = f"[File: {path_str} | Lines: {total}]"
results.append(header + "\n" + "\n".join(output_lines))
success_count += 1
logger.info(f"Read file (batch): {path_str} ({total} lines)")
except ValueError as ve:
logger.error(f"Failed to read file '{path_str}' in batch: {ve}")
results.append(f"[File: {path_str}]\nError: {ve}")
error_count += 1
except Exception as ex:
logger.error(f"Unexpected error reading file '{path_str}' in batch: {ex}")
results.append(f"[File: {path_str}]\nError: {str(ex)}")
error_count += 1
# 添加批量读取摘要
summary = f"\n{'='*60}\n[Batch Read Summary: {success_count} succeeded, {error_count} failed]\n{'='*60}"
logger.info(f"Batch read completed: {success_count} succeeded, {error_count} failed out of {len(paths_list)} files")
return "\n\n".join(results) + summary
class WriteFileTool(Tool):
"""写入文件工具 - 支持覆盖和追加模式,大文件请分段追加写入"""
+47 -1
View File
@@ -128,13 +128,14 @@ class ToolRegistry:
logger.debug(f"Generated {len(definitions)} tool definitions")
return definitions
async def execute(self, tool_name: str, arguments: dict[str, Any]) -> str:
async def execute(self, tool_name: str, arguments: dict[str, Any], auto_record: bool = True) -> str:
"""
执行工具
Args:
tool_name: 工具名称
arguments: 工具参数
auto_record: 是否自动记录到工具对话历史默认 True
Returns:
str: 工具执行结果包括错误信息
@@ -169,6 +170,21 @@ class ToolRegistry:
error_msg = f"Error: Invalid parameters for tool '{tool_name}': " + "; ".join(errors)
logger.error(error_msg)
# 记录到工具对话历史(参数验证失败)
if auto_record and self._session_id:
try:
from backend.modules.tools.conversation_history import get_conversation_history
conversation_history = get_conversation_history()
conversation_history.add_conversation(
session_id=self._session_id,
tool_name=tool_name,
arguments=arguments,
error=error_msg,
duration_ms=0
)
except Exception as conv_err:
logger.warning(f"Failed to record tool conversation: {conv_err}")
return error_msg
logger.info(f"Executing tool: {tool_name} with arguments: {arguments}")
@@ -181,6 +197,21 @@ class ToolRegistry:
if self._audit_enabled:
file_audit_logger.update_result(call_id, result, "success", duration_ms=duration_ms)
# 记录到工具对话历史(成功)
if auto_record and self._session_id:
try:
from backend.modules.tools.conversation_history import get_conversation_history
conversation_history = get_conversation_history()
conversation_history.add_conversation(
session_id=self._session_id,
tool_name=tool_name,
arguments=arguments,
result=result,
duration_ms=duration_ms
)
except Exception as conv_err:
logger.warning(f"Failed to record tool conversation: {conv_err}")
logger.info(f"Tool '{tool_name}' executed successfully")
return result
@@ -195,6 +226,21 @@ class ToolRegistry:
if self._audit_enabled:
file_audit_logger.update_result(call_id, str(e), "error", error=str(e), duration_ms=duration_ms)
# 记录到工具对话历史(失败)
if auto_record and self._session_id:
try:
from backend.modules.tools.conversation_history import get_conversation_history
conversation_history = get_conversation_history()
conversation_history.add_conversation(
session_id=self._session_id,
tool_name=tool_name,
arguments=arguments,
error=error_msg,
duration_ms=duration_ms
)
except Exception as conv_err:
logger.warning(f"Failed to record tool conversation: {conv_err}")
return error_msg
def get_stats(self) -> dict[str, Any]:
+20 -6
View File
@@ -1,21 +1,37 @@
"""日志配置"""
import sys
import io
from pathlib import Path
from loguru import logger
# 使用统一路径管理
from backend.utils.paths import DATA_DIR
# 日志目录
LOG_DIR = Path(__file__).parent.parent.parent / "data" / "logs"
LOG_DIR = DATA_DIR / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
def setup_logger() -> None:
"""配置日志系统"""
# 移除默认处理器
# Windows UTF-8 编码
if sys.platform == "win32":
if not isinstance(sys.stderr, io.TextIOWrapper) or sys.stderr.encoding.lower() != "utf-8":
try:
sys.stderr = io.TextIOWrapper(
sys.stderr.buffer,
encoding="utf-8",
errors="replace",
line_buffering=True
)
except Exception:
pass
logger.remove()
# 控制台输出INFO 级别)- 简化格式,不显示模块路径
# 控制台输出
logger.add(
sys.stderr,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>",
@@ -24,7 +40,7 @@ def setup_logger() -> None:
filter=lambda record: record["level"].name in ["INFO", "WARNING", "ERROR", "CRITICAL"]
)
# 文件输出DEBUG 级别)
# 文件输出
logger.add(
LOG_DIR / "CountBot_{time:YYYY-MM-DD}.log",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
@@ -32,7 +48,6 @@ def setup_logger() -> None:
rotation="00:00",
retention="7 days",
compression="zip",
encoding="utf-8",
)
# 错误日志单独记录
@@ -43,7 +58,6 @@ def setup_logger() -> None:
rotation="00:00",
retention="30 days",
compression="zip",
encoding="utf-8",
)
logger.info("日志系统初始化完成")
+73
View File
@@ -0,0 +1,73 @@
"""统一路径管理 - 跨平台兼容"""
import sys
from pathlib import Path
def get_application_root() -> Path:
"""获取应用程序根目录
编译版: 使用可执行文件所在目录
开发版: 使用项目根目录
"""
if getattr(sys, "frozen", False):
# 编译版: _internal 目录包含所有资源
if sys.platform == "darwin":
# macOS onedir: CountBot.app/Contents/MacOS/CountBot -> 使用 _internal
exe_dir = Path(sys.executable).parent
if (exe_dir / "_internal").exists():
root = exe_dir / "_internal"
else:
# BUNDLE 模式: Contents/MacOS/CountBot -> Contents/Resources/
root = exe_dir.parent / "Resources"
else:
# Windows/Linux onedir: CountBot.exe 旁边的 _internal
exe_dir = Path(sys.executable).parent
root = exe_dir / "_internal" if (exe_dir / "_internal").exists() else exe_dir
else:
# 开发版: 项目根目录
root = Path(__file__).parent.parent.parent
return root.resolve()
def get_data_dir() -> Path:
"""获取数据目录(数据库、日志)"""
data_dir = get_application_root() / "data"
data_dir.mkdir(parents=True, exist_ok=True)
return data_dir
def get_workspace_dir() -> Path:
"""获取工作区目录
注意: 为兼容现有 skills 目录默认返回应用根目录
"""
return get_application_root()
def get_config_dir() -> Path:
"""获取配置目录"""
config_dir = get_application_root() / "config"
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir
# 导出路径常量
APPLICATION_ROOT = get_application_root()
DATA_DIR = get_data_dir()
WORKSPACE_DIR = get_workspace_dir()
CONFIG_DIR = get_config_dir()
if __name__ == "__main__":
print("=" * 70)
print("CountBot 路径配置")
print("=" * 70)
print(f"运行模式: {'编译版' if getattr(sys, 'frozen', False) else '开发版'}")
print(f"平台: {sys.platform}")
print(f"\n应用程序根目录: {APPLICATION_ROOT}")
print(f"数据目录: {DATA_DIR}")
print(f"工作区目录: {WORKSPACE_DIR}")
print(f"配置目录: {CONFIG_DIR}")
print("=" * 70)
+90
View File
@@ -0,0 +1,90 @@
# 工具调用显示修复总结
## 问题描述
工具调用tool calls在页面刷新后消失即使数据已存储在数据库中。
## 根本原因
前端 `ChatWindow.vue` 在加载消息时存在数据覆盖问题:
1. API 正确返回了包含 `tool_calls` 的消息数据
2. 前端加载消息后,又查询 `/api/tools/conversations` 作为备用数据源
3. **关键问题**:备用数据无条件覆盖了 API 返回的 `tool_calls`
```typescript
// 问题代码
if (byMsgId.has(mid)) {
msg.toolCalls = byMsgId.get(mid) // ❌ 直接覆盖
}
```
## 修复方案
### 后端修改
1. **API 返回工具调用** (`backend/api/chat.py`)
- `get_session_messages` 查询 `tool_conversations`
- 返回 `MessageResponse` 包含 `tool_calls` 字段
2. **统一记录机制** (`backend/modules/tools/registry.py`)
- `ToolRegistry.execute()` 自动记录工具调用
- 主 agent 循环禁用自动记录(使用自定义逻辑)
- 子 agent 和 API 调用自动记录
### 前端修改
**`frontend/src/modules/chat/ChatWindow.vue`**
修改 `loadSessionMessages` 函数,只在消息没有 `toolCalls` 时才使用备用数据:
```typescript
// 修复后的代码
if (byMsgId.has(mid) && (!msg.toolCalls || msg.toolCalls.length === 0)) {
msg.toolCalls = byMsgId.get(mid) // ✅ 仅在需要时填充
}
```
同样的逻辑应用于时间戳关联的旧数据兼容:
```typescript
const assistantMsgs = messages.value
.map((msg, idx) => ({ msg, idx }))
.filter(item => item.msg.role === 'assistant' && (!item.msg.toolCalls || item.msg.toolCalls.length === 0))
```
## 数据流程
```
数据库 → API (返回 tool_calls) → Store → ChatWindow → MessageItem → UI 显示
备用查询(仅用于旧数据兼容)
```
## 修改文件
### 后端
- `backend/api/chat.py` - API 返回工具调用
- `backend/modules/tools/registry.py` - 统一记录机制
- `backend/modules/agent/loop.py` - 禁用主循环自动记录
### 前端
- `frontend/src/modules/chat/ChatWindow.vue` - 修复数据覆盖问题
- `frontend/src/api/endpoints.ts` - 类型定义
- `frontend/src/store/chat.ts` - 数据传递
## 测试验证
1. 发送包含工具调用的消息
2. 刷新页面
3. 确认工具调用正确显示
## 兼容性
- 新数据:优先使用 API 返回的 `tool_calls`
- 旧数据:通过 `message_id` 或时间戳关联备用数据
- 向后兼容:不影响现有功能
## 相关 Issue
修复了工具调用在页面刷新后消失的问题。
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
var f=(y,m,a)=>new Promise((p,d)=>{var v=n=>{try{l(a.next(n))}catch(o){d(o)}},r=n=>{try{l(a.throw(n))}catch(o){d(o)}},l=n=>n.done?p(n.value):Promise.resolve(n.value).then(v,r);l((a=a.apply(y,m)).next())});import{d as V,o as x,a as w,c as i,b as t,e as B,t as b,w as S,f as h,v as g,g as _,h as C,u as I,r as c,i as u,_ as M,j as N}from"./index-B0hDJ7YE.js";const q={class:"login-page"},D={class:"login-card"},L={class:"login-hint"},P={class:"field"},U=["placeholder"],T={class:"field"},j=["placeholder","autocomplete"],A={key:0,class:"field"},E={key:1,class:"password-rules"},R={key:2,class:"error-box",role:"alert"},z=["disabled"],F={key:0,class:"spinner"},G={class:"login-footer"},H={key:0},J={key:1},K=V({__name:"LoginView",setup(y){const m=I(),a=c(!1),p=c(""),d=c(""),v=c(""),r=c(""),l=c(!1);x(()=>f(this,null,function*(){try{const{data:o}=yield w.get("/api/auth/status");if(o.authenticated){m.replace("/");return}a.value=!o.auth_enabled}catch(o){}}));function n(){return f(this,null,function*(){var o,e;r.value="",l.value=!0;try{if(a.value){if(d.value!==v.value){r.value="两次输入的密码不一致";return}const{data:s}=yield w.post("/api/auth/setup",{username:p.value,password:d.value});s.token&&localStorage.setItem("CountBot_token",s.token)}else{const{data:s}=yield w.post("/api/auth/login",{username:p.value,password:d.value});s.token&&localStorage.setItem("CountBot_token",s.token)}m.replace("/")}catch(s){const k=(e=(o=s==null?void 0:s.response)==null?void 0:o.data)==null?void 0:e.detail;r.value=k||"操作失败,请重试"}finally{l.value=!1}})}return(o,e)=>(u(),i("div",q,[t("div",D,[e[6]||(e[6]=B('<div class="login-brand" data-v-e02d2553><div class="brand-icon" data-v-e02d2553><img src="'+M+'" alt="CountBot Logo" data-v-e02d2553></div><h1 class="brand-name" data-v-e02d2553><span class="brand-count" data-v-e02d2553>Count</span><span class="brand-bot" data-v-e02d2553>Bot</span></h1><p class="brand-tagline" data-v-e02d2553>654321, AI Delivers</p></div>',1)),t("p",L,b(a.value?"首次远程访问,请设置管理员账号和密码":"远程访问需要身份验证"),1),t("form",{onSubmit:S(n,["prevent"]),class:"login-form",autocomplete:"on"},[t("div",P,[e[3]||(e[3]=t("label",{for:"username"},"账号",-1)),h(t("input",{id:"username","onUpdate:modelValue":e[0]||(e[0]=s=>p.value=s),type:"text",placeholder:a.value?"设置管理员账号":"请输入账号",autocomplete:"username",required:""},null,8,U),[[g,p.value]])]),t("div",T,[e[4]||(e[4]=t("label",{for:"password"},"密码",-1)),h(t("input",{id:"password","onUpdate:modelValue":e[1]||(e[1]=s=>d.value=s),type:"password",placeholder:a.value?"设置密码":"请输入密码",autocomplete:a.value?"new-password":"current-password",required:""},null,8,j),[[g,d.value]])]),a.value?(u(),i("div",A,[e[5]||(e[5]=t("label",{for:"confirmPassword"},"确认密码",-1)),h(t("input",{id:"confirmPassword","onUpdate:modelValue":e[2]||(e[2]=s=>v.value=s),type:"password",placeholder:"再次输入密码",autocomplete:"new-password",required:""},null,512),[[g,v.value]])])):_("",!0),a.value?(u(),i("div",E," 密码要求:至少 8 位,必须同时包含大写字母、小写字母和数字 ")):_("",!0),r.value?(u(),i("div",R,b(r.value),1)):_("",!0),t("button",{type:"submit",class:"submit-btn",disabled:l.value},[l.value?(u(),i("span",F)):_("",!0),C(" "+b(l.value?"请稍候...":a.value?"设置并登录":"登录"),1)],8,z)],32),t("div",G,[a.value?(u(),i("span",J,"设置完成后将自动登录")):(u(),i("span",H,"密码要求:至少 8 位,包含大写字母、小写字母和数字"))])])]))}}),W=N(K,[["__scopeId","data-v-e02d2553"]]);export{W as default};
var f=(y,m,a)=>new Promise((p,d)=>{var v=n=>{try{l(a.next(n))}catch(o){d(o)}},r=n=>{try{l(a.throw(n))}catch(o){d(o)}},l=n=>n.done?p(n.value):Promise.resolve(n.value).then(v,r);l((a=a.apply(y,m)).next())});import{d as V,o as x,a as w,c as i,b as t,e as B,t as b,w as S,f as h,v as g,g as _,h as C,u as I,r as c,i as u,_ as M,j as N}from"./index-B31PeGeJ.js";const q={class:"login-page"},D={class:"login-card"},L={class:"login-hint"},P={class:"field"},U=["placeholder"],T={class:"field"},j=["placeholder","autocomplete"],A={key:0,class:"field"},E={key:1,class:"password-rules"},R={key:2,class:"error-box",role:"alert"},z=["disabled"],F={key:0,class:"spinner"},G={class:"login-footer"},H={key:0},J={key:1},K=V({__name:"LoginView",setup(y){const m=I(),a=c(!1),p=c(""),d=c(""),v=c(""),r=c(""),l=c(!1);x(()=>f(this,null,function*(){try{const{data:o}=yield w.get("/api/auth/status");if(o.authenticated){m.replace("/");return}a.value=!o.auth_enabled}catch(o){}}));function n(){return f(this,null,function*(){var o,e;r.value="",l.value=!0;try{if(a.value){if(d.value!==v.value){r.value="两次输入的密码不一致";return}const{data:s}=yield w.post("/api/auth/setup",{username:p.value,password:d.value});s.token&&localStorage.setItem("CountBot_token",s.token)}else{const{data:s}=yield w.post("/api/auth/login",{username:p.value,password:d.value});s.token&&localStorage.setItem("CountBot_token",s.token)}m.replace("/")}catch(s){const k=(e=(o=s==null?void 0:s.response)==null?void 0:o.data)==null?void 0:e.detail;r.value=k||"操作失败,请重试"}finally{l.value=!1}})}return(o,e)=>(u(),i("div",q,[t("div",D,[e[6]||(e[6]=B('<div class="login-brand" data-v-e02d2553><div class="brand-icon" data-v-e02d2553><img src="'+M+'" alt="CountBot Logo" data-v-e02d2553></div><h1 class="brand-name" data-v-e02d2553><span class="brand-count" data-v-e02d2553>Count</span><span class="brand-bot" data-v-e02d2553>Bot</span></h1><p class="brand-tagline" data-v-e02d2553>654321, AI Delivers</p></div>',1)),t("p",L,b(a.value?"首次远程访问,请设置管理员账号和密码":"远程访问需要身份验证"),1),t("form",{onSubmit:S(n,["prevent"]),class:"login-form",autocomplete:"on"},[t("div",P,[e[3]||(e[3]=t("label",{for:"username"},"账号",-1)),h(t("input",{id:"username","onUpdate:modelValue":e[0]||(e[0]=s=>p.value=s),type:"text",placeholder:a.value?"设置管理员账号":"请输入账号",autocomplete:"username",required:""},null,8,U),[[g,p.value]])]),t("div",T,[e[4]||(e[4]=t("label",{for:"password"},"密码",-1)),h(t("input",{id:"password","onUpdate:modelValue":e[1]||(e[1]=s=>d.value=s),type:"password",placeholder:a.value?"设置密码":"请输入密码",autocomplete:a.value?"new-password":"current-password",required:""},null,8,j),[[g,d.value]])]),a.value?(u(),i("div",A,[e[5]||(e[5]=t("label",{for:"confirmPassword"},"确认密码",-1)),h(t("input",{id:"confirmPassword","onUpdate:modelValue":e[2]||(e[2]=s=>v.value=s),type:"password",placeholder:"再次输入密码",autocomplete:"new-password",required:""},null,512),[[g,v.value]])])):_("",!0),a.value?(u(),i("div",E," 密码要求:至少 8 位,必须同时包含大写字母、小写字母和数字 ")):_("",!0),r.value?(u(),i("div",R,b(r.value),1)):_("",!0),t("button",{type:"submit",class:"submit-btn",disabled:l.value},[l.value?(u(),i("span",F)):_("",!0),C(" "+b(l.value?"请稍候...":a.value?"设置并登录":"登录"),1)],8,z)],32),t("div",G,[a.value?(u(),i("span",J,"设置完成后将自动登录")):(u(),i("span",H,"密码要求:至少 8 位,包含大写字母、小写字母和数字"))])])]))}}),W=N(K,[["__scopeId","data-v-e02d2553"]]);export{W as default};
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="CountBot" />
<title>CountBot</title>
<script type="module" crossorigin src="/assets/js/index-B0hDJ7YE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/css/index-Dj6JHmId.css">
<script type="module" crossorigin src="/assets/js/index-B31PeGeJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/css/index-Dfkqh4cK.css">
</head>
<body>
+3 -1
View File
@@ -16,7 +16,6 @@ pyyaml>=6.0.2
python-multipart>=0.0.20
aiosqlite>=0.20.0
greenlet>=3.1.0
cryptography>=44.0.0
asyncpg>=0.30.0
psycopg2-binary>=2.9.10
tenacity>=9.0.0
@@ -24,6 +23,9 @@ croniter>=5.0.0
pywebview>=5.3
netifaces>=0.11.0 # 网络接口检测,用于本地 IP 监控
# 可选依赖API 密钥加密(编译版本不需要)
# cryptography>=41.0.0
# 网页内容提取web 工具)
trafilatura>=1.6.0
readability-lxml>=0.8.1
+16 -18
View File
@@ -10,33 +10,38 @@ import sys
import webbrowser
import threading
from pathlib import Path
from backend.utils.network import get_local_ips
# 添加项目根目录到 Python 路径
# Windows UTF-8 编码
if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8"
try:
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleCP(65001)
kernel32.SetConsoleOutputCP(65001)
except Exception:
pass
# 项目根目录
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
# 跨平台 SSL 兼容性处理macOS 需要额外配置证书)
# SSL 证书配置
from backend.utils.ssl_compat import ensure_ssl_certificates
ensure_ssl_certificates()
def open_browser_delayed(url: str, delay: float = 2.0) -> None:
"""
延迟打开浏览器
Args:
url: 要打开的 URL
delay: 延迟时间
"""
"""延迟打开浏览器"""
def _open():
import time
time.sleep(delay)
try:
webbrowser.open(url)
except Exception:
pass # 静默失败,不影响服务器启动
pass
threading.Thread(target=_open, daemon=True).start()
@@ -48,17 +53,11 @@ def main() -> None:
from backend.utils.process_manager import setup_graceful_shutdown
from loguru import logger
# 初始化日志系统
setup_logger()
# 设置优雅关闭机制(包括清理孤儿进程、写入 PID、注册信号处理器
process_manager = setup_graceful_shutdown(logger=logger)
# 配置
host = os.getenv("HOST", "127.0.0.1")
port = int(os.getenv("PORT", "8000"))
# 确保环境变量与实际绑定地址一致(供 app.py 读取)
os.environ["HOST"] = host
# 获取本地 IP 地址
@@ -102,7 +101,7 @@ def main() -> None:
"backend.app:app",
host=host,
port=port,
reload=False, # 生产模式不启用热重载
reload=False,
log_level="info"
)
except KeyboardInterrupt:
@@ -111,7 +110,6 @@ def main() -> None:
logger.error(f"Server error: {e}")
raise
finally:
# 确保清理 PID 文件
process_manager.remove_pid_file()
logger.info("Application shutdown complete")
+80 -138
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""CountBot Desktop — pywebview 桌面启动入口"""
"""CountBot Desktop 启动入口"""
import os
import sys
@@ -7,9 +7,32 @@ import platform
import threading
from pathlib import Path
# 项目根目录(兼容 PyInstaller 打包)
# Windows UTF-8 编码
if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8"
try:
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleCP(65001)
kernel32.SetConsoleOutputCP(65001)
except Exception:
pass
# 项目根目录
if getattr(sys, "frozen", False):
PROJECT_ROOT = Path(sys._MEIPASS)
# 编译版本: _internal 目录包含所有资源
if sys.platform == "darwin":
# macOS onedir: CountBot.app/Contents/MacOS/CountBot -> 使用 _internal
exe_dir = Path(sys.executable).parent
if (exe_dir / "_internal").exists():
PROJECT_ROOT = exe_dir / "_internal"
else:
# BUNDLE 模式: Contents/MacOS/CountBot -> Contents/Resources/
PROJECT_ROOT = exe_dir.parent / "Resources"
else:
# Windows/Linux onedir: CountBot.exe 旁边的 _internal
exe_dir = Path(sys.executable).parent
PROJECT_ROOT = exe_dir / "_internal" if (exe_dir / "_internal").exists() else exe_dir
else:
PROJECT_ROOT = Path(__file__).parent
@@ -22,75 +45,51 @@ _server = None
RESOURCES_DIR = PROJECT_ROOT / "resources"
# ── 友好的错误提示 ──────────────────────────────────────
def show_error_dialog(title: str, message: str) -> None:
"""显示错误对话框(跨平台"""
"""显示错误对话框(macOS 需在主线程"""
if sys.platform == "darwin" and threading.current_thread() != threading.main_thread():
print(f"\n{'='*60}\n错误: {title}\n{'='*60}\n{message}\n{'='*60}\n")
return
try:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw() # 隐藏主窗口
root.withdraw()
messagebox.showerror(title, message)
root.destroy()
except Exception:
# 如果 tkinter 不可用,打印到控制台
print(f"\n{'='*60}")
print(f"错误: {title}")
print(f"{'='*60}")
print(message)
print(f"{'='*60}\n")
print(f"\n{'='*60}\n错误: {title}\n{'='*60}\n{message}\n{'='*60}\n")
def check_dependencies() -> tuple[bool, str]:
"""检查关键依赖是否可用"""
"""检查依赖"""
missing = []
try:
import webview
except ImportError:
missing.append("pywebview")
try:
import fastapi
except ImportError:
missing.append("fastapi")
try:
import uvicorn
except ImportError:
missing.append("uvicorn")
try:
import litellm
except ImportError:
missing.append("litellm")
for pkg in ["webview", "fastapi", "uvicorn", "litellm"]:
try:
__import__(pkg)
except ImportError:
missing.append(pkg if pkg != "webview" else "pywebview")
if missing:
deps = ", ".join(missing)
msg = (
f"缺少必要的依赖: {deps}\n\n"
f"请运行以下命令安装:\n"
f"pip install -r requirements.txt\n\n"
f"或使用国内镜像:\n"
f"pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/"
f"缺少依赖: {', '.join(missing)}\n\n"
f"安装命令:\n"
f"pip install -r requirements.txt"
)
return False, msg
return True, ""
# ── 图标 ──────────────────────────────────────────────
def get_icon_path() -> str | None:
"""按平台返回图标路径: .ico(Win) / .icns(Mac) / .png(Linux)"""
"""获取图标路径"""
name_map = {"Windows": "countbot.ico", "Darwin": "countbot.icns"}
icon = RESOURCES_DIR / name_map.get(platform.system(), "countbot.png")
return str(icon) if icon.exists() else None
def _set_macos_dock_icon(path: str) -> None:
"""通过 PyObjC 设置 macOS Dock 图标"""
"""设置 macOS Dock 图标"""
try:
from AppKit import NSApplication, NSImage
img = NSImage.alloc().initWithContentsOfFile_(path)
@@ -101,65 +100,54 @@ def _set_macos_dock_icon(path: str) -> None:
def _set_windows_app_id() -> None:
"""设置 Windows AppUserModelID使任务栏显示自定义图标"""
"""设置 Windows 任务栏图标"""
try:
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
"countbot.desktop.app"
)
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("countbot.desktop.app")
except Exception:
pass
# ── 后端服务 ──────────────────────────────────────────
def _start_backend(host: str, port: int) -> None:
"""后台线程启动 FastAPI/Uvicorn"""
"""启动后端服务"""
global _server
import uvicorn
from loguru import logger
try:
cfg = uvicorn.Config("backend.app:app", host=host, port=port,
reload=False, log_level="info")
cfg = uvicorn.Config("backend.app:app", host=host, port=port, reload=False, log_level="info")
_server = uvicorn.Server(cfg)
_server.run()
except OSError as e:
if "Address already in use" in str(e) or "Only one usage" in str(e):
error_msg = (
f"端口 {port} 已被占用!\n\n"
f"可能的原因:\n"
f"1. CountBot 已经在运行中\n"
f"2. 其他程序占用了该端口\n\n"
f"解决方法:\n"
f"1. 关闭其他 CountBot 实例\n"
f"2. 修改端口: 设置环境变量 PORT=8001\n"
f"3. 使用命令查看占用: netstat -ano | findstr {port} (Windows)\n"
f" 或: lsof -i :{port} (Mac/Linux)"
)
logger.error(error_msg)
show_error_dialog("端口被占用", error_msg)
msg = f"端口 {port} 已被占用\n\n解决方法:\n1. 关闭其他实例\n2. 修改端口: export PORT=8001"
logger.error(msg)
show_error_dialog("端口被占用", msg)
else:
logger.error(f"后端启动失败: {e}")
show_error_dialog("启动失败", f"后端服务启动失败:\n{e}")
sys.exit(1)
except Exception as e:
logger.error(f"后端启动失败: {e}")
show_error_dialog("启动失败", f"后端服务启动失败:\n{e}\n\n请检查日志文件获取详细信息。")
show_error_dialog("启动失败", f"后端服务启动失败:\n{e}")
sys.exit(1)
def _shutdown() -> None:
"""关闭后端服务"""
global _server
if _server:
_server.should_exit = True
def _wait_for_server(host: str, port: int, timeout: float = 15.0) -> bool:
"""轮询 /api/health 直到后端就绪"""
import time, urllib.request
"""等待后端就绪"""
import time
import urllib.request
url = f"http://{host}:{port}/api/health"
deadline = time.time() + timeout
while time.time() < deadline:
try:
if urllib.request.urlopen(url, timeout=2).status == 200:
@@ -171,31 +159,18 @@ def _wait_for_server(host: str, port: int, timeout: float = 15.0) -> bool:
def _check_frontend() -> tuple[bool, str]:
"""检查前端文件是否存在"""
"""检查前端文件"""
index = PROJECT_ROOT / "frontend" / "dist" / "index.html"
if not index.exists():
msg = (
f"前端文件不存在!\n\n"
f"缺少文件: {index}\n\n"
f"可能的原因:\n"
f"1. 首次运行,前端尚未构建\n"
f"2. 文件被误删除\n\n"
f"解决方法:\n"
f"1. 如果是源码运行,请先构建前端:\n"
f" cd frontend && npm install && npm run build\n"
f"2. 如果是下载的桌面版,请重新下载完整包\n"
f"3. 检查解压是否完整"
)
msg = f"前端文件不存在: {index}\n\n解决方法:\ncd frontend && npm install && npm run build"
return False, msg
return True, ""
# ── 主入口 ────────────────────────────────────────────
def main():
"""主入口"""
from loguru import logger
# 检查依赖
deps_ok, deps_msg = check_dependencies()
if not deps_ok:
show_error_dialog("缺少依赖", deps_msg)
@@ -203,48 +178,34 @@ def main():
import webview
# Windows 优先使用 EdgeChromium 后端,避免 pythonnet 依赖问题
if platform.system() == "Windows":
os.environ["PYWEBVIEW_GUI"] = "edgechromium"
logger.info("Windows 平台: 使用 EdgeChromium 渲染引擎")
logger.info("使用 EdgeChromium 渲染引擎")
host = os.getenv("HOST", "127.0.0.1")
port = int(os.getenv("PORT", "8000"))
os.environ["HOST"] = host
logger.info(f"CountBot Desktop 启动中 http://{host}:{port}")
logger.info(f"CountBot Desktop 启动中 http://{host}:{port}")
# 检查前端文件
frontend_ok, frontend_msg = _check_frontend()
if not frontend_ok:
logger.error(frontend_msg)
show_error_dialog("前端文件缺失", frontend_msg)
sys.exit(1)
# 启动后端
logger.info("正在启动后端服务...")
logger.info("启动后端服务...")
threading.Thread(target=_start_backend, args=(host, port), daemon=True).start()
logger.info("等待后端服务就绪...")
logger.info("等待后端就绪...")
if not _wait_for_server(host, port):
error_msg = (
f"后端服务启动超时15秒\n\n"
f"可能的原因:\n"
f"1. 端口 {port} 被占用\n"
f"2. 防火墙阻止了连接\n"
f"3. 系统资源不足\n\n"
f"建议:\n"
f"1. 检查是否有其他 CountBot 实例在运行\n"
f"2. 尝试更换端口: 设置环境变量 PORT=8001\n"
f"3. 查看日志文件: data/logs/CountBot_*.log"
)
logger.error(error_msg)
show_error_dialog("启动超时", error_msg)
msg = f"后端启动超时 (15秒)\n\n可能原因:\n1. 端口 {port} 被占用\n2. 防火墙阻止\n3. 资源不足"
logger.error(msg)
show_error_dialog("启动超时", msg)
sys.exit(1)
logger.info("后端服务就绪")
logger.info("后端服务就绪")
# 设置平台图标
icon_path = get_icon_path()
if icon_path:
logger.info(f"图标: {icon_path}")
@@ -253,15 +214,16 @@ def main():
elif platform.system() == "Windows":
_set_windows_app_id()
# 创建窗口
try:
logger.info("正在创建应用窗口...")
logger.info("创建应用窗口...")
window = webview.create_window(
title="CountBot Desktop",
url=f"http://{host}:{port}",
width=960, height=680,
width=960,
height=680,
min_size=(720, 480),
resizable=True, text_select=True,
resizable=True,
text_select=True,
)
window.events.closing += lambda: _shutdown()
@@ -270,25 +232,12 @@ def main():
start_kwargs["icon"] = icon_path
logger.info("CountBot Desktop 启动成功")
logger.info(f"访问地址: http://{host}:{port}")
webview.start(**start_kwargs)
except Exception as e:
error_msg = (
f"窗口创建失败:\n{e}\n\n"
f"可能的原因:\n"
f"1. 缺少必要的系统组件\n"
f"2. 显示驱动问题\n\n"
f"Windows 用户:\n"
f"- 确保已安装 Edge WebView2 运行时\n"
f"- 下载地址: https://go.microsoft.com/fwlink/p/?LinkId=2124703\n\n"
f"Mac 用户:\n"
f"- 确保系统版本 >= 10.13\n\n"
f"Linux 用户:\n"
f"- 确保已安装 WebKit2GTK: sudo apt install webkit2gtk-4.0"
)
logger.error(error_msg)
show_error_dialog("窗口创建失败", error_msg)
msg = f"窗口创建失败:\n{e}\n\nWindows: 安装 Edge WebView2\nMac: 系统 >= 10.13\nLinux: apt install webkit2gtk-4.0"
logger.error(msg)
show_error_dialog("窗口创建失败", msg)
sys.exit(1)
logger.info("CountBot Desktop 已退出")
@@ -299,18 +248,11 @@ if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n用户中断,正在退出...")
print("\n用户中断,退出...")
sys.exit(0)
except Exception as e:
error_msg = (
f"程序发生未预期的错误:\n{e}\n\n"
f"请尝试以下操作:\n"
f"1. 重新启动程序\n"
f"2. 检查日志文件: data/logs/CountBot_*.log\n"
f"3. 如果问题持续,请在 GitHub 提交 Issue:\n"
f" https://github.com/countbot-ai/CountBot/issues"
)
show_error_dialog("程序错误", error_msg)
msg = f"程序错误:\n{e}\n\n1. 重启程序\n2. 查看日志: data/logs/\n3. 提交 Issue"
show_error_dialog("程序错误", msg)
import traceback
traceback.print_exc()
sys.exit(1)
+13 -1
View File
@@ -8,9 +8,21 @@ CountBot 应用启动脚本
import os
import sys
from pathlib import Path
from backend.utils.network import get_local_ips
# Windows 平台强制 UTF-8 编码,避免 GBK 编码错误
if sys.platform == "win32":
# Python 3.7+ 支持
os.environ["PYTHONIOENCODING"] = "utf-8"
# 设置控制台代码页为 UTF-8
try:
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleCP(65001)
kernel32.SetConsoleOutputCP(65001)
except Exception:
pass
# 添加项目根目录到 Python 路径
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))