feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
215
coworker/teams/chat.py
Normal file
215
coworker/teams/chat.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""The chat store — group chat as its own abstraction (eighth pass, 2026-08-16).
|
||||
|
||||
A GROUP is `{group_id, name, members[]}` plus an append-only message log and
|
||||
per-member unread cursors. One group per team in v1 (created at the staffing gate
|
||||
when chat is enabled), but nothing here knows about boards or teams — groups can
|
||||
later serve non-team chats and the external-chat dialect.
|
||||
|
||||
Wake semantics live in the read side: an agent post is "for" exactly its @mentioned
|
||||
members; a USER post is for every member ([User] outranks — posting to the channel
|
||||
is rare and deliberate). Un-mentioned agent chatter wakes nobody, which is what
|
||||
keeps chat an exception channel structurally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .model import BoardError
|
||||
|
||||
|
||||
class ChatStore:
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self.db_path = str(db_path)
|
||||
if self.db_path != ":memory:":
|
||||
Path(self.db_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS chat_groups (
|
||||
group_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
members TEXT NOT NULL,
|
||||
created_ts TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
author_role TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
mentions TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_group ON chat_messages (group_id, seq);
|
||||
CREATE TABLE IF NOT EXISTS chat_cursors (
|
||||
cursor_key TEXT PRIMARY KEY,
|
||||
read_seq INTEGER NOT NULL
|
||||
);
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
# ---------------------------------------------------------------------- groups
|
||||
|
||||
def create_group(self, name: str, members: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""`members`: [{name, persona, role}] — `name` is the member's handle
|
||||
(@mention target). The user participates implicitly and is not a member row."""
|
||||
handles = [str(m.get("name", "")).strip() for m in members]
|
||||
if not name.strip():
|
||||
raise BoardError("group name is required")
|
||||
if not all(handles) or len(set(handles)) != len(handles):
|
||||
raise BoardError("every member needs a unique name")
|
||||
group = {
|
||||
"group_id": uuid.uuid4().hex[:12],
|
||||
"name": name.strip(),
|
||||
"members": [
|
||||
{
|
||||
"name": str(m.get("name")),
|
||||
"persona": str(m.get("persona", "")),
|
||||
"role": str(m.get("role", "worker")),
|
||||
}
|
||||
for m in members
|
||||
],
|
||||
"created_ts": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT INTO chat_groups (group_id, name, members, created_ts)"
|
||||
" VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
group["group_id"],
|
||||
group["name"],
|
||||
json.dumps(group["members"]),
|
||||
group["created_ts"],
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
return group
|
||||
|
||||
def get_group(self, group_id: str) -> Optional[dict[str, Any]]:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM chat_groups WHERE group_id = ?", (group_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
group = dict(row)
|
||||
group["members"] = json.loads(group.pop("members") or "[]")
|
||||
return group
|
||||
|
||||
# -------------------------------------------------------------------- messages
|
||||
|
||||
def post(
|
||||
self, group_id: str, author: str, text: str, *, author_role: str = "worker"
|
||||
) -> dict[str, Any]:
|
||||
"""Append one message. Mentions are parsed against member handles —
|
||||
`@name` anywhere in the text — so tagging needs no separate parameter."""
|
||||
group = self.get_group(group_id)
|
||||
if group is None:
|
||||
raise BoardError(f"no chat group '{group_id}'")
|
||||
if not (text or "").strip():
|
||||
raise BoardError("message text is required")
|
||||
handles = {m["name"] for m in group["members"]}
|
||||
mentions = sorted(
|
||||
{
|
||||
m.group(1)
|
||||
for m in re.finditer(r"@([\w.-]+)", text)
|
||||
if m.group(1) in handles
|
||||
}
|
||||
)
|
||||
message = {
|
||||
"group_id": group_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"author": author,
|
||||
"author_role": author_role,
|
||||
"text": text,
|
||||
"mentions": mentions,
|
||||
}
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"INSERT INTO chat_messages"
|
||||
" (group_id, ts, author, author_role, text, mentions)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
group_id,
|
||||
message["ts"],
|
||||
author,
|
||||
author_role,
|
||||
text,
|
||||
json.dumps(mentions),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
return {**message, "seq": cursor.lastrowid}
|
||||
|
||||
def messages(
|
||||
self, group_id: str, *, since_seq: int = 0, limit: int = 200
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM chat_messages WHERE group_id = ? AND seq > ?"
|
||||
" ORDER BY seq LIMIT ?",
|
||||
(group_id, since_seq, max(1, min(int(limit or 200), 2000))),
|
||||
).fetchall()
|
||||
return [_row_to_message(row) for row in rows]
|
||||
|
||||
# ------------------------------------------------------- unread / wake reads
|
||||
|
||||
def unread_for(self, group_id: str, member: str) -> list[dict[str, Any]]:
|
||||
"""Messages this member should be WOKEN for: posts that @mention it, plus
|
||||
every user post. Its own posts never count."""
|
||||
out = []
|
||||
for message in self.messages(group_id, since_seq=self._cursor(group_id, member)):
|
||||
if message["author"] == member:
|
||||
continue
|
||||
if member in message["mentions"] or message["author_role"] == "user":
|
||||
out.append(message)
|
||||
return out
|
||||
|
||||
def unread_count(self, group_id: str, member: str) -> int:
|
||||
"""Plain unread count (all messages since the member's cursor) — drives the
|
||||
sidebar badge for the USER, whose 'member' key is "user"."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM chat_messages WHERE group_id = ?"
|
||||
" AND seq > ? AND author != ?",
|
||||
(group_id, self._cursor(group_id, member), member),
|
||||
).fetchone()
|
||||
return int(row["n"])
|
||||
|
||||
def consume(self, group_id: str, member: str, upto_seq: int) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT INTO chat_cursors (cursor_key, read_seq) VALUES (?, ?)"
|
||||
" ON CONFLICT(cursor_key) DO UPDATE SET read_seq ="
|
||||
" MAX(read_seq, ?)",
|
||||
(f"{group_id}:{member}", int(upto_seq), int(upto_seq)),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def _cursor(self, group_id: str, member: str) -> int:
|
||||
row = self._conn.execute(
|
||||
"SELECT read_seq FROM chat_cursors WHERE cursor_key = ?",
|
||||
(f"{group_id}:{member}",),
|
||||
).fetchone()
|
||||
return int(row["read_seq"]) if row else 0
|
||||
|
||||
|
||||
def _row_to_message(row: sqlite3.Row) -> dict[str, Any]:
|
||||
message = dict(row)
|
||||
try:
|
||||
message["mentions"] = json.loads(message.get("mentions") or "[]")
|
||||
except json.JSONDecodeError:
|
||||
message["mentions"] = []
|
||||
return message
|
||||
Reference in New Issue
Block a user