From 1ec5bc5c5d42c41ec7d5623a2ebdd190c23da7d1 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 14 Jun 2026 13:26:45 +0800 Subject: [PATCH] init: AI chat with FastAPI + SQLite + tools --- app.py | 454 +++++++++++++++++++++++++++++++++++++++++++++++ chat.db | Bin 0 -> 61440 bytes fetch_balance.py | 11 ++ 3 files changed, 465 insertions(+) create mode 100644 app.py create mode 100644 chat.db create mode 100644 fetch_balance.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..b6bc495 --- /dev/null +++ b/app.py @@ -0,0 +1,454 @@ +import os, json, logging, httpx, sqlite3, uuid, base64, re, subprocess +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse +import uvicorn +from datetime import datetime, timezone, timedelta + +app = FastAPI() +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") +logger = logging.getLogger("agent") + +API_KEY = "sk-bbca4a0380d549389f0d27cdea0b5228" +API_URL = "https://api.deepseek.com/v1/chat/completions" +MODEL = "deepseek-v4-flash" +GITEA_TOKEN = "513f4f5151daf5754069f5c7dc0bfe6283989ef1" +GITEA_BASE = "https://gitea.xybkwd.top" +MEMOS_TOKEN = "memos_pat_GtKuYpUlRJP7KjWsCcBK51u87CS1f0CQ" +MEMOS_URL = "http://localhost:5230" +DB_PATH = "/home/ubuntu/ai-chat/chat.db" +BLOG_RSS = "https://www.xybkwd.top/rss.xml" +IC_DIR = "/home/ubuntu/inspiration-collector" +TZ_BJ = timezone(timedelta(hours=8)) + +SYSTEM_PROMPT = """你是冯总部署在东京服务器上的AI智能体。 + +## 冯总 +- 33岁,省供电公司工会副主席 +- 量化交易者,ETH/USDT永续合约 +- freqtrade v2.2d策略运行中,Al Brooks价格行为学 + +## 你的能力 +你可以通过以下命令执行操作(用户在对话中说相关的指令即可): + +### 读取类 +- "查memos/最近的灵感" → 列出最近7天的灵感记录 +- "读gitea/读文库/看笔记" → 读取Gitea仓库中的文件 +- "看博客/读博客" → 读取博客最新文章 + +### 操作类 +- "运行灵感分析/跑日报" → 立即运行daily_digest.py并推送到Gitea + +### 服务器类 +- "看状态/检查服务器" → 查看Docker容器和服务器状态 + +当用户提出上述需求时,直接执行并返回结果。 +回答朴实有内容,北京时间UTC+8。""" + + +def init_db(): + conn = sqlite3.connect(DB_PATH) + conn.execute("CREATE TABLE IF NOT EXISTS conversations (id TEXT PRIMARY KEY, title TEXT, created_at TEXT, updated_at TEXT)") + conn.execute("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, conv_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, created_at TEXT)") + conn.commit() + conn.close() +init_db() + + +# ============ TOOL FUNCTIONS ============ + +def tool_memos_list(days=7): + """List recent memos.""" + try: + import requests + payload = {"pageSize": 50} + r = requests.post(MEMOS_URL + "/memos.api.v1.MemoService/ListMemos", + json=payload, headers={"Authorization": "Bearer " + MEMOS_TOKEN, "Content-Type": "application/json"}, timeout=10) + memos = r.json().get("memos", []) + since = datetime.now(timezone.utc) - timedelta(days=days) + results = [] + for m in memos: + ct = m.get("createTime", "") + try: + dt = datetime.fromisoformat(ct.replace("Z", "+00:00")) + except: + dt = datetime.now(timezone.utc) + if dt < since: continue + bj = dt + timedelta(hours=8) + results.append(f"[{bj.strftime('%m/%d %H:%M')}] {m.get('content','')}") + if not results: + return f"最近{days}天没有灵感记录。" + return "## 最近的灵感记录\n\n" + "\n\n".join(results[-20:]) + except Exception as e: + return f"查询Memos失败: {e}" + + +def tool_gitea_list(repo, path=""): + """List files in a Gitea repo.""" + try: + url = f"{GITEA_BASE}/api/v1/repos/{repo}/contents/{path}" + r = httpx.get(url, headers={"Authorization": "token " + GITEA_TOKEN}, timeout=15) + data = r.json() + if isinstance(data, list): + items = [f"- [{i['type']}] {i['name']}" for i in data] + return f"## {repo}/{path}\n\n" + "\n".join(items) + elif isinstance(data, dict) and "message" in data: + return f"Gitea错误: {data['message']}" + return str(data)[:2000] + except Exception as e: + return f"Gitea读取失败: {e}" + + +def tool_gitea_read(repo, path, ref="main"): + """Read a file from Gitea.""" + try: + url = f"{GITEA_BASE}/api/v1/repos/{repo}/contents/{path.replace('/', '%2F')}?ref={ref}" + r = httpx.get(url, headers={"Authorization": "token " + GITEA_TOKEN}, timeout=15) + data = r.json() + if isinstance(data, dict) and "content" in data: + decoded = base64.b64decode(data["content"]).decode("utf-8") + if len(decoded) > 3000: + decoded = decoded[:3000] + f"\n\n... [截断,全文{len(decoded)}字]" + return decoded + elif isinstance(data, dict) and "message" in data: + return f"Gitea错误: {data['message']}" + return str(data)[:2000] + except Exception as e: + return f"Gitea读取失败: {e}" + + +def tool_blog_latest(count=5): + """Read latest blog posts from RSS.""" + try: + import feedparser + feed = feedparser.parse(BLOG_RSS) + entries = feed.entries[:count] + if not entries: + return "博客暂无文章。" + results = [] + for e in entries: + results.append(f"### {e.title}\n- 时间: {e.get('published', '?')}\n- 链接: {e.link}\n- 摘要: {e.get('summary', '')[:300]}") + return "## 最新博客文章\n\n" + "\n\n".join(results) + except Exception as e: + return f"读取博客失败: {e}" + + +def tool_run_analysis(): + """Run the daily digest and push to Gitea.""" + try: + script = os.path.join(IC_DIR, "analyzers", "daily_digest.py") + if not os.path.exists(script): + return f"分析脚本不存在: {script}" + result = subprocess.run(["python3", script], cwd=IC_DIR, capture_output=True, text=True, timeout=120) + output = (result.stdout or "") + (result.stderr or "") + # Extract key info + lines = [l for l in output.split("\n") if "written" in l.lower() or "done" in l.lower() or "pushed" in l.lower() or "error" in l.lower()] + summary = "\n".join(lines) if lines else output[:500] + return f"灵感分析已运行完成。\n{summary}\n\n查看: https://gitea.xybkwd.top/fxy/inspiration-collector/src/branch/main/ai-insights/daily/" + except subprocess.TimeoutExpired: + return "分析超时(>120秒),可能输出较长,请稍后检查Gitea。" + except Exception as e: + return f"运行分析失败: {e}" + + +def tool_server_status(): + """Check server status.""" + try: + result = [] + # docker + dp = subprocess.run("docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'", shell=True, capture_output=True, text=True, timeout=10) + result.append("### Docker\n" + (dp.stdout or dp.stderr)) + # disk/memory + df = subprocess.run("df -h / | tail -1", shell=True, capture_output=True, text=True, timeout=5) + result.append("### 磁盘\n" + (df.stdout or "")) + fm = subprocess.run("free -h | head -2", shell=True, capture_output=True, text=True, timeout=5) + result.append("### 内存\n" + (fm.stdout or "")) + return "\n".join(result) + except Exception as e: + return f"状态查询失败: {e}" + + +# ============ INTENT DETECTION ============ + +TOOL_PATTERNS = [ + (r"(memos|灵感|最近记录|随手记)", lambda m: tool_memos_list(7)), + (r"(读一读|看一.*gitea|gitea.*文件|obsidian.*笔记|文库)", lambda m: tool_gitea_list("fxy/obsidian-vault")), + (r"(gitea.*obsidian|obsidian.*目录)", lambda m: tool_gitea_list("fxy/obsidian-vault")), + (r"(gitea.*灵感|灵感.*目录|分析.*目录)", lambda m: tool_gitea_list("fxy/inspiration-collector/ai-insights/daily")), + (r"(博客|blog|xybkwd|博文)", lambda m: tool_blog_latest(5)), + (r"(运行分析|跑日报|执行分析|run.*daily|run.*digest)", lambda m: tool_run_analysis()), + (r"(服务器状态|检查状态|docker|容器|磁盘)", lambda m: tool_server_status()), +] + +def detect_intent(msg): + """Detect if message matches a tool pattern. Returns result or None.""" + msg_lower = msg.lower() + for pattern, handler in TOOL_PATTERNS: + if re.search(pattern, msg_lower): + logger.info("Intent matched: %s", pattern) + return handler(msg) + return None + + +# ============ HTML PAGE ============ + +INDEX_HTML = """ + + + + +AI 智能体 + + + + +
+

FXY Agent

就绪
+
+
+ + +
+
+ + +""" + + +@app.get("/") +async def root(): + h = {"Cache-Control": "no-cache, no-store, must-revalidate"} + return HTMLResponse(INDEX_HTML, headers=h) + + +@app.get("/convs") +async def list_convs(): + conn = sqlite3.connect(DB_PATH) + rows = conn.execute("SELECT id, title FROM conversations ORDER BY updated_at DESC LIMIT 50").fetchall() + conn.close() + return {"convs": [{"id": r[0], "title": r[1]} for r in rows]} + + +@app.get("/convs/{conv_id}/messages") +async def get_messages(conv_id: str): + conn = sqlite3.connect(DB_PATH) + rows = conn.execute("SELECT role, content FROM messages WHERE conv_id=? ORDER BY id", (conv_id,)).fetchall() + conn.close() + return {"messages": [{"role": r[0], "content": r[1]} for r in rows]} + + +@app.delete("/convs/{conv_id}") +async def delete_conv(conv_id: str): + conn = sqlite3.connect(DB_PATH) + conn.execute("DELETE FROM messages WHERE conv_id=?", (conv_id,)) + conn.execute("DELETE FROM conversations WHERE id=?", (conv_id,)) + conn.commit() + conn.close() + return {"deleted": conv_id} + + +@app.post("/chat") +async def chat(req: Request): + body = await req.json() + msg = body.get("message", "") + conv_id = body.get("conv_id") + logger.info("User[%s]: %s", conv_id or "new", msg[:80]) + + # Save message to DB + conn = sqlite3.connect(DB_PATH) + if not conv_id: + conv_id = str(uuid.uuid4())[:8] + now = datetime.now(TZ_BJ).strftime("%Y-%m-%d %H:%M:%S") + title = (msg.strip()[:30] + "...") if len(msg.strip()) > 30 else msg.strip() + conn.execute("INSERT INTO conversations (id,title,created_at,updated_at) VALUES (?,?,?,?)", (conv_id, title, now, now)) + conn.execute("INSERT INTO messages (conv_id,role,content) VALUES (?,'user',?)", (conv_id, msg)) + conn.execute("UPDATE conversations SET updated_at=datetime('now','localtime') WHERE id=?", (conv_id,)) + conn.commit() + conn.close() + + # STEP 1: Try intent detection for tool commands + tool_result = detect_intent(msg) + tool_name = None + + if tool_result: + # Determine which tool was used + for pattern, handler in TOOL_PATTERNS: + if re.search(pattern, msg.lower()) and handler(msg) == tool_result: + tool_name = pattern + break + # Simplify tool name for display + if "memos" in msg.lower() or "灵感" in msg: + tool_name = "查Memos" + elif "gitea" in msg.lower() or "文库" in msg or "笔记" in msg or "obsidian" in msg.lower(): + tool_name = "读Gitea" + elif "博客" in msg or "blog" in msg.lower(): + tool_name = "看博客" + elif "分析" in msg or "日报" in msg or "run" in msg.lower(): + tool_name = "运行分析" + elif "状态" in msg or "docker" in msg.lower() or "磁盘" in msg: + tool_name = "服务器状态" + else: + tool_name = "工具" + + # Augment tool result with AI commentary + try: + payload = { + "model": MODEL, + "messages": [ + {"role": "system", "content": "你是一个智能体。用户执行了一个工具操作,以下是工具返回的结果。请用自然语言总结回复用户,指出关键信息。回复要简洁有实质。"}, + {"role": "user", "content": f"工具({tool_name})返回结果:\n\n{tool_result}"} + ], + "temperature": 0.3, "max_tokens": 1024 + } + headers = {"Authorization": "Bearer " + API_KEY, "Content-Type": "application/json"} + async with httpx.AsyncClient(timeout=60) as client: + r = await client.post(API_URL, json=payload, headers=headers) + data = r.json() + reply = data.get("choices", [{}])[0].get("message", {}).get("content", "") + if not reply: + reply = tool_result[:2000] + except Exception: + reply = tool_result[:2000] + + # Save reply + conn2 = sqlite3.connect(DB_PATH) + conn2.execute("INSERT INTO messages (conv_id,role,content) VALUES (?,'assistant',?)", (conv_id, reply)) + conn2.execute("UPDATE conversations SET updated_at=datetime('now','localtime') WHERE id=?", (conv_id,)) + conn2.commit() + conn2.close() + + logger.info("Tool[%s]: %s", tool_name, msg[:60]) + return {"reply": reply, "conv_id": conv_id, "tool": tool_name} + + # STEP 2: No tool intent — normal chat + conn3 = sqlite3.connect(DB_PATH) + rows = conn3.execute("SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 10", (conv_id,)).fetchall() + rows.reverse() + conn3.close() + + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + for r in rows: + messages.append({"role": r[0], "content": r[1]}) + + try: + payload = {"model": MODEL, "messages": messages, "temperature": 0.6, "max_tokens": 4096} + headers = {"Authorization": "Bearer " + API_KEY, "Content-Type": "application/json"} + async with httpx.AsyncClient(timeout=120) as client: + r = await client.post(API_URL, json=payload, headers=headers) + data = r.json() + reply = data.get("choices", [{}])[0].get("message", {}).get("content", "") + + conn4 = sqlite3.connect(DB_PATH) + conn4.execute("INSERT INTO messages (conv_id,role,content) VALUES (?,'assistant',?)", (conv_id, reply)) + conn4.execute("UPDATE conversations SET updated_at=datetime('now','localtime') WHERE id=?", (conv_id,)) + conn4.commit() + conn4.close() + + logger.info("Chat[%s]: %d chars", conv_id, len(reply)) + return {"reply": reply, "conv_id": conv_id} + + except Exception as e: + logger.error("Error: %s", e) + return JSONResponse({"error": str(e)}, status_code=500) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8900) diff --git a/chat.db b/chat.db new file mode 100644 index 0000000000000000000000000000000000000000..20999600d5f41a690803bf31935faafa09266bb0 GIT binary patch literal 61440 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%0DWR_)JHAWMNP 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 VN(#V%VM&3ol0c@U6UTGK{}1$%