Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5a5356a0d | |||
| eaea8d94b1 |
@@ -1,23 +1,45 @@
|
|||||||
# oAI-Web - Personal AI Agent
|
# oAI-Web - Personal AI Agent
|
||||||
|
|
||||||
A secure, self-hosted personal AI agent. Handles calendar, email, files, web research, and Telegram - controlled by you, running on your own hardware.
|
A secure, self-hosted personal AI agent. Handles calendar, email, files, web research, Telegram, and more - controlled by you, running on your own hardware.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Chat interface** - conversational UI via browser, with model selector
|
- **Chat interface** - conversational UI via browser, with model selector and markdown rendering
|
||||||
- **CalDAV** - read and write calendar events (per-user credentials, configured in Settings)
|
- **CalDAV** - read and write calendar events (per-user credentials, configured in Settings)
|
||||||
- **CardDAV / Contacts** - search and manage contacts from your CardDAV server
|
- **CardDAV / Contacts** - search and manage contacts from your CardDAV server
|
||||||
- **Email** - read inbox, send replies (whitelist-managed recipients)
|
- **Email** - read inbox, send replies (whitelist-managed recipients)
|
||||||
- **Filesystem** - read/write files in your personal data folder
|
- **Filesystem** - read/write files in your personal data folder
|
||||||
- **Web access** - tiered: whitelisted domains always allowed, others on request
|
- **Web access** - tiered: whitelisted domains always allowed, others on request
|
||||||
|
- **Browser** - headless Chromium via Playwright; fetch pages, take screenshots, click, fill forms, and interact with web UIs (full image only)
|
||||||
- **Push notifications** - Pushover for iOS/Android (set your own User Key in Settings)
|
- **Push notifications** - Pushover for iOS/Android (set your own User Key in Settings)
|
||||||
- **Telegram** - send and receive messages via your own bot
|
- **Telegram** - send and receive messages via your own bot
|
||||||
- **Webhooks** - trigger agents from external services (iOS Shortcuts, GitHub, Home Assistant, etc.)
|
- **Webhooks** - trigger agents from external services (iOS Shortcuts, GitHub, Home Assistant, etc.)
|
||||||
- **Monitors** - page-change and RSS feed monitors that dispatch agents automatically
|
- **Monitors** - page-change and RSS feed monitors that dispatch agents automatically
|
||||||
- **Scheduled tasks** - cron-based autonomous tasks with declared permission scopes
|
- **Scheduled tasks** - cron-based autonomous tasks with declared permission scopes
|
||||||
- **Agents** - goal-oriented runs with model selection and full run history
|
- **Agents** - goal-oriented runs with model selection and full run history
|
||||||
|
- **2nd Brain** - personal semantic memory powered by pgvector; capture and search thoughts across sessions
|
||||||
|
- **MCP Servers** - connect external Model Context Protocol servers and expose their tools to your agents
|
||||||
|
- **Image generation** - generate images via your configured provider
|
||||||
- **Audit log** - every tool call logged, append-only
|
- **Audit log** - every tool call logged, append-only
|
||||||
- **Multi-user** - each user has their own credentials and settings
|
- **Multi-user** - each user has their own credentials and settings
|
||||||
|
- **PWA** - installable on iOS and Android home screens; mobile-optimised layout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Images
|
||||||
|
|
||||||
|
Four pre-built images are available. Choose the one that matches your architecture and whether you need the browser tool.
|
||||||
|
|
||||||
|
| Image | Architecture | Browser tool |
|
||||||
|
|-------|-------------|--------------|
|
||||||
|
| `image.gitlab.pm/rune/oai-web:latest` | amd64 | Yes |
|
||||||
|
| `image.gitlab.pm/rune/oai-web:latest-no-browser` | amd64 | No |
|
||||||
|
| `image.gitlab.pm/rune/oai-web:latest_arm64` | arm64 | Yes |
|
||||||
|
| `image.gitlab.pm/rune/oai-web:latest-no-browser_arm64` | arm64 | No |
|
||||||
|
|
||||||
|
**Full image** includes Playwright and a Chromium installation. This adds roughly 350 MB to the image size but enables the browser tool - the agent can fetch pages, take screenshots, click elements, fill forms, and navigate web UIs on your behalf.
|
||||||
|
|
||||||
|
**No-browser image** is leaner and faster to pull. All other tools and features are identical. Choose this if you don't need the agent to interact with web pages directly, or if you are constrained on disk space.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,9 +50,10 @@ A secure, self-hosted personal AI agent. Handles calendar, email, files, web res
|
|||||||
- A PostgreSQL-compatible host (included in the compose file)
|
- A PostgreSQL-compatible host (included in the compose file)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
There is a [documentation site](https://docs.jarvis.pm) with in depth information on the project.
|
There is a [documentation site](https://docs.jarvis.pm) with in-depth information on the project.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -98,7 +121,6 @@ PORT=8080
|
|||||||
TIMEZONE=Europe/Oslo
|
TIMEZONE=Europe/Oslo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Database *
|
### Database *
|
||||||
@@ -223,11 +245,11 @@ The file is mounted read-only into the container. Changes take effect on the nex
|
|||||||
|
|
||||||
## Your Settings
|
## Your Settings
|
||||||
|
|
||||||
After logging in, go to **Settings** to configure your personal services. Each user has their own credentials — nothing is shared with other users.
|
After logging in, go to **Settings** to configure your personal services. Each user has their own credentials - nothing is shared with other users.
|
||||||
|
|
||||||
### CalDAV / CardDAV
|
### CalDAV / CardDAV
|
||||||
|
|
||||||
Set up your personal calendar and contacts server under **Settings → CalDAV / CardDAV**:
|
Set up your personal calendar and contacts server under **Settings - CalDAV / CardDAV**:
|
||||||
|
|
||||||
- Enter your server URL (e.g. `mail.example.com`), username, and password
|
- Enter your server URL (e.g. `mail.example.com`), username, and password
|
||||||
- Optionally specify a calendar name (leave blank for the default calendar)
|
- Optionally specify a calendar name (leave blank for the default calendar)
|
||||||
@@ -235,7 +257,7 @@ Set up your personal calendar and contacts server under **Settings → CalDAV /
|
|||||||
- Use the **Test** buttons to verify your connection before saving
|
- Use the **Test** buttons to verify your connection before saving
|
||||||
- Enable **Allow contact writes** if you want agents to be able to create and update contacts
|
- Enable **Allow contact writes** if you want agents to be able to create and update contacts
|
||||||
|
|
||||||
There is no system-wide fallback — if you don't configure it, calendar and contacts tools won't be available to your agents.
|
There is no system-wide fallback - if you don't configure it, calendar and contacts tools won't be available to your agents.
|
||||||
|
|
||||||
### Pushover
|
### Pushover
|
||||||
|
|
||||||
@@ -243,29 +265,33 @@ To receive push notifications on your iOS or Android device:
|
|||||||
|
|
||||||
1. Create a free account at [pushover.net](https://pushover.net)
|
1. Create a free account at [pushover.net](https://pushover.net)
|
||||||
2. Copy your **User Key** from the dashboard
|
2. Copy your **User Key** from the dashboard
|
||||||
3. Go to **Settings → Pushover** and save your User Key
|
3. Go to **Settings - Pushover** and save your User Key
|
||||||
|
|
||||||
The app is already registered by your admin — you only need your own User Key.
|
The app is already registered by your admin - you only need your own User Key.
|
||||||
|
|
||||||
### Webhooks
|
### Webhooks
|
||||||
|
|
||||||
Create inbound webhooks under **Settings → Webhooks** to trigger your agents from external services:
|
Create inbound webhooks under **Settings - Webhooks** to trigger your agents from external services:
|
||||||
|
|
||||||
- Assign a name and target agent, then copy the secret token shown at creation (it's shown only once)
|
- Assign a name and target agent, then copy the secret token shown at creation (it's shown only once)
|
||||||
- **POST trigger**: send `{"message": "your message"}` to `/webhook/{token}`
|
- **POST trigger**: send `{"message": "your message"}` to `/webhook/{token}`
|
||||||
- **GET trigger**: visit `/webhook/{token}?q=your+message` — ideal for iOS Shortcuts URL actions
|
- **GET trigger**: visit `/webhook/{token}?q=your+message` - ideal for iOS Shortcuts URL actions
|
||||||
- Enable or disable webhooks without deleting them
|
- Enable or disable webhooks without deleting them
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|
||||||
Set your personal bot token under **Settings → Telegram** (or **Settings → Profile → Telegram Bot Token**) if you want your own Telegram bot. Your chat ID must be whitelisted by the admin before messages are processed.
|
Set your personal bot token under **Settings - Telegram** (or **Settings - Profile - Telegram Bot Token**) if you want your own Telegram bot. Your chat ID must be whitelisted by the admin before messages are processed.
|
||||||
|
|
||||||
### Email Accounts
|
### Email Accounts
|
||||||
|
|
||||||
Set up your own email accounts under **Settings → Email Accounts**:
|
Set up your own email accounts under **Settings - Email Accounts**:
|
||||||
|
|
||||||
- **Trigger account** — dispatches agents based on keyword rules in incoming emails
|
- **Trigger account** - dispatches agents based on keyword rules in incoming emails
|
||||||
- **Handling account** — a dedicated AI agent reads and handles each incoming email
|
- **Handling account** - a dedicated AI agent reads and handles each incoming email
|
||||||
|
|
||||||
|
### Browser - Trusted Domains
|
||||||
|
|
||||||
|
When the agent uses the browser tool to interact with a page (clicking, filling forms), it will ask for confirmation unless the domain is pre-approved. Add trusted domains under **Settings - Browser** to skip the confirmation prompt for those sites.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -283,11 +309,14 @@ docker compose up -d
|
|||||||
| URL | Description |
|
| URL | Description |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `/` | Chat - send messages, select model, view tool activity |
|
| `/` | Chat - send messages, select model, view tool activity |
|
||||||
|
| `/chats` | Chat history - browse and resume past conversations |
|
||||||
| `/tasks` | Scheduled tasks - cron-based autonomous tasks |
|
| `/tasks` | Scheduled tasks - cron-based autonomous tasks |
|
||||||
| `/agents` | Agents - goal-oriented runs with model selection and run history |
|
| `/agents` | Agents - goal-oriented runs with model selection and run history |
|
||||||
| `/monitors` | Monitors - page-change watchers and RSS feed monitors |
|
| `/monitors` | Monitors - page-change watchers and RSS feed monitors |
|
||||||
| `/files` | Files - browse, download, and manage your personal data folder |
|
| `/models` | Model browser - all available models, capabilities, and pricing |
|
||||||
|
| `/files` | Files - browse, view, and manage your personal data folder |
|
||||||
| `/audit` | Audit log - filterable view of every tool call |
|
| `/audit` | Audit log - filterable view of every tool call |
|
||||||
|
| `/usage` | Usage and costs - token usage and cost tracking across runs |
|
||||||
| `/settings` | Your personal settings: CalDAV, CardDAV, Pushover, Webhooks, Telegram, Email Accounts, and more |
|
| `/settings` | Your personal settings: CalDAV, CardDAV, Pushover, Webhooks, Telegram, Email Accounts, and more |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -316,10 +345,10 @@ Contributions are welcome!
|
|||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
oAI-Web takes real actions on your behalf — it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind — the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
|
oAI-Web takes real actions on your behalf - it can send emails, write files, make calendar changes, post Telegram messages, and interact with web pages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind - the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**⭐ Star this project if you find it useful!**
|
**Star this project if you find it useful!**
|
||||||
|
|
||||||
**🐛 Found a bug?** [Open an issue](https://gitlab.pm/rune/oai-web/issues/new)
|
**Found a bug?** [Open an issue](https://gitlab.pm/rune/oai-web/issues/new)
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ async def list_accounts(user_id: str | None = None) -> list[dict]:
|
|||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea"
|
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea"
|
||||||
" LEFT JOIN agents a ON a.id = ea.agent_id"
|
" LEFT JOIN agents a ON a.id = ea.agent_id"
|
||||||
" ORDER BY ea.created_at"
|
" ORDER BY ea.created_at"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea"
|
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea"
|
||||||
" LEFT JOIN agents a ON a.id = ea.agent_id"
|
" LEFT JOIN agents a ON a.id = ea.agent_id"
|
||||||
" WHERE ea.user_id = $1 ORDER BY ea.created_at",
|
" WHERE ea.user_id = $1 ORDER BY ea.created_at",
|
||||||
user_id,
|
user_id,
|
||||||
@@ -46,7 +46,7 @@ async def list_accounts_enabled() -> list[dict]:
|
|||||||
"""Return all enabled accounts (used by listener on startup)."""
|
"""Return all enabled accounts (used by listener on startup)."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea"
|
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea"
|
||||||
" LEFT JOIN agents a ON a.id = ea.agent_id"
|
" LEFT JOIN agents a ON a.id = ea.agent_id"
|
||||||
" WHERE ea.enabled = TRUE ORDER BY ea.created_at"
|
" WHERE ea.enabled = TRUE ORDER BY ea.created_at"
|
||||||
)
|
)
|
||||||
@@ -56,7 +56,7 @@ async def list_accounts_enabled() -> list[dict]:
|
|||||||
async def get_account(account_id: str) -> dict | None:
|
async def get_account(account_id: str) -> dict | None:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(
|
||||||
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea"
|
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea"
|
||||||
" LEFT JOIN agents a ON a.id = ea.agent_id"
|
" LEFT JOIN agents a ON a.id = ea.agent_id"
|
||||||
" WHERE ea.id = $1",
|
" WHERE ea.id = $1",
|
||||||
account_id,
|
account_id,
|
||||||
|
|||||||
@@ -496,8 +496,10 @@ class EmailAccountListener:
|
|||||||
f"- Memory file: {mem_path}\n"
|
f"- Memory file: {mem_path}\n"
|
||||||
f"- Reasoning log: {log_path}\n"
|
f"- Reasoning log: {log_path}\n"
|
||||||
f"Read the memory file before acting. "
|
f"Read the memory file before acting. "
|
||||||
f"Append a reasoning entry to the reasoning log for each email you act on. "
|
f"Append a brief reasoning entry to the reasoning log for each email you act on. "
|
||||||
f"If either file doesn't exist yet, create it with an appropriate template."
|
f"If either file doesn't exist yet, create it with an appropriate template. "
|
||||||
|
f"If the memory file exceeds 120 lines, summarise it in-place (condense key facts, "
|
||||||
|
f"drop old entries) before appending — keep it concise to reduce token costs."
|
||||||
)
|
)
|
||||||
|
|
||||||
await agent_runner.run_agent_and_wait(
|
await agent_runner.run_agent_and_wait(
|
||||||
|
|||||||
+8
-1
@@ -55,7 +55,7 @@ logging.basicConfig(
|
|||||||
logging.getLogger("server.tools.caldav_tool").setLevel(logging.DEBUG)
|
logging.getLogger("server.tools.caldav_tool").setLevel(logging.DEBUG)
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
@@ -412,6 +412,13 @@ app.add_middleware(_AuthMiddleware)
|
|||||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "web" / "static")), name="static")
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "web" / "static")), name="static")
|
||||||
app.include_router(api_router, prefix="/api")
|
app.include_router(api_router, prefix="/api")
|
||||||
|
|
||||||
|
_STATIC_DIR = BASE_DIR / "web" / "static"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/service-worker.js", include_in_schema=False)
|
||||||
|
async def _service_worker():
|
||||||
|
return FileResponse(_STATIC_DIR / "service-worker.js", media_type="application/javascript")
|
||||||
|
|
||||||
# 2nd Brain MCP server — mounted at /brain-mcp (SSE transport)
|
# 2nd Brain MCP server — mounted at /brain-mcp (SSE transport)
|
||||||
app.mount("/brain-mcp", create_mcp_app())
|
app.mount("/brain-mcp", create_mcp_app())
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ class AnthropicProvider(AIProvider):
|
|||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
}
|
}
|
||||||
if system:
|
if system:
|
||||||
params["system"] = system
|
# Wrap in a content block with cache_control so the system prompt is
|
||||||
|
# cached across repeated calls (e.g. email handling runs). Cached tokens
|
||||||
|
# cost ~10% of normal input price after the first write.
|
||||||
|
params["system"] = [
|
||||||
|
{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}
|
||||||
|
]
|
||||||
if tools:
|
if tools:
|
||||||
# aide tool schemas ARE Anthropic format — pass through directly
|
# aide tool schemas ARE Anthropic format — pass through directly
|
||||||
params["tools"] = tools
|
params["tools"] = tools
|
||||||
@@ -163,10 +168,17 @@ class AnthropicProvider(AIProvider):
|
|||||||
arguments=block.input,
|
arguments=block.input,
|
||||||
))
|
))
|
||||||
|
|
||||||
usage = UsageStats(
|
if response.usage:
|
||||||
input_tokens=response.usage.input_tokens,
|
cache_read = getattr(response.usage, "cache_read_input_tokens", 0) or 0
|
||||||
output_tokens=response.usage.output_tokens,
|
cache_write = getattr(response.usage, "cache_creation_input_tokens", 0) or 0
|
||||||
) if response.usage else UsageStats()
|
usage = UsageStats(
|
||||||
|
input_tokens=response.usage.input_tokens,
|
||||||
|
output_tokens=response.usage.output_tokens,
|
||||||
|
cache_read_tokens=cache_read,
|
||||||
|
cache_write_tokens=cache_write,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
usage = UsageStats()
|
||||||
|
|
||||||
finish_reason = response.stop_reason or "stop"
|
finish_reason = response.stop_reason or "stop"
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class ToolCallResult:
|
|||||||
class UsageStats:
|
class UsageStats:
|
||||||
input_tokens: int = 0
|
input_tokens: int = 0
|
||||||
output_tokens: int = 0
|
output_tokens: int = 0
|
||||||
|
cache_read_tokens: int = 0 # Anthropic: tokens served from cache (billed at ~10%)
|
||||||
|
cache_write_tokens: int = 0 # Anthropic: tokens written to cache (billed at ~125%)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_tokens(self) -> int:
|
def total_tokens(self) -> int:
|
||||||
|
|||||||
+16
-1
@@ -2585,6 +2585,14 @@ async def create_my_email_account(request: Request):
|
|||||||
agent_model = body.get("agent_model", "").strip()
|
agent_model = body.get("agent_model", "").strip()
|
||||||
agent_prompt = body.get("agent_prompt", "").strip()
|
agent_prompt = body.get("agent_prompt", "").strip()
|
||||||
|
|
||||||
|
agent_max_tool_calls = body.get("agent_max_tool_calls")
|
||||||
|
if agent_max_tool_calls is not None:
|
||||||
|
try:
|
||||||
|
agent_max_tool_calls = int(agent_max_tool_calls)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
agent_max_tool_calls = None
|
||||||
|
agent_prompt_mode = body.get("agent_prompt_mode") or "combined"
|
||||||
|
|
||||||
# Auto-create a dedicated agent for this account
|
# Auto-create a dedicated agent for this account
|
||||||
agent = await create_agent(
|
agent = await create_agent(
|
||||||
name=f"Email Handler: {label}",
|
name=f"Email Handler: {label}",
|
||||||
@@ -2593,6 +2601,8 @@ async def create_my_email_account(request: Request):
|
|||||||
allowed_tools=["email"],
|
allowed_tools=["email"],
|
||||||
created_by="user",
|
created_by="user",
|
||||||
owner_user_id=user.id,
|
owner_user_id=user.id,
|
||||||
|
max_tool_calls=agent_max_tool_calls,
|
||||||
|
prompt_mode=agent_prompt_mode,
|
||||||
)
|
)
|
||||||
agent_id = agent["id"]
|
agent_id = agent["id"]
|
||||||
|
|
||||||
@@ -2629,7 +2639,7 @@ async def update_my_email_account(request: Request, account_id: str):
|
|||||||
raise HTTPException(status_code=404, detail="Account not found")
|
raise HTTPException(status_code=404, detail="Account not found")
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|
||||||
# Update the linked agent's model and prompt if provided
|
# Update the linked agent's model, prompt, and cost-control fields if provided
|
||||||
if acct.get("agent_id"):
|
if acct.get("agent_id"):
|
||||||
agent_fields = {}
|
agent_fields = {}
|
||||||
if "agent_model" in body:
|
if "agent_model" in body:
|
||||||
@@ -2638,6 +2648,11 @@ async def update_my_email_account(request: Request, account_id: str):
|
|||||||
agent_fields["prompt"] = body["agent_prompt"]
|
agent_fields["prompt"] = body["agent_prompt"]
|
||||||
if "label" in body:
|
if "label" in body:
|
||||||
agent_fields["name"] = f"Email Handler: {body['label']}"
|
agent_fields["name"] = f"Email Handler: {body['label']}"
|
||||||
|
if "agent_max_tool_calls" in body:
|
||||||
|
v = body["agent_max_tool_calls"]
|
||||||
|
agent_fields["max_tool_calls"] = int(v) if v is not None else None
|
||||||
|
if "agent_prompt_mode" in body:
|
||||||
|
agent_fields["prompt_mode"] = body["agent_prompt_mode"] or "combined"
|
||||||
if agent_fields:
|
if agent_fields:
|
||||||
await update_agent(acct["agent_id"], **agent_fields)
|
await update_agent(acct["agent_id"], **agent_fields)
|
||||||
|
|
||||||
|
|||||||
+181
-13
@@ -140,6 +140,53 @@ let _skipNextRestore = false; // true when snapshot was just restored — skip
|
|||||||
let _cachedModels = null; // { models, default, capabilities } — cached from WS "models" message
|
let _cachedModels = null; // { models, default, capabilities } — cached from WS "models" message
|
||||||
let _modelCapabilities = {}; // { "provider:model-id": { vision, tools, online } }
|
let _modelCapabilities = {}; // { "provider:model-id": { vision, tools, online } }
|
||||||
|
|
||||||
|
/* ── PWA install ─────────────────────────────────────────────────────────── */
|
||||||
|
let _deferredInstallPrompt = null;
|
||||||
|
|
||||||
|
window.addEventListener("beforeinstallprompt", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
_deferredInstallPrompt = e;
|
||||||
|
const btn = document.getElementById("pwa-install-btn");
|
||||||
|
if (btn) btn.style.display = "flex";
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInstall() {
|
||||||
|
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||||
|
const isStandalone = window.navigator.standalone === true;
|
||||||
|
if (isStandalone) return;
|
||||||
|
|
||||||
|
if (_deferredInstallPrompt) {
|
||||||
|
_deferredInstallPrompt.prompt();
|
||||||
|
_deferredInstallPrompt.userChoice.then(() => { _deferredInstallPrompt = null; });
|
||||||
|
} else if (isIOS) {
|
||||||
|
alert("To add to your Home Screen:\n1. Tap the Share button (\u25a1\u2191) in Safari's toolbar\n2. Tap \"Add to Home Screen\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(function _initPWA() {
|
||||||
|
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||||
|
const isStandalone = window.navigator.standalone === true || window.matchMedia("(display-mode: standalone)").matches;
|
||||||
|
if (isIOS && !isStandalone) {
|
||||||
|
const btn = document.getElementById("pwa-install-btn");
|
||||||
|
if (btn) btn.style.display = "flex";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
const sidebar = document.getElementById("sidebar");
|
||||||
|
const overlay = document.getElementById("sidebar-overlay");
|
||||||
|
if (!sidebar) return;
|
||||||
|
const isOpen = sidebar.classList.toggle("open");
|
||||||
|
overlay && overlay.classList.toggle("visible", isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
const sidebar = document.getElementById("sidebar");
|
||||||
|
const overlay = document.getElementById("sidebar-overlay");
|
||||||
|
sidebar && sidebar.classList.remove("open");
|
||||||
|
overlay && overlay.classList.remove("visible");
|
||||||
|
}
|
||||||
|
|
||||||
function initNav() {
|
function initNav() {
|
||||||
_setActiveNav(location.pathname);
|
_setActiveNav(location.pathname);
|
||||||
|
|
||||||
@@ -149,6 +196,7 @@ function initNav() {
|
|||||||
const href = el.getAttribute("href");
|
const href = el.getAttribute("href");
|
||||||
if (!href || href.startsWith("http")) return; // external links: normal
|
if (!href || href.startsWith("http")) return; // external links: normal
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
closeSidebar();
|
||||||
navigateTo(href);
|
navigateTo(href);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -344,6 +392,10 @@ async function fileBrowserNavigate(path) {
|
|||||||
nameTd.style.cssText = "cursor:pointer;color:var(--accent)";
|
nameTd.style.cssText = "cursor:pointer;color:var(--accent)";
|
||||||
nameTd.textContent = entry.name;
|
nameTd.textContent = entry.name;
|
||||||
nameTd.onclick = (function(p) { return function() { fileBrowserNavigate(p); }; })(entry.path);
|
nameTd.onclick = (function(p) { return function() { fileBrowserNavigate(p); }; })(entry.path);
|
||||||
|
} else if (_fbIsTextFile(entry.name)) {
|
||||||
|
nameTd.style.cssText = "cursor:pointer;color:var(--accent)";
|
||||||
|
nameTd.textContent = entry.name;
|
||||||
|
nameTd.onclick = (function(p, n) { return function() { fileBrowserViewFile(p, n); }; })(entry.path, entry.name);
|
||||||
} else {
|
} else {
|
||||||
nameTd.textContent = entry.name;
|
nameTd.textContent = entry.name;
|
||||||
}
|
}
|
||||||
@@ -359,16 +411,6 @@ async function fileBrowserNavigate(path) {
|
|||||||
const actionTd = document.createElement("td");
|
const actionTd = document.createElement("td");
|
||||||
actionTd.style.cssText = "text-align:right;display:flex;justify-content:flex-end;gap:6px;align-items:center";
|
actionTd.style.cssText = "text-align:right;display:flex;justify-content:flex-end;gap:6px;align-items:center";
|
||||||
|
|
||||||
// View button — text files only
|
|
||||||
if (!entry.is_dir && _fbIsTextFile(entry.name)) {
|
|
||||||
const viewBtn = document.createElement("button");
|
|
||||||
viewBtn.className = "btn btn-ghost btn-small";
|
|
||||||
viewBtn.title = "View file";
|
|
||||||
viewBtn.textContent = "View";
|
|
||||||
viewBtn.onclick = (function(p, n) { return function(e) { e.stopPropagation(); fileBrowserViewFile(p, n); }; })(entry.path, entry.name);
|
|
||||||
actionTd.appendChild(viewBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.className = "btn btn-ghost btn-small";
|
btn.className = "btn btn-ghost btn-small";
|
||||||
if (entry.is_dir) {
|
if (entry.is_dir) {
|
||||||
@@ -785,7 +827,10 @@ function openModelPicker() {
|
|||||||
_mpFocusIdx = -1;
|
_mpFocusIdx = -1;
|
||||||
modal.classList.remove("hidden");
|
modal.classList.remove("hidden");
|
||||||
const s = document.getElementById("model-picker-search");
|
const s = document.getElementById("model-picker-search");
|
||||||
if (s) { s.value = ""; s.focus(); }
|
if (s) {
|
||||||
|
s.value = "";
|
||||||
|
if (window.innerWidth > 768) s.focus();
|
||||||
|
}
|
||||||
_renderModelPicker("");
|
_renderModelPicker("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1548,6 +1593,45 @@ function closeAuditDetail() {
|
|||||||
SETTINGS PAGE
|
SETTINGS PAGE
|
||||||
══════════════════════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Mobile settings tab select ──────────────────────────────────────────── */
|
||||||
|
function _initMobileTabSelect(switchFn) {
|
||||||
|
if (window.innerWidth > 768) return;
|
||||||
|
const bar = document.querySelector(".tab-bar");
|
||||||
|
if (!bar || bar.dataset.selectBuilt) return;
|
||||||
|
bar.dataset.selectBuilt = "1";
|
||||||
|
|
||||||
|
const buttons = [...bar.querySelectorAll(".tab-btn")];
|
||||||
|
if (!buttons.length) return;
|
||||||
|
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "tab-select-wrap";
|
||||||
|
|
||||||
|
const sel = document.createElement("select");
|
||||||
|
sel.className = "tab-select";
|
||||||
|
buttons.forEach(btn => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = btn.id.replace(/^[us]tab-|^stab-/, "");
|
||||||
|
opt.textContent = btn.textContent.trim();
|
||||||
|
if (btn.classList.contains("active")) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
sel.addEventListener("change", () => switchFn(sel.value));
|
||||||
|
|
||||||
|
const arrow = document.createElement("span");
|
||||||
|
arrow.className = "tab-select-arrow";
|
||||||
|
arrow.textContent = "▾";
|
||||||
|
|
||||||
|
wrap.appendChild(sel);
|
||||||
|
wrap.appendChild(arrow);
|
||||||
|
bar.parentNode.insertBefore(wrap, bar);
|
||||||
|
bar.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _syncTabSelect(name) {
|
||||||
|
const sel = document.querySelector(".tab-select");
|
||||||
|
if (sel) sel.value = name;
|
||||||
|
}
|
||||||
|
|
||||||
function switchSettingsTab(name) {
|
function switchSettingsTab(name) {
|
||||||
["general", "whitelists", "credentials", "caldav", "pushover", "inbox", "emailaccounts", "telegram", "system", "brain", "mcp", "security", "branding", "webhooks", "mfa"].forEach(t => {
|
["general", "whitelists", "credentials", "caldav", "pushover", "inbox", "emailaccounts", "telegram", "system", "brain", "mcp", "security", "branding", "webhooks", "mfa"].forEach(t => {
|
||||||
const pane = document.getElementById(`spane-${t}`);
|
const pane = document.getElementById(`spane-${t}`);
|
||||||
@@ -1555,6 +1639,7 @@ function switchSettingsTab(name) {
|
|||||||
if (pane) pane.style.display = t === name ? "" : "none";
|
if (pane) pane.style.display = t === name ? "" : "none";
|
||||||
if (btn) btn.classList.toggle("active", t === name);
|
if (btn) btn.classList.toggle("active", t === name);
|
||||||
});
|
});
|
||||||
|
_syncTabSelect(name);
|
||||||
if (name === "inbox") { loadInboxStatus(); }
|
if (name === "inbox") { loadInboxStatus(); }
|
||||||
if (name === "emailaccounts") { loadEmailAccounts(); }
|
if (name === "emailaccounts") { loadEmailAccounts(); }
|
||||||
if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); }
|
if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); }
|
||||||
@@ -1698,6 +1783,7 @@ function switchUserTab(name) {
|
|||||||
const pane = document.getElementById("uspane-" + name);
|
const pane = document.getElementById("uspane-" + name);
|
||||||
if (tab) tab.classList.add("active");
|
if (tab) tab.classList.add("active");
|
||||||
if (pane) pane.style.display = "";
|
if (pane) pane.style.display = "";
|
||||||
|
_syncTabSelect(name);
|
||||||
if (name === "mcp") loadMyMcpServers();
|
if (name === "mcp") loadMyMcpServers();
|
||||||
if (name === "inbox") { loadMyInboxConfig(); loadMyInboxTriggers(); }
|
if (name === "inbox") { loadMyInboxConfig(); loadMyInboxTriggers(); }
|
||||||
if (name === "emailaccounts") { loadEmailAccounts(); }
|
if (name === "emailaccounts") { loadEmailAccounts(); }
|
||||||
@@ -2187,6 +2273,10 @@ function openEmailAccountModal(id) {
|
|||||||
document.getElementById("eam-folders-display").textContent = (acct?.monitored_folders || ["INBOX"]).join(", ");
|
document.getElementById("eam-folders-display").textContent = (acct?.monitored_folders || ["INBOX"]).join(", ");
|
||||||
document.getElementById("eam-folders-hidden").value = JSON.stringify(acct?.monitored_folders || ["INBOX"]);
|
document.getElementById("eam-folders-hidden").value = JSON.stringify(acct?.monitored_folders || ["INBOX"]);
|
||||||
document.getElementById("eam-agent-prompt").value = acct?.agent_prompt || "";
|
document.getElementById("eam-agent-prompt").value = acct?.agent_prompt || "";
|
||||||
|
const mtc = document.getElementById("eam-max-tool-calls");
|
||||||
|
if (mtc) mtc.value = acct?.agent_max_tool_calls != null ? acct.agent_max_tool_calls : "";
|
||||||
|
const pm = document.getElementById("eam-prompt-mode");
|
||||||
|
if (pm) pm.value = acct?.agent_prompt_mode || "combined";
|
||||||
_populateEamModelSelect(acct?.agent_model);
|
_populateEamModelSelect(acct?.agent_model);
|
||||||
_loadEamExtraTools(acct?.extra_tools || [], acct?.telegram_chat_id || "", acct?.telegram_keyword || "");
|
_loadEamExtraTools(acct?.extra_tools || [], acct?.telegram_chat_id || "", acct?.telegram_keyword || "");
|
||||||
|
|
||||||
@@ -2347,6 +2437,9 @@ async function saveEmailAccount() {
|
|||||||
const imap_password = document.getElementById("eam-imap-password").value;
|
const imap_password = document.getElementById("eam-imap-password").value;
|
||||||
const agent_model = document.getElementById("eam-agent-model").value;
|
const agent_model = document.getElementById("eam-agent-model").value;
|
||||||
const agent_prompt = document.getElementById("eam-agent-prompt").value.trim();
|
const agent_prompt = document.getElementById("eam-agent-prompt").value.trim();
|
||||||
|
const _mtcRaw = document.getElementById("eam-max-tool-calls")?.value;
|
||||||
|
const agent_max_tool_calls = _mtcRaw ? parseInt(_mtcRaw) : null;
|
||||||
|
const agent_prompt_mode = document.getElementById("eam-prompt-mode")?.value || "combined";
|
||||||
const initial_load_limit = parseInt(document.getElementById("eam-initial-load-limit").value || "200");
|
const initial_load_limit = parseInt(document.getElementById("eam-initial-load-limit").value || "200");
|
||||||
const monitored_folders = JSON.parse(document.getElementById("eam-folders-hidden").value || '["INBOX"]');
|
const monitored_folders = JSON.parse(document.getElementById("eam-folders-hidden").value || '["INBOX"]');
|
||||||
const extra_tools = Array.from(document.querySelectorAll("#eam-extra-tools-area input[type=checkbox]:checked")).map(c => c.value);
|
const extra_tools = Array.from(document.querySelectorAll("#eam-extra-tools-area input[type=checkbox]:checked")).map(c => c.value);
|
||||||
@@ -2364,7 +2457,8 @@ async function saveEmailAccount() {
|
|||||||
if (extra_tools.includes("telegram") && !telegram_keyword) { showFlash("Please enter a reply keyword for Telegram (e.g. 'work')."); return; }
|
if (extra_tools.includes("telegram") && !telegram_keyword) { showFlash("Please enter a reply keyword for Telegram (e.g. 'work')."); return; }
|
||||||
|
|
||||||
const payload = { label, imap_host, imap_port, imap_username, imap_password,
|
const payload = { label, imap_host, imap_port, imap_username, imap_password,
|
||||||
agent_model, agent_prompt, initial_load_limit, monitored_folders, extra_tools,
|
agent_model, agent_prompt, agent_max_tool_calls, agent_prompt_mode,
|
||||||
|
initial_load_limit, monitored_folders, extra_tools,
|
||||||
telegram_chat_id, telegram_keyword };
|
telegram_chat_id, telegram_keyword };
|
||||||
|
|
||||||
const url = _editingAccountId ? `/api/my/email-accounts/${_editingAccountId}` : "/api/my/email-accounts";
|
const url = _editingAccountId ? `/api/my/email-accounts/${_editingAccountId}` : "/api/my/email-accounts";
|
||||||
@@ -2668,6 +2762,7 @@ function togglePasswordVisibility(inputId, btn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initUserSettings() {
|
function initUserSettings() {
|
||||||
|
_initMobileTabSelect(switchUserTab);
|
||||||
const tab = new URLSearchParams(window.location.search).get("tab") || "apikeys";
|
const tab = new URLSearchParams(window.location.search).get("tab") || "apikeys";
|
||||||
switchUserTab(tab);
|
switchUserTab(tab);
|
||||||
loadMyProviderKeys();
|
loadMyProviderKeys();
|
||||||
@@ -2688,6 +2783,7 @@ function initSettings() {
|
|||||||
}
|
}
|
||||||
if (!document.getElementById("limits-form")) return;
|
if (!document.getElementById("limits-form")) return;
|
||||||
|
|
||||||
|
_initMobileTabSelect(switchSettingsTab);
|
||||||
loadApiKeyStatus();
|
loadApiKeyStatus();
|
||||||
loadProviderKeys();
|
loadProviderKeys();
|
||||||
loadUsersBaseFolder();
|
loadUsersBaseFolder();
|
||||||
@@ -4848,7 +4944,46 @@ async function editCurrentAgent() {
|
|||||||
if (!agentId) return;
|
if (!agentId) return;
|
||||||
const r = await fetch(`/api/agents/${agentId}`);
|
const r = await fetch(`/api/agents/${agentId}`);
|
||||||
const agent = await r.json();
|
const agent = await r.json();
|
||||||
showAgentModal(agent);
|
openPromptEditor(agent.name, agent.prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPromptEditor(name, prompt) {
|
||||||
|
const overlay = document.getElementById("prompt-editor-overlay");
|
||||||
|
if (!overlay) return;
|
||||||
|
document.getElementById("pe-agent-name").textContent = name || "";
|
||||||
|
const ta = document.getElementById("pe-textarea");
|
||||||
|
ta.value = prompt || "";
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
ta.focus();
|
||||||
|
// Place cursor at start
|
||||||
|
ta.setSelectionRange(0, 0);
|
||||||
|
ta.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePromptEditor() {
|
||||||
|
const overlay = document.getElementById("prompt-editor-overlay");
|
||||||
|
if (overlay) overlay.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePromptEditor() {
|
||||||
|
const agentId = window.AGENT_ID;
|
||||||
|
if (!agentId) return;
|
||||||
|
const prompt = document.getElementById("pe-textarea").value;
|
||||||
|
const btn = document.getElementById("pe-save-btn");
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = "Saving…"; }
|
||||||
|
const r = await fetch(`/api/agents/${agentId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ prompt }),
|
||||||
|
});
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = "Save"; }
|
||||||
|
if (r.ok) {
|
||||||
|
closePromptEditor();
|
||||||
|
loadAgentDetail();
|
||||||
|
} else {
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
alert("Save failed: " + (d.detail || r.statusText));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCurrentAgent() {
|
async function runCurrentAgent() {
|
||||||
@@ -4876,6 +5011,13 @@ async function saveAgentAndReload() {
|
|||||||
function initAgentDetail() {
|
function initAgentDetail() {
|
||||||
if (!document.getElementById("agent-detail-container")) return;
|
if (!document.getElementById("agent-detail-container")) return;
|
||||||
_loadAvailableModels().then(() => loadAgentDetail());
|
_loadAvailableModels().then(() => loadAgentDetail());
|
||||||
|
// Keyboard shortcuts for the prompt editor
|
||||||
|
document.addEventListener("keydown", function _detailKeys(e) {
|
||||||
|
const overlay = document.getElementById("prompt-editor-overlay");
|
||||||
|
if (!overlay || overlay.style.display === "none") return;
|
||||||
|
if (e.key === "Escape") { e.preventDefault(); closePromptEditor(); }
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); savePromptEditor(); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAgentDetail() {
|
async function loadAgentDetail() {
|
||||||
@@ -5257,6 +5399,23 @@ function initHelp() {
|
|||||||
// Disconnect any previous observer (SPA re-navigation)
|
// Disconnect any previous observer (SPA re-navigation)
|
||||||
if (_helpObserver) { _helpObserver.disconnect(); _helpObserver = null; }
|
if (_helpObserver) { _helpObserver.disconnect(); _helpObserver = null; }
|
||||||
|
|
||||||
|
// Mobile: add collapsible TOC toggle
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
const toc = document.querySelector(".help-toc");
|
||||||
|
if (toc && !toc.querySelector(".toc-toggle")) {
|
||||||
|
toc.classList.add("toc-collapsed");
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "toc-toggle";
|
||||||
|
btn.innerHTML = "Contents <span class='toc-arrow'>▾</span>";
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
toc.classList.toggle("toc-collapsed");
|
||||||
|
btn.querySelector(".toc-arrow").textContent =
|
||||||
|
toc.classList.contains("toc-collapsed") ? "▾" : "▴";
|
||||||
|
});
|
||||||
|
toc.insertBefore(btn, toc.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tocLinks = document.querySelectorAll(".toc-list a[href^='#']");
|
const tocLinks = document.querySelectorAll(".toc-list a[href^='#']");
|
||||||
const sections = document.querySelectorAll("section[data-section]");
|
const sections = document.querySelectorAll("section[data-section]");
|
||||||
|
|
||||||
@@ -5279,6 +5438,15 @@ function initHelp() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const target = document.querySelector(a.getAttribute("href"));
|
const target = document.querySelector(a.getAttribute("href"));
|
||||||
if (target) target.scrollIntoView({ behavior: "smooth" });
|
if (target) target.scrollIntoView({ behavior: "smooth" });
|
||||||
|
// On mobile: collapse TOC after selecting a section
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
const toc = document.querySelector(".help-toc");
|
||||||
|
if (toc) {
|
||||||
|
toc.classList.add("toc-collapsed");
|
||||||
|
const arrow = toc.querySelector(".toc-arrow");
|
||||||
|
if (arrow) arrow.textContent = "▾";
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "oAI-Web",
|
||||||
|
"short_name": "Jarvis",
|
||||||
|
"description": "Personal AI Agent",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f1117",
|
||||||
|
"theme_color": "#0f1117",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icon.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const CACHE = 'jarvis-v1';
|
||||||
|
const SHELL = [
|
||||||
|
'/static/style.css',
|
||||||
|
'/static/app.js',
|
||||||
|
'/static/icon.png',
|
||||||
|
'/static/logo.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', e => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.open(CACHE).then(c => c.addAll(SHELL)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', e => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', e => {
|
||||||
|
const url = new URL(e.request.url);
|
||||||
|
if (url.pathname.startsWith('/static/')) {
|
||||||
|
// Cache-first for static assets
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(e.request).then(r => r || fetch(e.request))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Network-first for everything else (pages, API)
|
||||||
|
e.respondWith(
|
||||||
|
fetch(e.request).catch(() => caches.match(e.request))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -669,6 +669,25 @@ tr:hover td { background: var(--bg2); }
|
|||||||
.http-put { color: var(--yellow); font-weight: 700; font-family: var(--mono); font-size: 11px; }
|
.http-put { color: var(--yellow); font-weight: 700; font-family: var(--mono); font-size: 11px; }
|
||||||
.http-del { color: var(--red); font-weight: 700; font-family: var(--mono); font-size: 11px; }
|
.http-del { color: var(--red); font-weight: 700; font-family: var(--mono); font-size: 11px; }
|
||||||
|
|
||||||
|
/* ── Help TOC collapse toggle (mobile) ───────────────────────────────────── */
|
||||||
|
.toc-toggle {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.toc-toggle { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Prompt mode toggle ───────────────────────────────────────────────────── */
|
/* ── Prompt mode toggle ───────────────────────────────────────────────────── */
|
||||||
.prompt-mode-toggle { display: flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
.prompt-mode-toggle { display: flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||||
.pm-btn { flex: 1; padding: 6px 8px; font-size: 12px; border: none; border-right: 1px solid var(--border); background: var(--bg2); color: var(--text-dim); cursor: pointer; transition: background 0.15s, color 0.15s; }
|
.pm-btn { flex: 1; padding: 6px 8px; font-size: 12px; border: none; border-right: 1px solid var(--border); background: var(--bg2); color: var(--text-dim); cursor: pointer; transition: background 0.15s, color 0.15s; }
|
||||||
@@ -680,3 +699,213 @@ tr:hover td { background: var(--bg2); }
|
|||||||
.stat-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 20px; }
|
.stat-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 20px; }
|
||||||
.stat-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 6px; }
|
.stat-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 6px; }
|
||||||
.stat-value { font-size: 22px; font-weight: 700; color: var(--text); font-family: var(--mono); }
|
.stat-value { font-size: 22px; font-weight: 700; color: var(--text); font-family: var(--mono); }
|
||||||
|
|
||||||
|
/* ── Responsive layout ────────────────────────────────────────────────────── */
|
||||||
|
.app-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.hamburger-btn:hover { background: var(--bg3); }
|
||||||
|
.hamburger-btn svg { width: 20px; height: 20px; }
|
||||||
|
|
||||||
|
.mobile-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-install-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.mobile-install-btn:hover { background: var(--bg3); color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Mobile tab select (replaces tab bar on small screens) ───────────────── */
|
||||||
|
.tab-select-wrap {
|
||||||
|
display: none;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-select {
|
||||||
|
width: 100%;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 10px 36px 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.tab-select:focus { border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.tab-select-arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-dim);
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tab-select-wrap { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab bar (settings + others) ─────────────────────────────────────────── */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.tab-bar::-webkit-scrollbar { display: none; }
|
||||||
|
.tab-bar .tab-btn { flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── Usage page header ────────────────────────────────────────────────────── */
|
||||||
|
.usage-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.usage-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
html, body { overflow: hidden; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: -220px;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 200;
|
||||||
|
transition: left 0.25s ease;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open { left: 0; }
|
||||||
|
|
||||||
|
.sidebar-overlay.visible { display: block; }
|
||||||
|
|
||||||
|
.mobile-header { display: flex; }
|
||||||
|
|
||||||
|
.app-body { width: 100%; }
|
||||||
|
|
||||||
|
/* Page padding */
|
||||||
|
.page { padding: 16px; }
|
||||||
|
.page h1 { margin-bottom: 16px; }
|
||||||
|
|
||||||
|
/* Chat */
|
||||||
|
.chat-messages { padding: 12px 16px; }
|
||||||
|
.status-bar { padding: 6px 12px; flex-wrap: wrap; gap: 6px; font-size: 10px; }
|
||||||
|
|
||||||
|
/* Prevent iOS auto-zoom on input focus (triggered when font-size < 16px) */
|
||||||
|
input, textarea, select, .form-input, .chat-input, .tab-select {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat input — textarea full-width row, buttons on second row */
|
||||||
|
.chat-input-wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chat-input-wrap .chat-input {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-height: 80px;
|
||||||
|
max-height: 160px;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
#attach-btn { order: 2; }
|
||||||
|
#clear-btn { order: 3; }
|
||||||
|
#send-btn { order: 4; margin-left: auto; }
|
||||||
|
|
||||||
|
/* Modals slide up from bottom */
|
||||||
|
.modal-overlay { align-items: flex-end; }
|
||||||
|
.modal {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0 !important;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help page stacks with collapsible TOC */
|
||||||
|
.help-layout { flex-direction: column; overflow: auto; }
|
||||||
|
.help-toc {
|
||||||
|
width: 100%; min-width: unset;
|
||||||
|
border-right: none; border-bottom: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.help-toc .toc-list, .help-toc .form-input { display: none; }
|
||||||
|
.help-toc.toc-collapsed .toc-list,
|
||||||
|
.help-toc.toc-collapsed .form-input { display: none; }
|
||||||
|
.help-toc:not(.toc-collapsed) .toc-list,
|
||||||
|
.help-toc:not(.toc-collapsed) .form-input { display: block; margin-top: 10px; }
|
||||||
|
.help-content { padding: 20px 16px; }
|
||||||
|
|
||||||
|
/* Tab bar scrollable */
|
||||||
|
.tabs { overflow-x: auto; flex-wrap: nowrap; }
|
||||||
|
|
||||||
|
/* Filter bar */
|
||||||
|
.filter-bar { gap: 6px; }
|
||||||
|
.filter-bar .form-input { min-width: 0; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,57 +114,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit modal (reuse agent modal markup with inline) -->
|
<!-- Fullscreen prompt editor -->
|
||||||
<div class="modal-overlay hidden" id="agent-modal">
|
<div id="prompt-editor-overlay" style="
|
||||||
<div class="modal" style="max-width:560px;width:100%">
|
display:none;position:fixed;inset:0;z-index:1001;
|
||||||
<h3 id="agent-modal-title">Edit Agent</h3>
|
background:var(--bg);flex-direction:column;
|
||||||
<input type="hidden" id="a-id">
|
">
|
||||||
|
<!-- Header bar -->
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
<div style="
|
||||||
<div class="form-group">
|
display:flex;align-items:center;justify-content:space-between;
|
||||||
<label>Name</label>
|
padding:12px 20px;border-bottom:1px solid var(--border);
|
||||||
<input type="text" id="a-name" class="form-input" required>
|
background:var(--bg2);flex-shrink:0;gap:12px
|
||||||
</div>
|
">
|
||||||
<div class="form-group">
|
<div style="display:flex;align-items:center;gap:12px;min-width:0">
|
||||||
<label>Model</label>
|
<span style="font-size:13px;color:var(--text-dim);white-space:nowrap">Editing prompt —</span>
|
||||||
<select id="a-model" class="form-input"></select>
|
<span id="pe-agent-name" style="font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
||||||
<div class="form-group">
|
<span style="font-size:11px;color:var(--text-dim)">Ctrl+S to save · Esc to cancel</span>
|
||||||
<label>Description</label>
|
<button class="btn btn-ghost" onclick="closePromptEditor()">Cancel</button>
|
||||||
<input type="text" id="a-desc" class="form-input">
|
<button class="btn btn-primary" id="pe-save-btn" onclick="savePromptEditor()">Save</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Prompt</label>
|
|
||||||
<textarea id="a-prompt" class="form-input" rows="5" style="resize:vertical"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Schedule</label>
|
|
||||||
<input type="text" id="a-schedule" class="form-input"
|
|
||||||
placeholder="0 8 * * *" oninput="updateAgentCronPreview(this.value)">
|
|
||||||
<div id="a-cron-preview" style="font-size:11px;color:var(--text-dim);margin-top:4px"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="display:flex;flex-direction:column;justify-content:center;gap:8px;padding-top:18px">
|
|
||||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
|
||||||
<input type="checkbox" id="a-subagents">
|
|
||||||
<span>Can create sub-agents</span>
|
|
||||||
</label>
|
|
||||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
|
||||||
<input type="checkbox" id="a-enabled" checked>
|
|
||||||
<span>Enabled</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-buttons">
|
|
||||||
<button class="btn btn-ghost" onclick="closeAgentModal()">Cancel</button>
|
|
||||||
<button class="btn btn-primary" onclick="saveAgentAndReload()">Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Editor -->
|
||||||
|
<textarea id="pe-textarea" spellcheck="false" style="
|
||||||
|
flex:1;width:100%;box-sizing:border-box;resize:none;border:none;outline:none;
|
||||||
|
background:var(--bg);color:var(--text);font-family:var(--mono);font-size:13px;
|
||||||
|
line-height:1.7;padding:24px 32px;
|
||||||
|
"></textarea>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,26 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||||
<title>{% block title %}{{ brand_name }}{% endblock %}</title>
|
<title>{% block title %}{{ brand_name }}{% endblock %}</title>
|
||||||
<link rel="icon" type="image/png" href="/static/icon.png">
|
<link rel="icon" type="image/png" href="/static/icon.png">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#0f1117">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icon.png">
|
||||||
<link rel="stylesheet" href="/static/style.css?v={{ sv }}">
|
<link rel="stylesheet" href="/static/style.css?v={{ sv }}">
|
||||||
{% if theme_css %}<style>{{ theme_css | safe }}</style>{% endif %}
|
{% if theme_css %}<style>{{ theme_css | safe }}</style>{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- ── Sidebar ── -->
|
<!-- ── Sidebar ── -->
|
||||||
<nav class="sidebar">
|
<nav class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-logo">
|
<div class="sidebar-logo">
|
||||||
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
|
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
|
||||||
<div class="sidebar-logo-text">
|
<div class="sidebar-logo-text">
|
||||||
<div class="sidebar-logo-name">{{ brand_name }}</div>
|
<div class="sidebar-logo-name">{{ brand_name }}</div>
|
||||||
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.3</span></div>
|
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.5</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,8 +94,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- ── Sidebar overlay (mobile) ── -->
|
||||||
|
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
|
||||||
|
|
||||||
<!-- ── Main column (nag + content) ── -->
|
<!-- ── Main column (nag + content) ── -->
|
||||||
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden">
|
<div class="app-body">
|
||||||
|
|
||||||
|
<!-- ── Mobile header ── -->
|
||||||
|
<div class="mobile-header">
|
||||||
|
<button class="hamburger-btn" onclick="toggleSidebar()" aria-label="Menu">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="mobile-title">{{ agent_name }}</span>
|
||||||
|
<button class="mobile-install-btn" id="pwa-install-btn" onclick="handleInstall()" title="Add to Home Screen" style="display:none">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M12 2v13M7 9l5 5 5-5"/><path d="M5 20h14"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if needs_personality_setup %}
|
{% if needs_personality_setup %}
|
||||||
<div class="nag-banner" id="nag-banner">
|
<div class="nag-banner" id="nag-banner">
|
||||||
@@ -110,6 +129,11 @@
|
|||||||
|
|
||||||
<script>window.AGENT_NAME = "{{ agent_name }}";</script>
|
<script>window.AGENT_NAME = "{{ agent_name }}";</script>
|
||||||
<script src="/static/app.js?v={{ sv }}"></script>
|
<script src="/static/app.js?v={{ sv }}"></script>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/service-worker.js').catch(() => {});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<!-- ── Admin Tabs ── -->
|
<!-- ── Admin Tabs ── -->
|
||||||
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:28px">
|
<div class="tab-bar" style="border-bottom:1px solid var(--border);margin-bottom:28px">
|
||||||
<button type="button" class="tab-btn active" id="stab-general" onclick="switchSettingsTab('general')">General</button>
|
<button type="button" class="tab-btn active" id="stab-general" onclick="switchSettingsTab('general')">General</button>
|
||||||
<button type="button" class="tab-btn" id="stab-whitelists" onclick="switchSettingsTab('whitelists')">Whitelists</button>
|
<button type="button" class="tab-btn" id="stab-whitelists" onclick="switchSettingsTab('whitelists')">Whitelists</button>
|
||||||
<button type="button" class="tab-btn" id="stab-credentials" onclick="switchSettingsTab('credentials')">Credentials</button>
|
<button type="button" class="tab-btn" id="stab-credentials" onclick="switchSettingsTab('credentials')">Credentials</button>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- ── User Tabs ── -->
|
<!-- ── User Tabs ── -->
|
||||||
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:28px">
|
<div class="tab-bar" style="border-bottom:1px solid var(--border);margin-bottom:28px">
|
||||||
<button type="button" class="tab-btn active" id="ustab-apikeys" onclick="switchUserTab('apikeys')">API Keys</button>
|
<button type="button" class="tab-btn active" id="ustab-apikeys" onclick="switchUserTab('apikeys')">API Keys</button>
|
||||||
<button type="button" class="tab-btn" id="ustab-personality" onclick="switchUserTab('personality')">Personality</button>
|
<button type="button" class="tab-btn" id="ustab-personality" onclick="switchUserTab('personality')">Personality</button>
|
||||||
<button type="button" class="tab-btn" id="ustab-inbox" onclick="switchUserTab('inbox')">Inbox</button>
|
<button type="button" class="tab-btn" id="ustab-inbox" onclick="switchUserTab('inbox')">Inbox</button>
|
||||||
@@ -1934,18 +1934,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Email Handling Account modal ── -->
|
<!-- ── Email Handling Account modal ── -->
|
||||||
<div class="modal-overlay" id="email-account-modal" style="display:none">
|
<div class="modal-overlay" id="email-account-modal" style="display:none;align-items:flex-start;padding:32px 16px;overflow-y:auto">
|
||||||
<div class="modal" style="max-width:820px;width:100%">
|
<div class="modal" style="max-width:1100px;width:100%">
|
||||||
<h3 id="eam-title">Add Handling Account</h3>
|
<h3 id="eam-title" style="margin-bottom:16px">Add Handling Account</h3>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:16px">
|
<div style="display:grid;grid-template-columns:1fr 1.4fr 1fr;gap:24px">
|
||||||
|
|
||||||
<!-- Left column: IMAP account -->
|
<!-- Column 1: IMAP account -->
|
||||||
<div>
|
<div>
|
||||||
<p style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:14px;font-weight:600">Account</p>
|
<p style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:14px;font-weight:600">Account</p>
|
||||||
<div class="form-group"><label>Label</label>
|
<div class="form-group"><label>Label</label>
|
||||||
<input type="text" id="eam-label" class="form-input" placeholder="e.g. Work Email"></div>
|
<input type="text" id="eam-label" class="form-input" placeholder="e.g. Work Email"></div>
|
||||||
<div style="display:grid;grid-template-columns:2fr 1fr;gap:10px">
|
<div style="display:grid;grid-template-columns:2fr 1fr;gap:8px">
|
||||||
<div class="form-group"><label>IMAP Host</label>
|
<div class="form-group"><label>IMAP Host</label>
|
||||||
<input type="text" id="eam-imap-host" class="form-input" placeholder="imap.example.com"></div>
|
<input type="text" id="eam-imap-host" class="form-input" placeholder="imap.example.com"></div>
|
||||||
<div class="form-group"><label>Port</label>
|
<div class="form-group"><label>Port</label>
|
||||||
@@ -1955,7 +1955,7 @@
|
|||||||
<input type="text" id="eam-imap-username" class="form-input" placeholder="user@example.com"></div>
|
<input type="text" id="eam-imap-username" class="form-input" placeholder="user@example.com"></div>
|
||||||
<div class="form-group"><label>Password</label>
|
<div class="form-group"><label>Password</label>
|
||||||
<input type="password" id="eam-imap-password" class="form-input" placeholder="Leave blank to keep existing"></div>
|
<input type="password" id="eam-imap-password" class="form-input" placeholder="Leave blank to keep existing"></div>
|
||||||
<div class="form-group"><label>Initial load limit <span style="color:var(--text-dim);font-size:11px">(emails on first connect)</span></label>
|
<div class="form-group"><label>Initial load limit <span style="color:var(--text-dim);font-size:11px">(on first connect)</span></label>
|
||||||
<input type="number" id="eam-initial-load-limit" class="form-input" value="200" min="0" max="5000"></div>
|
<input type="number" id="eam-initial-load-limit" class="form-input" value="200" min="0" max="5000"></div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Monitored folders</label>
|
<label>Monitored folders</label>
|
||||||
@@ -1963,26 +1963,41 @@
|
|||||||
<span id="eam-folders-display" style="font-size:13px;color:var(--text-dim)">INBOX</span>
|
<span id="eam-folders-display" style="font-size:13px;color:var(--text-dim)">INBOX</span>
|
||||||
<button type="button" class="btn btn-ghost btn-small" id="eam-load-folders-btn" onclick="loadEamFolders()">Load folders</button>
|
<button type="button" class="btn btn-ghost btn-small" id="eam-load-folders-btn" onclick="loadEamFolders()">Load folders</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="eam-folders-checklist" style="display:none;max-height:160px;overflow-y:auto;background:var(--bg2);padding:8px 12px;border-radius:var(--radius);border:1px solid var(--border)"></div>
|
<div id="eam-folders-checklist" style="display:none;max-height:140px;overflow-y:auto;background:var(--bg2);padding:8px 12px;border-radius:var(--radius);border:1px solid var(--border)"></div>
|
||||||
<input type="hidden" id="eam-folders-hidden" value='["INBOX"]'>
|
<input type="hidden" id="eam-folders-hidden" value='["INBOX"]'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right column: Handling agent -->
|
<!-- Column 2: Agent prompt + model -->
|
||||||
<div>
|
<div>
|
||||||
<p style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:14px;font-weight:600">Handling Agent</p>
|
<p style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:14px;font-weight:600">Handling Agent</p>
|
||||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:14px;line-height:1.5">
|
|
||||||
A dedicated agent is created for this account. It can use email tools plus any notification tools you enable below.
|
|
||||||
</p>
|
|
||||||
<div class="form-group"><label>Model</label>
|
<div class="form-group"><label>Model</label>
|
||||||
<select id="eam-agent-model" class="form-input"><option value="">Loading…</option></select>
|
<select id="eam-agent-model" class="form-input"><option value="">Loading…</option></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="display:flex;flex-direction:column;flex:1">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Max tool calls <span style="color:var(--text-dim);font-size:11px">(blank = default)</span></label>
|
||||||
|
<input type="number" id="eam-max-tool-calls" class="form-input" min="1" max="50" placeholder="e.g. 6">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Prompt mode</label>
|
||||||
|
<select id="eam-prompt-mode" class="form-input">
|
||||||
|
<option value="combined">Combined (full personality)</option>
|
||||||
|
<option value="agent_only">Agent only (cheaper)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;flex-direction:column">
|
||||||
<label>Agent prompt</label>
|
<label>Agent prompt</label>
|
||||||
<textarea id="eam-agent-prompt" class="form-input" rows="8"
|
<textarea id="eam-agent-prompt" class="form-input" rows="10"
|
||||||
style="resize:vertical;font-size:13px;line-height:1.5"
|
style="resize:vertical;font-size:13px;line-height:1.5;flex:1"
|
||||||
placeholder="Describe how the agent should handle incoming emails…"></textarea>
|
placeholder="Describe how the agent should handle incoming emails…"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column 3: Options -->
|
||||||
|
<div>
|
||||||
|
<p style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:14px;font-weight:600">Options</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Notification tools <span style="font-size:11px;color:var(--text-dim)">(optional)</span></label>
|
<label>Notification tools <span style="font-size:11px;color:var(--text-dim)">(optional)</span></label>
|
||||||
<div id="eam-extra-tools-area" style="margin-top:6px;display:flex;flex-direction:column;gap:8px">
|
<div id="eam-extra-tools-area" style="margin-top:6px;display:flex;flex-direction:column;gap:8px">
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page" id="usage-container">
|
<div class="page" id="usage-container">
|
||||||
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
|
<div class="usage-header">
|
||||||
<h1>Usage</h1>
|
<h1>Usage</h1>
|
||||||
<!-- Time range filter + admin actions -->
|
<!-- Time range filter + admin actions -->
|
||||||
<div style="display:flex;gap:6px;align-items:center">
|
<div class="usage-header-actions">
|
||||||
<button class="btn" id="usage-range-today" type="button" onclick="setUsageRange('today')">Today</button>
|
<button class="btn" id="usage-range-today" type="button" onclick="setUsageRange('today')">Today</button>
|
||||||
<button class="btn" id="usage-range-7d" type="button" onclick="setUsageRange('7d')" style="background:var(--accent);color:#fff;border-color:var(--accent)">7 days</button>
|
<button class="btn" id="usage-range-7d" type="button" onclick="setUsageRange('7d')" style="background:var(--accent);color:#fff;border-color:var(--accent)">7 days</button>
|
||||||
<button class="btn" id="usage-range-30d" type="button" onclick="setUsageRange('30d')">30 days</button>
|
<button class="btn" id="usage-range-30d" type="button" onclick="setUsageRange('30d')">30 days</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user