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_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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user