feat: show AI progress and configure prompt directory

This commit is contained in:
Azimkin
2026-09-14 14:25:50 +02:00
parent b68e715267
commit 40e7930e60
9 changed files with 64 additions and 12 deletions

View File

@@ -14,6 +14,7 @@ DEEPSEEK_API_URL=https://api.deepseek.com
DEEPSEEK_API_KEY=your_deepseek_api_key_here DEEPSEEK_API_KEY=your_deepseek_api_key_here
DEEPSEEK_MODEL=deepseek-v4-flash DEEPSEEK_MODEL=deepseek-v4-flash
# AI_MAX_CONTEXT_MESSAGES=100 # AI_MAX_CONTEXT_MESSAGES=100
# AI_PROMPT_DIR=prompts
# AI_MAX_MEDIA_BYTES=5242880 # AI_MAX_MEDIA_BYTES=5242880
# AI_MAX_TOOL_ROUNDS=16 # AI_MAX_TOOL_ROUNDS=16
BRAVE_SEARCH_API_KEY=your_brave_search_api_key_here BRAVE_SEARCH_API_KEY=your_brave_search_api_key_here

View File

@@ -30,7 +30,7 @@ The `.txt` transcription command uses `[elevenlabs]` settings or `ELEVENLABS_API
The `.ai` agent command uses `[ai.glm]` by default and `[ai.deepseek]` with the `.ds` flag. Provider environment variables are `GLM_API_URL`, `GLM_API_KEY`, `GLM_MODEL`, `DEEPSEEK_API_URL`, `DEEPSEEK_API_KEY`, and `DEEPSEEK_MODEL`. Z.AI GLM uses the Chat Completions protocol; DeepSeek uses the Responses protocol. `.th` enables reasoning. Brave Search is available in every AI session and requires `BRAVE_SEARCH_API_KEY` or `ai.brave_api_key`. Crawl4AI's `read_web_page` tool is also always available and uses headless Playwright Chromium to render public pages into Markdown before analysis. Configure it with `ai.web_page_timeout_seconds` / `AI_WEB_PAGE_TIMEOUT_SECONDS` and `ai.web_page_max_chars` / `AI_WEB_PAGE_MAX_CHARS`. The `.ai` agent command uses `[ai.glm]` by default and `[ai.deepseek]` with the `.ds` flag. Provider environment variables are `GLM_API_URL`, `GLM_API_KEY`, `GLM_MODEL`, `DEEPSEEK_API_URL`, `DEEPSEEK_API_KEY`, and `DEEPSEEK_MODEL`. Z.AI GLM uses the Chat Completions protocol; DeepSeek uses the Responses protocol. `.th` enables reasoning. Brave Search is available in every AI session and requires `BRAVE_SEARCH_API_KEY` or `ai.brave_api_key`. Crawl4AI's `read_web_page` tool is also always available and uses headless Playwright Chromium to render public pages into Markdown before analysis. Configure it with `ai.web_page_timeout_seconds` / `AI_WEB_PAGE_TIMEOUT_SECONDS` and `ai.web_page_max_chars` / `AI_WEB_PAGE_MAX_CHARS`.
AI prompt templates are always loaded from `prompts/MASTER.txt` and `prompts/USER.txt` for every invocation. C-style `/* ... */` blocks and `//` comments (at line start or following whitespace) are removed before rendering. Both `{name}` and `{{name}}` placeholders are supported, including dotted values such as `caller_user.id`, `caller_user.name`, `owner.id`, `owner.name`, `is_owner`, `text`, `chat.*`, `message.*`, `reply.*`, `account.*`, `provider`, `thinking`, and `context_messages`. Unknown placeholders fail the command with 👎. AI prompt templates are always loaded from `MASTER.txt` and `USER.txt` in `ai.prompt_dir` (or `AI_PROMPT_DIR`) for every invocation. The directory defaults to `prompts`; relative paths are resolved from the repository root. C-style `/* ... */` blocks and `//` comments (at line start or following whitespace) are removed before rendering. Both `{name}` and `{{name}}` placeholders are supported, including dotted values such as `caller_user.id`, `caller_user.name`, `owner.id`, `owner.name`, `is_owner`, `text`, `chat.*`, `message.*`, `reply.*`, `account.*`, `provider`, `thinking`, and `context_messages`. Unknown placeholders fail the command with 👎.
## Architecture ## Architecture
@@ -55,7 +55,9 @@ On supported platforms, `main.py` runs the application with `uvloop`; Windows au
`.ai [count] [.ds] [.th] <prompt>` runs `micro-agents` with GLM by default. `.ds` selects DeepSeek and `.th` enables reasoning. Every session exposes the `micro-agents-tools` Brave Search tool. Unknown leading dot-arguments are ignored. The optional count adds preceding Telegram messages; a replied message is always injected once. Media at or below 5 MiB is attached. Invalid syntax and runtime/API failures only react with 👎. Guests need an ACL grant for `ai` and invoke it as `..ai`. `.ai [count] [.ds] [.th] <prompt>` runs `micro-agents` with GLM by default. `.ds` selects DeepSeek and `.th` enables reasoning. Every session exposes the `micro-agents-tools` Brave Search tool. Unknown leading dot-arguments are ignored. The optional count adds preceding Telegram messages; a replied message is always injected once. Media at or below 5 MiB is attached. Invalid syntax and runtime/API failures only react with 👎. Guests need an ACL grant for `ai` and invoke it as `..ai`.
The master and user prompt callbacks must render `prompts/MASTER.txt` and `prompts/USER.txt`; do not add a config or hard-coded fallback that bypasses those files. While an `.ai` command is running, its Telegram message has a 🤔 reaction. The reaction is removed after a successful answer and replaced with 👎 on failure.
The master and user prompt callbacks must render `MASTER.txt` and `USER.txt` from the configured prompt directory; do not add a hard-coded fallback that bypasses those files.
Every historical or replied Telegram message is a `UserMessage` rendered through `USER.txt`; `message.id`, `message.text`, `message.date`, `message.user.*`, and `message.is_reply` describe that specific message. Filesystem and Python execution tools are registered through `AgentToolManager` but exposed and executable only when `is_owner` is true. Telegram `send_message`, `send_image`, and `send_file` tools target the current chat. Owner file sends are confined to the repository root; guest file sends are confined to the current session's temporary media directory to prevent repository data disclosure. Every historical or replied Telegram message is a `UserMessage` rendered through `USER.txt`; `message.id`, `message.text`, `message.date`, `message.user.*`, and `message.is_reply` describe that specific message. Filesystem and Python execution tools are registered through `AgentToolManager` but exposed and executable only when `is_owner` is true. Telegram `send_message`, `send_image`, and `send_file` tools target the current chat. Owner file sends are confined to the repository root; guest file sends are confined to the current session's temporary media directory to prevent repository data disclosure.

View File

@@ -123,7 +123,9 @@ def load_prompt_templates(prompt_dir: Path) -> PromptTemplates:
(prompt_dir / "USER.txt").read_text(encoding="utf-8") (prompt_dir / "USER.txt").read_text(encoding="utf-8")
) )
except OSError as exc: except OSError as exc:
raise AgentError("Could not load prompts/MASTER.txt and prompts/USER.txt") from exc raise AgentError(
f"Could not load {prompt_dir / 'MASTER.txt'} and {prompt_dir / 'USER.txt'}"
) from exc
if not master.strip() or not user.strip(): if not master.strip() or not user.strip():
raise AgentError("Prompt templates must not be empty") raise AgentError("Prompt templates must not be empty")
return PromptTemplates(master=master, user=user) return PromptTemplates(master=master, user=user)

View File

@@ -54,11 +54,19 @@ def _split_transcript(value: str, *, limit: int = 4000) -> list[str]:
return chunks return chunks
async def _dislike(message: Any) -> None: async def _set_reaction(message: Any, emoji: str | None) -> None:
try: try:
await message.react("👎") await message.react(emoji)
except Exception: except Exception:
log.exception("Could not add a dislike reaction to message %s", getattr(message, "id", None)) log.exception(
"Could not set reaction %r on message %s",
emoji,
getattr(message, "id", None),
)
async def _dislike(message: Any) -> None:
await _set_reaction(message, "👎")
def _transcribable_media(message: Any) -> Any | None: def _transcribable_media(message: Any) -> Any | None:
@@ -416,6 +424,7 @@ async def echo(ctx: "CommandContext") -> None:
category="shared", category="shared",
).shared() ).shared()
async def ai(ctx: "CommandContext") -> None: async def ai(ctx: "CommandContext") -> None:
await _set_reaction(ctx.message, "🤔")
failed = False failed = False
try: try:
invocation = parse_agent_invocation(ctx.args, ctx.manager.config.ai) invocation = parse_agent_invocation(ctx.args, ctx.manager.config.ai)
@@ -436,6 +445,8 @@ async def ai(ctx: "CommandContext") -> None:
if failed: if failed:
await _dislike(ctx.message) await _dislike(ctx.message)
else:
await _set_reaction(ctx.message, None)
@command_registry.command( @command_registry.command(

View File

@@ -168,6 +168,14 @@ def _parse_elevenlabs_config(raw: Any) -> ElevenLabsConfig:
def _parse_ai_config(raw: Any) -> AiConfig: def _parse_ai_config(raw: Any) -> AiConfig:
raw_config = _parse_mapping(raw, field_name="ai") raw_config = _parse_mapping(raw, field_name="ai")
raw_prompt_dir = raw_config.get("prompt_dir", "prompts")
prompt_dir_value = str(raw_prompt_dir).strip()
if os.environ.get("AI_PROMPT_DIR") is not None:
prompt_dir_value = os.environ["AI_PROMPT_DIR"].strip()
if not prompt_dir_value:
raise ValueError("ai.prompt_dir is required")
prompt_dir = Path(prompt_dir_value).expanduser()
def parse_provider( def parse_provider(
name: str, name: str,
*, *,
@@ -286,6 +294,7 @@ def _parse_ai_config(raw: Any) -> AiConfig:
return AiConfig( return AiConfig(
glm=glm, glm=glm,
deepseek=deepseek, deepseek=deepseek,
prompt_dir=prompt_dir,
max_context_messages=max_context_messages, max_context_messages=max_context_messages,
max_media_bytes=max_media_bytes, max_media_bytes=max_media_bytes,
max_tool_rounds=max_tool_rounds, max_tool_rounds=max_tool_rounds,
@@ -439,7 +448,9 @@ def load_app_config(root: Path) -> AppConfig:
elevenlabs = _parse_elevenlabs_config(raw.get("elevenlabs")) elevenlabs = _parse_elevenlabs_config(raw.get("elevenlabs"))
ai = _parse_ai_config(raw.get("ai")) ai = _parse_ai_config(raw.get("ai"))
ai.prompt_dir = root / "prompts" if not ai.prompt_dir.is_absolute():
ai.prompt_dir = root / ai.prompt_dir
ai.prompt_dir = ai.prompt_dir.resolve()
ai.base_dir = root ai.base_dir = root
if not accounts: if not accounts:

View File

@@ -19,6 +19,7 @@ language_code = ""
request_timeout_seconds = 300 request_timeout_seconds = 300
[ai] [ai]
prompt_dir = "prompts"
max_context_messages = 100 max_context_messages = 100
max_media_bytes = 5242880 max_media_bytes = 5242880
max_tool_rounds = 16 max_tool_rounds = 16

View File

@@ -4,7 +4,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, call, patch
from app.agentic import ( from app.agentic import (
AgentSessionHandle, AgentSessionHandle,
@@ -302,7 +302,7 @@ class AiCommandTests(unittest.IsolatedAsyncioTestCase):
handle, handle,
ctx.reply.return_value, ctx.reply.return_value,
) )
ctx.message.react.assert_not_awaited() self.assertEqual(ctx.message.react.await_args_list, [call("🤔"), call(None)])
async def test_invalid_syntax_only_dislikes(self) -> None: async def test_invalid_syntax_only_dislikes(self) -> None:
ctx = self._ctx(".ds .th") ctx = self._ctx(".ds .th")
@@ -310,7 +310,7 @@ class AiCommandTests(unittest.IsolatedAsyncioTestCase):
with self.assertLogs("app.commands.builtins", level="ERROR"): with self.assertLogs("app.commands.builtins", level="ERROR"):
await ai(ctx) await ai(ctx)
ctx.message.react.assert_awaited_once_with("👎") self.assertEqual(ctx.message.react.await_args_list, [call("🤔"), call("👎")])
ctx.reply.assert_not_awaited() ctx.reply.assert_not_awaited()
async def test_agent_error_only_dislikes(self) -> None: async def test_agent_error_only_dislikes(self) -> None:
@@ -322,9 +322,27 @@ class AiCommandTests(unittest.IsolatedAsyncioTestCase):
with self.assertLogs("app.commands.builtins", level="ERROR"): with self.assertLogs("app.commands.builtins", level="ERROR"):
await ai(ctx) await ai(ctx)
ctx.message.react.assert_awaited_once_with("👎") self.assertEqual(ctx.message.react.await_args_list, [call("🤔"), call("👎")])
ctx.reply.assert_not_awaited() ctx.reply.assert_not_awaited()
async def test_thinking_reaction_failure_does_not_stop_ai(self) -> None:
ctx = self._ctx()
ctx.message.react.side_effect = [RuntimeError("reactions disabled"), None]
with (
patch(
"app.commands.builtins.run_agent_turn",
AsyncMock(
return_value=SimpleNamespace(answer="answer", handle=MagicMock())
),
),
self.assertLogs("app.commands.builtins", level="ERROR"),
):
await ai(ctx)
ctx.reply.assert_awaited_once_with("answer")
self.assertEqual(ctx.message.react.await_args_list, [call("🤔"), call(None)])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from app.config import _parse_ai_config, _parse_elevenlabs_config from app.config import _parse_ai_config, _parse_elevenlabs_config
@@ -65,6 +66,7 @@ class ConfigAiTests(unittest.TestCase):
"DEEPSEEK_MODEL": "env-ds-model", "DEEPSEEK_MODEL": "env-ds-model",
"AI_WEB_PAGE_TIMEOUT_SECONDS": "30", "AI_WEB_PAGE_TIMEOUT_SECONDS": "30",
"AI_WEB_PAGE_MAX_CHARS": "54321", "AI_WEB_PAGE_MAX_CHARS": "54321",
"AI_PROMPT_DIR": "alternate-prompts",
} }
with patch.dict("os.environ", env, clear=True): with patch.dict("os.environ", env, clear=True):
config = _parse_ai_config({}) config = _parse_ai_config({})
@@ -75,6 +77,7 @@ class ConfigAiTests(unittest.TestCase):
self.assertEqual(config.deepseek.model, "env-ds-model") self.assertEqual(config.deepseek.model, "env-ds-model")
self.assertEqual(config.web_page_timeout_seconds, 30) self.assertEqual(config.web_page_timeout_seconds, 30)
self.assertEqual(config.web_page_max_chars, 54321) self.assertEqual(config.web_page_max_chars, 54321)
self.assertEqual(config.prompt_dir, Path("alternate-prompts"))
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -26,6 +26,9 @@ use_qr = true
[elevenlabs] [elevenlabs]
api_key = "test-key" api_key = "test-key"
model_id = "scribe_v2" model_id = "scribe_v2"
[ai]
prompt_dir = "custom-prompts"
""".strip(), """.strip(),
encoding="utf-8", encoding="utf-8",
) )
@@ -38,7 +41,7 @@ model_id = "scribe_v2"
self.assertTrue(config.accounts[0].use_qr) self.assertTrue(config.accounts[0].use_qr)
self.assertEqual(config.elevenlabs.api_key, "test-key") self.assertEqual(config.elevenlabs.api_key, "test-key")
self.assertEqual(config.session_dir, root / "sessions") self.assertEqual(config.session_dir, root / "sessions")
self.assertEqual(config.ai.prompt_dir, root / "prompts") self.assertEqual(config.ai.prompt_dir, root / "custom-prompts")
self.assertEqual(config.ai.base_dir, root) self.assertEqual(config.ai.base_dir, root)