mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f60ec41f1 | ||
|
|
f23aacdec9 | ||
|
|
17cd4d9f86 | ||
|
|
43935e4e41 | ||
|
|
8dc8b9f49f | ||
|
|
135b05435c | ||
|
|
be502d823f | ||
|
|
9be1266747 | ||
|
|
8ce13a9fec | ||
|
|
e6a346aa22 |
No files matched your search
@@ -3,7 +3,7 @@
|
||||
"stack": "claude",
|
||||
"profile": "sdk",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.7",
|
||||
"hubRef": "v1.1.8",
|
||||
"templateVersion": "1.1.7",
|
||||
"installedAt": "2026-09-07T10:02:11Z"
|
||||
}
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.8
|
||||
@@ -16,8 +16,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.8
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -25,8 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.8
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].7
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].8
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.8
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -87,9 +87,9 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.8
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.8
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].7
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].8
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.8
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.8
|
||||
@@ -17,6 +17,25 @@ def _tcp_open(host: str, port: int, timeout: float = 2.0) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_MCP_PROBE_TIMEOUT_SEC = 2.0
|
||||
|
||||
|
||||
def _is_slow_mcp_probe(detail: str) -> bool:
|
||||
lowered = detail.lower()
|
||||
return any(token in lowered for token in ("timed out", "timeout", "temporarily unavailable"))
|
||||
|
||||
|
||||
def _mcp_servers_probe(app_port: int) -> tuple[str, str]:
|
||||
"""Probe MCP list endpoint; slow stdio connect must not fail the whole doctor run."""
|
||||
status, detail = _http_status(
|
||||
f"http://127.0.0.1:{app_port}/api/mcp/servers",
|
||||
timeout=_MCP_PROBE_TIMEOUT_SEC,
|
||||
)
|
||||
if status == "fail" and _is_slow_mcp_probe(detail):
|
||||
return "warn", detail
|
||||
return status, detail
|
||||
|
||||
|
||||
def _http_status(url: str, timeout: float = 5.0) -> tuple[str, str]:
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "praisonai-voice-doctor"})
|
||||
@@ -64,7 +83,7 @@ def run_voice_doctor(*, app_port: int = 8001, sidecar_port: int = 8002) -> dict[
|
||||
checks.append({"name": "Speech Engine config", "status": status, "detail": detail})
|
||||
status, detail = _http_status(f"http://127.0.0.1:{app_port}/api/voice/realtime/config")
|
||||
checks.append({"name": "GPT Realtime config", "status": status, "detail": detail})
|
||||
status, detail = _http_status(f"http://127.0.0.1:{app_port}/api/mcp/servers")
|
||||
status, detail = _mcp_servers_probe(app_port)
|
||||
checks.append({"name": "MCP servers API", "status": status, "detail": detail})
|
||||
else:
|
||||
checks.append(
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.144"
|
||||
version = "0.3.146"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.144"
|
||||
__version__ = "0.3.146"
|
||||
@@ -44,6 +44,43 @@ def _icon(symbol: str, fallback: str) -> str:
|
||||
return symbol if _supports_unicode() else fallback
|
||||
|
||||
|
||||
def _load_env_files() -> int:
|
||||
"""Load environment variables from the standard PraisonAI ``.env`` files.
|
||||
|
||||
Mirrors the PraisonAI CLI convention so ``env:VAR`` references (e.g. in
|
||||
Channels config) resolve when the server was started without the shell
|
||||
exporting the tokens. Reads ``~/.praisonai/.env`` first, then a ``.env`` in
|
||||
the current working directory. Existing process env vars are never
|
||||
overridden, and parsing is intentionally dependency-free (no python-dotenv).
|
||||
|
||||
Returns the number of variables newly set into ``os.environ``.
|
||||
"""
|
||||
import os
|
||||
|
||||
loaded = 0
|
||||
for env_path in (Path.home() / ".praisonai" / ".env", Path.cwd() / ".env"):
|
||||
try:
|
||||
if not env_path.is_file():
|
||||
continue
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[len("export "):]
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
loaded += 1
|
||||
except Exception:
|
||||
# Never let env loading break startup — resolution falls back to
|
||||
# the existing process environment.
|
||||
continue
|
||||
return loaded
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
name="aiui",
|
||||
help="PraisonAIUI - YAML-driven website generator",
|
||||
@@ -1380,6 +1417,17 @@ def run(
|
||||
console.print(f"[red]Error:[/red] App file not found: {app_file}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Auto-load standard PraisonAI env files so `env:VAR` references (e.g. in
|
||||
# Channels config) resolve even when the shell did not export the tokens.
|
||||
# This matches the PraisonAI CLI convention and fixes the "Connected but
|
||||
# bot never replies" class of issues caused by empty env-resolved tokens.
|
||||
_loaded = _load_env_files()
|
||||
if _loaded:
|
||||
console.print(
|
||||
f"[green]{_icon('✓', 'OK')}[/green] Loaded {_loaded} environment "
|
||||
f"variable(s) from PraisonAI config"
|
||||
)
|
||||
|
||||
is_yaml = app_file.suffix in (".yaml", ".yml")
|
||||
|
||||
if is_yaml:
|
||||
|
||||
@@ -652,13 +652,24 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
if hasattr(bot, "_session") and hasattr(bot._session, "chat"):
|
||||
_original_chat = bot._session.chat
|
||||
|
||||
async def _wrapped_chat(agent: Any, user_id: str, text: str) -> str:
|
||||
async def _wrapped_chat(
|
||||
agent: Any,
|
||||
user_id: str,
|
||||
text: str,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Wrap agent chat to broadcast responses AND intermediate
|
||||
tool/step events to the Chat UI.
|
||||
|
||||
Hooks into the agent's StreamEventEmitter (same pattern as
|
||||
PraisonAIProvider._run_direct_mode) to capture tool_call,
|
||||
reasoning, and content events during execution.
|
||||
|
||||
Any extra positional/keyword arguments (e.g. ``chat_id``,
|
||||
``platform``, ``thread_id``) are forwarded untouched to the
|
||||
wrapped session ``chat`` so per-conversation memory/session
|
||||
continuity is preserved instead of raising ``TypeError``.
|
||||
"""
|
||||
from .chat import _enrich_tool_payload, get_chat_manager
|
||||
|
||||
@@ -831,7 +842,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
drain_task = asyncio.create_task(_drain_events())
|
||||
|
||||
try:
|
||||
response = await _original_chat(agent, user_id, text)
|
||||
response = await _original_chat(agent, user_id, text, *args, **kwargs)
|
||||
finally:
|
||||
# Signal drain to stop and clean up callback
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Regression coverage for issue #287: the channels chat bridge dropped
|
||||
extra keyword arguments (e.g. ``chat_id``, ``platform``) because the
|
||||
``_wrapped_chat`` wrapper installed over ``bot._session.chat`` had a fixed
|
||||
``(agent, user_id, text)`` signature.
|
||||
|
||||
When the bot internals invoked the session with per-conversation kwargs the
|
||||
wrapper raised ``TypeError: _wrapped_chat() got an unexpected keyword
|
||||
argument 'chat_id'`` and the bot silently failed to reply. The wrapper must
|
||||
forward any extra positional/keyword arguments untouched to the original
|
||||
session ``chat`` so memory/session continuity is preserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.features.channels import ChannelsFeature
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Minimal session whose ``chat`` records forwarded arguments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received_args: tuple = ()
|
||||
self.received_kwargs: dict = {}
|
||||
|
||||
async def chat(self, agent: Any, user_id: str, text: str, *args: Any, **kwargs: Any) -> str:
|
||||
self.received_args = args
|
||||
self.received_kwargs = dict(kwargs)
|
||||
return f"reply to {text}"
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self) -> None:
|
||||
self._session = _FakeSession()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapped_chat_forwards_kwargs() -> None:
|
||||
feature = ChannelsFeature()
|
||||
bot = _FakeBot()
|
||||
|
||||
feature._attach_chat_bridge("chan-1", bot, "telegram")
|
||||
|
||||
# The bridge replaced the session chat with the wrapper.
|
||||
assert bot._session.chat.__name__ == "_wrapped_chat"
|
||||
|
||||
reply = await bot._session.chat(
|
||||
object(),
|
||||
"user-42",
|
||||
"Hi",
|
||||
chat_id="987654321",
|
||||
platform="telegram",
|
||||
user_id_meta="987654321",
|
||||
)
|
||||
|
||||
assert reply == "reply to Hi"
|
||||
assert bot._session.received_kwargs["chat_id"] == "987654321"
|
||||
assert bot._session.received_kwargs["platform"] == "telegram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapped_chat_forwards_extra_positional_args() -> None:
|
||||
feature = ChannelsFeature()
|
||||
bot = _FakeBot()
|
||||
|
||||
feature._attach_chat_bridge("chan-2", bot, "slack")
|
||||
|
||||
reply = await bot._session.chat(object(), "user-7", "Hello", "extra")
|
||||
|
||||
assert reply == "reply to Hello"
|
||||
assert bot._session.received_args == ("extra",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapped_chat_still_works_without_kwargs() -> None:
|
||||
"""No-kwargs path (web UI / existing callers) must keep working."""
|
||||
feature = ChannelsFeature()
|
||||
bot = _FakeBot()
|
||||
|
||||
feature._attach_chat_bridge("chan-3", bot, "discord")
|
||||
|
||||
reply = await bot._session.chat(object(), "user-9", "Ping")
|
||||
|
||||
assert reply == "reply to Ping"
|
||||
assert bot._session.received_kwargs == {}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Unit tests for `_load_env_files` — auto-loading PraisonAI `.env` files.
|
||||
|
||||
Regression coverage for issue #286: `env:VAR` channel token references
|
||||
resolved to empty strings because `aiui run` never loaded the standard
|
||||
`~/.praisonai/.env` file, so bots showed "Connected" but never replied.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from praisonaiui.cli import _load_env_files
|
||||
|
||||
|
||||
def _write_env(path, body):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
def test_loads_home_praisonai_env(tmp_path, monkeypatch):
|
||||
"""Variables in ~/.praisonai/.env are loaded into the process env."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
|
||||
|
||||
_write_env(tmp_path / ".praisonai" / ".env", 'TELEGRAM_BOT_TOKEN="123:AAtoken"\n')
|
||||
|
||||
loaded = _load_env_files()
|
||||
|
||||
assert loaded >= 1
|
||||
assert os.environ["TELEGRAM_BOT_TOKEN"] == "123:AAtoken"
|
||||
|
||||
|
||||
def test_does_not_override_existing_env(tmp_path, monkeypatch):
|
||||
"""An already-set process variable is never clobbered by the file."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-from-shell")
|
||||
|
||||
_write_env(tmp_path / ".praisonai" / ".env", "SLACK_BOT_TOKEN=xoxb-from-file\n")
|
||||
|
||||
_load_env_files()
|
||||
|
||||
assert os.environ["SLACK_BOT_TOKEN"] == "xoxb-from-shell"
|
||||
|
||||
|
||||
def test_ignores_comments_blanks_and_export_prefix(tmp_path, monkeypatch):
|
||||
"""Comments/blank lines are skipped and `export ` prefixes are stripped."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("FOO", raising=False)
|
||||
monkeypatch.delenv("BAR", raising=False)
|
||||
|
||||
_write_env(
|
||||
tmp_path / ".praisonai" / ".env",
|
||||
"# a comment\n\nexport FOO=foo\nBAR='bar'\n",
|
||||
)
|
||||
|
||||
_load_env_files()
|
||||
|
||||
assert os.environ["FOO"] == "foo"
|
||||
assert os.environ["BAR"] == "bar"
|
||||
|
||||
|
||||
def test_missing_files_are_harmless(tmp_path, monkeypatch):
|
||||
"""No env files present → returns 0 and does not raise."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
assert _load_env_files() == 0
|
||||
@@ -478,6 +478,26 @@ def test_voice_doctor_report(monkeypatch):
|
||||
assert report["summary"]["failed"] >= 1
|
||||
|
||||
|
||||
def test_voice_doctor_mcp_probe_timeout_is_warn(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-key-12345678")
|
||||
|
||||
def fake_http_status(url: str, timeout: float = 5.0):
|
||||
if "/api/mcp/servers" in url:
|
||||
assert timeout == 2.0
|
||||
return "fail", "timed out"
|
||||
return "pass", "HTTP 200"
|
||||
|
||||
with _voice_modules() as imp:
|
||||
doctor = imp("integrations.voice.doctor")
|
||||
monkeypatch.setattr(doctor, "_tcp_open", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(doctor, "_http_status", fake_http_status)
|
||||
report = doctor.run_voice_doctor(app_port=8001, sidecar_port=59998)
|
||||
|
||||
mcp = next(c for c in report["checks"] if c["name"] == "MCP servers API")
|
||||
assert mcp["status"] == "warn"
|
||||
assert mcp["detail"] == "timed out"
|
||||
|
||||
|
||||
def test_call_detail_ui_includes_analytics():
|
||||
with _voice_modules() as imp:
|
||||
ui = imp("integrations.voice.call_detail_ui")
|
||||
|
||||
Reference in new issue
Block a user