feat: show AI progress and configure prompt directory
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user