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_MODEL=deepseek-v4-flash
# AI_MAX_CONTEXT_MESSAGES=100
# AI_PROMPT_DIR=prompts
# AI_MAX_MEDIA_BYTES=5242880
# AI_MAX_TOOL_ROUNDS=16
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`.
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
@@ -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`.
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.

View File

@@ -123,7 +123,9 @@ def load_prompt_templates(prompt_dir: Path) -> PromptTemplates:
(prompt_dir / "USER.txt").read_text(encoding="utf-8")
)
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():
raise AgentError("Prompt templates must not be empty")
return PromptTemplates(master=master, user=user)

View File

@@ -54,11 +54,19 @@ def _split_transcript(value: str, *, limit: int = 4000) -> list[str]:
return chunks
async def _dislike(message: Any) -> None:
async def _set_reaction(message: Any, emoji: str | None) -> None:
try:
await message.react("👎")
await message.react(emoji)
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:
@@ -416,6 +424,7 @@ async def echo(ctx: "CommandContext") -> None:
category="shared",
).shared()
async def ai(ctx: "CommandContext") -> None:
await _set_reaction(ctx.message, "🤔")
failed = False
try:
invocation = parse_agent_invocation(ctx.args, ctx.manager.config.ai)
@@ -436,6 +445,8 @@ async def ai(ctx: "CommandContext") -> None:
if failed:
await _dislike(ctx.message)
else:
await _set_reaction(ctx.message, None)
@command_registry.command(

View File

@@ -168,6 +168,14 @@ def _parse_elevenlabs_config(raw: Any) -> ElevenLabsConfig:
def _parse_ai_config(raw: Any) -> AiConfig:
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(
name: str,
*,
@@ -286,6 +294,7 @@ def _parse_ai_config(raw: Any) -> AiConfig:
return AiConfig(
glm=glm,
deepseek=deepseek,
prompt_dir=prompt_dir,
max_context_messages=max_context_messages,
max_media_bytes=max_media_bytes,
max_tool_rounds=max_tool_rounds,
@@ -439,7 +448,9 @@ def load_app_config(root: Path) -> AppConfig:
elevenlabs = _parse_elevenlabs_config(raw.get("elevenlabs"))
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
if not accounts:

View File

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

View File

@@ -4,7 +4,7 @@ import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, call, patch
from app.agentic import (
AgentSessionHandle,
@@ -302,7 +302,7 @@ class AiCommandTests(unittest.IsolatedAsyncioTestCase):
handle,
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:
ctx = self._ctx(".ds .th")
@@ -310,7 +310,7 @@ class AiCommandTests(unittest.IsolatedAsyncioTestCase):
with self.assertLogs("app.commands.builtins", level="ERROR"):
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()
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"):
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()
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__":
unittest.main()

View File

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

View File

@@ -26,6 +26,9 @@ use_qr = true
[elevenlabs]
api_key = "test-key"
model_id = "scribe_v2"
[ai]
prompt_dir = "custom-prompts"
""".strip(),
encoding="utf-8",
)
@@ -38,7 +41,7 @@ model_id = "scribe_v2"
self.assertTrue(config.accounts[0].use_qr)
self.assertEqual(config.elevenlabs.api_key, "test-key")
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)