add files
This commit is contained in:
1 parent
ab8adbfa12
commit
fbb31409da
8 files changed
+775
No files matched your search
@@ -0,0 +1,18 @@
|
||||
# Zalo Bot token (bắt buộc)
|
||||
BOT_TOKEN=your_zalo_bot_token
|
||||
|
||||
# Hermes API Server (gateway) — OpenAI-compatible, full agent tools
|
||||
# Bật: API_SERVER_ENABLED=true + API_SERVER_KEY trong ~/.hermes/.env rồi restart hermes-gateway
|
||||
HERMES_API_BASE=http://127.0.0.1:8642/v1
|
||||
HERMES_API_KEY=change-me-local-dev
|
||||
HERMES_MODEL=hermes-agent
|
||||
HERMES_TIMEOUT=180
|
||||
|
||||
# Giữ bao nhiêu lượt user/assistant trong session Zalo → Hermes
|
||||
HERMES_HISTORY_TURNS=12
|
||||
|
||||
# System prompt gắn vào mỗi request (tuỳ chọn)
|
||||
HERMES_SYSTEM_PROMPT=Bạn đang chat với người dùng qua Zalo Bot. Trả lời ngắn gọn, tiếng Việt trừ khi họ hỏi tiếng khác. Không dùng markdown nặng (**, ##, [[wiki]]); ưu tiên plain text dễ đọc trên Zalo.
|
||||
|
||||
# Backend: api (mặc định, API Server) | cli (hermes chat -q, chậm hơn, không giữ history tốt)
|
||||
HERMES_BACKEND=api
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
# Debug json
|
||||
*.json
|
||||
data/
|
||||
chroma_db/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
#poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
#pdm.lock
|
||||
#pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
#pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Cursor
|
||||
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Zalo Bot → Hermes Agent (API Server). Không dùng support KB :8766."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pathlib import Path
|
||||
|
||||
# Load .env TRƯỚC mọi import đọc os.environ (hermes_client / BOT_TOKEN)
|
||||
load_dotenv(Path(__file__).resolve().parent / ".env")
|
||||
|
||||
import zalo_bot
|
||||
|
||||
from format_zalo import format_for_zalo
|
||||
from hermes_client import (
|
||||
HERMES_API_BASE,
|
||||
HERMES_BACKEND,
|
||||
ask_hermes,
|
||||
clear_history,
|
||||
hermes_health,
|
||||
)
|
||||
|
||||
logging.getLogger("zalo_bot").setLevel(logging.ERROR)
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||
if not BOT_TOKEN:
|
||||
raise ValueError("BOT_TOKEN not found in .env")
|
||||
|
||||
_HERMES_WORKERS = int(os.getenv("HERMES_WORKERS", "2"))
|
||||
_executor = ThreadPoolExecutor(max_workers=max(1, _HERMES_WORKERS))
|
||||
_busy: set[str] = set()
|
||||
|
||||
|
||||
def split_zalo_text(text: str, limit: int = 1800) -> list[str]:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return ["(không có nội dung)"]
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
parts: list[str] = []
|
||||
rest = text
|
||||
while rest:
|
||||
if len(rest) <= limit:
|
||||
parts.append(rest)
|
||||
break
|
||||
cut = rest.rfind("\n", 0, limit)
|
||||
if cut < limit // 3:
|
||||
cut = rest.rfind(" ", 0, limit)
|
||||
if cut < limit // 3:
|
||||
cut = limit
|
||||
parts.append(rest[:cut].rstrip())
|
||||
rest = rest[cut:].lstrip()
|
||||
return parts or [text[:limit]]
|
||||
|
||||
|
||||
def get_answer(chat_id: str, question: str) -> str:
|
||||
q = (question or "").strip()
|
||||
if not q:
|
||||
return "Bạn vui lòng gửi nội dung cần hỏi nhé."
|
||||
|
||||
if q.lower() in {"/reset", "/new", "reset", "xóa lịch sử", "xoa lich su"}:
|
||||
clear_history(chat_id)
|
||||
return "Đã xóa lịch sử hội thoại với Hermes cho chat này."
|
||||
|
||||
result = ask_hermes(chat_id, q)
|
||||
answer = (result.get("answer") or "").strip()
|
||||
plain = format_for_zalo(answer)
|
||||
return plain or answer
|
||||
|
||||
|
||||
async def send_long_message(bot: zalo_bot.Bot, chat_id: str, text: str) -> None:
|
||||
for part in split_zalo_text(text):
|
||||
await bot.send_message(chat_id, part)
|
||||
|
||||
|
||||
async def handle_user_message(bot: zalo_bot.Bot, chat_id: str, question: str) -> None:
|
||||
"""Chạy nền: Hermes → trả Zalo (không chặn long-poll)."""
|
||||
key = str(chat_id)
|
||||
if key in _busy:
|
||||
await bot.send_message(
|
||||
chat_id,
|
||||
"Mình đang xử lý câu trước của bạn. Vui lòng đợi một chút…",
|
||||
)
|
||||
return
|
||||
_busy.add(key)
|
||||
try:
|
||||
try:
|
||||
await bot.send_chat_action(chat_id, "typing")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
answer = await loop.run_in_executor(_executor, get_answer, chat_id, question)
|
||||
await send_long_message(bot, chat_id, answer)
|
||||
print(f"[zalo] Sent to {chat_id} ({len(answer)} chars)")
|
||||
except Exception as e:
|
||||
print(f"[zalo] handle error for {chat_id}: {e}")
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id,
|
||||
"Có lỗi khi nhờ Hermes trả lời. Bạn thử lại sau nhé.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_busy.discard(key)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
health = hermes_health()
|
||||
print(f"[hermes] backend={HERMES_BACKEND} base={HERMES_API_BASE} health={health}")
|
||||
if not health.get("ok"):
|
||||
print(
|
||||
"[hermes] WARNING: API Server chưa sẵn sàng. "
|
||||
"Bật API_SERVER_ENABLED=true + API_SERVER_KEY trong ~/.hermes/.env "
|
||||
"rồi systemctl --user restart hermes-gateway "
|
||||
"(listen http://127.0.0.1:8642)."
|
||||
)
|
||||
|
||||
bot = zalo_bot.Bot(BOT_TOKEN)
|
||||
async with bot:
|
||||
me = await bot.get_me()
|
||||
print(
|
||||
f"[zalo] started as {getattr(me, 'account_name', None) or me} "
|
||||
f"id={getattr(me, 'id', None)}; listening…"
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
update = await bot.get_update(timeout=30)
|
||||
if not (update and update.message and update.message.text):
|
||||
continue
|
||||
|
||||
chat_id = update.message.chat.id
|
||||
question = update.message.text
|
||||
print(f"[zalo] Message from {chat_id}: {question}")
|
||||
asyncio.create_task(handle_user_message(bot, chat_id, question))
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except zalo_bot.error.ZaloError:
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
print(f"[zalo] Unexpected error: {e}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\n[zalo] stopped (Ctrl+C)")
|
||||
finally:
|
||||
_executor.shutdown(wait=False, cancel_futures=True)
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
"""Chuẩn hóa câu trả lời Hermes → text thuần cho Zalo (không markdown)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_MD_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||
_BOLD = re.compile(r"\*\*([^*]+)\*\*")
|
||||
_ITALIC = re.compile(r"(?<!\*)\*([^*]+)\*(?!\*)")
|
||||
_STRIKE = re.compile(r"~~([^~]+)~~")
|
||||
_CODE_FENCE = re.compile(r"```[\s\S]*?```")
|
||||
_INLINE_CODE = re.compile(r"`([^`]+)`")
|
||||
_HEADING = re.compile(r"(?m)^#{1,6}\s+")
|
||||
_BULLET = re.compile(r"(?m)^[\-\*]\s+")
|
||||
_ORDERED = re.compile(r"(?m)^\d+\.\s+")
|
||||
_BLOCKQUOTE = re.compile(r"(?m)^>\s?")
|
||||
_HR = re.compile(r"(?m)^(?:-{3,}|\*{3,}|_{3,})\s*$")
|
||||
_HTML_TAG = re.compile(r"</?[a-zA-Z][^>]*>")
|
||||
_WIKI_OR_REF = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]")
|
||||
_MULTI_NL = re.compile(r"\n{3,}")
|
||||
_MULTI_SPACE = re.compile(r"[ \t]{2,}")
|
||||
_DASHY = re.compile(r"\s*[—–]\s*")
|
||||
|
||||
# Lỗi chính tả / khoảng trắng tiếng Việt thường gặp từ model
|
||||
_TYPO_FIXES: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"\s+([,.!?;:…])"), r"\1"),
|
||||
(re.compile(r"([(\[])\s+"), r"\1"),
|
||||
(re.compile(r"\s+([)\]])"), r"\1"),
|
||||
(re.compile(r"(?i)\bko\b"), "không"),
|
||||
(re.compile(r"(?i)\bdc\b"), "được"),
|
||||
(re.compile(r"(?i)\bđc\b"), "được"),
|
||||
(re.compile(r"(?i)\bkq\b"), "kết quả"),
|
||||
(re.compile(r"(?i)\bntn\b"), "như thế nào"),
|
||||
(re.compile(r"(?i)\bntn\b"), "như thế nào"),
|
||||
(re.compile(r"(?i)\bmn\b"), "mọi người"),
|
||||
(re.compile(r"(?i)\bbj\b"), "bây giờ"),
|
||||
(re.compile(r"(?i)\bhqua\b"), "hôm qua"),
|
||||
(re.compile(r"(?i)\bhnay\b"), "hôm nay"),
|
||||
(re.compile(r"(?i)\bsdt\b"), "SĐT"),
|
||||
(re.compile(r"(?i)\bvs\b"), "với"),
|
||||
(re.compile(r"\u00a0"), " "),
|
||||
]
|
||||
|
||||
|
||||
def _nfc(text: str) -> str:
|
||||
return unicodedata.normalize("NFC", text or "")
|
||||
|
||||
|
||||
def _strip_markdown(text: str) -> str:
|
||||
t = text
|
||||
t = _CODE_FENCE.sub(lambda m: "\n".join(
|
||||
line for line in m.group(0).splitlines()[1:-1]
|
||||
), t)
|
||||
t = _HTML_TAG.sub("", t)
|
||||
t = _WIKI_OR_REF.sub(lambda m: (m.group(2) or m.group(1) or "").strip(), t)
|
||||
t = _MD_LINK.sub(lambda m: m.group(1).strip() or m.group(2).strip(), t)
|
||||
t = _BOLD.sub(r"\1", t)
|
||||
t = _ITALIC.sub(r"\1", t)
|
||||
t = _STRIKE.sub(r"\1", t)
|
||||
t = _INLINE_CODE.sub(r"\1", t)
|
||||
t = _HEADING.sub("", t)
|
||||
t = _BLOCKQUOTE.sub("", t)
|
||||
t = _HR.sub("", t)
|
||||
t = _BULLET.sub("• ", t)
|
||||
t = _ORDERED.sub(lambda m: f"{m.group(0).split('.')[0]}. ", t)
|
||||
return t
|
||||
|
||||
|
||||
def _fix_vietnamese_typos(text: str) -> str:
|
||||
t = text
|
||||
for pat, repl in _TYPO_FIXES:
|
||||
t = pat.sub(repl, t)
|
||||
# khoảng trắng quanh dấu tiếng Việt kiểu "ạ ," / "không ?"
|
||||
t = re.sub(r"([^\s])\s+([?!…])", r"\1\2", t)
|
||||
return t
|
||||
|
||||
|
||||
def _tidy_lines(text: str) -> str:
|
||||
lines: list[str] = []
|
||||
for line in text.splitlines():
|
||||
line = _MULTI_SPACE.sub(" ", line).strip()
|
||||
if re.fullmatch(r"[—–\-.…\s]*", line or ""):
|
||||
continue
|
||||
if "—" in line or "–" in line:
|
||||
parts = [p.strip() for p in _DASHY.split(line) if p.strip()]
|
||||
if len(parts) > 1 and any(len(p) > 12 for p in parts):
|
||||
lines.extend(parts)
|
||||
continue
|
||||
line = " ".join(parts)
|
||||
if line:
|
||||
lines.append(line)
|
||||
t = "\n".join(lines)
|
||||
t = _MULTI_NL.sub("\n\n", t).strip()
|
||||
t = re.sub(r"\s+\.\s*$", ".", t)
|
||||
t = re.sub(r"\.{2,}", "…", t)
|
||||
t = re.sub(r"(?m)^[.\s]+$", "", t)
|
||||
return _MULTI_NL.sub("\n\n", t).strip()
|
||||
|
||||
|
||||
def format_for_zalo(text: str, *, max_chars: int = 1800) -> str:
|
||||
"""Bỏ markdown, chuẩn hóa khoảng trắng / lỗi chính tả VN, cắt nếu quá dài."""
|
||||
t = _nfc((text or "").strip())
|
||||
if not t:
|
||||
return t
|
||||
|
||||
t = _strip_markdown(t)
|
||||
t = _fix_vietnamese_typos(t)
|
||||
t = _tidy_lines(t)
|
||||
|
||||
if max_chars and len(t) > max_chars:
|
||||
cut = t.rfind("\n", 0, max_chars)
|
||||
if cut < max_chars // 3:
|
||||
cut = t.rfind(". ", 0, max_chars)
|
||||
if cut > 0:
|
||||
cut += 1
|
||||
if cut < max_chars // 3:
|
||||
cut = max_chars
|
||||
t = t[:cut].rstrip(" ,;:") + "…"
|
||||
|
||||
return t.strip()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Client tới Hermes Agent — kênh Zalo riêng (không phải support KB).
|
||||
|
||||
Backend mặc định: Hermes API Server (OpenAI-compatible) trên gateway :8642.
|
||||
Đọc env lúc gọi (không snapshot lúc import) để load_dotenv() luôn có hiệu lực.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any, Deque, Dict, List
|
||||
|
||||
import requests
|
||||
|
||||
MISS = (
|
||||
"Hermes chưa trả lời được lúc này. Bạn thử lại sau hoặc diễn đạt lại câu hỏi nhé."
|
||||
)
|
||||
|
||||
# chat_id -> deque of {role, content} — maxlen set on first use / refresh
|
||||
_HISTORIES: Dict[str, Deque[dict[str, str]]] = {}
|
||||
|
||||
|
||||
def _cfg() -> dict[str, Any]:
|
||||
turns = int(os.environ.get("HERMES_HISTORY_TURNS", "12"))
|
||||
return {
|
||||
"base": os.environ.get("HERMES_API_BASE", "http://127.0.0.1:8642/v1").rstrip("/"),
|
||||
"key": (os.environ.get("HERMES_API_KEY") or "").strip(),
|
||||
"model": os.environ.get("HERMES_MODEL", "hermes-agent"),
|
||||
"timeout": float(os.environ.get("HERMES_TIMEOUT", "180")),
|
||||
"turns": turns,
|
||||
"backend": (os.environ.get("HERMES_BACKEND") or "api").strip().lower(),
|
||||
"system": (
|
||||
os.environ.get("HERMES_SYSTEM_PROMPT")
|
||||
or (
|
||||
"Bạn đang chat với người dùng qua Zalo Bot. "
|
||||
"Trả lời ngắn gọn, tiếng Việt trừ khi họ hỏi tiếng khác. "
|
||||
"Không dùng markdown nặng; ưu tiên plain text dễ đọc trên Zalo."
|
||||
)
|
||||
).strip(),
|
||||
}
|
||||
|
||||
|
||||
# Compat exports used by bot_app logging
|
||||
def _refresh_exports() -> None:
|
||||
global HERMES_API_BASE, HERMES_BACKEND
|
||||
c = _cfg()
|
||||
HERMES_API_BASE = c["base"]
|
||||
HERMES_BACKEND = c["backend"]
|
||||
|
||||
|
||||
_refresh_exports()
|
||||
|
||||
|
||||
def _auth_headers(key: str) -> dict[str, str]:
|
||||
h = {"Content-Type": "application/json", "User-Agent": "zalo-hermes-bot/0.1"}
|
||||
if key:
|
||||
h["Authorization"] = f"Bearer {key}"
|
||||
return h
|
||||
|
||||
|
||||
def _history_deque(chat_id: str, turns: int) -> Deque[dict[str, str]]:
|
||||
key = str(chat_id)
|
||||
maxlen = max(2, turns * 2)
|
||||
d = _HISTORIES.get(key)
|
||||
if d is None or d.maxlen != maxlen:
|
||||
old = list(d) if d is not None else []
|
||||
d = deque(old, maxlen=maxlen)
|
||||
_HISTORIES[key] = d
|
||||
return d
|
||||
|
||||
|
||||
def hermes_health() -> dict[str, Any]:
|
||||
c = _cfg()
|
||||
_refresh_exports()
|
||||
if c["backend"] == "cli":
|
||||
try:
|
||||
p = subprocess.run(
|
||||
["hermes", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return {
|
||||
"ok": p.returncode == 0,
|
||||
"backend": "cli",
|
||||
"body": (p.stdout or p.stderr or "")[:200],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "backend": "cli", "error": str(e)}
|
||||
|
||||
candidates = [
|
||||
f"{c['base']}/models",
|
||||
c["base"].replace("/v1", "") + "/health",
|
||||
"http://127.0.0.1:8642/health",
|
||||
]
|
||||
last: dict[str, Any] = {"ok": False, "backend": "api"}
|
||||
for url in candidates:
|
||||
try:
|
||||
r = requests.get(url, timeout=5, headers=_auth_headers(c["key"]))
|
||||
ctype = r.headers.get("content-type", "")
|
||||
body: Any = r.json() if "json" in ctype else r.text[:200]
|
||||
last = {
|
||||
"ok": r.status_code < 400,
|
||||
"backend": "api",
|
||||
"url": url,
|
||||
"status_code": r.status_code,
|
||||
"body": body,
|
||||
"api_key_len": len(c["key"]),
|
||||
}
|
||||
if last["ok"]:
|
||||
return last
|
||||
except Exception as e:
|
||||
last = {"ok": False, "backend": "api", "url": url, "error": str(e)}
|
||||
return last
|
||||
|
||||
|
||||
def _history_for(chat_id: str, turns: int) -> List[dict[str, str]]:
|
||||
return list(_history_deque(chat_id, turns))
|
||||
|
||||
|
||||
def _remember(chat_id: str, role: str, content: str, turns: int) -> None:
|
||||
_history_deque(chat_id, turns).append({"role": role, "content": content})
|
||||
|
||||
|
||||
def clear_history(chat_id: str) -> None:
|
||||
_HISTORIES.pop(str(chat_id), None)
|
||||
|
||||
|
||||
def ask_hermes_api(chat_id: str, message: str) -> dict[str, Any]:
|
||||
c = _cfg()
|
||||
msg = (message or "").strip()
|
||||
if not msg:
|
||||
return {"answer": MISS, "meta": "empty"}
|
||||
if not c["key"]:
|
||||
print("HERMES CLIENT: HERMES_API_KEY empty — load_dotenv before import, or export env")
|
||||
return {"answer": MISS, "meta": "missing api key"}
|
||||
|
||||
messages: list[dict[str, str]] = []
|
||||
if c["system"]:
|
||||
messages.append({"role": "system", "content": c["system"]})
|
||||
messages.extend(_history_for(chat_id, c["turns"]))
|
||||
messages.append({"role": "user", "content": msg})
|
||||
|
||||
url = f"{c['base']}/chat/completions"
|
||||
try:
|
||||
r = requests.post(
|
||||
url,
|
||||
json={
|
||||
"model": c["model"],
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
},
|
||||
timeout=c["timeout"],
|
||||
headers=_auth_headers(c["key"]),
|
||||
)
|
||||
if r.status_code == 401:
|
||||
print(
|
||||
"HERMES CLIENT: unauthorized — "
|
||||
f"api_key_len={len(c['key'])} (must match API_SERVER_KEY)"
|
||||
)
|
||||
return {"answer": MISS, "meta": "unauthorized"}
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
choices = data.get("choices") or []
|
||||
content = ""
|
||||
if choices:
|
||||
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
||||
if not content:
|
||||
return {"answer": MISS, "meta": "empty response"}
|
||||
_remember(chat_id, "user", msg, c["turns"])
|
||||
_remember(chat_id, "assistant", content, c["turns"])
|
||||
return {"answer": content, "meta": "api"}
|
||||
except Exception as e:
|
||||
print(f"HERMES CLIENT ERROR: {e}")
|
||||
return {"answer": MISS, "meta": f"error: {e}"}
|
||||
|
||||
|
||||
def ask_hermes_cli(message: str) -> dict[str, Any]:
|
||||
c = _cfg()
|
||||
msg = (message or "").strip()
|
||||
if not msg:
|
||||
return {"answer": MISS, "meta": "empty"}
|
||||
try:
|
||||
p = subprocess.run(
|
||||
["hermes", "chat", "-q", msg],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=c["timeout"],
|
||||
)
|
||||
out = (p.stdout or "").strip() or (p.stderr or "").strip()
|
||||
if p.returncode != 0 or not out:
|
||||
print(f"HERMES CLI fail rc={p.returncode}: {out[:300]}")
|
||||
return {"answer": MISS, "meta": f"cli rc={p.returncode}"}
|
||||
return {"answer": out, "meta": "cli"}
|
||||
except Exception as e:
|
||||
print(f"HERMES CLI ERROR: {e}")
|
||||
return {"answer": MISS, "meta": f"cli error: {e}"}
|
||||
|
||||
|
||||
def ask_hermes(chat_id: str, message: str) -> dict[str, Any]:
|
||||
c = _cfg()
|
||||
if c["backend"] == "cli":
|
||||
return ask_hermes_cli(message)
|
||||
return ask_hermes_api(chat_id, message)
|
||||
@@ -0,0 +1,3 @@
|
||||
python-dotenv
|
||||
python-zalo-bot
|
||||
requests
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Enable Hermes API Server on the local gateway (one-time helper).
|
||||
# Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server
|
||||
set -euo pipefail
|
||||
|
||||
HERMES_ENV="${HERMES_HOME:-$HOME/.hermes}/.env"
|
||||
KEY="${1:-change-me-local-dev}"
|
||||
|
||||
mkdir -p "$(dirname "$HERMES_ENV")"
|
||||
touch "$HERMES_ENV"
|
||||
|
||||
set_kv() {
|
||||
local k="$1" v="$2"
|
||||
if grep -q "^${k}=" "$HERMES_ENV" 2>/dev/null; then
|
||||
sed -i "s|^${k}=.*|${k}=${v}|" "$HERMES_ENV"
|
||||
else
|
||||
printf '%s=%s\n' "$k" "$v" >>"$HERMES_ENV"
|
||||
fi
|
||||
}
|
||||
|
||||
set_kv API_SERVER_ENABLED true
|
||||
set_kv API_SERVER_KEY "$KEY"
|
||||
|
||||
echo "Updated $HERMES_ENV"
|
||||
echo " API_SERVER_ENABLED=true"
|
||||
echo " API_SERVER_KEY=${KEY}"
|
||||
echo
|
||||
echo "Restart gateway:"
|
||||
echo " systemctl --user restart hermes-gateway"
|
||||
echo "Smoke:"
|
||||
echo " curl -sS http://127.0.0.1:8642/v1/models -H \"Authorization: Bearer ${KEY}\""
|
||||
echo
|
||||
echo "Put the same key in zalo-hermes-bot/.env as HERMES_API_KEY"
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Orange Pi VN Zalo ↔ Hermes Agent bridge (python-zalo-bot)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/orangepi/workspace/zalo-hermes-bot
|
||||
EnvironmentFile=-/home/orangepi/workspace/zalo-hermes-bot/.env
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/home/orangepi/workspace/zalo-hermes-bot/.venv/bin/python /home/orangepi/workspace/zalo-hermes-bot/bot_app.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Reference in new issue
Block a user