mirror of
https://github.com/countbot-ai/CountBot.git
synced 2026-09-14 20:46:47 +08:00
Merge branch 'main' into main
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""写入文件工具 - 支持覆盖和追加模式,大文件请分段追加写入"""
|
||||
|
||||
@@ -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
@@ -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("日志系统初始化完成")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
修复了工具调用在页面刷新后消失的问题。
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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};
|
||||
+28
-28
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user