mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 09:48:22 +08:00
Compare commits
17 Commits
feat-web-c
...
feat-web-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d258b5202 | ||
|
|
5edbf4ce32 | ||
|
|
3ddbdd713d | ||
|
|
9ba107b511 | ||
|
|
c9adddb76a | ||
|
|
f0a12d5ff5 | ||
|
|
7cce224499 | ||
|
|
97397ca585 | ||
|
|
f2fbc602a8 | ||
|
|
925d728a86 | ||
|
|
f5f229871b | ||
|
|
9917552b4b | ||
|
|
adca89b973 | ||
|
|
29bfbecdc9 | ||
|
|
1a7a8c98d9 | ||
|
|
cddb38ac3d | ||
|
|
d610608391 |
@@ -24,8 +24,9 @@
|
||||
|
||||
## 声明
|
||||
|
||||
1. 本项目遵循 [MIT开源协议](/LICENSE),主要用于技术研究和学习,使用本项目时需遵守所在地法律法规、相关政策以及企业章程,禁止用于任何违法或侵犯他人权益的行为。任何个人、团队和企业,无论以何种方式使用该项目、对何对象提供服务,所产生的一切后果,本项目均不承担任何责任
|
||||
2. 成本与安全:Agent模式下Token使用量高于普通对话模式,请根据效果及成本综合选择模型。Agent具有访问所在操作系统的能力,请谨慎选择项目部署环境。同时项目也会持续升级安全机制、并降低模型消耗成本
|
||||
1. 本项目遵循 [MIT开源协议](/LICENSE),主要用于技术研究和学习,使用本项目时需遵守所在地法律法规、相关政策以及企业章程,禁止用于任何违法或侵犯他人权益的行为。任何个人、团队和企业,无论以何种方式使用该项目、对何对象提供服务,所产生的一切后果,本项目均不承担任何责任。
|
||||
2. 成本与安全:Agent模式下Token使用量高于普通对话模式,请根据效果及成本综合选择模型。Agent具有访问所在操作系统的能力,请谨慎选择项目部署环境。同时项目也会持续升级安全机制、并降低模型消耗成本。
|
||||
3. CowAgent项目专注于开源技术开发,不会参与、授权或发行任何加密货币。
|
||||
|
||||
## 演示
|
||||
|
||||
@@ -607,10 +608,12 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
||||
|
||||
以下对可接入通道的配置方式进行说明,应用通道代码在项目的 `channel/` 目录下。
|
||||
|
||||
支持同时可接入多个通道,配置时可通过逗号进行分割,例如 `"channel_type": "feishu,dingtalk"`。
|
||||
|
||||
<details>
|
||||
<summary>1. Web</summary>
|
||||
|
||||
项目启动后默认运行Web通道,配置如下:
|
||||
项目启动后会默认运行Web控制台,配置如下:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -158,6 +158,7 @@ class ChatService:
|
||||
logger.info(f"[ChatService] Agent run completed: session={session_id}")
|
||||
|
||||
|
||||
|
||||
class _StreamState:
|
||||
"""Mutable state shared between the event callback and the run method."""
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
"""
|
||||
Memory module for AgentMesh
|
||||
|
||||
Provides long-term memory capabilities with hybrid search (vector + keyword)
|
||||
Provides both long-term memory (vector/keyword search) and short-term
|
||||
conversation history persistence (SQLite).
|
||||
"""
|
||||
|
||||
from agent.memory.manager import MemoryManager
|
||||
from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config
|
||||
from agent.memory.embedding import create_embedding_provider
|
||||
from agent.memory.conversation_store import ConversationStore, get_conversation_store
|
||||
|
||||
__all__ = ['MemoryManager', 'MemoryConfig', 'get_default_memory_config', 'set_global_memory_config', 'create_embedding_provider']
|
||||
__all__ = [
|
||||
'MemoryManager',
|
||||
'MemoryConfig',
|
||||
'get_default_memory_config',
|
||||
'set_global_memory_config',
|
||||
'create_embedding_provider',
|
||||
'ConversationStore',
|
||||
'get_conversation_store',
|
||||
]
|
||||
|
||||
618
agent/memory/conversation_store.py
Normal file
618
agent/memory/conversation_store.py
Normal file
@@ -0,0 +1,618 @@
|
||||
"""
|
||||
Conversation history persistence using SQLite.
|
||||
|
||||
Design:
|
||||
- sessions table: per-session metadata (channel_type, last_active, msg_count)
|
||||
- messages table: individual messages stored as JSON, append-only
|
||||
- Pruning: age-based only (sessions not updated within N days are deleted)
|
||||
- Thread-safe via a single in-process lock
|
||||
|
||||
Storage path: ~/cow/sessions/conversations.db
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_active INTEGER NOT NULL,
|
||||
msg_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE (session_id, seq)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session
|
||||
ON messages (session_id, seq);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_last_active
|
||||
ON sessions (last_active);
|
||||
"""
|
||||
|
||||
# Migration: add channel_type column to existing databases that predate it.
|
||||
_MIGRATION_ADD_CHANNEL_TYPE = """
|
||||
ALTER TABLE sessions ADD COLUMN channel_type TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
DEFAULT_MAX_AGE_DAYS: int = 30
|
||||
|
||||
|
||||
def _is_visible_user_message(content: Any) -> bool:
|
||||
"""
|
||||
Return True when a user-role message represents actual user input
|
||||
(not an internal tool_result injected by the agent loop).
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return bool(content.strip())
|
||||
if isinstance(content, list):
|
||||
return any(
|
||||
isinstance(b, dict) and b.get("type") == "text"
|
||||
for b in content
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _extract_display_text(content: Any) -> str:
|
||||
"""
|
||||
Extract the human-readable text portion from a message content value.
|
||||
Returns an empty string for tool_use / tool_result blocks.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
b.get("text", "")
|
||||
for b in content
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
]
|
||||
return "\n".join(p for p in parts if p).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Extract tool_use blocks from an assistant message content.
|
||||
Returns a list of {name, arguments} dicts (result filled in later).
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return []
|
||||
return [
|
||||
{"id": b.get("id", ""), "name": b.get("name", ""), "arguments": b.get("input", {})}
|
||||
for b in content
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use"
|
||||
]
|
||||
|
||||
|
||||
def _extract_tool_results(content: Any) -> Dict[str, str]:
|
||||
"""
|
||||
Extract tool_result blocks from a user message, keyed by tool_use_id.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return {}
|
||||
results = {}
|
||||
for b in content:
|
||||
if not isinstance(b, dict) or b.get("type") != "tool_result":
|
||||
continue
|
||||
tool_id = b.get("tool_use_id", "")
|
||||
result_content = b.get("content", "")
|
||||
if isinstance(result_content, list):
|
||||
result_content = "\n".join(
|
||||
rb.get("text", "") for rb in result_content
|
||||
if isinstance(rb, dict) and rb.get("type") == "text"
|
||||
)
|
||||
results[tool_id] = str(result_content)
|
||||
return results
|
||||
|
||||
|
||||
def _group_into_display_turns(
|
||||
rows: List[tuple],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Convert raw (role, content_json, created_at) DB rows into display turns.
|
||||
|
||||
One display turn = one visible user message + one merged assistant reply.
|
||||
All intermediate assistant messages (those carrying tool_use) and the final
|
||||
assistant text reply produced for the same user query are collapsed into a
|
||||
single assistant turn, exactly matching the live SSE rendering where tools
|
||||
and the final answer appear inside the same bubble.
|
||||
|
||||
Grouping rules:
|
||||
- A visible user message starts a new group.
|
||||
- tool_result user messages are internal; their content is attached to the
|
||||
matching tool_use entry via tool_use_id and they never become own turns.
|
||||
- All assistant messages within a group are merged:
|
||||
* tool_use blocks → tool_calls list (result filled from tool_results)
|
||||
* text blocks → last non-empty text becomes the display content
|
||||
"""
|
||||
# ------------------------------------------------------------------ #
|
||||
# Pass 1: split rows into groups, each starting with a visible user msg
|
||||
# ------------------------------------------------------------------ #
|
||||
# group = (user_row | None, [subsequent_rows])
|
||||
# user_row: (content, created_at)
|
||||
groups: List[tuple] = []
|
||||
cur_user: Optional[tuple] = None
|
||||
cur_rest: List[tuple] = []
|
||||
started = False
|
||||
|
||||
for role, raw_content, created_at in rows:
|
||||
try:
|
||||
content = json.loads(raw_content)
|
||||
except Exception:
|
||||
content = raw_content
|
||||
|
||||
if role == "user" and _is_visible_user_message(content):
|
||||
if started:
|
||||
groups.append((cur_user, cur_rest))
|
||||
cur_user = (content, created_at)
|
||||
cur_rest = []
|
||||
started = True
|
||||
else:
|
||||
cur_rest.append((role, content, created_at))
|
||||
|
||||
if started:
|
||||
groups.append((cur_user, cur_rest))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Pass 2: build display turns from each group
|
||||
# ------------------------------------------------------------------ #
|
||||
turns: List[Dict[str, Any]] = []
|
||||
|
||||
for user_row, rest in groups:
|
||||
# User turn
|
||||
if user_row:
|
||||
content, created_at = user_row
|
||||
text = _extract_display_text(content)
|
||||
if text:
|
||||
turns.append({"role": "user", "content": text, "created_at": created_at})
|
||||
|
||||
# Collect all tool_calls and tool_results from the rest of the group
|
||||
all_tool_calls: List[Dict[str, Any]] = []
|
||||
tool_results: Dict[str, str] = {}
|
||||
final_text = ""
|
||||
final_ts: Optional[int] = None
|
||||
|
||||
for role, content, created_at in rest:
|
||||
if role == "user":
|
||||
tool_results.update(_extract_tool_results(content))
|
||||
elif role == "assistant":
|
||||
tcs = _extract_tool_calls(content)
|
||||
all_tool_calls.extend(tcs)
|
||||
t = _extract_display_text(content)
|
||||
if t:
|
||||
final_text = t
|
||||
final_ts = created_at
|
||||
|
||||
# Attach tool results to their matching tool_call entries
|
||||
for tc in all_tool_calls:
|
||||
tc["result"] = tool_results.get(tc.get("id", ""), "")
|
||||
|
||||
if final_text or all_tool_calls:
|
||||
turns.append({
|
||||
"role": "assistant",
|
||||
"content": final_text,
|
||||
"tool_calls": all_tool_calls,
|
||||
"created_at": final_ts or (user_row[1] if user_row else 0),
|
||||
})
|
||||
|
||||
return turns
|
||||
|
||||
|
||||
class ConversationStore:
|
||||
"""
|
||||
SQLite-backed store for per-session conversation history.
|
||||
|
||||
Usage:
|
||||
store = ConversationStore(db_path)
|
||||
store.append_messages("user_123", new_messages, channel_type="feishu")
|
||||
msgs = store.load_messages("user_123", max_turns=30)
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
self._init_db()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
max_turns: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Load the most recent messages for a session, for injection into the LLM.
|
||||
|
||||
ALL message types (user text, assistant tool_use, tool_result) are returned
|
||||
in their original JSON form so the LLM can reconstruct the full context.
|
||||
|
||||
max_turns is a *visible-turn* count: we count only user messages whose
|
||||
content is actual user text (not tool_result blocks). This prevents
|
||||
tool-heavy sessions from exhausting the turn budget prematurely.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier.
|
||||
max_turns: Maximum number of visible user-assistant turns to keep.
|
||||
|
||||
Returns:
|
||||
Chronologically ordered list of message dicts (role, content).
|
||||
"""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT seq, role, content
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY seq DESC
|
||||
""",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Walk newest-to-oldest counting *visible* user turns (actual user text,
|
||||
# not tool_result injections). Record the seq of every visible user
|
||||
# message so we can find a clean cut point later.
|
||||
visible_turn_seqs: List[int] = [] # newest first
|
||||
for seq, role, raw_content in rows:
|
||||
if role != "user":
|
||||
continue
|
||||
try:
|
||||
content = json.loads(raw_content)
|
||||
except Exception:
|
||||
content = raw_content
|
||||
if _is_visible_user_message(content):
|
||||
visible_turn_seqs.append(seq)
|
||||
|
||||
# Determine the seq of the oldest visible user message we want to keep.
|
||||
# If the total turns fit within max_turns, keep everything.
|
||||
if len(visible_turn_seqs) <= max_turns:
|
||||
cutoff_seq = None # keep all
|
||||
else:
|
||||
# The Nth visible user message (0-indexed) is the oldest we keep.
|
||||
cutoff_seq = visible_turn_seqs[max_turns - 1]
|
||||
|
||||
# Build result in chronological order, starting from cutoff.
|
||||
# IMPORTANT: we start exactly at cutoff_seq (the visible user message),
|
||||
# never mid-group, so tool_use / tool_result pairs are always complete.
|
||||
result = []
|
||||
for seq, role, raw_content in reversed(rows):
|
||||
if cutoff_seq is not None and seq < cutoff_seq:
|
||||
continue
|
||||
try:
|
||||
content = json.loads(raw_content)
|
||||
except Exception:
|
||||
content = raw_content
|
||||
result.append({"role": role, "content": content})
|
||||
return result
|
||||
|
||||
def append_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
channel_type: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
Append new messages to a session's history.
|
||||
|
||||
Seq numbers continue from the session's current maximum, so
|
||||
concurrent callers on distinct sessions never collide.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier.
|
||||
messages: List of message dicts to append.
|
||||
channel_type: Source channel (e.g. "feishu", "web", "wechat").
|
||||
Only written on session creation; ignored on update.
|
||||
"""
|
||||
if not messages:
|
||||
return
|
||||
|
||||
now = int(time.time())
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
with conn:
|
||||
# INSERT OR IGNORE creates the row on first visit;
|
||||
# the UPDATE always refreshes last_active.
|
||||
# Avoids ON CONFLICT...DO UPDATE (requires SQLite >= 3.24).
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO sessions
|
||||
(session_id, channel_type, created_at, last_active, msg_count)
|
||||
VALUES (?, ?, ?, ?, 0)
|
||||
""",
|
||||
(session_id, channel_type, now, now),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE sessions SET last_active = ? WHERE session_id = ?",
|
||||
(now, session_id),
|
||||
)
|
||||
|
||||
# Determine starting seq for the new batch.
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(seq), -1) FROM messages WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
next_seq = row[0] + 1
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = json.dumps(
|
||||
msg.get("content", ""), ensure_ascii=False
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO messages
|
||||
(session_id, seq, role, content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(session_id, next_seq, role, content, now),
|
||||
)
|
||||
next_seq += 1
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET msg_count = (
|
||||
SELECT COUNT(*) FROM messages WHERE session_id = ?
|
||||
)
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(session_id, session_id),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def clear_session(self, session_id: str) -> None:
|
||||
"""Delete all messages and the session record for a given session_id."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
with conn:
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def cleanup_old_sessions(self, max_age_days: Optional[int] = None) -> int:
|
||||
"""
|
||||
Delete sessions that have not been active within max_age_days.
|
||||
|
||||
Args:
|
||||
max_age_days: Override the default retention period.
|
||||
|
||||
Returns:
|
||||
Number of sessions deleted.
|
||||
"""
|
||||
try:
|
||||
from config import conf
|
||||
max_age = max_age_days or conf().get(
|
||||
"conversation_max_age_days", DEFAULT_MAX_AGE_DAYS
|
||||
)
|
||||
except Exception:
|
||||
max_age = max_age_days or DEFAULT_MAX_AGE_DAYS
|
||||
|
||||
cutoff = int(time.time()) - max_age * 86400
|
||||
deleted = 0
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
with conn:
|
||||
stale = conn.execute(
|
||||
"SELECT session_id FROM sessions WHERE last_active < ?",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
for (sid,) in stale:
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE session_id = ?", (sid,)
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM sessions WHERE session_id = ?", (sid,)
|
||||
)
|
||||
deleted += 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted:
|
||||
logger.info(f"[ConversationStore] Pruned {deleted} expired sessions")
|
||||
return deleted
|
||||
|
||||
def load_history_page(
|
||||
self,
|
||||
session_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Load a page of conversation history for UI display, grouped into turns.
|
||||
|
||||
Each "turn" maps to one of:
|
||||
- A user message (role="user", content=str)
|
||||
- An assistant message (role="assistant", content=str,
|
||||
tool_calls=[{name, arguments, result}] when tools were used)
|
||||
|
||||
Internal tool_result user messages are merged into the preceding
|
||||
assistant entry's tool_calls list and never appear as standalone items.
|
||||
|
||||
Pages are numbered from 1 (most recent). Messages within a page are
|
||||
returned in chronological order.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user" | "assistant",
|
||||
"content": str,
|
||||
"tool_calls": [...], # assistant only, may be []
|
||||
"created_at": int,
|
||||
},
|
||||
...
|
||||
],
|
||||
"total": <visible turn count>,
|
||||
"page": <current page>,
|
||||
"page_size": <page_size>,
|
||||
"has_more": bool,
|
||||
}
|
||||
"""
|
||||
page = max(1, page)
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT role, content, created_at
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY seq ASC
|
||||
""",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
visible = _group_into_display_turns(rows)
|
||||
|
||||
total = len(visible)
|
||||
offset = (page - 1) * page_size
|
||||
page_items = list(reversed(visible))[offset: offset + page_size]
|
||||
page_items = list(reversed(page_items))
|
||||
|
||||
return {
|
||||
"messages": page_items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"has_more": offset + page_size < total,
|
||||
}
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Return basic stats keyed by channel_type, for monitoring."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
total_sessions = conn.execute(
|
||||
"SELECT COUNT(*) FROM sessions"
|
||||
).fetchone()[0]
|
||||
total_messages = conn.execute(
|
||||
"SELECT COUNT(*) FROM messages"
|
||||
).fetchone()[0]
|
||||
by_channel = conn.execute(
|
||||
"""
|
||||
SELECT channel_type, COUNT(*) as cnt
|
||||
FROM sessions
|
||||
GROUP BY channel_type
|
||||
ORDER BY cnt DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return {
|
||||
"total_sessions": total_sessions,
|
||||
"total_messages": total_messages,
|
||||
"by_channel": {row[0] or "unknown": row[1] for row in by_channel},
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.executescript(_DDL)
|
||||
conn.commit()
|
||||
self._migrate(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _migrate(self, conn: sqlite3.Connection) -> None:
|
||||
"""Apply incremental schema migrations on existing databases."""
|
||||
cols = {
|
||||
row[1]
|
||||
for row in conn.execute("PRAGMA table_info(sessions)").fetchall()
|
||||
}
|
||||
if "channel_type" not in cols:
|
||||
try:
|
||||
conn.execute(_MIGRATION_ADD_CHANNEL_TYPE)
|
||||
conn.commit()
|
||||
logger.info("[ConversationStore] Migrated: added channel_type column")
|
||||
except Exception as e:
|
||||
logger.warning(f"[ConversationStore] Migration failed: {e}")
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self._db_path), timeout=10)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_store_instance: Optional[ConversationStore] = None
|
||||
_store_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_conversation_store() -> ConversationStore:
|
||||
"""
|
||||
Return the process-wide ConversationStore singleton.
|
||||
|
||||
Reuses the long-term memory database so the project stays with a single
|
||||
SQLite file: ~/cow/memory/long-term/index.db
|
||||
The conversation tables (sessions / messages) are separate from the
|
||||
memory tables (memory_chunks / file_metadata) — no conflicts.
|
||||
"""
|
||||
global _store_instance
|
||||
if _store_instance is not None:
|
||||
return _store_instance
|
||||
|
||||
with _store_lock:
|
||||
if _store_instance is not None:
|
||||
return _store_instance
|
||||
|
||||
try:
|
||||
from agent.memory.config import get_default_memory_config
|
||||
db_path = get_default_memory_config().get_db_path()
|
||||
except Exception:
|
||||
from common.utils import expand_path
|
||||
db_path = Path(expand_path("~/cow")) / "memory" / "long-term" / "index.db"
|
||||
|
||||
_store_instance = ConversationStore(db_path)
|
||||
logger.debug(f"[ConversationStore] Using shared DB at: {db_path}")
|
||||
return _store_instance
|
||||
@@ -509,7 +509,7 @@ class MemoryStorage:
|
||||
"""Destructor to ensure connection is closed"""
|
||||
try:
|
||||
self.close()
|
||||
except:
|
||||
except Exception:
|
||||
pass # Ignore errors during cleanup
|
||||
|
||||
# Helper methods
|
||||
|
||||
@@ -501,7 +501,7 @@ class AgentStreamExecutor:
|
||||
|
||||
# Prepare messages
|
||||
messages = self._prepare_messages()
|
||||
logger.debug(f"Sending {len(messages)} messages to LLM")
|
||||
logger.info(f"Sending {len(messages)} messages to LLM")
|
||||
|
||||
# Prepare tool definitions (OpenAI/Claude format)
|
||||
tools_schema = None
|
||||
@@ -574,7 +574,7 @@ class AgentStreamExecutor:
|
||||
raise Exception(f"{error_msg} (Status: {status_code}, Code: {error_code}, Type: {error_type})")
|
||||
|
||||
# Parse chunk
|
||||
if isinstance(chunk, dict) and "choices" in chunk:
|
||||
if isinstance(chunk, dict) and chunk.get("choices"):
|
||||
choice = chunk["choices"][0]
|
||||
delta = choice.get("delta", {})
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class Ls(BaseTool):
|
||||
results.append(entry + '/')
|
||||
else:
|
||||
results.append(entry)
|
||||
except:
|
||||
except Exception:
|
||||
# Skip entries we can't stat
|
||||
continue
|
||||
|
||||
|
||||
@@ -451,8 +451,7 @@ def attach_scheduler_to_tool(tool, context: Context = None):
|
||||
if context:
|
||||
tool.current_context = context
|
||||
|
||||
# Also set channel_type from config
|
||||
channel_type = conf().get("channel_type", "unknown")
|
||||
channel_type = context.get("channel_type") or conf().get("channel_type", "unknown")
|
||||
if not tool.config:
|
||||
tool.config = {}
|
||||
tool.config["channel_type"] = channel_type
|
||||
|
||||
@@ -147,7 +147,7 @@ class SchedulerService:
|
||||
return False
|
||||
|
||||
return now >= next_run
|
||||
except:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _calculate_next_run(self, task: dict, from_time: datetime) -> Optional[datetime]:
|
||||
@@ -195,7 +195,7 @@ class SchedulerService:
|
||||
# Only return if in the future
|
||||
if run_at > from_time:
|
||||
return run_at
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -424,7 +424,7 @@ class SchedulerTool(BaseTool):
|
||||
try:
|
||||
dt = datetime.fromisoformat(run_at)
|
||||
return f"一次性 ({dt.strftime('%Y-%m-%d %H:%M')})"
|
||||
except:
|
||||
except Exception:
|
||||
return "一次性"
|
||||
|
||||
return "未知"
|
||||
@@ -438,6 +438,6 @@ class SchedulerTool(BaseTool):
|
||||
return msg.other_user_nickname or "群聊"
|
||||
else:
|
||||
return msg.from_user_nickname or "用户"
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
return "未知"
|
||||
|
||||
@@ -72,7 +72,7 @@ class TaskStore:
|
||||
with open(self.store_path, 'r') as src:
|
||||
with open(backup_path, 'w') as dst:
|
||||
dst.write(src.read())
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Save tasks
|
||||
|
||||
180
app.py
180
app.py
@@ -13,7 +13,6 @@ from plugins import *
|
||||
import threading
|
||||
|
||||
|
||||
# Global channel manager for restart support
|
||||
_channel_mgr = None
|
||||
|
||||
|
||||
@@ -21,92 +20,159 @@ def get_channel_manager():
|
||||
return _channel_mgr
|
||||
|
||||
|
||||
def _parse_channel_type(raw) -> list:
|
||||
"""
|
||||
Parse channel_type config value into a list of channel names.
|
||||
Supports:
|
||||
- single string: "feishu"
|
||||
- comma-separated string: "feishu, dingtalk"
|
||||
- list: ["feishu", "dingtalk"]
|
||||
"""
|
||||
if isinstance(raw, list):
|
||||
return [ch.strip() for ch in raw if ch.strip()]
|
||||
if isinstance(raw, str):
|
||||
return [ch.strip() for ch in raw.split(",") if ch.strip()]
|
||||
return []
|
||||
|
||||
|
||||
class ChannelManager:
|
||||
"""
|
||||
Manage the lifecycle of a channel, supporting restart from sub-threads.
|
||||
The channel.startup() runs in a daemon thread so that the main thread
|
||||
remains available and a new channel can be started at any time.
|
||||
Manage the lifecycle of multiple channels running concurrently.
|
||||
Each channel.startup() runs in its own daemon thread.
|
||||
The web channel is started as default console unless explicitly disabled.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._channel = None
|
||||
self._channel_thread = None
|
||||
self._channels = {} # channel_name -> channel instance
|
||||
self._threads = {} # channel_name -> thread
|
||||
self._primary_channel = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def channel(self):
|
||||
return self._channel
|
||||
"""Return the primary (first non-web) channel for backward compatibility."""
|
||||
return self._primary_channel
|
||||
|
||||
def start(self, channel_name: str, first_start: bool = False):
|
||||
def get_channel(self, channel_name: str):
|
||||
return self._channels.get(channel_name)
|
||||
|
||||
def start(self, channel_names: list, first_start: bool = False):
|
||||
"""
|
||||
Create and start a channel in a sub-thread.
|
||||
Create and start one or more channels in sub-threads.
|
||||
If first_start is True, plugins and linkai client will also be initialized.
|
||||
"""
|
||||
with self._lock:
|
||||
channel = channel_factory.create_channel(channel_name)
|
||||
self._channel = channel
|
||||
channels = []
|
||||
for name in channel_names:
|
||||
ch = channel_factory.create_channel(name)
|
||||
self._channels[name] = ch
|
||||
channels.append((name, ch))
|
||||
if self._primary_channel is None and name != "web":
|
||||
self._primary_channel = ch
|
||||
|
||||
if self._primary_channel is None and channels:
|
||||
self._primary_channel = channels[0][1]
|
||||
|
||||
if first_start:
|
||||
if channel_name in ["wx", "wxy", "terminal", "wechatmp", "web",
|
||||
"wechatmp_service", "wechatcom_app", "wework",
|
||||
const.FEISHU, const.DINGTALK]:
|
||||
PluginManager().load_plugins()
|
||||
PluginManager().load_plugins()
|
||||
|
||||
if conf().get("use_linkai"):
|
||||
try:
|
||||
from common import cloud_client
|
||||
threading.Thread(target=cloud_client.start, args=(channel, self), daemon=True).start()
|
||||
except Exception as e:
|
||||
threading.Thread(
|
||||
target=cloud_client.start,
|
||||
args=(self._primary_channel, self),
|
||||
daemon=True,
|
||||
).start()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Run channel.startup() in a daemon thread so we can restart later
|
||||
self._channel_thread = threading.Thread(
|
||||
target=self._run_channel, args=(channel,), daemon=True
|
||||
)
|
||||
self._channel_thread.start()
|
||||
logger.debug(f"[ChannelManager] Channel '{channel_name}' started in sub-thread")
|
||||
# Start web console first so its logs print cleanly,
|
||||
# then start remaining channels after a brief pause.
|
||||
web_entry = None
|
||||
other_entries = []
|
||||
for entry in channels:
|
||||
if entry[0] == "web":
|
||||
web_entry = entry
|
||||
else:
|
||||
other_entries.append(entry)
|
||||
|
||||
def _run_channel(self, channel):
|
||||
ordered = ([web_entry] if web_entry else []) + other_entries
|
||||
for i, (name, ch) in enumerate(ordered):
|
||||
if i > 0 and name != "web":
|
||||
time.sleep(0.1)
|
||||
t = threading.Thread(target=self._run_channel, args=(name, ch), daemon=True)
|
||||
self._threads[name] = t
|
||||
t.start()
|
||||
logger.debug(f"[ChannelManager] Channel '{name}' started in sub-thread")
|
||||
|
||||
def _run_channel(self, name: str, channel):
|
||||
try:
|
||||
channel.startup()
|
||||
except Exception as e:
|
||||
logger.error(f"[ChannelManager] Channel startup error: {e}")
|
||||
logger.error(f"[ChannelManager] Channel '{name}' startup error: {e}")
|
||||
logger.exception(e)
|
||||
|
||||
def stop(self):
|
||||
def stop(self, channel_name: str = None):
|
||||
"""
|
||||
Stop the current channel. Since most channel startup() methods block
|
||||
on an HTTP server or stream client, we stop by terminating the thread.
|
||||
Stop channel(s). If channel_name is given, stop only that channel;
|
||||
otherwise stop all channels.
|
||||
"""
|
||||
# Pop under lock, then stop outside lock to avoid deadlock
|
||||
with self._lock:
|
||||
if self._channel is None:
|
||||
return
|
||||
channel_type = getattr(self._channel, 'channel_type', 'unknown')
|
||||
logger.info(f"[ChannelManager] Stopping channel '{channel_type}'...")
|
||||
names = [channel_name] if channel_name else list(self._channels.keys())
|
||||
to_stop = []
|
||||
for name in names:
|
||||
ch = self._channels.pop(name, None)
|
||||
th = self._threads.pop(name, None)
|
||||
to_stop.append((name, ch, th))
|
||||
if channel_name and self._primary_channel is self._channels.get(channel_name):
|
||||
self._primary_channel = None
|
||||
|
||||
# Try graceful stop if channel implements it
|
||||
for name, ch, th in to_stop:
|
||||
if ch is None:
|
||||
logger.warning(f"[ChannelManager] Channel '{name}' not found in managed channels")
|
||||
if th and th.is_alive():
|
||||
self._interrupt_thread(th, name)
|
||||
continue
|
||||
logger.info(f"[ChannelManager] Stopping channel '{name}'...")
|
||||
try:
|
||||
if hasattr(self._channel, 'stop'):
|
||||
self._channel.stop()
|
||||
if hasattr(ch, 'stop'):
|
||||
ch.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"[ChannelManager] Error during channel stop: {e}")
|
||||
logger.warning(f"[ChannelManager] Error during channel '{name}' stop: {e}")
|
||||
if th and th.is_alive():
|
||||
self._interrupt_thread(th, name)
|
||||
|
||||
self._channel = None
|
||||
self._channel_thread = None
|
||||
@staticmethod
|
||||
def _interrupt_thread(th: threading.Thread, name: str):
|
||||
"""Raise SystemExit in target thread to break blocking loops like start_forever."""
|
||||
import ctypes
|
||||
try:
|
||||
tid = th.ident
|
||||
if tid is None:
|
||||
return
|
||||
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
||||
ctypes.c_ulong(tid), ctypes.py_object(SystemExit)
|
||||
)
|
||||
if res == 1:
|
||||
logger.info(f"[ChannelManager] Interrupted thread for channel '{name}'")
|
||||
elif res > 1:
|
||||
ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(tid), None)
|
||||
logger.warning(f"[ChannelManager] Failed to interrupt thread for channel '{name}'")
|
||||
except Exception as e:
|
||||
logger.warning(f"[ChannelManager] Thread interrupt error for '{name}': {e}")
|
||||
|
||||
def restart(self, new_channel_name: str):
|
||||
"""
|
||||
Restart the channel with a new channel type.
|
||||
Restart a single channel with a new channel type.
|
||||
Can be called from any thread (e.g. linkai config callback).
|
||||
"""
|
||||
logger.info(f"[ChannelManager] Restarting channel to '{new_channel_name}'...")
|
||||
self.stop()
|
||||
|
||||
# Clear singleton cache so a fresh channel instance is created
|
||||
self.stop(new_channel_name)
|
||||
_clear_singleton_cache(new_channel_name)
|
||||
|
||||
time.sleep(1) # Brief pause to allow resources to release
|
||||
self.start(new_channel_name, first_start=False)
|
||||
time.sleep(1)
|
||||
self.start([new_channel_name], first_start=False)
|
||||
logger.info(f"[ChannelManager] Channel restarted to '{new_channel_name}' successfully")
|
||||
|
||||
|
||||
@@ -130,14 +196,11 @@ def _clear_singleton_cache(channel_name: str):
|
||||
module_path = cls_map.get(channel_name)
|
||||
if not module_path:
|
||||
return
|
||||
# The singleton decorator stores instances in a closure dict keyed by class.
|
||||
# We need to find the actual class and clear it from the closure.
|
||||
try:
|
||||
parts = module_path.rsplit(".", 1)
|
||||
module_name, class_name = parts[0], parts[1]
|
||||
import importlib
|
||||
module = importlib.import_module(module_name)
|
||||
# The module-level name is the wrapper function from @singleton
|
||||
wrapper = getattr(module, class_name, None)
|
||||
if wrapper and hasattr(wrapper, '__closure__') and wrapper.__closure__:
|
||||
for cell in wrapper.__closure__:
|
||||
@@ -176,17 +239,28 @@ def run():
|
||||
# kill signal
|
||||
sigterm_handler_wrap(signal.SIGTERM)
|
||||
|
||||
# create channel
|
||||
channel_name = conf().get("channel_type", "wx")
|
||||
# Parse channel_type into a list
|
||||
raw_channel = conf().get("channel_type", "web")
|
||||
|
||||
if "--cmd" in sys.argv:
|
||||
channel_name = "terminal"
|
||||
channel_names = ["terminal"]
|
||||
else:
|
||||
channel_names = _parse_channel_type(raw_channel)
|
||||
if not channel_names:
|
||||
channel_names = ["web"]
|
||||
|
||||
if channel_name == "wxy":
|
||||
if "wxy" in channel_names:
|
||||
os.environ["WECHATY_LOG"] = "warn"
|
||||
|
||||
# Auto-start web console unless explicitly disabled
|
||||
web_console_enabled = conf().get("web_console", True)
|
||||
if web_console_enabled and "web" not in channel_names:
|
||||
channel_names.append("web")
|
||||
|
||||
logger.info(f"[App] Starting channels: {channel_names}")
|
||||
|
||||
_channel_mgr = ChannelManager()
|
||||
_channel_mgr.start(channel_name, first_start=True)
|
||||
_channel_mgr.start(channel_names, first_start=True)
|
||||
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
@@ -65,30 +65,67 @@ class AgentLLMModel(LLMModel):
|
||||
LLM Model adapter that uses COW's existing bot infrastructure
|
||||
"""
|
||||
|
||||
_MODEL_BOT_TYPE_MAP = {
|
||||
"wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
|
||||
"xunfei": const.XUNFEI, const.QWEN: const.QWEN,
|
||||
const.MODELSCOPE: const.MODELSCOPE,
|
||||
}
|
||||
_MODEL_PREFIX_MAP = [
|
||||
("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), ("qvq", const.QWEN_DASHSCOPE),
|
||||
("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI),
|
||||
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
||||
("doubao", const.DOUBAO),
|
||||
]
|
||||
|
||||
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
|
||||
# Get model name directly from config
|
||||
from config import conf
|
||||
model_name = conf().get("model", const.GPT_41)
|
||||
super().__init__(model=model_name)
|
||||
super().__init__(model=conf().get("model", const.GPT_41))
|
||||
self.bridge = bridge
|
||||
self.bot_type = bot_type
|
||||
self._bot = None
|
||||
self._use_linkai = conf().get("use_linkai", False) and conf().get("linkai_api_key")
|
||||
|
||||
self._bot_model = None
|
||||
|
||||
@property
|
||||
def model(self):
|
||||
from config import conf
|
||||
return conf().get("model", const.GPT_41)
|
||||
|
||||
@model.setter
|
||||
def model(self, value):
|
||||
pass
|
||||
|
||||
def _resolve_bot_type(self, model_name: str) -> str:
|
||||
"""Resolve bot type from model name, matching Bridge.__init__ logic."""
|
||||
from config import conf
|
||||
if conf().get("use_linkai", False) and conf().get("linkai_api_key"):
|
||||
return const.LINKAI
|
||||
if not model_name or not isinstance(model_name, str):
|
||||
return const.CHATGPT
|
||||
if model_name in self._MODEL_BOT_TYPE_MAP:
|
||||
return self._MODEL_BOT_TYPE_MAP[model_name]
|
||||
if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]:
|
||||
return const.MiniMax
|
||||
if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
|
||||
return const.QWEN_DASHSCOPE
|
||||
if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
|
||||
return const.MOONSHOT
|
||||
if model_name in [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER]:
|
||||
return const.CHATGPT
|
||||
for prefix, btype in self._MODEL_PREFIX_MAP:
|
||||
if model_name.startswith(prefix):
|
||||
return btype
|
||||
return const.CHATGPT
|
||||
|
||||
@property
|
||||
def bot(self):
|
||||
"""Lazy load the bot and enhance it with tool calling if needed"""
|
||||
if self._bot is None:
|
||||
# If use_linkai is enabled, use LinkAI bot directly
|
||||
if self._use_linkai:
|
||||
self._bot = self.bridge.find_chat_bot(const.LINKAI)
|
||||
else:
|
||||
self._bot = self.bridge.get_bot(self.bot_type)
|
||||
# Automatically add tool calling support if not present
|
||||
self._bot = add_openai_compatible_support(self._bot)
|
||||
|
||||
# Log bot info
|
||||
bot_name = type(self._bot).__name__
|
||||
"""Lazy load the bot, re-create when model changes"""
|
||||
from models.bot_factory import create_bot
|
||||
cur_model = self.model
|
||||
if self._bot is None or self._bot_model != cur_model:
|
||||
bot_type = self._resolve_bot_type(cur_model)
|
||||
self._bot = create_bot(bot_type)
|
||||
self._bot = add_openai_compatible_support(self._bot)
|
||||
self._bot_model = cur_model
|
||||
return self._bot
|
||||
|
||||
def call(self, request: LLMRequest):
|
||||
@@ -135,7 +172,7 @@ class AgentLLMModel(LLMModel):
|
||||
# Use tool-enabled streaming call if available
|
||||
# Extract system prompt if present
|
||||
system_prompt = getattr(request, 'system', None)
|
||||
|
||||
|
||||
# Build kwargs for call_with_tools
|
||||
kwargs = {
|
||||
'messages': request.messages,
|
||||
@@ -143,15 +180,20 @@ class AgentLLMModel(LLMModel):
|
||||
'stream': True,
|
||||
'model': self.model # Pass model parameter
|
||||
}
|
||||
|
||||
|
||||
# Only pass max_tokens if explicitly set, let the bot use its default
|
||||
if request.max_tokens is not None:
|
||||
kwargs['max_tokens'] = request.max_tokens
|
||||
|
||||
|
||||
# Add system prompt if present
|
||||
if system_prompt:
|
||||
kwargs['system'] = system_prompt
|
||||
|
||||
|
||||
# Pass channel_type for linkai tracking
|
||||
channel_type = getattr(self, 'channel_type', None)
|
||||
if channel_type:
|
||||
kwargs['channel_type'] = channel_type
|
||||
|
||||
stream = self.bot.call_with_tools(**kwargs)
|
||||
|
||||
# Convert stream format to our expected format
|
||||
@@ -325,6 +367,14 @@ class AgentBridge:
|
||||
logger.warning(f"[AgentBridge] Failed to attach context to scheduler: {e}")
|
||||
break
|
||||
|
||||
# Pass channel_type to model so linkai requests carry it
|
||||
if context and hasattr(agent, 'model'):
|
||||
agent.model.channel_type = context.get("channel_type", "")
|
||||
|
||||
# Record message count before execution so we can diff new messages
|
||||
with agent.messages_lock:
|
||||
pre_run_len = len(agent.messages)
|
||||
|
||||
try:
|
||||
# Use agent's run_stream method with event handler
|
||||
response = agent.run_stream(
|
||||
@@ -336,9 +386,16 @@ class AgentBridge:
|
||||
# Restore original tools
|
||||
if context and context.get("is_scheduled_task"):
|
||||
agent.tools = original_tools
|
||||
|
||||
|
||||
# Log execution summary
|
||||
event_handler.log_summary()
|
||||
|
||||
# Persist new messages generated during this run
|
||||
if session_id:
|
||||
channel_type = (context.get("channel_type") or "") if context else ""
|
||||
with agent.messages_lock:
|
||||
new_messages = agent.messages[pre_run_len:]
|
||||
self._persist_messages(session_id, list(new_messages), channel_type)
|
||||
|
||||
# Check if there are files to send (from read tool)
|
||||
if hasattr(agent, 'stream_executor') and hasattr(agent.stream_executor, 'files_to_send'):
|
||||
@@ -475,6 +532,32 @@ class AgentBridge:
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentBridge] Failed to migrate API keys: {e}")
|
||||
|
||||
def _persist_messages(
|
||||
self, session_id: str, new_messages: list, channel_type: str = ""
|
||||
) -> None:
|
||||
"""
|
||||
Persist new messages to the conversation store after each agent run.
|
||||
|
||||
Failures are logged but never propagate — they must not interrupt replies.
|
||||
"""
|
||||
if not new_messages:
|
||||
return
|
||||
try:
|
||||
from config import conf
|
||||
if not conf().get("conversation_persistence", True):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from agent.memory import get_conversation_store
|
||||
get_conversation_store().append_messages(
|
||||
session_id, new_messages, channel_type=channel_type
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[AgentBridge] Failed to persist messages for session={session_id}: {e}"
|
||||
)
|
||||
|
||||
def clear_session(self, session_id: str):
|
||||
"""
|
||||
Clear a specific session's agent and conversation history
|
||||
|
||||
@@ -118,8 +118,47 @@ class AgentInitializer:
|
||||
# Attach memory manager
|
||||
if memory_manager:
|
||||
agent.memory_manager = memory_manager
|
||||
|
||||
|
||||
# Restore persisted conversation history for this session
|
||||
if session_id:
|
||||
self._restore_conversation_history(agent, session_id)
|
||||
|
||||
return agent
|
||||
|
||||
def _restore_conversation_history(self, agent, session_id: str) -> None:
|
||||
"""
|
||||
Load persisted conversation messages from SQLite and inject them
|
||||
into the agent's in-memory message list.
|
||||
|
||||
Only runs when conversation persistence is enabled (default: True).
|
||||
Respects agent_max_context_turns to limit how many turns are loaded.
|
||||
"""
|
||||
from config import conf
|
||||
if not conf().get("conversation_persistence", True):
|
||||
return
|
||||
|
||||
try:
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
# On restore, load at most min(10, max_turns // 2) turns so that
|
||||
# a long-running session does not immediately fill the context window
|
||||
# after a restart. The full max_turns budget is reserved for the
|
||||
# live conversation that follows.
|
||||
max_turns = conf().get("agent_max_context_turns", 30)
|
||||
restore_turns = max(4, max_turns // 5)
|
||||
saved = store.load_messages(session_id, max_turns=restore_turns)
|
||||
if saved:
|
||||
with agent.messages_lock:
|
||||
agent.messages = saved
|
||||
logger.debug(
|
||||
f"[AgentInitializer] Restored {len(saved)} messages "
|
||||
f"({restore_turns} turns cap) for session={session_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[AgentInitializer] Failed to restore conversation history for "
|
||||
f"session={session_id}: {e}"
|
||||
)
|
||||
|
||||
def _load_env_file(self):
|
||||
"""Load environment variables from .env file"""
|
||||
@@ -283,7 +322,14 @@ class AgentInitializer:
|
||||
tool.scheduler_service = scheduler_service
|
||||
if not tool.config:
|
||||
tool.config = {}
|
||||
tool.config["channel_type"] = conf().get("channel_type", "unknown")
|
||||
raw_ct = conf().get("channel_type", "unknown")
|
||||
if isinstance(raw_ct, list):
|
||||
ct = raw_ct[0] if raw_ct else "unknown"
|
||||
elif isinstance(raw_ct, str) and "," in raw_ct:
|
||||
ct = raw_ct.split(",")[0].strip()
|
||||
else:
|
||||
ct = raw_ct
|
||||
tool.config["channel_type"] = ct
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentInitializer] Failed to inject scheduler dependencies: {e}")
|
||||
|
||||
@@ -330,7 +376,7 @@ class AgentInitializer:
|
||||
return {
|
||||
"model": conf().get("model", "unknown"),
|
||||
"workspace": workspace_root,
|
||||
"channel": conf().get("channel_type", "unknown"),
|
||||
"channel": ", ".join(conf().get("channel_type")) if isinstance(conf().get("channel_type"), list) else conf().get("channel_type", "unknown"),
|
||||
"_get_current_time": get_current_time # Dynamic time function
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,16 @@ handler_pool = ThreadPoolExecutor(max_workers=8) # 处理消息的线程池
|
||||
class ChatChannel(Channel):
|
||||
name = None # 登录的用户名
|
||||
user_id = None # 登录的用户id
|
||||
futures = {} # 记录每个session_id提交到线程池的future对象, 用于重置会话时把没执行的future取消掉,正在执行的不会被取消
|
||||
sessions = {} # 用于控制并发,每个session_id同时只能有一个context在处理
|
||||
lock = threading.Lock() # 用于控制对sessions的访问
|
||||
|
||||
def __init__(self):
|
||||
# Instance-level attributes so each channel subclass has its own
|
||||
# independent session queue and lock. Previously these were class-level,
|
||||
# which caused contexts from one channel (e.g. Feishu) to be consumed
|
||||
# by another channel's consume() thread (e.g. Web), leading to errors
|
||||
# like "No request_id found in context".
|
||||
self.futures = {}
|
||||
self.sessions = {}
|
||||
self.lock = threading.Lock()
|
||||
_thread = threading.Thread(target=self.consume)
|
||||
_thread.setDaemon(True)
|
||||
_thread.start()
|
||||
@@ -37,9 +42,8 @@ class ChatChannel(Channel):
|
||||
def _compose_context(self, ctype: ContextType, content, **kwargs):
|
||||
context = Context(ctype, content)
|
||||
context.kwargs = kwargs
|
||||
# context首次传入时,origin_ctype是None,
|
||||
# 引入的起因是:当输入语音时,会嵌套生成两个context,第一步语音转文本,第二步通过文本生成文字回复。
|
||||
# origin_ctype用于第二步文本回复时,判断是否需要匹配前缀,如果是私聊的语音,就不需要匹配前缀
|
||||
if "channel_type" not in context:
|
||||
context["channel_type"] = self.channel_type
|
||||
if "origin_ctype" not in context:
|
||||
context["origin_ctype"] = ctype
|
||||
# context首次传入时,receiver是None,根据类型设置receiver
|
||||
|
||||
@@ -101,6 +101,8 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
||||
# 历史消息id暂存,用于幂等控制
|
||||
self.receivedMsgs = ExpiredDict(conf().get("expires_in_seconds", 3600))
|
||||
self._stream_client = None
|
||||
self._running = False
|
||||
self._event_loop = None
|
||||
logger.debug("[DingTalk] client_id={}, client_secret={} ".format(
|
||||
self.dingtalk_client_id, self.dingtalk_client_secret))
|
||||
# 无需群校验和前缀
|
||||
@@ -114,21 +116,54 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
||||
self._robot_code = None
|
||||
|
||||
def startup(self):
|
||||
import asyncio
|
||||
self.dingtalk_client_id = conf().get('dingtalk_client_id')
|
||||
self.dingtalk_client_secret = conf().get('dingtalk_client_secret')
|
||||
self._running = True
|
||||
credential = dingtalk_stream.Credential(self.dingtalk_client_id, self.dingtalk_client_secret)
|
||||
client = dingtalk_stream.DingTalkStreamClient(credential)
|
||||
self._stream_client = client
|
||||
client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self)
|
||||
logger.info("[DingTalk] ✅ Stream connected, ready to receive messages")
|
||||
client.start_forever()
|
||||
logger.info("[DingTalk] ✅ Stream client initialized, ready to receive messages")
|
||||
_first_connect = True
|
||||
while self._running:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
self._event_loop = loop
|
||||
try:
|
||||
if not _first_connect:
|
||||
logger.info("[DingTalk] Reconnecting...")
|
||||
_first_connect = False
|
||||
loop.run_until_complete(client.start())
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
logger.info("[DingTalk] Startup loop received stop signal, exiting")
|
||||
break
|
||||
except Exception as e:
|
||||
if not self._running:
|
||||
break
|
||||
logger.warning(f"[DingTalk] Stream connection error: {e}, reconnecting in 3s...")
|
||||
time.sleep(3)
|
||||
finally:
|
||||
self._event_loop = None
|
||||
try:
|
||||
loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("[DingTalk] Startup loop exited")
|
||||
|
||||
def stop(self):
|
||||
if self._stream_client:
|
||||
import asyncio
|
||||
logger.info("[DingTalk] stop() called, setting _running=False")
|
||||
self._running = False
|
||||
loop = self._event_loop
|
||||
if loop and not loop.is_closed():
|
||||
try:
|
||||
self._stream_client.stop()
|
||||
logger.info("[DingTalk] Stream client stopped")
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
logger.info("[DingTalk] Sent stop signal to event loop")
|
||||
except Exception as e:
|
||||
logger.warning(f"[DingTalk] Error stopping stream client: {e}")
|
||||
self._stream_client = None
|
||||
logger.warning(f"[DingTalk] Error stopping event loop: {e}")
|
||||
self._stream_client = None
|
||||
logger.info("[DingTalk] stop() completed")
|
||||
|
||||
def get_access_token(self):
|
||||
"""
|
||||
@@ -465,23 +500,21 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
||||
async def process(self, callback: dingtalk_stream.CallbackMessage):
|
||||
try:
|
||||
incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data)
|
||||
|
||||
|
||||
# 缓存 robot_code,用于后续图片下载
|
||||
if hasattr(incoming_message, 'robot_code'):
|
||||
self._robot_code_cache = incoming_message.robot_code
|
||||
|
||||
# Debug: 打印完整的 event 数据
|
||||
logger.debug(f"[DingTalk] ===== Incoming Message Debug =====")
|
||||
logger.debug(f"[DingTalk] callback.data keys: {callback.data.keys() if hasattr(callback.data, 'keys') else 'N/A'}")
|
||||
logger.debug(f"[DingTalk] incoming_message attributes: {dir(incoming_message)}")
|
||||
logger.debug(f"[DingTalk] robot_code: {getattr(incoming_message, 'robot_code', 'N/A')}")
|
||||
logger.debug(f"[DingTalk] chatbot_corp_id: {getattr(incoming_message, 'chatbot_corp_id', 'N/A')}")
|
||||
logger.debug(f"[DingTalk] chatbot_user_id: {getattr(incoming_message, 'chatbot_user_id', 'N/A')}")
|
||||
logger.debug(f"[DingTalk] conversation_id: {getattr(incoming_message, 'conversation_id', 'N/A')}")
|
||||
logger.debug(f"[DingTalk] Raw callback.data: {callback.data}")
|
||||
logger.debug(f"[DingTalk] =====================================")
|
||||
|
||||
image_download_handler = self # 传入方法所在的类实例
|
||||
|
||||
# Filter out stale messages from before channel startup (offline backlog)
|
||||
create_at = getattr(incoming_message, 'create_at', None)
|
||||
if create_at:
|
||||
msg_age_s = time.time() - int(create_at) / 1000
|
||||
if msg_age_s > 60:
|
||||
logger.warning(f"[DingTalk] stale msg filtered (age={msg_age_s:.0f}s), "
|
||||
f"msg_id={getattr(incoming_message, 'message_id', 'N/A')}")
|
||||
return AckMessage.STATUS_OK, 'OK'
|
||||
|
||||
image_download_handler = self
|
||||
dingtalk_msg = DingTalkMessage(incoming_message, image_download_handler)
|
||||
|
||||
if dingtalk_msg.is_group:
|
||||
@@ -490,8 +523,7 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
||||
self.handle_single(dingtalk_msg)
|
||||
return AckMessage.STATUS_OK, 'OK'
|
||||
except Exception as e:
|
||||
logger.error(f"[DingTalk] process error: {e}")
|
||||
logger.exception(e) # 打印完整堆栈跟踪
|
||||
logger.error(f"[DingTalk] process error: {e}", exc_info=True)
|
||||
return AckMessage.STATUS_SYSTEM_EXCEPTION, 'ERROR'
|
||||
|
||||
@time_checker
|
||||
|
||||
@@ -61,6 +61,8 @@ class FeiShuChanel(ChatChannel):
|
||||
# 历史消息id暂存,用于幂等控制
|
||||
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
|
||||
self._http_server = None
|
||||
self._ws_client = None
|
||||
self._ws_thread = None
|
||||
logger.debug("[FeiShu] app_id={}, app_secret={}, verification_token={}, event_mode={}".format(
|
||||
self.feishu_app_id, self.feishu_app_secret, self.feishu_token, self.feishu_event_mode))
|
||||
# 无需群校验和前缀
|
||||
@@ -73,12 +75,37 @@ class FeiShuChanel(ChatChannel):
|
||||
raise Exception("lark_oapi not installed")
|
||||
|
||||
def startup(self):
|
||||
self.feishu_app_id = conf().get('feishu_app_id')
|
||||
self.feishu_app_secret = conf().get('feishu_app_secret')
|
||||
self.feishu_token = conf().get('feishu_token')
|
||||
self.feishu_event_mode = conf().get('feishu_event_mode', 'websocket')
|
||||
if self.feishu_event_mode == 'websocket':
|
||||
self._startup_websocket()
|
||||
else:
|
||||
self._startup_webhook()
|
||||
|
||||
def stop(self):
|
||||
import ctypes
|
||||
logger.info("[FeiShu] stop() called")
|
||||
ws_client = self._ws_client
|
||||
self._ws_client = None
|
||||
ws_thread = self._ws_thread
|
||||
self._ws_thread = None
|
||||
# Interrupt the ws thread first so its blocking start() unblocks
|
||||
if ws_thread and ws_thread.is_alive():
|
||||
try:
|
||||
tid = ws_thread.ident
|
||||
if tid:
|
||||
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
||||
ctypes.c_ulong(tid), ctypes.py_object(SystemExit)
|
||||
)
|
||||
if res == 1:
|
||||
logger.info("[FeiShu] Interrupted ws thread via ctypes")
|
||||
elif res > 1:
|
||||
ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(tid), None)
|
||||
except Exception as e:
|
||||
logger.warning(f"[FeiShu] Error interrupting ws thread: {e}")
|
||||
# lark.ws.Client has no stop() method; thread interruption above is sufficient
|
||||
if self._http_server:
|
||||
try:
|
||||
self._http_server.stop()
|
||||
@@ -86,6 +113,7 @@ class FeiShuChanel(ChatChannel):
|
||||
except Exception as e:
|
||||
logger.warning(f"[FeiShu] Error stopping HTTP server: {e}")
|
||||
self._http_server = None
|
||||
logger.info("[FeiShu] stop() completed")
|
||||
|
||||
def _startup_webhook(self):
|
||||
"""启动HTTP服务器接收事件(webhook模式)"""
|
||||
@@ -129,29 +157,26 @@ class FeiShuChanel(ChatChannel):
|
||||
.register_p2_im_message_receive_v1(handle_message_event) \
|
||||
.build()
|
||||
|
||||
# 尝试连接,如果遇到SSL错误则自动禁用证书验证
|
||||
def start_client_with_retry():
|
||||
"""启动websocket客户端,自动处理SSL证书错误"""
|
||||
# 全局禁用SSL证书验证(在导入lark_oapi之前设置)
|
||||
"""Run ws client in this thread with its own event loop to avoid conflicts."""
|
||||
import asyncio
|
||||
import ssl as ssl_module
|
||||
|
||||
# 保存原始的SSL上下文创建方法
|
||||
original_create_default_context = ssl_module.create_default_context
|
||||
|
||||
def create_unverified_context(*args, **kwargs):
|
||||
"""创建一个不验证证书的SSL上下文"""
|
||||
context = original_create_default_context(*args, **kwargs)
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
return context
|
||||
|
||||
# 尝试正常连接,如果失败则禁用SSL验证
|
||||
# Give this thread its own event loop so lark SDK can call run_until_complete
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
if attempt == 1:
|
||||
# 第二次尝试:禁用SSL验证
|
||||
logger.warning("[FeiShu] SSL certificate verification disabled due to certificate error. "
|
||||
"This may happen when using corporate proxy or self-signed certificates.")
|
||||
logger.warning("[FeiShu] Retrying with SSL verification disabled...")
|
||||
ssl_module.create_default_context = create_unverified_context
|
||||
ssl_module._create_unverified_context = create_unverified_context
|
||||
|
||||
@@ -159,39 +184,36 @@ class FeiShuChanel(ChatChannel):
|
||||
self.feishu_app_id,
|
||||
self.feishu_app_secret,
|
||||
event_handler=event_handler,
|
||||
log_level=lark.LogLevel.DEBUG if conf().get("debug") else lark.LogLevel.WARNING
|
||||
log_level=lark.LogLevel.WARNING
|
||||
)
|
||||
|
||||
self._ws_client = ws_client
|
||||
logger.debug("[FeiShu] Websocket client starting...")
|
||||
ws_client.start()
|
||||
# 如果成功启动,跳出循环
|
||||
break
|
||||
|
||||
except (SystemExit, KeyboardInterrupt):
|
||||
logger.info("[FeiShu] Websocket thread received stop signal")
|
||||
break
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
# 检查是否是SSL证书验证错误
|
||||
is_ssl_error = "CERTIFICATE_VERIFY_FAILED" in error_msg or "certificate verify failed" in error_msg.lower()
|
||||
|
||||
is_ssl_error = ("CERTIFICATE_VERIFY_FAILED" in error_msg
|
||||
or "certificate verify failed" in error_msg.lower())
|
||||
if is_ssl_error and attempt == 0:
|
||||
# 第一次遇到SSL错误,记录日志并继续循环(下次会禁用验证)
|
||||
logger.warning(f"[FeiShu] SSL certificate verification failed: {error_msg}")
|
||||
logger.info("[FeiShu] Retrying connection with SSL verification disabled...")
|
||||
logger.warning(f"[FeiShu] SSL error: {error_msg}, retrying...")
|
||||
continue
|
||||
else:
|
||||
# 其他错误或禁用验证后仍失败,抛出异常
|
||||
logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True)
|
||||
# 恢复原始方法
|
||||
ssl_module.create_default_context = original_create_default_context
|
||||
raise
|
||||
logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True)
|
||||
ssl_module.create_default_context = original_create_default_context
|
||||
break
|
||||
try:
|
||||
loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("[FeiShu] Websocket thread exited")
|
||||
|
||||
# 注意:不恢复原始方法,因为ws_client.start()会持续运行
|
||||
|
||||
# 在新线程中启动客户端,避免阻塞主线程
|
||||
ws_thread = threading.Thread(target=start_client_with_retry, daemon=True)
|
||||
self._ws_thread = ws_thread
|
||||
ws_thread.start()
|
||||
|
||||
# 保持主线程运行
|
||||
logger.info("[FeiShu] ✅ Websocket connected, ready to receive messages")
|
||||
logger.info("[FeiShu] ✅ Websocket thread started, ready to receive messages")
|
||||
ws_thread.join()
|
||||
|
||||
def _handle_message_event(self, event: dict):
|
||||
@@ -212,6 +234,15 @@ class FeiShuChanel(ChatChannel):
|
||||
return
|
||||
self.receivedMsgs[msg_id] = True
|
||||
|
||||
# Filter out stale messages from before channel startup (offline backlog)
|
||||
import time as _time
|
||||
create_time_ms = msg.get("create_time")
|
||||
if create_time_ms:
|
||||
msg_age_s = _time.time() - int(create_time_ms) / 1000
|
||||
if msg_age_s > 60:
|
||||
logger.warning(f"[FeiShu] stale msg filtered (age={msg_age_s:.0f}s), msg_id={msg_id}")
|
||||
return
|
||||
|
||||
is_group = False
|
||||
chat_type = msg.get("chat_type")
|
||||
|
||||
@@ -698,6 +729,8 @@ class FeiShuChanel(ChatChannel):
|
||||
def _compose_context(self, ctype: ContextType, content, **kwargs):
|
||||
context = Context(ctype, content)
|
||||
context.kwargs = kwargs
|
||||
if "channel_type" not in context:
|
||||
context["channel_type"] = self.channel_type
|
||||
if "origin_ctype" not in context:
|
||||
context["origin_ctype"] = ctype
|
||||
|
||||
|
||||
@@ -43,8 +43,17 @@
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/css/console.css">
|
||||
<!-- Apply theme/lang before first paint to avoid flash of unstyled content.
|
||||
This runs synchronously in <head> so the correct class is on <html>
|
||||
before any CSS or body rendering occurs. -->
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('cow_theme') || 'dark';
|
||||
if (theme === 'dark') document.documentElement.classList.add('dark');
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-screen overflow-hidden bg-gray-50 dark:bg-[#111111] text-slate-800 dark:text-slate-200 font-sans transition-colors duration-200">
|
||||
<body class="h-screen overflow-hidden bg-gray-50 dark:bg-[#111111] text-slate-800 dark:text-slate-200 font-sans">
|
||||
<div id="app" class="flex h-screen">
|
||||
|
||||
<!-- ================================================================ -->
|
||||
@@ -285,68 +294,122 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-6">
|
||||
|
||||
<!-- Model Config Card -->
|
||||
<div class="placeholder-card bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
|
||||
<i class="fas fa-microchip text-primary-500 text-sm"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_model">Model Configuration</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
|
||||
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0">Model</span>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-model">--</span>
|
||||
<div class="space-y-5">
|
||||
<!-- Provider -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_provider">Provider</label>
|
||||
<div id="cfg-provider" class="cfg-dropdown" tabindex="0">
|
||||
<div class="cfg-dropdown-selected">
|
||||
<span class="cfg-dropdown-text">--</span>
|
||||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||||
</div>
|
||||
<div class="cfg-dropdown-menu"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Model -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_model_name">Model</label>
|
||||
<div id="cfg-model-select" class="cfg-dropdown" tabindex="0">
|
||||
<div class="cfg-dropdown-selected">
|
||||
<span class="cfg-dropdown-text">--</span>
|
||||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||||
</div>
|
||||
<div class="cfg-dropdown-menu"></div>
|
||||
</div>
|
||||
<div id="cfg-model-custom-wrap" class="mt-2 hidden">
|
||||
<input id="cfg-model-custom" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||
data-i18n-placeholder="config_custom_model_hint" placeholder="Enter custom model name">
|
||||
</div>
|
||||
</div>
|
||||
<!-- API Key -->
|
||||
<div id="cfg-api-key-wrap">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
|
||||
<div class="relative">
|
||||
<input id="cfg-api-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||
class="w-full px-3 py-2 pr-10 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors cfg-key-masked"
|
||||
placeholder="sk-...">
|
||||
<button type="button" id="cfg-api-key-toggle"
|
||||
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600
|
||||
dark:hover:text-slate-300 cursor-pointer transition-colors p-1"
|
||||
onclick="toggleApiKeyVisibility()">
|
||||
<i class="fas fa-eye text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- API Base -->
|
||||
<div id="cfg-api-base-wrap" class="hidden">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
|
||||
<input id="cfg-api-base" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||
placeholder="https://...">
|
||||
</div>
|
||||
<!-- Save Model Button -->
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<span id="cfg-model-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||
<button id="cfg-model-save"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="saveModelConfig()" data-i18n="config_save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Config Card -->
|
||||
<div class="placeholder-card bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-9 h-9 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<i class="fas fa-robot text-emerald-500 text-sm"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_agent">Agent Configuration</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
|
||||
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_agent_enabled">Agent Mode</span>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1" id="cfg-agent">--</span>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_max_tokens">Max Context Tokens</label>
|
||||
<input id="cfg-max-tokens" type="number" min="1000" max="200000" step="1000"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors">
|
||||
</div>
|
||||
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
|
||||
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_max_tokens">Max Tokens</span>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-max-tokens">--</span>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_max_turns">Max Context Turns</label>
|
||||
<input id="cfg-max-turns" type="number" min="1" max="100" step="1"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors">
|
||||
</div>
|
||||
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
|
||||
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_max_turns">Max Turns</span>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-max-turns">--</span>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_max_steps">Max Steps</label>
|
||||
<input id="cfg-max-steps" type="number" min="1" max="50" step="1"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors">
|
||||
</div>
|
||||
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
|
||||
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_max_steps">Max Steps</span>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-max-steps">--</span>
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<span id="cfg-agent-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||
<button id="cfg-agent-save"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="saveAgentConfig()" data-i18n="config_save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Channel Config Card -->
|
||||
<div class="placeholder-card bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-9 h-9 rounded-lg bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<i class="fas fa-tower-broadcast text-amber-500 text-sm"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_channel">Channel Configuration</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
|
||||
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_channel_type">Channel Type</span>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-channel">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Coming Soon Banner -->
|
||||
<div class="mt-6 p-4 rounded-xl bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800/50 flex items-center gap-3">
|
||||
<i class="fas fa-info-circle text-primary-500"></i>
|
||||
<span class="text-sm text-primary-700 dark:text-primary-300" data-i18n="config_coming_soon">Full editing capability coming soon. Currently displaying read-only configuration.</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,14 +427,35 @@
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="skills_desc">View, enable, or disable agent skills</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="skills-empty" class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-16 h-16 rounded-2xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-bolt text-amber-400 text-xl"></i>
|
||||
|
||||
<!-- Built-in Tools Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500" data-i18n="tools_section_title">Built-in Tools</span>
|
||||
<span id="tools-count-badge" class="hidden px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400"></span>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="skills_loading">Loading skills...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="skills_loading_desc">Skills will be displayed here after loading</p>
|
||||
<div id="tools-empty" class="flex items-center gap-2 py-4 text-slate-400 dark:text-slate-500 text-sm">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
<span data-i18n="tools_loading">Loading tools...</span>
|
||||
</div>
|
||||
<div id="tools-list" class="grid gap-3 sm:grid-cols-2 hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Section -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500" data-i18n="skills_section_title">Skills</span>
|
||||
<span id="skills-count-badge" class="hidden px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400"></span>
|
||||
</div>
|
||||
<div id="skills-empty" class="flex flex-col items-center justify-center py-12">
|
||||
<div class="w-14 h-14 rounded-2xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-3">
|
||||
<i class="fas fa-bolt text-amber-400 text-lg"></i>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="skills_loading">Loading skills...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="skills_loading_desc">Skills will be displayed here after loading</p>
|
||||
</div>
|
||||
<div id="skills-list" class="grid gap-4 sm:grid-cols-2"></div>
|
||||
</div>
|
||||
<div id="skills-list" class="grid gap-4 sm:grid-cols-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -451,8 +535,15 @@
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="channels_title">Channels</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="channels_desc">View and manage messaging channels</p>
|
||||
</div>
|
||||
<button id="add-channel-btn" onclick="openAddChannelPanel()"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600
|
||||
text-white text-sm font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
<span data-i18n="channels_add">Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="channels-content" class="grid gap-4"></div>
|
||||
<div id="channels-add-panel" class="hidden mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -519,6 +610,32 @@
|
||||
</div><!-- /main-content -->
|
||||
</div><!-- /app -->
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||
w-full max-w-sm mx-4 overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-red-50 dark:bg-red-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-triangle-exclamation text-red-500"></i>
|
||||
</div>
|
||||
<h3 id="confirm-dialog-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
|
||||
</div>
|
||||
<p id="confirm-dialog-message" class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed ml-[52px]"></p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5">
|
||||
<button id="confirm-dialog-cancel"
|
||||
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||
hover:bg-slate-50 dark:hover:bg-white/5
|
||||
cursor-pointer transition-colors duration-150"></button>
|
||||
<button id="confirm-dialog-ok"
|
||||
class="px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/console.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -222,6 +222,121 @@
|
||||
/* Tool failed state */
|
||||
.agent-tool-step.tool-failed .tool-name { color: #f87171; }
|
||||
|
||||
/* Config form controls */
|
||||
#view-config input[type="text"],
|
||||
#view-config input[type="number"],
|
||||
#view-config input[type="password"] {
|
||||
height: 40px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
#view-config input:focus {
|
||||
border-color: #4ABE6E;
|
||||
box-shadow: 0 0 0 3px rgba(74, 190, 110, 0.12);
|
||||
}
|
||||
#view-config input[type="text"]:hover,
|
||||
#view-config input[type="number"]:hover,
|
||||
#view-config input[type="password"]:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
.dark #view-config input[type="text"]:hover,
|
||||
.dark #view-config input[type="number"]:hover,
|
||||
.dark #view-config input[type="password"]:hover {
|
||||
border-color: #64748b;
|
||||
}
|
||||
|
||||
/* Custom dropdown */
|
||||
.cfg-dropdown {
|
||||
position: relative;
|
||||
outline: none;
|
||||
}
|
||||
.cfg-dropdown-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 0.875rem;
|
||||
color: #1e293b;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
.dark .cfg-dropdown-selected {
|
||||
border-color: #475569;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
.cfg-dropdown-selected:hover { border-color: #94a3b8; }
|
||||
.dark .cfg-dropdown-selected:hover { border-color: #64748b; }
|
||||
.cfg-dropdown.open .cfg-dropdown-selected,
|
||||
.cfg-dropdown:focus .cfg-dropdown-selected {
|
||||
border-color: #4ABE6E;
|
||||
box-shadow: 0 0 0 3px rgba(74, 190, 110, 0.12);
|
||||
}
|
||||
.cfg-dropdown-arrow {
|
||||
font-size: 0.625rem;
|
||||
color: #94a3b8;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.cfg-dropdown.open .cfg-dropdown-arrow { transform: rotate(180deg); }
|
||||
.cfg-dropdown-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 4px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
padding: 4px;
|
||||
}
|
||||
.dark .cfg-dropdown-menu {
|
||||
border-color: #334155;
|
||||
background: #1e1e1e;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.cfg-dropdown.open .cfg-dropdown-menu { display: block; }
|
||||
.cfg-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dark .cfg-dropdown-item { color: #cbd5e1; }
|
||||
.cfg-dropdown-item:hover { background: #f1f5f9; }
|
||||
.dark .cfg-dropdown-item:hover { background: rgba(255, 255, 255, 0.08); }
|
||||
.cfg-dropdown-item.active {
|
||||
background: rgba(74, 190, 110, 0.1);
|
||||
color: #228547;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dark .cfg-dropdown-item.active {
|
||||
background: rgba(74, 190, 110, 0.15);
|
||||
color: #74E9A4;
|
||||
}
|
||||
|
||||
/* API Key masking via CSS (avoids browser password prompts) */
|
||||
.cfg-key-masked {
|
||||
-webkit-text-security: disc;
|
||||
text-security: disc;
|
||||
}
|
||||
|
||||
/* Chat Input */
|
||||
#chat-input {
|
||||
resize: none; height: 42px; max-height: 180px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,35 @@
|
||||
import sys
|
||||
import time
|
||||
import web
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from queue import Queue, Empty
|
||||
|
||||
import web
|
||||
|
||||
from bridge.context import *
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.chat_channel import ChatChannel, check_prefix
|
||||
from channel.chat_message import ChatMessage
|
||||
from collections import OrderedDict
|
||||
from common import const
|
||||
from common.log import logger
|
||||
from common.singleton import singleton
|
||||
from config import conf
|
||||
import os
|
||||
import mimetypes
|
||||
import threading
|
||||
import logging
|
||||
|
||||
|
||||
class WebMessage(ChatMessage):
|
||||
def __init__(
|
||||
self,
|
||||
msg_id,
|
||||
content,
|
||||
ctype=ContextType.TEXT,
|
||||
from_user_id="User",
|
||||
to_user_id="Chatgpt",
|
||||
other_user_id="Chatgpt",
|
||||
self,
|
||||
msg_id,
|
||||
content,
|
||||
ctype=ContextType.TEXT,
|
||||
from_user_id="User",
|
||||
to_user_id="Chatgpt",
|
||||
other_user_id="Chatgpt",
|
||||
):
|
||||
self.msg_id = msg_id
|
||||
self.ctype = ctype
|
||||
@@ -38,7 +43,7 @@ class WebMessage(ChatMessage):
|
||||
class WebChannel(ChatChannel):
|
||||
NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE]
|
||||
_instance = None
|
||||
|
||||
|
||||
# def __new__(cls):
|
||||
# if cls._instance is None:
|
||||
# cls._instance = super(WebChannel, cls).__new__(cls)
|
||||
@@ -47,12 +52,11 @@ class WebChannel(ChatChannel):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.msg_id_counter = 0
|
||||
self.session_queues = {} # session_id -> Queue (fallback polling)
|
||||
self.request_to_session = {} # request_id -> session_id
|
||||
self.sse_queues = {} # request_id -> Queue (SSE streaming)
|
||||
self.session_queues = {} # session_id -> Queue (fallback polling)
|
||||
self.request_to_session = {} # request_id -> session_id
|
||||
self.sse_queues = {} # request_id -> Queue (SSE streaming)
|
||||
self._http_server = None
|
||||
|
||||
|
||||
def _generate_msg_id(self):
|
||||
"""生成唯一的消息ID"""
|
||||
self.msg_id_counter += 1
|
||||
@@ -111,6 +115,7 @@ class WebChannel(ChatChannel):
|
||||
|
||||
def _make_sse_callback(self, request_id: str):
|
||||
"""Build an on_event callback that pushes agent stream events into the SSE queue."""
|
||||
|
||||
def on_event(event: dict):
|
||||
if request_id not in self.sse_queues:
|
||||
return
|
||||
@@ -237,28 +242,28 @@ class WebChannel(ChatChannel):
|
||||
data = web.data()
|
||||
json_data = json.loads(data)
|
||||
session_id = json_data.get('session_id')
|
||||
|
||||
|
||||
if not session_id or session_id not in self.session_queues:
|
||||
return json.dumps({"status": "error", "message": "Invalid session ID"})
|
||||
|
||||
|
||||
# 尝试从队列获取响应,不等待
|
||||
try:
|
||||
# 使用peek而不是get,这样如果前端没有成功处理,下次还能获取到
|
||||
response = self.session_queues[session_id].get(block=False)
|
||||
|
||||
|
||||
# 返回响应,包含请求ID以区分不同请求
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"status": "success",
|
||||
"has_content": True,
|
||||
"content": response["content"],
|
||||
"request_id": response["request_id"],
|
||||
"timestamp": response["timestamp"]
|
||||
})
|
||||
|
||||
|
||||
except Empty:
|
||||
# 没有新响应
|
||||
return json.dumps({"status": "success", "has_content": False})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error polling response: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
@@ -271,9 +276,10 @@ class WebChannel(ChatChannel):
|
||||
|
||||
def startup(self):
|
||||
port = conf().get("web_port", 9899)
|
||||
|
||||
|
||||
# 打印可用渠道类型提示
|
||||
logger.info("[WebChannel] 当前channel为web,可修改 config.json 配置文件中的 channel_type 字段进行切换。全部可用类型为:")
|
||||
logger.info(
|
||||
"[WebChannel] 全部可用通道如下,可修改 config.json 配置文件中的 channel_type 字段进行切换,多个通道用逗号分隔:")
|
||||
logger.info("[WebChannel] 1. web - 网页")
|
||||
logger.info("[WebChannel] 2. terminal - 终端")
|
||||
logger.info("[WebChannel] 3. feishu - 飞书")
|
||||
@@ -281,16 +287,16 @@ class WebChannel(ChatChannel):
|
||||
logger.info("[WebChannel] 5. wechatcom_app - 企微自建应用")
|
||||
logger.info("[WebChannel] 6. wechatmp - 个人公众号")
|
||||
logger.info("[WebChannel] 7. wechatmp_service - 企业公众号")
|
||||
logger.info("[WebChannel] ✅ Web控制台已运行")
|
||||
logger.info(f"[WebChannel] 🌐 本地访问: http://localhost:{port}")
|
||||
logger.info(f"[WebChannel] 🌍 服务器访问: http://YOUR_IP:{port} (请将YOUR_IP替换为服务器IP)")
|
||||
logger.info("[WebChannel] ✅ Web对话网页已运行")
|
||||
|
||||
|
||||
# 确保静态文件目录存在
|
||||
static_dir = os.path.join(os.path.dirname(__file__), 'static')
|
||||
if not os.path.exists(static_dir):
|
||||
os.makedirs(static_dir)
|
||||
logger.debug(f"[WebChannel] Created static directory: {static_dir}")
|
||||
|
||||
|
||||
urls = (
|
||||
'/', 'RootHandler',
|
||||
'/message', 'MessageHandler',
|
||||
@@ -298,26 +304,31 @@ class WebChannel(ChatChannel):
|
||||
'/stream', 'StreamHandler',
|
||||
'/chat', 'ChatHandler',
|
||||
'/config', 'ConfigHandler',
|
||||
'/api/channels', 'ChannelsHandler',
|
||||
'/api/tools', 'ToolsHandler',
|
||||
'/api/skills', 'SkillsHandler',
|
||||
'/api/memory', 'MemoryHandler',
|
||||
'/api/memory/content', 'MemoryContentHandler',
|
||||
'/api/scheduler', 'SchedulerHandler',
|
||||
'/api/history', 'HistoryHandler',
|
||||
'/api/logs', 'LogsHandler',
|
||||
'/assets/(.*)', 'AssetsHandler',
|
||||
)
|
||||
app = web.application(urls, globals(), autoreload=False)
|
||||
|
||||
|
||||
# 完全禁用web.py的HTTP日志输出
|
||||
web.httpserver.LogMiddleware.log = lambda self, status, environ: None
|
||||
|
||||
|
||||
# 配置web.py的日志级别为ERROR
|
||||
logging.getLogger("web").setLevel(logging.ERROR)
|
||||
logging.getLogger("web.httpserver").setLevel(logging.ERROR)
|
||||
|
||||
|
||||
# Build WSGI app with middleware (same as runsimple but without print)
|
||||
func = web.httpserver.StaticMiddleware(app.wsgifunc())
|
||||
func = web.httpserver.LogMiddleware(func)
|
||||
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
|
||||
# Allow concurrent requests by not blocking on in-flight handler threads
|
||||
server.daemon_threads = True
|
||||
self._http_server = server
|
||||
try:
|
||||
server.start()
|
||||
@@ -374,16 +385,137 @@ class ChatHandler:
|
||||
|
||||
|
||||
class ConfigHandler:
|
||||
|
||||
_RECOMMENDED_MODELS = [
|
||||
const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
|
||||
const.GLM_5, const.GLM_4_7,
|
||||
const.QWEN3_MAX, const.QWEN35_PLUS,
|
||||
const.KIMI_K2_5, const.KIMI_K2,
|
||||
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
||||
const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET,
|
||||
const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||
const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||
const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER,
|
||||
]
|
||||
|
||||
PROVIDER_MODELS = OrderedDict([
|
||||
("minimax", {
|
||||
"label": "MiniMax",
|
||||
"api_key_field": "minimax_api_key",
|
||||
"api_base_key": None,
|
||||
"api_base_default": None,
|
||||
"models": [const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING],
|
||||
}),
|
||||
("glm-4", {
|
||||
"label": "智谱AI",
|
||||
"api_key_field": "zhipu_ai_api_key",
|
||||
"api_base_key": "zhipu_ai_api_base",
|
||||
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"models": [const.GLM_5, const.GLM_4_7],
|
||||
}),
|
||||
("dashscope", {
|
||||
"label": "通义千问",
|
||||
"api_key_field": "dashscope_api_key",
|
||||
"api_base_key": None,
|
||||
"api_base_default": None,
|
||||
"models": [const.QWEN3_MAX, const.QWEN35_PLUS],
|
||||
}),
|
||||
("moonshot", {
|
||||
"label": "Kimi",
|
||||
"api_key_field": "moonshot_api_key",
|
||||
"api_base_key": "moonshot_base_url",
|
||||
"api_base_default": "https://api.moonshot.cn/v1",
|
||||
"models": [const.KIMI_K2_5, const.KIMI_K2],
|
||||
}),
|
||||
("doubao", {
|
||||
"label": "豆包",
|
||||
"api_key_field": "ark_api_key",
|
||||
"api_base_key": "ark_base_url",
|
||||
"api_base_default": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"models": [const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE],
|
||||
}),
|
||||
("claudeAPI", {
|
||||
"label": "Claude",
|
||||
"api_key_field": "claude_api_key",
|
||||
"api_base_key": "claude_api_base",
|
||||
"api_base_default": "https://api.anthropic.com/v1",
|
||||
"models": [const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET],
|
||||
}),
|
||||
("gemini", {
|
||||
"label": "Gemini",
|
||||
"api_key_field": "gemini_api_key",
|
||||
"api_base_key": "gemini_api_base",
|
||||
"api_base_default": "https://generativelanguage.googleapis.com",
|
||||
"models": [const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
||||
}),
|
||||
("openAI", {
|
||||
"label": "OpenAI",
|
||||
"api_key_field": "open_ai_api_key",
|
||||
"api_base_key": "open_ai_api_base",
|
||||
"api_base_default": "https://api.openai.com/v1",
|
||||
"models": [const.GPT_5, const.GPT_41, const.GPT_4o],
|
||||
}),
|
||||
("deepseek", {
|
||||
"label": "DeepSeek",
|
||||
"api_key_field": "open_ai_api_key",
|
||||
"api_base_key": None,
|
||||
"api_base_default": None,
|
||||
"models": [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER],
|
||||
}),
|
||||
("linkai", {
|
||||
"label": "LinkAI",
|
||||
"api_key_field": "linkai_api_key",
|
||||
"api_base_key": None,
|
||||
"api_base_default": None,
|
||||
"models": _RECOMMENDED_MODELS,
|
||||
}),
|
||||
])
|
||||
|
||||
EDITABLE_KEYS = {
|
||||
"model", "use_linkai",
|
||||
"open_ai_api_base", "claude_api_base", "gemini_api_base",
|
||||
"zhipu_ai_api_base", "moonshot_base_url", "ark_base_url",
|
||||
"open_ai_api_key", "claude_api_key", "gemini_api_key",
|
||||
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
|
||||
"ark_api_key", "minimax_api_key", "linkai_api_key",
|
||||
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _mask_key(value: str) -> str:
|
||||
"""Mask the middle part of an API key for display."""
|
||||
if not value or len(value) <= 8:
|
||||
return value
|
||||
return value[:4] + "*" * (len(value) - 8) + value[-4:]
|
||||
|
||||
def GET(self):
|
||||
"""Return configuration info for the web console."""
|
||||
"""Return configuration info and provider/model metadata."""
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
local_config = conf()
|
||||
use_agent = local_config.get("agent", False)
|
||||
title = "CowAgent" if use_agent else "AI Assistant"
|
||||
|
||||
if use_agent:
|
||||
title = "CowAgent"
|
||||
else:
|
||||
title = "AI Assistant"
|
||||
api_bases = {}
|
||||
api_keys_masked = {}
|
||||
for pid, pinfo in self.PROVIDER_MODELS.items():
|
||||
base_key = pinfo.get("api_base_key")
|
||||
if base_key:
|
||||
api_bases[base_key] = local_config.get(base_key, pinfo["api_base_default"])
|
||||
key_field = pinfo.get("api_key_field")
|
||||
if key_field and key_field not in api_keys_masked:
|
||||
raw = local_config.get(key_field, "")
|
||||
api_keys_masked[key_field] = self._mask_key(raw) if raw else ""
|
||||
|
||||
providers = {}
|
||||
for pid, p in self.PROVIDER_MODELS.items():
|
||||
providers[pid] = {
|
||||
"label": p["label"],
|
||||
"models": p["models"],
|
||||
"api_base_key": p["api_base_key"],
|
||||
"api_base_default": p["api_base_default"],
|
||||
"api_key_field": p.get("api_key_field"),
|
||||
}
|
||||
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
@@ -391,14 +523,375 @@ class ConfigHandler:
|
||||
"title": title,
|
||||
"model": local_config.get("model", ""),
|
||||
"channel_type": local_config.get("channel_type", ""),
|
||||
"agent_max_context_tokens": local_config.get("agent_max_context_tokens", ""),
|
||||
"agent_max_context_turns": local_config.get("agent_max_context_turns", ""),
|
||||
"agent_max_steps": local_config.get("agent_max_steps", ""),
|
||||
})
|
||||
"agent_max_context_tokens": local_config.get("agent_max_context_tokens", 50000),
|
||||
"agent_max_context_turns": local_config.get("agent_max_context_turns", 30),
|
||||
"agent_max_steps": local_config.get("agent_max_steps", 15),
|
||||
"api_bases": api_bases,
|
||||
"api_keys": api_keys_masked,
|
||||
"providers": providers,
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting config: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def POST(self):
|
||||
"""Update configuration values in memory and persist to config.json."""
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
data = json.loads(web.data())
|
||||
updates = data.get("updates", {})
|
||||
if not updates:
|
||||
return json.dumps({"status": "error", "message": "no updates provided"})
|
||||
|
||||
local_config = conf()
|
||||
applied = {}
|
||||
for key, value in updates.items():
|
||||
if key not in self.EDITABLE_KEYS:
|
||||
continue
|
||||
if key in ("agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps"):
|
||||
value = int(value)
|
||||
if key == "use_linkai":
|
||||
value = bool(value)
|
||||
local_config[key] = value
|
||||
applied[key] = value
|
||||
|
||||
if not applied:
|
||||
return json.dumps({"status": "error", "message": "no valid keys to update"})
|
||||
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)))), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
file_cfg = json.load(f)
|
||||
else:
|
||||
file_cfg = {}
|
||||
file_cfg.update(applied)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(file_cfg, f, indent=4, ensure_ascii=False)
|
||||
|
||||
logger.info(f"[WebChannel] Config updated: {list(applied.keys())}")
|
||||
return json.dumps({"status": "success", "applied": applied}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating config: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class ChannelsHandler:
|
||||
"""API for managing external channel configurations (feishu, dingtalk, etc)."""
|
||||
|
||||
CHANNEL_DEFS = OrderedDict([
|
||||
("feishu", {
|
||||
"label": {"zh": "飞书", "en": "Feishu"},
|
||||
"icon": "fa-paper-plane",
|
||||
"color": "blue",
|
||||
"fields": [
|
||||
{"key": "feishu_app_id", "label": "App ID", "type": "text"},
|
||||
{"key": "feishu_app_secret", "label": "App Secret", "type": "secret"},
|
||||
{"key": "feishu_token", "label": "Verification Token", "type": "secret"},
|
||||
{"key": "feishu_bot_name", "label": "Bot Name", "type": "text"},
|
||||
],
|
||||
}),
|
||||
("dingtalk", {
|
||||
"label": {"zh": "钉钉", "en": "DingTalk"},
|
||||
"icon": "fa-comments",
|
||||
"color": "blue",
|
||||
"fields": [
|
||||
{"key": "dingtalk_client_id", "label": "Client ID", "type": "text"},
|
||||
{"key": "dingtalk_client_secret", "label": "Client Secret", "type": "secret"},
|
||||
],
|
||||
}),
|
||||
("wechatcom_app", {
|
||||
"label": {"zh": "企微自建应用", "en": "WeCom App"},
|
||||
"icon": "fa-building",
|
||||
"color": "emerald",
|
||||
"fields": [
|
||||
{"key": "wechatcom_corp_id", "label": "Corp ID", "type": "text"},
|
||||
{"key": "wechatcomapp_agent_id", "label": "Agent ID", "type": "text"},
|
||||
{"key": "wechatcomapp_secret", "label": "Secret", "type": "secret"},
|
||||
{"key": "wechatcomapp_token", "label": "Token", "type": "secret"},
|
||||
{"key": "wechatcomapp_aes_key", "label": "AES Key", "type": "secret"},
|
||||
{"key": "wechatcomapp_port", "label": "Port", "type": "number", "default": 9898},
|
||||
],
|
||||
}),
|
||||
("wechatmp", {
|
||||
"label": {"zh": "公众号", "en": "WeChat MP"},
|
||||
"icon": "fa-comment-dots",
|
||||
"color": "emerald",
|
||||
"fields": [
|
||||
{"key": "wechatmp_app_id", "label": "App ID", "type": "text"},
|
||||
{"key": "wechatmp_app_secret", "label": "App Secret", "type": "secret"},
|
||||
{"key": "wechatmp_token", "label": "Token", "type": "secret"},
|
||||
{"key": "wechatmp_aes_key", "label": "AES Key", "type": "secret"},
|
||||
{"key": "wechatmp_port", "label": "Port", "type": "number", "default": 8080},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def _mask_secret(value: str) -> str:
|
||||
if not value or len(value) <= 8:
|
||||
return value
|
||||
return value[:4] + "*" * (len(value) - 8) + value[-4:]
|
||||
|
||||
@staticmethod
|
||||
def _parse_channel_list(raw) -> list:
|
||||
if isinstance(raw, list):
|
||||
return [ch.strip() for ch in raw if ch.strip()]
|
||||
if isinstance(raw, str):
|
||||
return [ch.strip() for ch in raw.split(",") if ch.strip()]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _active_channel_set(cls) -> set:
|
||||
return set(cls._parse_channel_list(conf().get("channel_type", "")))
|
||||
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
local_config = conf()
|
||||
active_channels = self._active_channel_set()
|
||||
channels = []
|
||||
for ch_name, ch_def in self.CHANNEL_DEFS.items():
|
||||
fields_out = []
|
||||
for f in ch_def["fields"]:
|
||||
raw_val = local_config.get(f["key"], f.get("default", ""))
|
||||
if f["type"] == "secret" and raw_val:
|
||||
display_val = self._mask_secret(str(raw_val))
|
||||
else:
|
||||
display_val = raw_val
|
||||
fields_out.append({
|
||||
"key": f["key"],
|
||||
"label": f["label"],
|
||||
"type": f["type"],
|
||||
"value": display_val,
|
||||
"default": f.get("default", ""),
|
||||
})
|
||||
channels.append({
|
||||
"name": ch_name,
|
||||
"label": ch_def["label"],
|
||||
"icon": ch_def["icon"],
|
||||
"color": ch_def["color"],
|
||||
"active": ch_name in active_channels,
|
||||
"fields": fields_out,
|
||||
})
|
||||
return json.dumps({"status": "success", "channels": channels}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Channels API error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def POST(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
body = json.loads(web.data())
|
||||
action = body.get("action")
|
||||
channel_name = body.get("channel")
|
||||
|
||||
if not action or not channel_name:
|
||||
return json.dumps({"status": "error", "message": "action and channel required"})
|
||||
|
||||
if channel_name not in self.CHANNEL_DEFS:
|
||||
return json.dumps({"status": "error", "message": f"unknown channel: {channel_name}"})
|
||||
|
||||
if action == "save":
|
||||
return self._handle_save(channel_name, body.get("config", {}))
|
||||
elif action == "connect":
|
||||
return self._handle_connect(channel_name, body.get("config", {}))
|
||||
elif action == "disconnect":
|
||||
return self._handle_disconnect(channel_name)
|
||||
else:
|
||||
return json.dumps({"status": "error", "message": f"unknown action: {action}"})
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Channels POST error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def _handle_save(self, channel_name: str, updates: dict):
|
||||
ch_def = self.CHANNEL_DEFS[channel_name]
|
||||
valid_keys = {f["key"] for f in ch_def["fields"]}
|
||||
secret_keys = {f["key"] for f in ch_def["fields"] if f["type"] == "secret"}
|
||||
|
||||
local_config = conf()
|
||||
applied = {}
|
||||
for key, value in updates.items():
|
||||
if key not in valid_keys:
|
||||
continue
|
||||
if key in secret_keys:
|
||||
if not value or (len(value) > 8 and "*" * 4 in value):
|
||||
continue
|
||||
field_def = next((f for f in ch_def["fields"] if f["key"] == key), None)
|
||||
if field_def:
|
||||
if field_def["type"] == "number":
|
||||
value = int(value)
|
||||
elif field_def["type"] == "bool":
|
||||
value = bool(value)
|
||||
local_config[key] = value
|
||||
applied[key] = value
|
||||
|
||||
if not applied:
|
||||
return json.dumps({"status": "error", "message": "no valid fields to update"})
|
||||
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)))), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
file_cfg = json.load(f)
|
||||
else:
|
||||
file_cfg = {}
|
||||
file_cfg.update(applied)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(file_cfg, f, indent=4, ensure_ascii=False)
|
||||
|
||||
logger.info(f"[WebChannel] Channel '{channel_name}' config updated: {list(applied.keys())}")
|
||||
|
||||
should_restart = False
|
||||
active_channels = self._active_channel_set()
|
||||
if channel_name in active_channels:
|
||||
should_restart = True
|
||||
try:
|
||||
import sys
|
||||
app_module = sys.modules.get('__main__') or sys.modules.get('app')
|
||||
mgr = getattr(app_module, '_channel_mgr', None) if app_module else None
|
||||
if mgr:
|
||||
threading.Thread(
|
||||
target=mgr.restart,
|
||||
args=(channel_name,),
|
||||
daemon=True,
|
||||
).start()
|
||||
logger.info(f"[WebChannel] Channel '{channel_name}' restart triggered")
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebChannel] Failed to restart channel '{channel_name}': {e}")
|
||||
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"applied": list(applied.keys()),
|
||||
"restarted": should_restart,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
def _handle_connect(self, channel_name: str, updates: dict):
|
||||
"""Save config fields, add channel to channel_type, and start it."""
|
||||
ch_def = self.CHANNEL_DEFS[channel_name]
|
||||
valid_keys = {f["key"] for f in ch_def["fields"]}
|
||||
secret_keys = {f["key"] for f in ch_def["fields"] if f["type"] == "secret"}
|
||||
|
||||
# Feishu connected via web console must use websocket (long connection) mode
|
||||
if channel_name == "feishu":
|
||||
updates.setdefault("feishu_event_mode", "websocket")
|
||||
valid_keys.add("feishu_event_mode")
|
||||
|
||||
local_config = conf()
|
||||
applied = {}
|
||||
for key, value in updates.items():
|
||||
if key not in valid_keys:
|
||||
continue
|
||||
if key in secret_keys:
|
||||
if not value or (len(value) > 8 and "*" * 4 in value):
|
||||
continue
|
||||
field_def = next((f for f in ch_def["fields"] if f["key"] == key), None)
|
||||
if field_def:
|
||||
if field_def["type"] == "number":
|
||||
value = int(value)
|
||||
elif field_def["type"] == "bool":
|
||||
value = bool(value)
|
||||
local_config[key] = value
|
||||
applied[key] = value
|
||||
|
||||
existing = self._parse_channel_list(conf().get("channel_type", ""))
|
||||
if channel_name not in existing:
|
||||
existing.append(channel_name)
|
||||
new_channel_type = ",".join(existing)
|
||||
local_config["channel_type"] = new_channel_type
|
||||
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)))), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
file_cfg = json.load(f)
|
||||
else:
|
||||
file_cfg = {}
|
||||
file_cfg.update(applied)
|
||||
file_cfg["channel_type"] = new_channel_type
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(file_cfg, f, indent=4, ensure_ascii=False)
|
||||
|
||||
logger.info(f"[WebChannel] Channel '{channel_name}' connecting, channel_type={new_channel_type}")
|
||||
|
||||
def _do_start():
|
||||
try:
|
||||
import sys
|
||||
app_module = sys.modules.get('__main__') or sys.modules.get('app')
|
||||
clear_fn = getattr(app_module, '_clear_singleton_cache', None) if app_module else None
|
||||
mgr = getattr(app_module, '_channel_mgr', None) if app_module else None
|
||||
if mgr is None:
|
||||
logger.warning(f"[WebChannel] ChannelManager not available, cannot start '{channel_name}'")
|
||||
return
|
||||
# Stop existing instance first if still running (e.g. re-connect without disconnect)
|
||||
existing_ch = mgr.get_channel(channel_name)
|
||||
if existing_ch is not None:
|
||||
logger.info(f"[WebChannel] Stopping existing '{channel_name}' before reconnect...")
|
||||
mgr.stop(channel_name)
|
||||
# Always wait for the remote service to release the old connection before
|
||||
# establishing a new one (DingTalk drops callbacks on duplicate connections)
|
||||
logger.info(f"[WebChannel] Waiting for '{channel_name}' old connection to close...")
|
||||
time.sleep(5)
|
||||
if clear_fn:
|
||||
clear_fn(channel_name)
|
||||
logger.info(f"[WebChannel] Starting channel '{channel_name}'...")
|
||||
mgr.start([channel_name], first_start=False)
|
||||
logger.info(f"[WebChannel] Channel '{channel_name}' start completed")
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Failed to start channel '{channel_name}': {e}",
|
||||
exc_info=True)
|
||||
|
||||
threading.Thread(target=_do_start, daemon=True).start()
|
||||
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"channel_type": new_channel_type,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
def _handle_disconnect(self, channel_name: str):
|
||||
existing = self._parse_channel_list(conf().get("channel_type", ""))
|
||||
existing = [ch for ch in existing if ch != channel_name]
|
||||
new_channel_type = ",".join(existing)
|
||||
|
||||
local_config = conf()
|
||||
local_config["channel_type"] = new_channel_type
|
||||
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)))), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
file_cfg = json.load(f)
|
||||
else:
|
||||
file_cfg = {}
|
||||
file_cfg["channel_type"] = new_channel_type
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(file_cfg, f, indent=4, ensure_ascii=False)
|
||||
|
||||
def _do_stop():
|
||||
try:
|
||||
import sys
|
||||
app_module = sys.modules.get('__main__') or sys.modules.get('app')
|
||||
mgr = getattr(app_module, '_channel_mgr', None) if app_module else None
|
||||
clear_fn = getattr(app_module, '_clear_singleton_cache', None) if app_module else None
|
||||
if mgr:
|
||||
mgr.stop(channel_name)
|
||||
else:
|
||||
logger.warning(f"[WebChannel] ChannelManager not found, cannot stop '{channel_name}'")
|
||||
if clear_fn:
|
||||
clear_fn(channel_name)
|
||||
logger.info(f"[WebChannel] Channel '{channel_name}' disconnected, "
|
||||
f"channel_type={new_channel_type}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebChannel] Failed to stop channel '{channel_name}': {e}",
|
||||
exc_info=True)
|
||||
|
||||
threading.Thread(target=_do_stop, daemon=True).start()
|
||||
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"channel_type": new_channel_type,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _get_workspace_root():
|
||||
"""Resolve the agent workspace directory."""
|
||||
@@ -406,6 +899,30 @@ def _get_workspace_root():
|
||||
return expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
|
||||
|
||||
class ToolsHandler:
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
from agent.tools.tool_manager import ToolManager
|
||||
tm = ToolManager()
|
||||
if not tm.tool_classes:
|
||||
tm.load_tools()
|
||||
tools = []
|
||||
for name, cls in tm.tool_classes.items():
|
||||
try:
|
||||
instance = cls()
|
||||
tools.append({
|
||||
"name": name,
|
||||
"description": instance.description,
|
||||
})
|
||||
except Exception:
|
||||
tools.append({"name": name, "description": ""})
|
||||
return json.dumps({"status": "success", "tools": tools}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Tools API error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SkillsHandler:
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
@@ -421,6 +938,30 @@ class SkillsHandler:
|
||||
logger.error(f"[WebChannel] Skills API error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def POST(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
from agent.skills.service import SkillService
|
||||
from agent.skills.manager import SkillManager
|
||||
body = json.loads(web.data())
|
||||
action = body.get("action")
|
||||
name = body.get("name")
|
||||
if not action or not name:
|
||||
return json.dumps({"status": "error", "message": "action and name are required"})
|
||||
workspace_root = _get_workspace_root()
|
||||
manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
|
||||
service = SkillService(manager)
|
||||
if action == "open":
|
||||
service.open({"name": name})
|
||||
elif action == "close":
|
||||
service.close({"name": name})
|
||||
else:
|
||||
return json.dumps({"status": "error", "message": f"unknown action: {action}"})
|
||||
return json.dumps({"status": "success"}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Skills POST error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class MemoryHandler:
|
||||
def GET(self):
|
||||
@@ -471,6 +1012,37 @@ class SchedulerHandler:
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class HistoryHandler:
|
||||
def GET(self):
|
||||
"""
|
||||
Return paginated conversation history for a session.
|
||||
|
||||
Query params:
|
||||
session_id (required)
|
||||
page int, default 1 (1 = most recent messages)
|
||||
page_size int, default 20
|
||||
"""
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
web.header('Access-Control-Allow-Origin', '*')
|
||||
try:
|
||||
params = web.input(session_id='', page='1', page_size='20')
|
||||
session_id = params.session_id.strip()
|
||||
if not session_id:
|
||||
return json.dumps({"status": "error", "message": "session_id required"})
|
||||
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
result = store.load_history_page(
|
||||
session_id=session_id,
|
||||
page=int(params.page),
|
||||
page_size=int(params.page_size),
|
||||
)
|
||||
return json.dumps({"status": "success", **result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] History API error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class LogsHandler:
|
||||
def GET(self):
|
||||
"""Stream the last N lines of run.log as SSE, then tail new lines."""
|
||||
|
||||
@@ -28,7 +28,7 @@ def check_dulwich():
|
||||
except ImportError:
|
||||
try:
|
||||
install("dulwich")
|
||||
except:
|
||||
except Exception:
|
||||
needwait = True
|
||||
try:
|
||||
import dulwich
|
||||
|
||||
@@ -160,7 +160,8 @@ available_setting = {
|
||||
# chatgpt指令自定义触发词
|
||||
"clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头
|
||||
# channel配置
|
||||
"channel_type": "", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service,wechatcom_app,dingtalk}
|
||||
"channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wechatmp,wechatmp_service,wechatcom_app
|
||||
"web_console": True, # 是否自动启动Web控制台(默认启动)。设为False可禁用
|
||||
"subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app
|
||||
"debug": False, # 是否开启debug模式,开启后会打印更多日志
|
||||
"appdata_dir": "", # 数据目录
|
||||
@@ -186,6 +187,7 @@ available_setting = {
|
||||
"linkai_api_key": "",
|
||||
"linkai_app_code": "",
|
||||
"linkai_api_base": "https://api.link-ai.tech", # linkAI服务地址
|
||||
"cloud_host": "client.link-ai.tech",
|
||||
"minimax_api_key": "",
|
||||
"Minimax_group_id": "",
|
||||
"Minimax_base_url": "",
|
||||
@@ -322,7 +324,7 @@ def load_config():
|
||||
logger.info("[INIT] override config by environ args: {}={}".format(name, value))
|
||||
try:
|
||||
config[name] = eval(value)
|
||||
except:
|
||||
except Exception:
|
||||
if value == "false":
|
||||
config[name] = False
|
||||
elif value == "true":
|
||||
|
||||
@@ -273,7 +273,7 @@ def get_contact(self, update=False):
|
||||
'User-Agent' : config.USER_AGENT, }
|
||||
try:
|
||||
r = self.s.get(url, headers=headers)
|
||||
except:
|
||||
except Exception:
|
||||
logger.info('Failed to fetch contact, that may because of the amount of your chatrooms')
|
||||
for chatroom in self.get_chatrooms():
|
||||
self.update_chatroom(chatroom['UserName'], detailedMember=True)
|
||||
|
||||
@@ -21,7 +21,7 @@ async def dump_login_status(self, fileDir=None):
|
||||
with open(fileDir, 'w') as f:
|
||||
f.write('itchat - DELETE THIS')
|
||||
os.remove(fileDir)
|
||||
except:
|
||||
except Exception:
|
||||
raise Exception('Incorrect fileDir')
|
||||
status = {
|
||||
'version' : VERSION,
|
||||
@@ -57,7 +57,7 @@ async def load_login_status(self, fileDir,
|
||||
self.storageClass.loads(j['storage'])
|
||||
try:
|
||||
msgList, contactList = self.get_msg()
|
||||
except:
|
||||
except Exception:
|
||||
msgList = contactList = None
|
||||
if (msgList or contactList) is None:
|
||||
self.logout()
|
||||
@@ -97,6 +97,6 @@ async def load_last_login_status(session, cookiesDict):
|
||||
'mm_lang': 'zh_CN',
|
||||
'MM_WX_NOTIFY_STATE': '1',
|
||||
'MM_WX_SOUND_STATE': '1', })
|
||||
except:
|
||||
except Exception:
|
||||
logger.info('Load status for push login failed, we may have experienced a cookies change.')
|
||||
logger.info('If you are using the newest version of itchat, you may report a bug.')
|
||||
|
||||
@@ -330,7 +330,7 @@ async def start_receiving(self, exitCallback=None, getReceivingFnOnly=False):
|
||||
retryCount = 0
|
||||
except requests.exceptions.ReadTimeout:
|
||||
pass
|
||||
except:
|
||||
except Exception:
|
||||
retryCount += 1
|
||||
logger.error(traceback.format_exc())
|
||||
if self.receivingRetryCount < retryCount:
|
||||
@@ -372,7 +372,7 @@ def sync_check(self):
|
||||
# 6f:00:8a:9c:09:74:e4:d8:e0:14:bf:96:3a:56:a0:64:1b:a4:25:5d:12:f4:31:a5:30:f1:c6:48:5f:c3:75:6a:99:93
|
||||
# seems like status of typing, but before I make further achievement code will remain like this
|
||||
return '2'
|
||||
except:
|
||||
except Exception:
|
||||
raise
|
||||
r.raise_for_status()
|
||||
regx = r'window.synccheck={retcode:"(\d+)",selector:"(\d+)"}'
|
||||
|
||||
@@ -63,7 +63,7 @@ async def configured_reply(self, event_stream, payload, message_container):
|
||||
r = await replyFn(msg)
|
||||
if r is not None:
|
||||
await self.send(r, msg.get('FromUserName'))
|
||||
except:
|
||||
except Exception:
|
||||
logger.warning(traceback.format_exc())
|
||||
|
||||
def msg_register(self, msgType, isFriendChat=False, isGroupChat=False, isMpChat=False):
|
||||
|
||||
@@ -287,7 +287,7 @@ def get_contact(self, update=False):
|
||||
'User-Agent': config.USER_AGENT, }
|
||||
try:
|
||||
r = self.s.get(url, headers=headers)
|
||||
except:
|
||||
except Exception:
|
||||
logger.info(
|
||||
'Failed to fetch contact, that may because of the amount of your chatrooms')
|
||||
for chatroom in self.get_chatrooms():
|
||||
|
||||
@@ -21,7 +21,7 @@ def dump_login_status(self, fileDir=None):
|
||||
with open(fileDir, 'w') as f:
|
||||
f.write('itchat - DELETE THIS')
|
||||
os.remove(fileDir)
|
||||
except:
|
||||
except Exception:
|
||||
raise Exception('Incorrect fileDir')
|
||||
status = {
|
||||
'version' : VERSION,
|
||||
@@ -57,7 +57,7 @@ def load_login_status(self, fileDir,
|
||||
self.storageClass.loads(j['storage'])
|
||||
try:
|
||||
msgList, contactList = self.get_msg()
|
||||
except:
|
||||
except Exception:
|
||||
msgList = contactList = None
|
||||
if (msgList or contactList) is None:
|
||||
self.logout()
|
||||
@@ -97,6 +97,6 @@ def load_last_login_status(session, cookiesDict):
|
||||
'mm_lang': 'zh_CN',
|
||||
'MM_WX_NOTIFY_STATE': '1',
|
||||
'MM_WX_SOUND_STATE': '1', })
|
||||
except:
|
||||
except Exception:
|
||||
logger.info('Load status for push login failed, we may have experienced a cookies change.')
|
||||
logger.info('If you are using the newest version of itchat, you may report a bug.')
|
||||
|
||||
@@ -318,7 +318,7 @@ def start_receiving(self, exitCallback=None, getReceivingFnOnly=False):
|
||||
retryCount = 0
|
||||
except requests.exceptions.ReadTimeout:
|
||||
pass
|
||||
except:
|
||||
except Exception:
|
||||
retryCount += 1
|
||||
logger.error(traceback.format_exc())
|
||||
if self.receivingRetryCount < retryCount:
|
||||
@@ -364,7 +364,7 @@ def sync_check(self):
|
||||
# 6f:00:8a:9c:09:74:e4:d8:e0:14:bf:96:3a:56:a0:64:1b:a4:25:5d:12:f4:31:a5:30:f1:c6:48:5f:c3:75:6a:99:93
|
||||
# seems like status of typing, but before I make further achievement code will remain like this
|
||||
return '2'
|
||||
except:
|
||||
except Exception:
|
||||
raise
|
||||
r.raise_for_status()
|
||||
regx = r'window.synccheck={retcode:"(\d+)",selector:"(\d+)"}'
|
||||
|
||||
@@ -63,7 +63,7 @@ def configured_reply(self):
|
||||
r = replyFn(msg)
|
||||
if r is not None:
|
||||
self.send(r, msg.get('FromUserName'))
|
||||
except:
|
||||
except Exception:
|
||||
logger.warning(traceback.format_exc())
|
||||
|
||||
def msg_register(self, msgType, isFriendChat=False, isGroupChat=False, isMpChat=False):
|
||||
|
||||
@@ -82,7 +82,7 @@ def check_file(fileDir):
|
||||
with open(fileDir):
|
||||
pass
|
||||
return True
|
||||
except:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def print_qr(fileDir):
|
||||
@@ -133,7 +133,7 @@ def test_connect(retryTime=5):
|
||||
try:
|
||||
r = requests.get(config.BASE_URL)
|
||||
return True
|
||||
except:
|
||||
except Exception:
|
||||
if i == retryTime - 1:
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@@ -208,7 +208,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
try:
|
||||
os.remove(image_path)
|
||||
logger.debug(f"[CHATGPT] Removed temp image file: {image_path}")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Reply(ReplyType.TEXT, content)
|
||||
|
||||
@@ -383,7 +383,7 @@ class ClaudeAPIBot(Bot, OpenAIImage):
|
||||
try:
|
||||
error_data = json.loads(error_text)
|
||||
error_msg = error_data.get("error", {}).get("message", error_text)
|
||||
except:
|
||||
except Exception:
|
||||
error_msg = error_text or "Unknown error"
|
||||
|
||||
yield {
|
||||
|
||||
@@ -347,7 +347,7 @@ class GoogleGeminiBot(Bot):
|
||||
tool_result_data = json.loads(tool_content)
|
||||
else:
|
||||
tool_result_data = tool_content
|
||||
except:
|
||||
except Exception:
|
||||
tool_result_data = {"result": tool_content}
|
||||
|
||||
# Find the tool name from previous messages
|
||||
|
||||
@@ -107,7 +107,7 @@ class LinkAIBot(Bot, OpenAICompatibleBot):
|
||||
"presence_penalty": conf().get("presence_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
|
||||
"session_id": session_id,
|
||||
"sender_id": session_id,
|
||||
"channel_type": conf().get("channel_type", "wx")
|
||||
"channel_type": context.get("channel_type") or conf().get("channel_type", "web")
|
||||
}
|
||||
try:
|
||||
from linkai import LinkAIClient
|
||||
@@ -526,6 +526,14 @@ def _linkai_call_with_tools(self, messages, tools=None, stream=False, **kwargs):
|
||||
logger.debug(f"[LinkAI] messages: {len(messages)}, tools: {len(tools) if tools else 0}, stream: {stream}")
|
||||
|
||||
# Build request parameters (LinkAI uses OpenAI-compatible format)
|
||||
raw_ct = conf().get("channel_type", "web")
|
||||
if isinstance(raw_ct, list):
|
||||
channel_type = raw_ct[0] if raw_ct else "web"
|
||||
elif isinstance(raw_ct, str) and "," in raw_ct:
|
||||
channel_type = raw_ct.split(",")[0].strip()
|
||||
else:
|
||||
channel_type = raw_ct
|
||||
|
||||
body = {
|
||||
"messages": messages,
|
||||
"model": kwargs.get("model", conf().get("model") or "gpt-3.5-turbo"),
|
||||
@@ -533,7 +541,8 @@ def _linkai_call_with_tools(self, messages, tools=None, stream=False, **kwargs):
|
||||
"top_p": kwargs.get("top_p", conf().get("top_p", 1)),
|
||||
"frequency_penalty": kwargs.get("frequency_penalty", conf().get("frequency_penalty", 0.0)),
|
||||
"presence_penalty": kwargs.get("presence_penalty", conf().get("presence_penalty", 0.0)),
|
||||
"stream": stream
|
||||
"stream": stream,
|
||||
"channel_type": kwargs.get("channel_type", channel_type),
|
||||
}
|
||||
|
||||
if tools:
|
||||
@@ -608,7 +617,7 @@ def _handle_linkai_stream_response(self, base_url, headers, body):
|
||||
try:
|
||||
error_data = json.loads(error_text)
|
||||
error_msg = error_data.get("error", {}).get("message", error_text)
|
||||
except:
|
||||
except Exception:
|
||||
error_msg = error_text or "Unknown error"
|
||||
|
||||
yield {
|
||||
|
||||
@@ -140,7 +140,9 @@ def get_help_text(isadmin, isgroup):
|
||||
for cmd, info in COMMANDS.items():
|
||||
if cmd in ["auth", "set_openai_api_key", "reset_openai_api_key", "set_gpt_model", "reset_gpt_model", "gpt_model"]: # 不显示帮助指令
|
||||
continue
|
||||
if cmd == "id" and conf().get("channel_type", "wx") not in ["wxy", "wechatmp"]:
|
||||
raw_ct = conf().get("channel_type", "web")
|
||||
active_channels = raw_ct if isinstance(raw_ct, list) else [c.strip() for c in str(raw_ct).split(",")]
|
||||
if cmd == "id" and not any(c in ["wxy", "wechatmp"] for c in active_channels):
|
||||
continue
|
||||
alias = ["#" + a for a in info["alias"][:1]]
|
||||
help_text += f"{','.join(alias)} "
|
||||
|
||||
Reference in New Issue
Block a user