fix: Feishu channel agents file_write permission blocked + memory system tests & docs
- Fix 8 Feishu agent handlers to use permission_level="acceptEdits" so file_write tool works without Web UI approval popup (lingxi/renshenguo/suyao/tiantian/orange/main/schedule) - Add P5-P7 memory improvements: offline keyword fallback, team sharing, file-based memory - Add auto_dream_service for daily memory consolidation - Add 99 memory system test cases (basic 18 + advanced 43 + pytest 38) - Add platform capability assessment report and unfinished project checklist Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Agent 定时任务服务:cron 解析、执行触发、下次执行时间计算"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
@@ -353,6 +354,170 @@ def check_and_run_autonomy_ticks() -> int:
|
||||
db.close()
|
||||
|
||||
|
||||
async def run_scheduler_loop() -> None:
|
||||
"""内置调度器循环:每 60 秒检查到期定时任务并直接执行(无需 Celery)。
|
||||
|
||||
同时检查 Auto Dream 每日记忆整合是否到期(凌晨 3:00)。
|
||||
在 FastAPI startup 事件中作为后台 asyncio 任务启动。
|
||||
"""
|
||||
import asyncio
|
||||
logger.info("内置调度器循环已启动,每60秒检查一次(含 Auto Dream 每日整合)")
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
triggered = await _check_and_run_due_schedules_direct()
|
||||
if triggered:
|
||||
logger.info("内置调度器触发 %d 个任务", triggered)
|
||||
|
||||
# Auto Dream:每日凌晨 3:00 记忆整合
|
||||
from app.services.auto_dream_service import _should_dream_today, run_auto_dream
|
||||
if _should_dream_today():
|
||||
asyncio.ensure_future(run_auto_dream())
|
||||
except Exception as e:
|
||||
logger.error("内置调度器循环异常: %s", e)
|
||||
|
||||
|
||||
async def _check_and_run_due_schedules_direct() -> int:
|
||||
"""直接执行到期的定时任务(不使用 Celery)。"""
|
||||
from app.models.agent_schedule import AgentSchedule
|
||||
from app.models.agent import Agent
|
||||
from app.models.execution import Execution
|
||||
from app.agent_runtime.core import AgentRuntime
|
||||
from app.agent_runtime.schemas import AgentConfig, AgentLLMConfig, AgentToolConfig, AgentMemoryConfig, AgentBudgetConfig
|
||||
|
||||
db: Optional[Session] = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
due_schedules = (
|
||||
db.query(AgentSchedule)
|
||||
.filter(
|
||||
AgentSchedule.enabled == True,
|
||||
AgentSchedule.next_run_at <= now,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
triggered = 0
|
||||
for sched in due_schedules:
|
||||
try:
|
||||
agent = db.query(Agent).filter(Agent.id == sched.agent_id).first()
|
||||
if not agent:
|
||||
logger.warning("定时任务 %s 的 Agent %s 不存在", sched.id, sched.agent_id)
|
||||
_mark_schedule_failed(db, sched, now, "Agent not found")
|
||||
continue
|
||||
|
||||
# 构建 AgentConfig
|
||||
wf = agent.workflow_config or {}
|
||||
nodes = wf.get("nodes", [])
|
||||
system_prompt = "你是一个有用的AI助手。"
|
||||
model_name = "deepseek-v4-pro"
|
||||
provider = "deepseek"
|
||||
temperature = 0.8
|
||||
max_iterations = 15
|
||||
tools_include = []
|
||||
|
||||
for node in nodes:
|
||||
nd = node.get("data", {}) if isinstance(node, dict) else {}
|
||||
node_type = node.get("type", "") if isinstance(node, dict) else ""
|
||||
if node_type == "start":
|
||||
system_prompt = nd.get("system_prompt", system_prompt)
|
||||
elif node_type in ("agent", "llm"):
|
||||
tools_include = nd.get("tools", nd.get("selected_tools", tools_include))
|
||||
model_name = nd.get("model", model_name)
|
||||
provider = nd.get("provider", provider)
|
||||
temperature = float(nd.get("temperature", temperature))
|
||||
max_iterations = int(nd.get("max_iterations", nd.get("max_tool_iterations", max_iterations)))
|
||||
|
||||
config = AgentConfig(
|
||||
name=agent.name,
|
||||
system_prompt=system_prompt,
|
||||
user_id=str(agent.user_id) if agent.user_id else None,
|
||||
llm=AgentLLMConfig(provider=provider, model=model_name, temperature=temperature, max_iterations=max_iterations),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=tools_include if tools_include else [],
|
||||
exclude_tools=[],
|
||||
permission_level="acceptEdits", # 飞书渠道无Web弹窗,编辑工具自动批准
|
||||
),
|
||||
memory=AgentMemoryConfig(enabled=True, persist_to_db=True, learning_enabled=True),
|
||||
budget=AgentBudgetConfig(),
|
||||
)
|
||||
|
||||
# 创建执行记录
|
||||
execution = Execution(
|
||||
agent_id=sched.agent_id,
|
||||
schedule_id=sched.id,
|
||||
input_data={
|
||||
"USER_INPUT": f"[定时任务提醒] {sched.input_message}",
|
||||
"query": f"[定时任务提醒] {sched.input_message}",
|
||||
"message": sched.input_message,
|
||||
"is_scheduled_reminder": True,
|
||||
},
|
||||
status="running",
|
||||
)
|
||||
db.add(execution)
|
||||
db.flush()
|
||||
|
||||
# 在当前事件循环中直接运行(不再创建嵌套事件循环,Windows 不兼容)
|
||||
try:
|
||||
runtime = AgentRuntime(config=config)
|
||||
result = await runtime.run(
|
||||
user_input=f"[定时任务提醒] {sched.input_message}",
|
||||
)
|
||||
execution.output_data = {"result": result.get("output", str(result)) if isinstance(result, dict) else str(result)}
|
||||
execution.status = "completed"
|
||||
except Exception as run_err:
|
||||
execution.status = "failed"
|
||||
execution.error_message = f"执行失败: {run_err!s}"
|
||||
logger.error("定时任务 %s 执行失败: %s", sched.id, run_err)
|
||||
|
||||
db.commit()
|
||||
if execution.status == "failed":
|
||||
_mark_schedule_failed(db, sched, now, execution.error_message)
|
||||
logger.info("定时任务直接执行完成(失败): name=%s agent=%s", sched.name, agent.name)
|
||||
else:
|
||||
_mark_schedule_completed(db, sched, now)
|
||||
logger.info("定时任务直接执行完成: name=%s agent=%s", sched.name, agent.name)
|
||||
|
||||
# 推送飞书通知(fire-and-forget,失败不影响主流程)
|
||||
try:
|
||||
notify_schedule_result(db, execution, execution.status, execution.error_message)
|
||||
except Exception as notify_err:
|
||||
logger.warning("定时任务通知失败: %s", notify_err)
|
||||
|
||||
triggered += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("定时任务 %s 处理失败: %s", sched.id, e)
|
||||
try:
|
||||
_mark_schedule_failed(db, sched, now, str(e))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return triggered
|
||||
except Exception as e:
|
||||
logger.error("检查定时任务失败: %s", e)
|
||||
return 0
|
||||
finally:
|
||||
if db:
|
||||
db.close()
|
||||
|
||||
|
||||
def _mark_schedule_completed(db: Session, sched, now: datetime) -> None:
|
||||
sched.last_run_at = now
|
||||
sched.last_run_status = "success"
|
||||
sched.next_run_at = compute_next_run(sched.cron_expression, after=now, tz=sched.timezone or "UTC")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _mark_schedule_failed(db: Session, sched, now: datetime, error: str) -> None:
|
||||
sched.last_run_at = now
|
||||
sched.last_run_status = "failed"
|
||||
sched.next_run_at = compute_next_run(sched.cron_expression, after=now, tz=sched.timezone or "UTC")
|
||||
db.commit()
|
||||
|
||||
|
||||
def notify_schedule_result(db: Session, execution, status: str, error_message: Optional[str] = None) -> None:
|
||||
"""如果 execution 关联了定时任务,创建通知并推送飞书消息。
|
||||
|
||||
|
||||
249
backend/app/services/auto_dream_service.py
Normal file
249
backend/app/services/auto_dream_service.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Auto Dream — 夜间记忆整合服务。
|
||||
|
||||
参考 Claude Code 的 Auto Dream 机制:
|
||||
- 每天凌晨 3:00 触发
|
||||
- 扫描过去 24 小时的向量记忆
|
||||
- 合并相似条目(余弦相似度 > 0.85)
|
||||
- 生成整合摘要并写入向量记忆池
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.embedding_service import embedding_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 相似度阈值:高于此值视为可合并
|
||||
MERGE_SIMILARITY_THRESHOLD = 0.85
|
||||
|
||||
# 每天凌晨 3:00 触发(UTC+8)
|
||||
DREAM_HOUR = 3
|
||||
DREAM_MINUTE = 0
|
||||
|
||||
# 上次整合日期(模块级,进程重启后重置)
|
||||
_last_dream_date: Optional[str] = None
|
||||
_dream_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _should_dream_today() -> bool:
|
||||
"""检查是否到了今天的整合时间且尚未执行。"""
|
||||
global _last_dream_date
|
||||
|
||||
now = datetime.now(timezone.utc).astimezone(
|
||||
timezone(timedelta(hours=8))
|
||||
)
|
||||
today_str = now.strftime("%Y-%m-%d")
|
||||
|
||||
# 已执行过
|
||||
if _last_dream_date == today_str:
|
||||
return False
|
||||
|
||||
# 在凌晨 3:00-4:00 之间触发
|
||||
if now.hour != DREAM_HOUR:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def run_auto_dream() -> dict:
|
||||
"""
|
||||
执行一次记忆整合。
|
||||
|
||||
返回: {"merged": int, "deleted": int, "dreams": int, "elapsed_s": float}
|
||||
"""
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
if not _dream_lock.locked():
|
||||
async with _dream_lock:
|
||||
return await _do_consolidate()
|
||||
|
||||
logger.info("Auto Dream:上一次整合仍在进行中,跳过")
|
||||
return {"merged": 0, "deleted": 0, "dreams": 0, "elapsed_s": 0, "skipped": True}
|
||||
|
||||
|
||||
async def _do_consolidate() -> dict:
|
||||
"""实际的整合逻辑。"""
|
||||
global _last_dream_date
|
||||
|
||||
from app.models.agent_vector_memory import AgentVectorMemory
|
||||
|
||||
now = datetime.now(timezone.utc).astimezone(
|
||||
timezone(timedelta(hours=8))
|
||||
)
|
||||
today_str = now.strftime("%Y-%m-%d")
|
||||
cutoff = now - timedelta(hours=24)
|
||||
|
||||
db: Optional[Session] = None
|
||||
merged = 0
|
||||
deleted = 0
|
||||
dreams = 0
|
||||
|
||||
try:
|
||||
db = SessionLocal()
|
||||
|
||||
# 1. 获取过去 24h 的所有向量记忆
|
||||
rows = (
|
||||
db.query(AgentVectorMemory)
|
||||
.filter(AgentVectorMemory.created_at >= cutoff)
|
||||
.order_by(AgentVectorMemory.created_at.desc())
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(rows) < 3:
|
||||
logger.info("Auto Dream:最近 24h 记忆不足(%d 条),跳过", len(rows))
|
||||
_last_dream_date = today_str
|
||||
return {"merged": 0, "deleted": 0, "dreams": 0, "elapsed_s": 0}
|
||||
|
||||
logger.info("Auto Dream:开始整合 %d 条最近记忆", len(rows))
|
||||
|
||||
# 2. 构建带 embedding 的条目列表
|
||||
entries: List[Tuple[Any, List[float]]] = []
|
||||
for row in rows:
|
||||
if not row.embedding:
|
||||
continue
|
||||
emb = embedding_service.deserialize_embedding(row.embedding)
|
||||
if emb and len(emb) > 0:
|
||||
entries.append((row, emb))
|
||||
|
||||
if len(entries) < 3:
|
||||
_last_dream_date = today_str
|
||||
return {"merged": 0, "deleted": 0, "dreams": 0, "elapsed_s": 0}
|
||||
|
||||
# 3. 两两计算相似度,找出可合并的对
|
||||
to_delete_ids: set = set()
|
||||
to_merge_pairs: List[Tuple[Any, Any]] = []
|
||||
|
||||
for i in range(len(entries)):
|
||||
if entries[i][0].id in to_delete_ids:
|
||||
continue
|
||||
for j in range(i + 1, len(entries)):
|
||||
if entries[j][0].id in to_delete_ids:
|
||||
continue
|
||||
sim = embedding_service.cosine_similarity(
|
||||
entries[i][1], entries[j][1]
|
||||
)
|
||||
if sim >= MERGE_SIMILARITY_THRESHOLD:
|
||||
# 保留较新的,删除较旧的
|
||||
newer = entries[i][0] if entries[i][0].created_at >= entries[j][0].created_at else entries[j][0]
|
||||
older = entries[j][0] if newer is entries[i][0] else entries[i][0]
|
||||
to_delete_ids.add(older.id)
|
||||
to_merge_pairs.append((newer, older))
|
||||
|
||||
# 4. 执行合并删除
|
||||
if to_delete_ids:
|
||||
for row_id in to_delete_ids:
|
||||
try:
|
||||
db.query(AgentVectorMemory).filter(
|
||||
AgentVectorMemory.id == row_id
|
||||
).delete()
|
||||
deleted += 1
|
||||
except Exception:
|
||||
db.rollback()
|
||||
db.commit()
|
||||
logger.info("Auto Dream:合并删除 %d 条重复记忆", deleted)
|
||||
merged = len(to_merge_pairs)
|
||||
|
||||
# 5. 生成整合摘要(Dream Summary)
|
||||
dream_text = await _generate_dream_summary(rows[:50])
|
||||
if dream_text:
|
||||
from app.services.embedding_service import embedding_service as es
|
||||
try:
|
||||
emb = await es.generate_embedding(dream_text)
|
||||
embedding_json = es.serialize_embedding(emb) if emb else ""
|
||||
dream_record = AgentVectorMemory(
|
||||
scope_kind="system",
|
||||
scope_id="auto_dream",
|
||||
session_key=f"dream_{today_str}",
|
||||
content_text=dream_text[:2000],
|
||||
embedding=embedding_json or None,
|
||||
metadata_={
|
||||
"type": "dream_summary",
|
||||
"memory_type": "project",
|
||||
"source": "auto_dream",
|
||||
"dream_date": today_str,
|
||||
"merged_count": merged,
|
||||
"deleted_count": deleted,
|
||||
},
|
||||
)
|
||||
db.add(dream_record)
|
||||
db.commit()
|
||||
dreams = 1
|
||||
logger.info("Auto Dream:已生成整合摘要 (%d 字)", len(dream_text))
|
||||
except Exception as e:
|
||||
logger.warning("Auto Dream 摘要写入失败: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Auto Dream 整合失败: %s", e)
|
||||
if db:
|
||||
db.rollback()
|
||||
finally:
|
||||
if db:
|
||||
db.close()
|
||||
|
||||
_last_dream_date = today_str
|
||||
elapsed = time.time() - start
|
||||
logger.info("Auto Dream 完成: merged=%d deleted=%d dreams=%d elapsed=%.1fs",
|
||||
merged, deleted, dreams, elapsed)
|
||||
return {"merged": merged, "deleted": deleted, "dreams": dreams, "elapsed_s": elapsed}
|
||||
|
||||
|
||||
async def _generate_dream_summary(rows: list) -> str:
|
||||
"""
|
||||
用 LLM 从最近记忆生成整合摘要。
|
||||
返回空字符串表示失败(不阻塞主流程)。
|
||||
"""
|
||||
if not rows or len(rows) < 3:
|
||||
return ""
|
||||
|
||||
# 提取记忆内容
|
||||
items = []
|
||||
for r in rows[-30:]: # 最近 30 条
|
||||
content = r.content_text[:300] if r.content_text else ""
|
||||
meta = r.metadata_ or {}
|
||||
mem_type = meta.get("memory_type", "unknown")
|
||||
items.append(f"[{mem_type}] {content}")
|
||||
|
||||
if not items:
|
||||
return ""
|
||||
|
||||
prompt = (
|
||||
"你是一个记忆整合助手。请分析以下 24 小时内的 Agent 对话记忆,\n"
|
||||
"生成一份简洁的每日摘要(200字以内),包含:\n"
|
||||
"1. 用户讨论了哪些主要话题\n"
|
||||
"2. 用户表达了哪些偏好或需求\n"
|
||||
"3. 有哪些值得保留的关键信息\n\n"
|
||||
"记忆条目:\n"
|
||||
) + "\n".join(f"- {item}" for item in items)
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
from app.core.config import settings
|
||||
|
||||
api_key = settings.DEEPSEEK_API_KEY or ""
|
||||
base_url = settings.DEEPSEEK_BASE_URL or "https://api.deepseek.com"
|
||||
|
||||
if not api_key:
|
||||
return ""
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
resp = await client.chat.completions.create(
|
||||
model="deepseek-v4-flash",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.3,
|
||||
max_tokens=600,
|
||||
timeout=30,
|
||||
)
|
||||
return resp.choices[0].message.content or ""
|
||||
except Exception as e:
|
||||
logger.warning("Auto Dream 摘要生成失败: %s", e)
|
||||
return ""
|
||||
@@ -224,6 +224,79 @@ class EmbeddingService:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
# ─── 离线兜底:关键词匹配(无需任何外部 API) ───
|
||||
|
||||
@staticmethod
|
||||
def _tokenize(text: str) -> set:
|
||||
"""
|
||||
轻量分词:中文用字符二元组,英文/数字用空格分词。
|
||||
混合文本同时提取中英文 token。零外部依赖,完全离线可用。
|
||||
"""
|
||||
tokens: set = set()
|
||||
text_lower = text.lower()
|
||||
|
||||
# 分离 CJK 字符和 ASCII/数字
|
||||
import re
|
||||
# 提取所有英文/数字词(>=2字符)
|
||||
alpha_words = re.findall(r'[a-z0-9]{2,}', text_lower)
|
||||
for w in alpha_words:
|
||||
tokens.add(w)
|
||||
|
||||
# 提取所有连续 CJK 字符段,生成二元组 + 单字
|
||||
cjk_segments = re.findall(r'[\u4e00-\u9fff]+', text_lower)
|
||||
for seg in cjk_segments:
|
||||
for i in range(len(seg) - 1):
|
||||
tokens.add(seg[i:i+2])
|
||||
for c in seg:
|
||||
tokens.add(c)
|
||||
|
||||
# 提取数字
|
||||
numbers = re.findall(r'\d+', text_lower)
|
||||
for n in numbers:
|
||||
tokens.add(n)
|
||||
|
||||
return tokens
|
||||
|
||||
def keyword_search(
|
||||
self,
|
||||
query: str,
|
||||
entries: List[VectorEntry],
|
||||
top_k: int = 5,
|
||||
min_score: float = 0.1,
|
||||
) -> List[VectorEntry]:
|
||||
"""
|
||||
离线关键词匹配(Embedding API 不可用时的兜底方案)。
|
||||
|
||||
对每个 entry 计算与 query 的 Jaccard 关键词重叠分数,
|
||||
返回 top-K 结果。
|
||||
|
||||
完全不依赖外部 API,零网络请求。
|
||||
"""
|
||||
q_tokens = self._tokenize(query)
|
||||
if not q_tokens:
|
||||
return entries[:top_k]
|
||||
|
||||
scored: List[VectorEntry] = []
|
||||
for entry in entries:
|
||||
text = entry.get("content_text", "")
|
||||
t_tokens = self._tokenize(text)
|
||||
if not t_tokens:
|
||||
continue
|
||||
intersection = q_tokens & t_tokens
|
||||
union = q_tokens | t_tokens
|
||||
score = len(intersection) / len(union) if union else 0.0
|
||||
if score >= min_score:
|
||||
entry["score"] = score
|
||||
scored.append(entry)
|
||||
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return scored[:top_k]
|
||||
|
||||
@property
|
||||
def offline_available(self) -> bool:
|
||||
"""离线兜底始终可用(关键词匹配无需外部依赖)。"""
|
||||
return True
|
||||
|
||||
|
||||
# 全局单例
|
||||
embedding_service = EmbeddingService()
|
||||
|
||||
419
backend/app/services/file_memory_service.py
Normal file
419
backend/app/services/file_memory_service.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
文件式记忆存储 (MEMORY.md) — 参考 Claude Code memdir 架构。
|
||||
|
||||
提供完全离线的文件系统记忆读写,数据库不可用时作为兜底。
|
||||
格式:YAML frontmatter + markdown 内容,MEMORY.md 索引。
|
||||
|
||||
使用方式:
|
||||
store = FileMemoryStore("/path/to/memory_dir")
|
||||
store.save("用户偏好", "用户喜欢用Python", mem_type="user")
|
||||
results = store.search("Python") # 关键词检索
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MEMORY.md 索引每行最大长度
|
||||
INDEX_LINE_MAX_LENGTH = 150
|
||||
# 索引总行数上限(不含标题和空行)
|
||||
INDEX_MAX_ENTRIES = 200
|
||||
# 单文件最大大小
|
||||
MAX_FILE_SIZE = 40_000
|
||||
|
||||
|
||||
class FileMemoryStore:
|
||||
"""
|
||||
文件式记忆存储。
|
||||
|
||||
- 所有记忆以 .md 文件存储,含 YAML frontmatter
|
||||
- MEMORY.md 维护索引(一行一条)
|
||||
- 支持按类型分组目录
|
||||
- 支持关键词检索
|
||||
- 完全离线,零外部依赖
|
||||
"""
|
||||
|
||||
def __init__(self, memory_dir: str = ""):
|
||||
self._base_dir = Path(memory_dir) if memory_dir else Path.home() / ".tiangong" / "memory"
|
||||
self._base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._memories_dir = self._base_dir / "memories"
|
||||
self._memories_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._index_path = self._base_dir / "MEMORY.md"
|
||||
self._ensure_index()
|
||||
|
||||
# ─── Public API ───
|
||||
|
||||
@property
|
||||
def base_dir(self) -> str:
|
||||
return str(self._base_dir)
|
||||
|
||||
@property
|
||||
def memory_count(self) -> int:
|
||||
"""返回已索引的记忆数量。"""
|
||||
return len(self._parse_index())
|
||||
|
||||
def save(
|
||||
self, name: str, content: str,
|
||||
mem_type: str = "reference",
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
保存一条记忆。
|
||||
|
||||
1. 写入 {memories_dir}/{safe_name}.md
|
||||
2. 更新 MEMORY.md 索引
|
||||
成功返回 True。
|
||||
"""
|
||||
try:
|
||||
safe_name = self._safe_filename(name)
|
||||
file_path = self._memories_dir / f"{safe_name}.md"
|
||||
|
||||
# 构建 frontmatter + content
|
||||
now = datetime.now(timezone.utc).astimezone(
|
||||
timezone(timedelta(hours=8))
|
||||
).isoformat(timespec="seconds")
|
||||
tags_yaml = f"[{', '.join(tags)}]" if tags else "[]"
|
||||
|
||||
body = (
|
||||
f"---\n"
|
||||
f"name: {name}\n"
|
||||
f"description: {self._one_line(content)}\n"
|
||||
f"type: {mem_type}\n"
|
||||
f"created: {now}\n"
|
||||
f"tags: {tags_yaml}\n"
|
||||
f"---\n\n"
|
||||
f"{content}\n"
|
||||
)
|
||||
|
||||
# 检查是否已存在同名文件(更新而非追加)
|
||||
if file_path.exists():
|
||||
existing = file_path.read_text(encoding="utf-8")
|
||||
if len(existing) + len(content) > MAX_FILE_SIZE:
|
||||
# 保留最近 2000 字 + 追加新内容
|
||||
existing = existing[-2000:]
|
||||
body = existing.rstrip() + f"\n\n---\n## 更新 {now}\n\n{content}\n"
|
||||
|
||||
file_path.write_text(body, encoding="utf-8")
|
||||
|
||||
# 更新索引
|
||||
self._update_index(name, mem_type, safe_name)
|
||||
|
||||
logger.debug("文件记忆已保存: %s (%s)", name, mem_type)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("文件记忆保存失败: %s", e)
|
||||
return False
|
||||
|
||||
def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
关键词检索所有记忆文件。
|
||||
对 query 分词后匹配文件内容,返回得分排序的结果。
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return self._recent(top_k)
|
||||
|
||||
tokens = self._tokenize(query)
|
||||
if not tokens:
|
||||
return []
|
||||
|
||||
scored: List[tuple] = [] # (score, file_path, name, mem_type)
|
||||
for md_file in self._memories_dir.glob("*.md"):
|
||||
try:
|
||||
text = md_file.read_text(encoding="utf-8")
|
||||
text_tokens = self._tokenize(text)
|
||||
if not text_tokens:
|
||||
continue
|
||||
intersection = tokens & text_tokens
|
||||
union = tokens | text_tokens
|
||||
score = len(intersection) / len(union) if union else 0
|
||||
|
||||
if score > 0:
|
||||
frontmatter = self._parse_frontmatter(text)
|
||||
name = frontmatter.get("name", md_file.stem)
|
||||
mem_type = frontmatter.get("type", "reference")
|
||||
scored.append((score, str(md_file), name, mem_type, text))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
results = []
|
||||
for score, path, name, mem_type, text in scored[:top_k]:
|
||||
# 提取匹配片段
|
||||
snippet = self._extract_snippet(text, tokens, max_len=300)
|
||||
results.append({
|
||||
"name": name,
|
||||
"type": mem_type,
|
||||
"content": snippet,
|
||||
"score": round(score, 3),
|
||||
"source": "file",
|
||||
"path": path,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def list_by_type(self, mem_type: str = "") -> List[Dict[str, Any]]:
|
||||
"""列出指定类型的所有记忆。"""
|
||||
entries = self._parse_index()
|
||||
if mem_type:
|
||||
entries = [e for e in entries if e.get("type") == mem_type]
|
||||
|
||||
results = []
|
||||
for entry in entries:
|
||||
file_path = self._memories_dir / f"{entry.get('file', '')}.md"
|
||||
content = ""
|
||||
if file_path.exists():
|
||||
try:
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
frontmatter = self._parse_frontmatter(text)
|
||||
content = frontmatter.get("description", "")[:300]
|
||||
except Exception:
|
||||
pass
|
||||
results.append({
|
||||
"name": entry.get("name", ""),
|
||||
"type": entry.get("type", ""),
|
||||
"content": content,
|
||||
"source": "file",
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def delete(self, name: str) -> bool:
|
||||
"""删除一条记忆(文件 + 索引)。"""
|
||||
try:
|
||||
safe_name = self._safe_filename(name)
|
||||
file_path = self._memories_dir / f"{safe_name}.md"
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
self._remove_from_index(name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("删除文件记忆失败: %s", e)
|
||||
return False
|
||||
|
||||
# ─── Private helpers ───
|
||||
|
||||
def _ensure_index(self) -> None:
|
||||
"""确保 MEMORY.md 存在。"""
|
||||
if not self._index_path.exists():
|
||||
self._index_path.write_text(
|
||||
"# Memory Index\n\n"
|
||||
"> 文件式记忆存储 — 数据库不可用时的离线兜底。\n"
|
||||
"> 格式参考 Claude Code memdir 架构。\n\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _parse_index(self) -> List[Dict[str, str]]:
|
||||
"""解析 MEMORY.md 索引,返回条目列表。"""
|
||||
entries = []
|
||||
if not self._index_path.exists():
|
||||
return entries
|
||||
|
||||
try:
|
||||
lines = self._index_path.read_text(encoding="utf-8").split("\n")
|
||||
for line in lines:
|
||||
# 匹配 `- [Name](file.md) — description` 或 `- [Name](file.md)`
|
||||
m = re.match(r'- \[(.+?)\]\((.+?)\)\s*[-—]?\s*(.*)', line)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
filename = m.group(2).strip().removesuffix(".md")
|
||||
desc = m.group(3).strip()
|
||||
mem_type = desc.split(" ")[0] if desc else "reference"
|
||||
entries.append({
|
||||
"name": name,
|
||||
"file": filename,
|
||||
"description": desc,
|
||||
"type": mem_type,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return entries
|
||||
|
||||
def _update_index(self, name: str, mem_type: str, safe_name: str) -> None:
|
||||
"""更新 MEMORY.md 索引。"""
|
||||
# 先移除旧条目
|
||||
self._remove_from_index(name)
|
||||
|
||||
try:
|
||||
# 读取现有内容
|
||||
content = self._index_path.read_text(encoding="utf-8")
|
||||
lines = content.rstrip().split("\n")
|
||||
|
||||
# 构建新行
|
||||
new_line = f"- [{name}]({safe_name}.md) — {mem_type}"
|
||||
if len(new_line) > INDEX_LINE_MAX_LENGTH:
|
||||
new_line = new_line[:INDEX_LINE_MAX_LENGTH - 3] + "..."
|
||||
|
||||
# 找到最后一个列表项后插入(或添加到末尾)
|
||||
insert_at = len(lines)
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
if lines[i].startswith("- ["):
|
||||
insert_at = i + 1
|
||||
break
|
||||
|
||||
lines.insert(insert_at, new_line)
|
||||
|
||||
# 裁剪索引到上限
|
||||
list_lines = [l for l in lines if l.startswith("- [")]
|
||||
if len(list_lines) > INDEX_MAX_ENTRIES:
|
||||
# 移除最早的条目
|
||||
excess = len(list_lines) - INDEX_MAX_ENTRIES
|
||||
new_lines = []
|
||||
removed = 0
|
||||
for line in lines:
|
||||
if line.startswith("- [") and removed < excess:
|
||||
removed += 1
|
||||
continue
|
||||
new_lines.append(line)
|
||||
lines = new_lines
|
||||
|
||||
self._index_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("更新 MEMORY.md 索引失败: %s", e)
|
||||
|
||||
def _remove_from_index(self, name: str) -> None:
|
||||
"""从 MEMORY.md 索引中移除指定条目。"""
|
||||
try:
|
||||
if not self._index_path.exists():
|
||||
return
|
||||
lines = self._index_path.read_text(encoding="utf-8").split("\n")
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
if f"[{name}]" in line:
|
||||
continue
|
||||
new_lines.append(line)
|
||||
self._index_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _recent(self, top_k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""返回最近修改的记忆文件。"""
|
||||
files = sorted(
|
||||
self._memories_dir.glob("*.md"),
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
results = []
|
||||
for f in files[:top_k]:
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8")
|
||||
fm = self._parse_frontmatter(text)
|
||||
results.append({
|
||||
"name": fm.get("name", f.stem),
|
||||
"type": fm.get("type", "reference"),
|
||||
"content": fm.get("description", "")[:300],
|
||||
"score": 1.0,
|
||||
"source": "file",
|
||||
"path": str(f),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _parse_frontmatter(text: str) -> Dict[str, Any]:
|
||||
"""解析 YAML frontmatter。轻量版,不依赖 PyYAML。"""
|
||||
result: Dict[str, Any] = {}
|
||||
if not text.startswith("---"):
|
||||
return result
|
||||
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return result
|
||||
|
||||
fm_text = parts[1].strip()
|
||||
for line in fm_text.split("\n"):
|
||||
line = line.strip()
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip().strip("'\"")
|
||||
# 解析简单列表 [a, b, c]
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
value = [v.strip().strip("'\"") for v in value[1:-1].split(",") if v.strip()]
|
||||
result[key] = value
|
||||
|
||||
# 提取正文第一段作为 description(如果 frontmatter 里没有)
|
||||
if "description" not in result:
|
||||
body = parts[2].strip()
|
||||
result["description"] = body[:200]
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""将名称转换为安全的文件名。"""
|
||||
# 只保留中文、英文、数字、下划线、连字符
|
||||
safe = re.sub(r'[^\w\u4e00-\u9fff\-]', '_', name)
|
||||
return safe.strip('_')[:64] or "memory"
|
||||
|
||||
@staticmethod
|
||||
def _one_line(text: str) -> str:
|
||||
"""提取文本的第一行或前 120 字符。"""
|
||||
first_line = text.split("\n")[0].strip()
|
||||
if len(first_line) > 120:
|
||||
first_line = first_line[:117] + "..."
|
||||
return first_line or text[:120]
|
||||
|
||||
@staticmethod
|
||||
def _tokenize(text: str) -> set:
|
||||
"""分词(复用 embedding_service 的逻辑)。"""
|
||||
tokens: set = set()
|
||||
text_lower = text.lower()
|
||||
|
||||
# 英文/数字词
|
||||
alpha_words = re.findall(r'[a-z0-9]{2,}', text_lower)
|
||||
for w in alpha_words:
|
||||
tokens.add(w)
|
||||
|
||||
# 中文二元组 + 单字
|
||||
cjk_segments = re.findall(r'[\u4e00-\u9fff]+', text_lower)
|
||||
for seg in cjk_segments:
|
||||
for i in range(len(seg) - 1):
|
||||
tokens.add(seg[i:i+2])
|
||||
for c in seg:
|
||||
tokens.add(c)
|
||||
|
||||
# 数字
|
||||
numbers = re.findall(r'\d+', text_lower)
|
||||
for n in numbers:
|
||||
tokens.add(n)
|
||||
|
||||
return tokens
|
||||
|
||||
@staticmethod
|
||||
def _extract_snippet(text: str, tokens: set, max_len: int = 300) -> str:
|
||||
"""从文本中提取包含关键词的片段。"""
|
||||
# 跳过 frontmatter
|
||||
if text.startswith("---"):
|
||||
parts = text.split("---", 2)
|
||||
text = parts[2] if len(parts) >= 3 else text
|
||||
|
||||
# 按段落找第一个匹配
|
||||
paragraphs = text.split("\n\n")
|
||||
for para in paragraphs:
|
||||
para_lower = para.lower()
|
||||
if any(t in para_lower for t in tokens):
|
||||
if len(para) > max_len:
|
||||
return para[:max_len - 3] + "..."
|
||||
return para
|
||||
|
||||
return text[:max_len]
|
||||
|
||||
|
||||
# 全局单例(延迟初始化,默认路径在首次使用项目目录时设置)
|
||||
_file_store: Optional[FileMemoryStore] = None
|
||||
|
||||
|
||||
def get_file_memory_store(memory_dir: str = "") -> FileMemoryStore:
|
||||
"""获取文件记忆存储单例。"""
|
||||
global _file_store
|
||||
if _file_store is None or (memory_dir and _file_store.base_dir != memory_dir):
|
||||
_file_store = FileMemoryStore(memory_dir)
|
||||
return _file_store
|
||||
@@ -89,10 +89,12 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("灵犀回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
try:
|
||||
from app.services.lingxi_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("灵犀回复卡片失败: %s", e)
|
||||
|
||||
@@ -197,7 +199,10 @@ async def _handle_message_async(data):
|
||||
model=model, provider=provider,
|
||||
temperature=temperature, max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(include_tools=tools_whitelist),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=tools_whitelist,
|
||||
permission_level="acceptEdits", # 飞书渠道无Web弹窗,编辑工具自动批准
|
||||
),
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 20)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 5)),
|
||||
@@ -214,7 +219,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -270,6 +291,19 @@ def _build_event_handler():
|
||||
|
||||
builder = EventDispatcherHandler.builder(encrypt_key="", verification_token="")
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
@@ -312,7 +312,10 @@ class MainAgentService:
|
||||
max_iterations=int(_cfg("max_iterations", 5)),
|
||||
request_timeout=float(_cfg("request_timeout", 60.0)),
|
||||
),
|
||||
tools=AgentToolConfig(include_tools=tools_whitelist),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=tools_whitelist,
|
||||
permission_level="acceptEdits", # 飞书渠道无Web弹窗,编辑工具自动批准
|
||||
),
|
||||
)
|
||||
|
||||
runtime = AgentRuntime(agent_config)
|
||||
|
||||
@@ -100,11 +100,13 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("橙子回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
"""通过橙子应用回复卡片消息。"""
|
||||
try:
|
||||
from app.services.orange_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("橙子回复卡片失败: %s", e)
|
||||
|
||||
@@ -230,7 +232,7 @@ async def _handle_message_async(data):
|
||||
temperature=temperature,
|
||||
max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(),
|
||||
tools=AgentToolConfig(permission_level="acceptEdits"), # 飞书渠道无Web弹窗
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 20)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 5)),
|
||||
@@ -256,7 +258,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"🍊 {agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"🍊 {agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -321,6 +339,19 @@ def _build_event_handler():
|
||||
verification_token="",
|
||||
)
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
@@ -108,10 +108,12 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("人参果1号回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
try:
|
||||
from app.services.renshenguo2_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("人参果1号回复卡片失败: %s", e)
|
||||
|
||||
@@ -260,7 +262,10 @@ async def _handle_message_async(data):
|
||||
model=model, provider=provider,
|
||||
temperature=temperature, max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(include_tools=tools_whitelist),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=tools_whitelist,
|
||||
permission_level="acceptEdits", # 飞书渠道无Web弹窗,编辑工具自动批准
|
||||
),
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 40)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 10)),
|
||||
@@ -277,7 +282,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -333,6 +354,19 @@ def _build_event_handler():
|
||||
|
||||
builder = EventDispatcherHandler.builder(encrypt_key="", verification_token="")
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
@@ -117,10 +117,12 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("人参果回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
try:
|
||||
from app.services.renshenguo_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("人参果回复卡片失败: %s", e)
|
||||
|
||||
@@ -265,7 +267,10 @@ async def _handle_message_async(data):
|
||||
model=model, provider=provider,
|
||||
temperature=temperature, max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(include_tools=tools_whitelist),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=tools_whitelist,
|
||||
permission_level="acceptEdits", # 飞书渠道无Web弹窗,编辑工具自动批准
|
||||
),
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 40)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 10)),
|
||||
@@ -282,7 +287,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -338,6 +359,19 @@ def _build_event_handler():
|
||||
|
||||
builder = EventDispatcherHandler.builder(encrypt_key="", verification_token="")
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
@@ -100,11 +100,13 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("苏瑶回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
"""通过苏瑶应用回复卡片消息。"""
|
||||
try:
|
||||
from app.services.suyao_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("苏瑶回复卡片失败: %s", e)
|
||||
|
||||
@@ -214,7 +216,7 @@ async def _handle_message_async(data):
|
||||
temperature=temperature,
|
||||
max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(),
|
||||
tools=AgentToolConfig(permission_level="acceptEdits"), # 飞书渠道无Web弹窗
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 20)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 5)),
|
||||
@@ -231,7 +233,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -296,6 +314,19 @@ def _build_event_handler():
|
||||
verification_token="",
|
||||
)
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
@@ -89,10 +89,12 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("甜甜回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
try:
|
||||
from app.services.tiantian_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("甜甜回复卡片失败: %s", e)
|
||||
|
||||
@@ -195,7 +197,7 @@ async def _handle_message_async(data):
|
||||
model=model, provider=provider,
|
||||
temperature=temperature, max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(),
|
||||
tools=AgentToolConfig(permission_level="acceptEdits"), # 飞书渠道无Web弹窗
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 20)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 5)),
|
||||
@@ -212,7 +214,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -268,6 +286,19 @@ def _build_event_handler():
|
||||
|
||||
builder = EventDispatcherHandler.builder(encrypt_key="", verification_token="")
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user