From 7255649abf6a948205160971a9a8ea28b5141f8f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 5 Jul 2026 23:43:16 -0500 Subject: [PATCH 01/20] refac --- cptr/frontend/src/lib/apis/admin.ts | 2 +- .../src/lib/components/Admin/AgentProfileModal.svelte | 7 +++++-- cptr/frontend/src/lib/components/Admin/Agents.svelte | 6 ++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cptr/frontend/src/lib/apis/admin.ts b/cptr/frontend/src/lib/apis/admin.ts index b2912a3..7aa2627 100644 --- a/cptr/frontend/src/lib/apis/admin.ts +++ b/cptr/frontend/src/lib/apis/admin.ts @@ -60,7 +60,7 @@ export const updateConfig = (config: Record) => // ── Agents ───────────────────────────────────────────────── -export type AgentType = 'codex' | 'claude_code' | 'cursor' | 'grok' | 'opencode'; +export type AgentType = 'codex' | 'claude_code' | 'cursor' | 'grok' | 'opencode' | 'cline'; export type AgentMode = 'auto' | 'enabled' | 'disabled'; export type AgentStatus = 'ready' | 'not_found' | 'missing_dependency' | 'auth_unknown' | 'error'; diff --git a/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte b/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte index 57ffe39..93d1986 100644 --- a/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte +++ b/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte @@ -47,7 +47,8 @@ claude_code: 'claude', cursor: 'agent', grok: 'grok', - opencode: 'opencode' + opencode: 'opencode', + cline: 'cline' }[agent]; } @@ -57,7 +58,8 @@ claude_code: 'Claude Code', cursor: 'Cursor', grok: 'Grok', - opencode: 'OpenCode' + opencode: 'OpenCode', + cline: 'Cline' }[agent]; } @@ -126,6 +128,7 @@ + diff --git a/cptr/frontend/src/lib/components/Admin/Agents.svelte b/cptr/frontend/src/lib/components/Admin/Agents.svelte index 5b20b12..a128992 100644 --- a/cptr/frontend/src/lib/components/Admin/Agents.svelte +++ b/cptr/frontend/src/lib/components/Admin/Agents.svelte @@ -111,7 +111,8 @@ claude_code: 'claude', cursor: 'agent', grok: 'grok', - opencode: 'opencode' + opencode: 'opencode', + cline: 'cline' }[agent]; } @@ -121,7 +122,8 @@ claude_code: 'Claude Code', cursor: 'Cursor', grok: 'Grok', - opencode: 'OpenCode' + opencode: 'OpenCode', + cline: 'Cline' }[agent]; } From d3c9df5c5175ae0c2bb228bcc18123d5a63389a5 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 5 Jul 2026 23:43:20 -0500 Subject: [PATCH 02/20] refac --- cptr/utils/agents/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cptr/utils/agents/__init__.py b/cptr/utils/agents/__init__.py index 60654d6..25c3b7d 100644 --- a/cptr/utils/agents/__init__.py +++ b/cptr/utils/agents/__init__.py @@ -1 +1 @@ -"""Coding agent support for Codex and Claude Code.""" +"""Coding agent support.""" From 6f7ee2d610ce9998d07b0aad7d7ddfa478986924 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 5 Jul 2026 23:43:29 -0500 Subject: [PATCH 03/20] refac --- cptr/utils/agents/acp.py | 21 +++++- cptr/utils/agents/cline.py | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 cptr/utils/agents/cline.py diff --git a/cptr/utils/agents/acp.py b/cptr/utils/agents/acp.py index 7870469..76a0754 100644 --- a/cptr/utils/agents/acp.py +++ b/cptr/utils/agents/acp.py @@ -17,7 +17,7 @@ def __init__( args: list[str], cwd: str, env: dict[str, str], - auth_method_id: str, + auth_method_id: str | None, client_capabilities: dict[str, Any] | None = None, resume_session_id: str | None = None, auto_approve_permissions: bool = False, @@ -37,6 +37,7 @@ def __init__( self.events: asyncio.Queue[dict[str, Any]] = asyncio.Queue() self.next_id = 1 self.session_id: str | None = None + self.initialize_result: dict[str, Any] = {} self.setup_result: dict[str, Any] = {} self.model_config_id: str | None = None @@ -52,7 +53,7 @@ async def start(self) -> None: ) self.reader_task = asyncio.create_task(self._reader_loop()) self.stderr_task = asyncio.create_task(self._stderr_loop()) - await self.request( + self.initialize_result = await self.request( "initialize", { "protocolVersion": 1, @@ -64,7 +65,9 @@ async def start(self) -> None: "clientInfo": {"name": "cptr", "version": "0"}, }, ) - await self.request("authenticate", {"methodId": self.auth_method_id}) + auth_method_id = self.auth_method_id or _first_auth_method(self.initialize_result) + if auth_method_id: + await self.request("authenticate", {"methodId": auth_method_id}) await self._open_session() async def close(self) -> None: @@ -270,6 +273,18 @@ def _extract_model_config_id(setup: dict[str, Any]) -> str | None: return None +def _first_auth_method(initialize_result: dict[str, Any]) -> str | None: + methods = initialize_result.get("authMethods") + if not isinstance(methods, list): + return None + for method in methods: + if isinstance(method, dict): + method_id = method.get("id") + if isinstance(method_id, str) and method_id.strip(): + return method_id.strip() + return None + + def _select_permission_option(params: dict[str, Any], kind: str) -> str | None: options = params.get("options") if not isinstance(options, list): diff --git a/cptr/utils/agents/cline.py b/cptr/utils/agents/cline.py new file mode 100644 index 0000000..ba385d7 --- /dev/null +++ b/cptr/utils/agents/cline.py @@ -0,0 +1,135 @@ +"""Cline ACP adapter.""" + +from __future__ import annotations + +import asyncio +import os +from contextlib import suppress +from typing import Any, AsyncIterator + +from cptr.utils.agents.acp import ( + AcpClient, + acp_event_stream, + acp_text_from_update, + acp_tool_from_update, +) +from cptr.utils.agents.attachments import PreparedAgentAttachments +from cptr.utils.agents.events import ( + AgentDone, + AgentError, + AgentEvent, + AgentTextDelta, + AgentToolUpdate, +) + + +def _prompt_from_messages(messages: list[dict[str, Any]]) -> str: + parts: list[str] = [] + for message in messages: + role = message.get("role", "user") + content = message.get("content", "") + if isinstance(content, list): + text = "\n".join( + str(block.get("text", "")) for block in content if isinstance(block, dict) + ) + else: + text = str(content or "") + if text: + parts.append(f"[{role}]\n{text}") + return "\n\n".join(parts) + + +def _auto_approve(chat_params: dict[str, Any]) -> bool: + if chat_params.get("tool_approval_mode") == "full": + return True + return bool(chat_params.get("auto_approve_tools")) + + +async def run_cline_agent( + *, + profile: dict[str, Any], + model: str, + workspace: str, + messages: list[dict[str, Any]], + system_prompt: str, + chat_params: dict[str, Any], + resume_state: dict[str, Any] | None, + attachments: PreparedAgentAttachments, +) -> AsyncIterator[AgentEvent]: + env = os.environ.copy() + if profile.get("home"): + env["HOME"] = os.path.expanduser(str(profile["home"])) + + session_id = None + if resume_state and isinstance(resume_state.get("session_id"), str): + session_id = resume_state["session_id"] + + client = AcpClient( + command=str(profile["command"]), + args=["--acp"], + cwd=workspace, + env=env, + auth_method_id=None, + resume_session_id=session_id, + auto_approve_permissions=_auto_approve(chat_params), + ) + try: + await client.start() + if model != "default": + await client.set_model(model) + + prompt = _prompt_from_messages(messages) + if system_prompt: + prompt = f"{system_prompt}\n\n{prompt}" if prompt else system_prompt + + images = [ + {"data": image.base64, "mimeType": image.mime_type} for image in attachments.images + ] + prompt_task = asyncio.create_task(client.prompt(prompt, images=images)) + try: + async for event in acp_event_stream(client): + params = event.get("params") if isinstance(event.get("params"), dict) else {} + text = acp_text_from_update(params) + if text: + yield AgentTextDelta(text) + tool = acp_tool_from_update(params) + if tool: + yield AgentToolUpdate(**tool) + if prompt_task.done(): + try: + next_event = await asyncio.wait_for(client.events.get(), timeout=0.25) + except asyncio.TimeoutError: + break + next_params = ( + next_event.get("params") + if isinstance(next_event.get("params"), dict) + else {} + ) + next_text = acp_text_from_update(next_params) + if next_text: + yield AgentTextDelta(next_text) + next_tool = acp_tool_from_update(next_params) + if next_tool: + yield AgentToolUpdate(**next_tool) + await prompt_task + finally: + if not prompt_task.done(): + prompt_task.cancel() + with suppress(asyncio.CancelledError): + await prompt_task + + yield AgentDone( + resume_state={ + "profile_id": profile["id"], + "session_id": client.session_id, + "workspace": workspace, + "model": model, + } + ) + except asyncio.CancelledError: + await client.cancel() + raise + except Exception as exc: # noqa: BLE001 - surfaced in chat. + yield AgentError(str(exc)) + finally: + await client.close() From e687f0e4baa9f043d50a212f357087aa306b3fc5 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 5 Jul 2026 23:43:37 -0500 Subject: [PATCH 04/20] refac --- cptr/utils/agents/detection.py | 34 ++++++++++++++++++++++++++++++++++ cptr/utils/agents/models.py | 17 +++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/cptr/utils/agents/detection.py b/cptr/utils/agents/detection.py index 364273a..672424d 100644 --- a/cptr/utils/agents/detection.py +++ b/cptr/utils/agents/detection.py @@ -158,6 +158,18 @@ async def detect_profile(profile: dict[str, Any]) -> AgentDetection: ) return AgentDetection("ready", command, version, None, models) + if profile.get("agent") == "cline": + models = await _probe_cline_models(command, profile) + if not models: + return AgentDetection( + "auth_unknown", + command, + version, + "Could not discover Cline models. Check `cline auth` or add models manually.", + [], + ) + return AgentDetection("ready", command, version, None, models) + return AgentDetection("error", command, version, "Unknown agent") @@ -387,6 +399,28 @@ async def _probe_opencode_models(command: str, profile: dict[str, Any]) -> list[ await proc.wait() +async def _probe_cline_models(command: str, profile: dict[str, Any]) -> list[str] | None: + from cptr.utils.agents.acp import AcpClient, acp_models_from_setup + + env = os.environ.copy() + if profile.get("home"): + env["HOME"] = os.path.expanduser(str(profile["home"])) + client = AcpClient( + command=command, + args=["--acp"], + cwd=os.getcwd(), + env=env, + auth_method_id=None, + ) + try: + await asyncio.wait_for(client.start(), timeout=10) + return acp_models_from_setup(client.setup_result) or None + except Exception: + return None + finally: + await client.close() + + def _free_port() -> int: with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) diff --git a/cptr/utils/agents/models.py b/cptr/utils/agents/models.py index 4fba931..f2acf66 100644 --- a/cptr/utils/agents/models.py +++ b/cptr/utils/agents/models.py @@ -70,10 +70,20 @@ "server_url": "", "server_password": "", }, + { + "id": "cline", + "agent": "cline", + "name": "Cline", + "mode": "auto", + "command": "cline", + "home": None, + "models": [], + "default_model": "", + }, ] _PROFILE_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") -_VALID_AGENTS = {"codex", "claude_code", "cursor", "grok", "opencode"} +_VALID_AGENTS = {"codex", "claude_code", "cursor", "grok", "opencode", "cline"} _VALID_MODES = {"auto", "enabled", "disabled"} _VALID_CODEX_APPROVAL = {"ask", "auto", "full"} _VALID_CODEX_SANDBOX = {"read-only", "workspace-write", "danger-full-access"} @@ -92,6 +102,7 @@ }, "grok": {"name": "Grok", "command": "grok", "model": ""}, "opencode": {"name": "OpenCode", "command": "opencode", "model": ""}, + "cline": {"name": "Cline", "command": "cline", "model": ""}, } @@ -107,7 +118,9 @@ def normalize_agent_profile(raw: dict[str, Any]) -> dict[str, Any]: agent = str(profile.get("agent") or "").strip() if agent not in _VALID_AGENTS: - raise HTTPException(400, "agent must be codex, claude_code, cursor, grok, or opencode") + raise HTTPException( + 400, "agent must be codex, claude_code, cursor, grok, opencode, or cline" + ) defaults = _AGENT_DEFAULTS[agent] mode = str(profile.get("mode") or "auto").strip() From 7be9a0877981f01b09b1e89739c1e7eca3b8150f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 5 Jul 2026 23:44:00 -0500 Subject: [PATCH 05/20] refac --- cptr/utils/chat_task.py | 2 ++ cptr/utils/model_targets.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 2226417..5e22161 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -1270,6 +1270,7 @@ async def save_session(agent_target: AgentModelTarget, resume_state: dict | None async def _run_agent_target(agent_target: AgentModelTarget): nonlocal content, text_buffer from cptr.utils.agents.claude_code import run_claude_code_agent + from cptr.utils.agents.cline import run_cline_agent from cptr.utils.agents.codex import run_codex_agent from cptr.utils.agents.cursor import run_cursor_agent from cptr.utils.agents.grok import run_grok_agent @@ -1322,6 +1323,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): "cursor": run_cursor_agent, "grok": run_grok_agent, "opencode": run_opencode_agent, + "cline": run_cline_agent, } runner = runners.get(agent_target.agent) if runner is None: diff --git a/cptr/utils/model_targets.py b/cptr/utils/model_targets.py index 41d7b23..fc01327 100644 --- a/cptr/utils/model_targets.py +++ b/cptr/utils/model_targets.py @@ -24,7 +24,7 @@ class ApiModelTarget: class AgentModelTarget: kind: Literal["agent"] profile_id: str - agent: Literal["codex", "claude_code", "cursor", "grok", "opencode"] + agent: Literal["codex", "claude_code", "cursor", "grok", "opencode", "cline"] model: str full_model_id: str config: dict[str, Any] From 4e73a201d1d8cd8177ba139cdf433d5b8ff82753 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 5 Jul 2026 23:44:05 -0500 Subject: [PATCH 06/20] refac --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11d27b0..d826508 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Bring your own API key (OpenAI, Anthropic, Ollama, or any OpenAI-compatible endp Connect a coding agent as a native backend and use the subscription you already pay for. No separate API key needed. -**Codex** · **Claude Code** · **Cursor** · **Grok** · **OpenCode** +**Codex** · **Claude Code** · **Cursor** · **Grok** · **OpenCode** · **Cline** Add an agent profile from Settings, pick your models, and it shows up in the model selector like any other provider. Conversations run inside your workspace with full tool access and resume where you left off. From 89b97c76260a3957996543f771d0f9dc2553ad9d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 5 Jul 2026 23:49:01 -0500 Subject: [PATCH 07/20] refac --- cptr/utils/chat_task.py | 27 +++------------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 5e22161..372dc8e 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -1778,30 +1778,8 @@ async def _run_agent_target(agent_target: AgentModelTarget): last_usage = usage new_messages_since = 0 - if not pending_calls: - # No tool calls — final response, we're done - _flush_text() - if streamed_reasoning_chars and not response_reasoning_items: - logger.warning( - "[task %s] reasoning output streamed (%d chars) but no completed reasoning item arrived before usage; DB output may contain only in-progress reasoning", - message_id[:8], - streamed_reasoning_chars, - ) - await _save_message( - "usage", - content=content, - output=output_items, - usage=usage, - done=True, - ) - _task_state.pop(message_id, None) - await _emit_done() - return - elif event["type"] == "done": - # Stream ended — if usage already triggered a save+return, we won't - # reach here. This is the fallback for providers that don't support - # stream_options.include_usage. + # Stream ended. Usage may have arrived earlier, multiple times, or never. if not pending_calls: _flush_text() if streamed_reasoning_chars and not response_reasoning_items: @@ -1811,7 +1789,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): streamed_reasoning_chars, ) await _save_message( - "done fallback", + "done", content=content, output=output_items, usage=last_usage, @@ -2030,6 +2008,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): "end", content=content, output=output_items, + usage=last_usage, done=True, ) _task_state.pop(message_id, None) From 38235c75235030ac5b87597665bb30aee6ad77aa Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 00:12:36 -0500 Subject: [PATCH 08/20] refac --- cptr/frontend/src/app.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cptr/frontend/src/app.css b/cptr/frontend/src/app.css index 4b10260..00ae9ae 100644 --- a/cptr/frontend/src/app.css +++ b/cptr/frontend/src/app.css @@ -155,6 +155,11 @@ color: var(--app-fg); } +.app-theme :where(option) { + background: var(--app-bg); + color: var(--app-fg); +} + .app-theme:where([class*='border-gray-'], [class*='border-black/'], [class*='border-white/']), .app-theme :where([class*='border-gray-'], [class*='border-black/'], [class*='border-white/']) { border-color: var(--app-border); From 11f5292d4805078bdcda1b6cbe23c5dbcd241b93 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 00:13:38 -0500 Subject: [PATCH 09/20] refac --- .../src/lib/components/FileBrowser.svelte | 18 ++ .../src/lib/components/GroupTabBar.svelte | 46 ++++- .../src/lib/components/chat/ChatInput.svelte | 13 ++ cptr/frontend/src/lib/constants.ts | 1 + cptr/frontend/src/lib/stores.ts | 119 ++++++------ cptr/frontend/src/routes/+page.svelte | 169 ++++++++++++------ 6 files changed, 246 insertions(+), 120 deletions(-) create mode 100644 cptr/frontend/src/lib/constants.ts diff --git a/cptr/frontend/src/lib/components/FileBrowser.svelte b/cptr/frontend/src/lib/components/FileBrowser.svelte index be0c803..d9c2a72 100644 --- a/cptr/frontend/src/lib/components/FileBrowser.svelte +++ b/cptr/frontend/src/lib/components/FileBrowser.svelte @@ -22,6 +22,7 @@ import Spinner from './common/Spinner.svelte'; import DropdownMenu from './DropdownMenu.svelte'; import { t } from '$lib/i18n'; + import { TAB_DRAG_MIME } from '$lib/constants'; interface FileEntry { name: string; @@ -573,6 +574,12 @@ // ── Drag to move ──────────────────────────────────────────── + function isTabDrag(e: DragEvent): boolean { + return Boolean( + e.dataTransfer?.types.includes(TAB_DRAG_MIME) || e.dataTransfer?.types.includes('text/tab-id') + ); + } + /** Collect all paths being dragged: either selected items (if the dragged item is selected) or just the single item */ function getDraggedPaths(entry: TreeEntry): string[] { if (selectedPaths.size > 0 && selectedPaths.has(entry.path)) { @@ -605,6 +612,8 @@ } function onDragOverDir(e: DragEvent, entry: TreeEntry) { + if (isTabDrag(e)) return; + // Determine the target folder for this entry let targetDir: string | null; if (entry.type === 'directory') { @@ -665,6 +674,8 @@ } async function onDropOnDir(e: DragEvent, entry: TreeEntry) { + if (isTabDrag(e)) return; + e.preventDefault(); if (dragExpandTimer) { clearTimeout(dragExpandTimer); @@ -744,6 +755,11 @@ // ── Drop zone for uploads ─────────────────────────────────── function onDropzoneOver(e: DragEvent) { + if (isTabDrag(e)) { + dropzoneActive = false; + return; + } + e.preventDefault(); if (draggedItem) { // Internal drag — allow drop to move to current directory @@ -762,6 +778,8 @@ } async function onDropzoneDrop(e: DragEvent) { + if (isTabDrag(e)) return; + e.preventDefault(); dropzoneActive = false; diff --git a/cptr/frontend/src/lib/components/GroupTabBar.svelte b/cptr/frontend/src/lib/components/GroupTabBar.svelte index b76178d..165d417 100644 --- a/cptr/frontend/src/lib/components/GroupTabBar.svelte +++ b/cptr/frontend/src/lib/components/GroupTabBar.svelte @@ -29,6 +29,7 @@ import { tooltip } from '$lib/tooltip'; import { t } from '$lib/i18n'; import VoiceMemoModal from './VoiceMemoModal.svelte'; + import { TAB_DRAG_MIME } from '$lib/constants'; interface Props { group: EditorGroup; @@ -57,6 +58,8 @@ // Drop target highlight for cross-group drag let dropHighlight = $state(false); + type TabDragPayload = { tabId: string; groupId: string }; + const displayTabs = $derived((group?.tabs ?? []).filter((t) => t.type !== 'git')); const isActiveGroup = $derived($activeWorkspace?.activeGroupId === group?.id); @@ -89,6 +92,11 @@ closeTab(tabId, group.id); } + function handleCloseGroup(e: Event) { + e.stopPropagation(); + closeGroup(group.id); + } + function handleContextMenu(e: MouseEvent, tab: Tab) { if (!isWideScreen) return; e.preventDefault(); @@ -108,11 +116,31 @@ // Native drag handlers are NOT needed. Sortable's setData handles it. // We keep the native drop handlers on the bar for cross-group drops. + function hasTabDrag(dataTransfer: DataTransfer): boolean { + return dataTransfer.types.includes(TAB_DRAG_MIME) || dataTransfer.types.includes('text/tab-id'); + } + + function readTabDragPayload(dataTransfer: DataTransfer): TabDragPayload | null { + const raw = dataTransfer.getData(TAB_DRAG_MIME); + if (raw) { + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.tabId === 'string' && typeof parsed.groupId === 'string') { + return { tabId: parsed.tabId, groupId: parsed.groupId }; + } + } catch { + // Fall through to the legacy payload below. + } + } + + const tabId = dataTransfer.getData('text/tab-id'); + const groupId = dataTransfer.getData('text/group-id'); + return tabId && groupId ? { tabId, groupId } : null; + } + function handleBarDragOver(e: DragEvent) { // Only accept tab drags (not file uploads) - if (!e.dataTransfer?.types.includes('text/tab-id')) return; - const sourceGroupId = e.dataTransfer.types.includes('text/group-id') ? 'unknown' : null; - if (!sourceGroupId) return; + if (!e.dataTransfer || !hasTabDrag(e.dataTransfer)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; dropHighlight = true; @@ -125,12 +153,11 @@ function handleBarDrop(e: DragEvent) { dropHighlight = false; if (!e.dataTransfer) return; - const tabId = e.dataTransfer.getData('text/tab-id'); - const fromGroupId = e.dataTransfer.getData('text/group-id'); - if (!tabId || !fromGroupId) return; - if (fromGroupId === group.id) return; // same group, ignore + const payload = readTabDragPayload(e.dataTransfer); + if (!payload) return; + if (payload.groupId === group.id) return; // same group, ignore e.preventDefault(); - moveTabToGroup(tabId, fromGroupId, group.id); + moveTabToGroup(payload.tabId, payload.groupId, group.id); } const plusMenuItems = $derived([ @@ -246,6 +273,7 @@ touchStartThreshold: 5, setData: (dataTransfer: DataTransfer, dragEl: HTMLElement) => { const tabId = dragEl.dataset.tabId ?? ''; + dataTransfer.setData(TAB_DRAG_MIME, JSON.stringify({ tabId, groupId: group.id })); dataTransfer.setData('text/tab-id', tabId); dataTransfer.setData('text/group-id', group.id); }, @@ -370,7 +398,7 @@ {#if canClose} - diff --git a/cptr/frontend/src/lib/components/Settings/Notifications.svelte b/cptr/frontend/src/lib/components/Settings/Notifications.svelte new file mode 100644 index 0000000..62cab56 --- /dev/null +++ b/cptr/frontend/src/lib/components/Settings/Notifications.svelte @@ -0,0 +1,460 @@ + + +
+
+

+ {$t('general.notifications')} +

+ +
+ +

+ {$t('general.browserNotificationsDesc')} +

+ + + +
+ + {$t('general.notificationTargets')} + + +
+ + {#if loadingTargets} +

{$t('common.loading')}

+ {:else if !targets.length} +

+ {$t('general.noNotificationTargets')} +

+ {:else} +
+ {#each targets as target} +
+
+
+
+ + {target.id} + + + {target.type === 'webhook' ? $t('general.webhook') : $t('general.bot')} + +
+

+ {target.type === 'webhook' + ? target.config.url_masked + : target.config.destination_chat_id} +

+
+ patchTarget(target, { enabled: v })} + /> +
+ +
+
+ {$t('general.automaticEvents')} +
+ {#if target.events.length} +
+ {#each eventOptions.filter((event) => target.events.includes(event.value)) as event} + + {/each} +
+ {:else} +

+ {$t('general.noChatAlerts')} +

+ {/if} +
+ +
+ {#if target.events.length} +
+
+ {$t('general.automaticDelivery')} +
+
+ {#each ['away', 'always'] as mode} + + {/each} +
+
+ {:else} +
+ {/if} +
+ + + +
+
+
+ {/each} +
+ {/if} +
+
+
+ +{#if formOpen} + (formOpen = false)} class="w-full max-w-md mx-4"> +
+

+ {editingId ? $t('common.edit') : $t('general.addNotificationTarget')} +

+ +
+ {#each targetTypes as type} + + {/each} +
+ + + + + {#if form.type === 'webhook'} + + + {:else} + + + + + + {/if} + + +
+ {#each eventOptions as event} + + {/each} +
+ + {#if form.events.length} + +
+ {#each ['away', 'always'] as mode} + + {/each} +
+ {/if} + +

+ {$t('general.notifyToolAlwaysSends')} +

+ +
+ + +
+
+
+{/if} diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte index 6c8c85b..ff0ac0a 100644 --- a/cptr/frontend/src/lib/components/SettingsModal.svelte +++ b/cptr/frontend/src/lib/components/SettingsModal.svelte @@ -3,6 +3,7 @@ import Icon from './Icon.svelte'; import Modal from './Modal.svelte'; import General from './Settings/General.svelte'; + import Notifications from './Settings/Notifications.svelte'; import Appearance from './Settings/Appearance.svelte'; import Memory from './Settings/Memory.svelte'; import PWA from './Settings/PWA.svelte'; @@ -25,6 +26,7 @@ type Tab = | 'general' + | 'notifications' | 'appearance' | 'memory' | 'pwa' @@ -76,6 +78,7 @@ const tabs: SettingsTab[] = [ { id: 'general', label: $t('settings.general'), icon: 'settings' }, { id: 'appearance', label: $t('settings.appearance'), icon: 'sun-light' }, + { id: 'notifications', label: $t('general.notifications'), icon: 'chat-bubble' }, { id: 'keyboard', label: $t('settings.keyboard'), icon: 'terminal' }, { id: 'account', label: $t('settings.account'), icon: 'user' } ]; @@ -178,6 +181,8 @@
{#if activeTab === 'general'} + {:else if activeTab === 'notifications'} + {:else if activeTab === 'appearance'} {:else if activeTab === 'memory'} diff --git a/cptr/frontend/src/lib/i18n/locales/de.json b/cptr/frontend/src/lib/i18n/locales/de.json index b66b75a..a62b383 100644 --- a/cptr/frontend/src/lib/i18n/locales/de.json +++ b/cptr/frontend/src/lib/i18n/locales/de.json @@ -399,10 +399,27 @@ "general.browserNotificationsDesc": "Systembenachrichtigungen anzeigen, wenn eine Aufgabe abgeschlossen ist und der Tab nicht fokussiert ist.", "general.notificationSound": "Benachrichtigungston", "general.notificationPermissionDenied": "Browser-Benachrichtigungserlaubnis verweigert", - "general.webhookUrl": "Webhook-URL", - "general.webhookUrlSaved": "Webhook-URL gespeichert", - "general.webhookUrlSaveFailed": "Webhook-URL konnte nicht gespeichert werden", - "general.webhookUrlHint": "Unterstützt Slack, Discord, Teams und generische JSON-Webhooks.", + "general.notificationTargets": "Benachrichtigungsziele", + "general.addNotificationTarget": "Benachrichtigungsziel hinzufügen", + "general.noNotificationTargets": "Noch keine Benachrichtigungsziele.", + "general.webhook": "Webhook", + "general.bot": "Bot", + "general.chatFinished": "Chat beendet", + "general.chatFailed": "Chat fehlgeschlagen", + "general.automaticEvents": "Chat-Hinweise (optional)", + "general.noChatAlerts": "Keine Chat-Hinweise", + "general.automaticDelivery": "Chat-Hinweise senden", + "general.onlyWhenAway": "Nur wenn ich abwesend bin", + "general.always": "Immer", + "general.sendTest": "Test senden", + "general.platformDestinationId": "Plattform-Ziel-ID", + "general.targetId": "Ziel-ID", + "general.targetIdForNotify": "Ziel-ID", + "general.notifyToolAlwaysSends": "Agenten können dieses Ziel auch aus einem Chat benachrichtigen.", + "general.keepWebhookUrl": "Leer lassen, um die aktuelle URL zu behalten", + "general.testSent": "Test gesendet", + "general.notificationTargetsLoadFailed": "Benachrichtigungsziele konnten nicht geladen werden", + "general.notificationTargetSaveFailed": "Benachrichtigungsziel konnte nicht gespeichert werden", "general.updates": "Aktualisierungen", "general.updateNotifications": "Update-Benachrichtigungen", "general.updateNotificationsDesc": "Eine Benachrichtigung anzeigen, wenn eine neue Version von Computer verfügbar ist.", diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index a77d492..3beb1fa 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -399,10 +399,27 @@ "general.browserNotificationsDesc": "Show OS-level notifications when a task completes and the tab is not focused.", "general.notificationSound": "Notification sound", "general.notificationPermissionDenied": "Browser notification permission denied", - "general.webhookUrl": "Webhook URL", - "general.webhookUrlSaved": "Webhook URL saved", - "general.webhookUrlSaveFailed": "Failed to save webhook URL", - "general.webhookUrlHint": "Supports Slack, Discord, Teams, and generic JSON webhooks.", + "general.notificationTargets": "Notification targets", + "general.addNotificationTarget": "Add notification target", + "general.noNotificationTargets": "No notification targets yet.", + "general.webhook": "Webhook", + "general.bot": "Bot", + "general.chatFinished": "Chat finished", + "general.chatFailed": "Chat failed", + "general.automaticEvents": "Chat alerts (optional)", + "general.noChatAlerts": "No chat alerts", + "general.automaticDelivery": "Send chat alerts", + "general.onlyWhenAway": "Only when I'm away", + "general.always": "Always", + "general.sendTest": "Send test", + "general.platformDestinationId": "Platform destination ID", + "general.targetId": "Target ID", + "general.targetIdForNotify": "Target ID", + "general.notifyToolAlwaysSends": "Agents can also notify this target from a chat.", + "general.keepWebhookUrl": "Leave blank to keep current URL", + "general.testSent": "Test sent", + "general.notificationTargetsLoadFailed": "Failed to load notification targets", + "general.notificationTargetSaveFailed": "Failed to save notification target", "general.updates": "Updates", "general.updateNotifications": "Update notifications", "general.updateNotificationsDesc": "Show a toast when a new version of Computer is available.", diff --git a/cptr/frontend/src/lib/i18n/locales/es.json b/cptr/frontend/src/lib/i18n/locales/es.json index 26bd64d..d585d57 100644 --- a/cptr/frontend/src/lib/i18n/locales/es.json +++ b/cptr/frontend/src/lib/i18n/locales/es.json @@ -399,10 +399,27 @@ "general.browserNotificationsDesc": "Mostrar notificaciones del sistema cuando una tarea se completa y la pestaña no está enfocada.", "general.notificationSound": "Sonido de notificación", "general.notificationPermissionDenied": "Permiso de notificación del navegador denegado", - "general.webhookUrl": "URL del webhook", - "general.webhookUrlSaved": "URL del webhook guardada", - "general.webhookUrlSaveFailed": "Error al guardar URL del webhook", - "general.webhookUrlHint": "Compatible con Slack, Discord, Teams y webhooks JSON genéricos.", + "general.notificationTargets": "Destinos de notificación", + "general.addNotificationTarget": "Agregar destino de notificación", + "general.noNotificationTargets": "Aún no hay destinos de notificación.", + "general.webhook": "Webhook", + "general.bot": "Bot", + "general.chatFinished": "Chat finalizado", + "general.chatFailed": "Chat fallido", + "general.automaticEvents": "Alertas de chat (opcional)", + "general.noChatAlerts": "Sin alertas de chat", + "general.automaticDelivery": "Enviar alertas de chat", + "general.onlyWhenAway": "Solo cuando estoy ausente", + "general.always": "Siempre", + "general.sendTest": "Enviar prueba", + "general.platformDestinationId": "ID de destino de la plataforma", + "general.targetId": "ID del destino", + "general.targetIdForNotify": "ID del destino", + "general.notifyToolAlwaysSends": "Los agentes también pueden notificar a este destino desde un chat.", + "general.keepWebhookUrl": "Deja en blanco para conservar la URL actual", + "general.testSent": "Prueba enviada", + "general.notificationTargetsLoadFailed": "Error al cargar destinos de notificación", + "general.notificationTargetSaveFailed": "Error al guardar destino de notificación", "general.updates": "Actualizaciones", "general.updateNotifications": "Notificaciones de actualización", "general.updateNotificationsDesc": "Mostrar un aviso cuando hay una nueva versión de Computer disponible.", diff --git a/cptr/frontend/src/lib/i18n/locales/fr.json b/cptr/frontend/src/lib/i18n/locales/fr.json index c3dc35e..67c7b74 100644 --- a/cptr/frontend/src/lib/i18n/locales/fr.json +++ b/cptr/frontend/src/lib/i18n/locales/fr.json @@ -399,10 +399,27 @@ "general.browserNotificationsDesc": "Afficher les notifications du système lorsqu'une tâche se termine et que l'onglet n'est pas actif.", "general.notificationSound": "Son de notification", "general.notificationPermissionDenied": "Permission de notification du navigateur refusée", - "general.webhookUrl": "URL du webhook", - "general.webhookUrlSaved": "URL du webhook enregistrée", - "general.webhookUrlSaveFailed": "Échec de l'enregistrement de l'URL du webhook", - "general.webhookUrlHint": "Prend en charge Slack, Discord, Teams et les webhooks JSON génériques.", + "general.notificationTargets": "Cibles de notification", + "general.addNotificationTarget": "Ajouter une cible de notification", + "general.noNotificationTargets": "Aucune cible de notification pour l’instant.", + "general.webhook": "Webhook", + "general.bot": "Bot", + "general.chatFinished": "Chat terminé", + "general.chatFailed": "Échec du chat", + "general.automaticEvents": "Alertes de chat (facultatif)", + "general.noChatAlerts": "Aucune alerte de chat", + "general.automaticDelivery": "Envoyer les alertes de chat", + "general.onlyWhenAway": "Seulement quand je suis absent", + "general.always": "Toujours", + "general.sendTest": "Envoyer un test", + "general.platformDestinationId": "ID de destination de la plateforme", + "general.targetId": "ID de la cible", + "general.targetIdForNotify": "ID de la cible", + "general.notifyToolAlwaysSends": "Les agents peuvent aussi notifier cette cible depuis un chat.", + "general.keepWebhookUrl": "Laisser vide pour conserver l’URL actuelle", + "general.testSent": "Test envoyé", + "general.notificationTargetsLoadFailed": "Échec du chargement des cibles de notification", + "general.notificationTargetSaveFailed": "Échec de l’enregistrement de la cible de notification", "general.updates": "Mises à jour", "general.updateNotifications": "Notifications de mise à jour", "general.updateNotificationsDesc": "Afficher une notification lorsqu'une nouvelle version de Computer est disponible.", diff --git a/cptr/frontend/src/lib/i18n/locales/ja.json b/cptr/frontend/src/lib/i18n/locales/ja.json index 481ac2f..ce1fe77 100644 --- a/cptr/frontend/src/lib/i18n/locales/ja.json +++ b/cptr/frontend/src/lib/i18n/locales/ja.json @@ -399,10 +399,27 @@ "general.browserNotificationsDesc": "タスク完了時にタブがフォーカスされていない場合、OS通知を表示します。", "general.notificationSound": "通知音", "general.notificationPermissionDenied": "ブラウザ通知の許可が拒否されました", - "general.webhookUrl": "Webhook URL", - "general.webhookUrlSaved": "Webhook URLを保存しました", - "general.webhookUrlSaveFailed": "Webhook URLの保存に失敗しました", - "general.webhookUrlHint": "Slack、Discord、Teams、汎用JSONウェブフックに対応。", + "general.notificationTargets": "通知先", + "general.addNotificationTarget": "通知先を追加", + "general.noNotificationTargets": "通知先はまだありません。", + "general.webhook": "Webhook", + "general.bot": "Bot", + "general.chatFinished": "チャット完了", + "general.chatFailed": "チャット失敗", + "general.automaticEvents": "チャット通知(任意)", + "general.noChatAlerts": "チャット通知なし", + "general.automaticDelivery": "チャット通知を送信", + "general.onlyWhenAway": "離席中のみ", + "general.always": "常に", + "general.sendTest": "テスト送信", + "general.platformDestinationId": "プラットフォーム送信先ID", + "general.targetId": "通知先ID", + "general.targetIdForNotify": "通知先ID", + "general.notifyToolAlwaysSends": "エージェントはチャットからこの通知先へ通知することもできます。", + "general.keepWebhookUrl": "現在のURLを保持する場合は空欄", + "general.testSent": "テストを送信しました", + "general.notificationTargetsLoadFailed": "通知先の読み込みに失敗しました", + "general.notificationTargetSaveFailed": "通知先の保存に失敗しました", "general.updates": "アップデート", "general.updateNotifications": "アップデート通知", "general.updateNotificationsDesc": "Computerの新しいバージョンが利用可能な場合にトーストを表示します。", diff --git a/cptr/frontend/src/lib/i18n/locales/ko.json b/cptr/frontend/src/lib/i18n/locales/ko.json index c69ea10..a92d137 100644 --- a/cptr/frontend/src/lib/i18n/locales/ko.json +++ b/cptr/frontend/src/lib/i18n/locales/ko.json @@ -399,10 +399,27 @@ "general.browserNotificationsDesc": "작업이 완료되고 탭이 포커스되지 않았을 때 OS 알림을 표시합니다.", "general.notificationSound": "알림 소리", "general.notificationPermissionDenied": "브라우저 알림 권한이 거부되었습니다", - "general.webhookUrl": "Webhook URL", - "general.webhookUrlSaved": "Webhook URL이 저장되었습니다", - "general.webhookUrlSaveFailed": "Webhook URL 저장에 실패했습니다", - "general.webhookUrlHint": "Slack, Discord, Teams 및 일반 JSON 웹훅을 지원합니다.", + "general.notificationTargets": "알림 대상", + "general.addNotificationTarget": "알림 대상 추가", + "general.noNotificationTargets": "아직 알림 대상이 없습니다.", + "general.webhook": "웹훅", + "general.bot": "봇", + "general.chatFinished": "채팅 완료", + "general.chatFailed": "채팅 실패", + "general.automaticEvents": "채팅 알림 (선택 사항)", + "general.noChatAlerts": "채팅 알림 없음", + "general.automaticDelivery": "채팅 알림 보내기", + "general.onlyWhenAway": "자리 비움일 때만", + "general.always": "항상", + "general.sendTest": "테스트 보내기", + "general.platformDestinationId": "플랫폼 대상 ID", + "general.targetId": "대상 ID", + "general.targetIdForNotify": "대상 ID", + "general.notifyToolAlwaysSends": "에이전트가 채팅에서 이 대상으로 알림을 보낼 수도 있습니다.", + "general.keepWebhookUrl": "현재 URL을 유지하려면 비워 두세요", + "general.testSent": "테스트를 보냈습니다", + "general.notificationTargetsLoadFailed": "알림 대상을 불러오지 못했습니다", + "general.notificationTargetSaveFailed": "알림 대상을 저장하지 못했습니다", "general.updates": "업데이트", "general.updateNotifications": "업데이트 알림", "general.updateNotificationsDesc": "Computer의 새 버전이 사용 가능할 때 알림을 표시합니다.", diff --git a/cptr/frontend/src/lib/i18n/locales/pt-BR.json b/cptr/frontend/src/lib/i18n/locales/pt-BR.json index 6199016..2cee645 100644 --- a/cptr/frontend/src/lib/i18n/locales/pt-BR.json +++ b/cptr/frontend/src/lib/i18n/locales/pt-BR.json @@ -399,10 +399,27 @@ "general.browserNotificationsDesc": "Mostrar notificações do sistema quando uma tarefa é concluída e a aba não está em foco.", "general.notificationSound": "Som de notificação", "general.notificationPermissionDenied": "Permissão de notificação do navegador negada", - "general.webhookUrl": "URL do webhook", - "general.webhookUrlSaved": "URL do webhook salva", - "general.webhookUrlSaveFailed": "Falha ao salvar URL do webhook", - "general.webhookUrlHint": "Suporta Slack, Discord, Teams e webhooks JSON genéricos.", + "general.notificationTargets": "Destinos de notificação", + "general.addNotificationTarget": "Adicionar destino de notificação", + "general.noNotificationTargets": "Ainda não há destinos de notificação.", + "general.webhook": "Webhook", + "general.bot": "Bot", + "general.chatFinished": "Chat concluído", + "general.chatFailed": "Chat falhou", + "general.automaticEvents": "Alertas de chat (opcional)", + "general.noChatAlerts": "Sem alertas de chat", + "general.automaticDelivery": "Enviar alertas de chat", + "general.onlyWhenAway": "Só quando eu estiver ausente", + "general.always": "Sempre", + "general.sendTest": "Enviar teste", + "general.platformDestinationId": "ID do destino na plataforma", + "general.targetId": "ID do destino", + "general.targetIdForNotify": "ID do destino", + "general.notifyToolAlwaysSends": "Agentes também podem notificar este destino a partir de um chat.", + "general.keepWebhookUrl": "Deixe em branco para manter a URL atual", + "general.testSent": "Teste enviado", + "general.notificationTargetsLoadFailed": "Falha ao carregar destinos de notificação", + "general.notificationTargetSaveFailed": "Falha ao salvar destino de notificação", "general.updates": "Atualizações", "general.updateNotifications": "Notificações de atualização", "general.updateNotificationsDesc": "Mostrar um aviso quando uma nova versão do Computer estiver disponível.", diff --git a/cptr/frontend/src/lib/i18n/locales/ru.json b/cptr/frontend/src/lib/i18n/locales/ru.json index 948517d..a37ef5b 100644 --- a/cptr/frontend/src/lib/i18n/locales/ru.json +++ b/cptr/frontend/src/lib/i18n/locales/ru.json @@ -399,10 +399,27 @@ "general.browserNotificationsDesc": "Показывать уведомления ОС при завершении задачи, когда вкладка не в фокусе.", "general.notificationSound": "Звук уведомления", "general.notificationPermissionDenied": "Разрешение на уведомления браузера отклонено", - "general.webhookUrl": "URL вебхука", - "general.webhookUrlSaved": "URL вебхука сохранён", - "general.webhookUrlSaveFailed": "Не удалось сохранить URL вебхука", - "general.webhookUrlHint": "Поддерживает Slack, Discord, Teams и универсальные JSON-вебхуки.", + "general.notificationTargets": "Цели уведомлений", + "general.addNotificationTarget": "Добавить цель уведомления", + "general.noNotificationTargets": "Целей уведомлений пока нет.", + "general.webhook": "Вебхук", + "general.bot": "Бот", + "general.chatFinished": "Чат завершён", + "general.chatFailed": "Ошибка чата", + "general.automaticEvents": "Оповещения чата (необязательно)", + "general.noChatAlerts": "Нет оповещений чата", + "general.automaticDelivery": "Отправлять оповещения чата", + "general.onlyWhenAway": "Только когда меня нет", + "general.always": "Всегда", + "general.sendTest": "Отправить тест", + "general.platformDestinationId": "ID назначения платформы", + "general.targetId": "ID цели", + "general.targetIdForNotify": "ID цели", + "general.notifyToolAlwaysSends": "Агенты также могут уведомлять эту цель из чата.", + "general.keepWebhookUrl": "Оставьте пустым, чтобы сохранить текущий URL", + "general.testSent": "Тест отправлен", + "general.notificationTargetsLoadFailed": "Не удалось загрузить цели уведомлений", + "general.notificationTargetSaveFailed": "Не удалось сохранить цель уведомлений", "general.updates": "Обновления", "general.updateNotifications": "Уведомления об обновлениях", "general.updateNotificationsDesc": "Показывать уведомление, когда доступна новая версия Computer.", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-CN.json b/cptr/frontend/src/lib/i18n/locales/zh-CN.json index 7013e23..84a15b7 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-CN.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-CN.json @@ -399,10 +399,27 @@ "general.browserNotificationsDesc": "当任务完成且标签页未聚焦时显示系统通知。", "general.notificationSound": "通知声音", "general.notificationPermissionDenied": "浏览器通知权限被拒绝", - "general.webhookUrl": "Webhook URL", - "general.webhookUrlSaved": "Webhook URL 已保存", - "general.webhookUrlSaveFailed": "保存 Webhook URL 失败", - "general.webhookUrlHint": "支持 Slack、Discord、Teams 和通用 JSON Webhook。", + "general.notificationTargets": "通知目标", + "general.addNotificationTarget": "添加通知目标", + "general.noNotificationTargets": "还没有通知目标。", + "general.webhook": "Webhook", + "general.bot": "机器人", + "general.chatFinished": "聊天已完成", + "general.chatFailed": "聊天失败", + "general.automaticEvents": "聊天提醒(可选)", + "general.noChatAlerts": "没有聊天提醒", + "general.automaticDelivery": "发送聊天提醒", + "general.onlyWhenAway": "仅在我离开时", + "general.always": "始终", + "general.sendTest": "发送测试", + "general.platformDestinationId": "平台目标 ID", + "general.targetId": "目标 ID", + "general.targetIdForNotify": "目标 ID", + "general.notifyToolAlwaysSends": "代理也可以从聊天中通知此目标。", + "general.keepWebhookUrl": "留空以保留当前 URL", + "general.testSent": "测试已发送", + "general.notificationTargetsLoadFailed": "无法加载通知目标", + "general.notificationTargetSaveFailed": "无法保存通知目标", "general.updates": "更新", "general.updateNotifications": "更新通知", "general.updateNotificationsDesc": "当 Computer 有新版本可用时显示提示。", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-TW.json b/cptr/frontend/src/lib/i18n/locales/zh-TW.json index 935ca37..8301dd2 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-TW.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-TW.json @@ -399,10 +399,27 @@ "general.browserNotificationsDesc": "當任務完成且分頁未聚焦時顯示系統通知。", "general.notificationSound": "通知聲音", "general.notificationPermissionDenied": "瀏覽器通知權限被拒絕", - "general.webhookUrl": "Webhook URL", - "general.webhookUrlSaved": "Webhook URL 已儲存", - "general.webhookUrlSaveFailed": "儲存 Webhook URL 失敗", - "general.webhookUrlHint": "支援 Slack、Discord、Teams 和通用 JSON Webhook。", + "general.notificationTargets": "通知目標", + "general.addNotificationTarget": "新增通知目標", + "general.noNotificationTargets": "尚無通知目標。", + "general.webhook": "Webhook", + "general.bot": "機器人", + "general.chatFinished": "聊天已完成", + "general.chatFailed": "聊天失敗", + "general.automaticEvents": "聊天提醒(選用)", + "general.noChatAlerts": "沒有聊天提醒", + "general.automaticDelivery": "傳送聊天提醒", + "general.onlyWhenAway": "僅在我離開時", + "general.always": "一律", + "general.sendTest": "傳送測試", + "general.platformDestinationId": "平台目標 ID", + "general.targetId": "目標 ID", + "general.targetIdForNotify": "目標 ID", + "general.notifyToolAlwaysSends": "代理也可以從聊天中通知此目標。", + "general.keepWebhookUrl": "留空以保留目前 URL", + "general.testSent": "測試已傳送", + "general.notificationTargetsLoadFailed": "無法載入通知目標", + "general.notificationTargetSaveFailed": "無法儲存通知目標", "general.updates": "更新", "general.updateNotifications": "更新通知", "general.updateNotificationsDesc": "當 Computer 有新版本可用時顯示提示。", diff --git a/cptr/routers/__init__.py b/cptr/routers/__init__.py index a64383a..07bca2f 100644 --- a/cptr/routers/__init__.py +++ b/cptr/routers/__init__.py @@ -13,6 +13,7 @@ from cptr.routers.git import router as git_router from cptr.routers.images import router as images_router from cptr.routers.memory import router as memory_router +from cptr.routers.notifications import router as notifications_router from cptr.routers.proxy import router as proxy_router from cptr.routers.search import router as search_router from cptr.routers.skills import router as skills_router @@ -34,6 +35,7 @@ "git_router", "images_router", "memory_router", + "notifications_router", "proxy_router", "search_router", "skills_router", diff --git a/cptr/routers/notifications.py b/cptr/routers/notifications.py new file mode 100644 index 0000000..0e0e4c4 --- /dev/null +++ b/cptr/routers/notifications.py @@ -0,0 +1,93 @@ +"""User notification target API.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from cptr.utils.notifications import ( + NotificationError, + create_target, + delete_target, + get_bot_options, + list_targets, + test_target, + update_target, +) + +router = APIRouter(prefix="/api/notifications", tags=["notifications"]) + + +class TargetPayload(BaseModel): + id: str | None = None + type: str | None = None + enabled: bool | None = None + events: list[str] | None = None + delivery: str | None = None + config: dict | None = None + + +def _user_id(request: Request) -> str: + auth = getattr(request.state, "auth", None) + if not auth or not auth.user_id: + raise HTTPException(401, "authentication required") + return auth.user_id + + +def _payload(body: TargetPayload) -> dict: + if hasattr(body, "model_dump"): + return body.model_dump(exclude_unset=True) + return body.dict(exclude_unset=True) + + +def _error(exc: NotificationError) -> HTTPException: + message = str(exc) + status = 404 if "not found" in message else 400 + return HTTPException(status, message) + + +@router.get("/targets") +async def api_list_targets(request: Request): + return {"targets": await list_targets(_user_id(request))} + + +@router.post("/targets") +async def api_create_target(request: Request, body: TargetPayload): + try: + return await create_target(_user_id(request), _payload(body)) + except NotificationError as exc: + raise _error(exc) from exc + + +@router.put("/targets/{target_id}") +async def api_update_target(request: Request, target_id: str, body: TargetPayload): + try: + return await update_target(_user_id(request), target_id, _payload(body)) + except NotificationError as exc: + raise _error(exc) from exc + + +@router.delete("/targets/{target_id}") +async def api_delete_target(request: Request, target_id: str): + ok = await delete_target(_user_id(request), target_id) + if not ok: + raise HTTPException(404, "notification target not found") + return {"ok": True} + + +@router.post("/targets/{target_id}/test") +async def api_test_target(request: Request, target_id: str): + try: + return await test_target(_user_id(request), target_id) + except NotificationError as exc: + raise _error(exc) from exc + + +@router.get("/bot-options") +async def api_bot_options(request: Request): + return { + "bots": await get_bot_options( + _user_id(request), + bot_manager=getattr(request.app.state, "bot_manager", None), + ) + } diff --git a/cptr/socket/main.py b/cptr/socket/main.py index b46c229..0108d74 100644 --- a/cptr/socket/main.py +++ b/cptr/socket/main.py @@ -55,6 +55,11 @@ async def emit_to_user(user_id: str, data: dict): await sio.emit("events:chat", data, to=sid) +def is_user_active(user_id: str) -> bool: + """Return True when the user has at least one connected app session.""" + return bool(_user_sids.get(user_id)) + + def get_asgi_app(other_app): """Wrap a FastAPI/Starlette app with the Socket.IO ASGI layer.""" return socketio.ASGIApp(sio, other_asgi_app=other_app) diff --git a/cptr/utils/bridge.py b/cptr/utils/bridge.py index 1a9f098..3b6b11f 100644 --- a/cptr/utils/bridge.py +++ b/cptr/utils/bridge.py @@ -20,6 +20,7 @@ from typing import Any, Awaitable, Callable, Optional logger = logging.getLogger(__name__) +_current_bot_manager: "BotManager | None" = None # How often to edit the streaming message (seconds). STREAM_EDIT_INTERVAL = 2.0 @@ -193,6 +194,10 @@ async def delete_bot_config(bot_id: str) -> bool: return True +def get_current_bot_manager() -> "BotManager | None": + return _current_bot_manager + + # ── Thread mapping via chat.meta ───────────────────────────── @@ -238,9 +243,11 @@ class BotManager: """ def __init__(self) -> None: + global _current_bot_manager self._adapters: dict[str, BaseAdapter] = {} # bot_id → adapter self._tasks: dict[str, asyncio.Task] = {} # bot_id → polling task self._stream_tasks: dict[str, asyncio.Task] = {} # key → streaming task + _current_bot_manager = self # ── Lifecycle ────────────────────────────────────────── @@ -317,6 +324,17 @@ def is_running(self, bot_id: str) -> bool: def get_status(self) -> dict[str, bool]: return {bid: not t.done() for bid, t in self._tasks.items()} + async def send_notification( + self, + bot_id: str, + destination_chat_id: str, + text: str, + ) -> str | None: + adapter = self._adapters.get(bot_id) + if not adapter or not self.is_running(bot_id): + raise RuntimeError("bot is not running") + return await adapter.send(destination_chat_id, text) + # ── Adapter factory ──────────────────────────────────── def _create_adapter(self, bot: dict) -> BaseAdapter | None: diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 372dc8e..d6280c6 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -11,6 +11,7 @@ import re import uuid +from cptr.events import EVENTS from cptr.env import CHAT_MAX_ITERATIONS, CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS from cptr.utils.context import resolve_compact_token_threshold, should_compact from cptr.utils.skills import discover_skills, load_skill, format_skill_content @@ -1182,6 +1183,27 @@ async def _emit_done(): workspace_name=ws_name, ) + async def _notify_chat(event: str, message: str | None = None): + try: + from cptr.utils.notifications import EVENT_LABELS, send_chat_event + + ws_name = workspace.rstrip("/").rsplit("/", 1)[-1] if workspace else "" + body = message if message is not None else content + await send_chat_event( + user_id, + event, + { + "title": EVENT_LABELS.get(event, "Chat"), + "message": body[:300] if body else "", + "chat_id": chat_id, + "workspace": {"id": workspace, "name": ws_name} if workspace else None, + }, + ) + except Exception: + logger.debug( + "[notifications] Error sending chat notification for %s", chat_id[:8], exc_info=True + ) + # Load existing state so continuations don't overwrite previous output msg = await ChatMessage.get_by_id(message_id) summary_message_id = message_id @@ -1495,6 +1517,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() + await _notify_chat(EVENTS.CHAT_FINISHED.name) return flushed_item = _flush_text() @@ -1503,6 +1526,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): await _save_message("agent stream ended", content=content, output=output_items, done=True) _task_state.pop(message_id, None) await _emit_done() + await _notify_chat(EVENTS.CHAT_FINISHED.name) return try: @@ -1797,6 +1821,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() + await _notify_chat(EVENTS.CHAT_FINISHED.name) return # ── Process collected tool calls ──────────────────── @@ -1853,7 +1878,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) if flushed_item: await emit(output=flushed_item) - await emit(output=item) + await emit(output=item) _task_state.pop(message_id, None) await emit(done=True) return @@ -2013,6 +2038,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() + await _notify_chat(EVENTS.CHAT_FINISHED.name) return # Max iterations reached @@ -2025,6 +2051,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() + await _notify_chat(EVENTS.CHAT_FAILED.name, "Max iterations reached.") except asyncio.CancelledError: _flush_text() @@ -2066,6 +2093,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await emit(done=True, error=error_msg) + await _notify_chat(EVENTS.CHAT_FAILED.name, error_msg) finally: # Guarantee the gateway SSE stream terminates. If emit() # already pushed a done/error event the sentinel is harmless @@ -2109,18 +2137,6 @@ async def _run_agent_target(agent_target: AgentModelTarget): logger.debug( "[title] Error in title generation for chat %s", chat_id[:8], exc_info=True ) - # Fire webhook notification if configured - try: - webhook_url = await Config.get("notifications.webhook_url") - if webhook_url: - chat_obj = chat_obj or await Chat.get_by_id(chat_id) - title = chat_obj.title if chat_obj else "Chat" - preview = content[:300] if content else "" - from cptr.utils.webhook import post_webhook - - await post_webhook(webhook_url, title, preview) - except Exception: - logger.debug("[webhook] Error sending webhook for chat %s", chat_id[:8], exc_info=True) # Best-effort post-turn memory review. Runs detached and never competes # with queued user input processing. try: diff --git a/cptr/utils/notifications.py b/cptr/utils/notifications.py new file mode 100644 index 0000000..6530489 --- /dev/null +++ b/cptr/utils/notifications.py @@ -0,0 +1,379 @@ +"""User-owned chat notification targets.""" + +from __future__ import annotations + +import ipaddress +import logging +import socket +import time +from urllib.parse import urlparse + +import httpx + +from cptr.events import CHAT_NOTIFICATION_EVENTS, EVENTS +from cptr.models import UserStates +from cptr.socket.main import is_user_active + +logger = logging.getLogger(__name__) + +VALID_EVENTS = {event.name for event in CHAT_NOTIFICATION_EVENTS} +VALID_TYPES = {"webhook", "bot"} +VALID_DELIVERY = {"away", "always"} + +EVENT_LABELS = {event.name: event.label for event in CHAT_NOTIFICATION_EVENTS} + + +class NotificationError(ValueError): + pass + + +def _now() -> int: + return int(time.time()) + + +async def _state(user_id: str) -> dict: + return dict(await UserStates.get_data(user_id) or {}) + + +async def _save_state(user_id: str, data: dict) -> None: + await UserStates.save_data(user_id, data) + + +def _targets(data: dict) -> list[dict]: + notifications = data.setdefault("notifications", {}) + targets = notifications.setdefault("targets", []) + if not isinstance(targets, list): + targets = [] + notifications["targets"] = targets + return targets + + +def _mask_url(url: str) -> str: + parsed = urlparse(url) + if not parsed.hostname: + return "****" + path = parsed.path or "" + suffix = path[-4:] if len(path) > 4 else path + return f"{parsed.scheme}://{parsed.hostname}/...{suffix}" + + +def _public_target(target: dict) -> dict: + public = {**target, "config": dict(target.get("config") or {})} + if public.get("type") == "webhook": + url = str(public["config"].pop("url", "") or "") + public["config"]["url_masked"] = _mask_url(url) if url else "" + return public + + +def _validate_events(events) -> list[str]: + if events is None: + return [] + if not isinstance(events, list): + raise NotificationError("events must be a list") + cleaned = [] + for event in events: + if event not in VALID_EVENTS: + raise NotificationError(f"unsupported notification event: {event}") + if event not in cleaned: + cleaned.append(event) + return cleaned + + +def validate_webhook_url(url: str) -> str: + url = (url or "").strip() + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise NotificationError("webhook URL must be http or https") + + host = parsed.hostname.lower() + if host in {"metadata.google.internal"} or host.endswith(".internal"): + raise NotificationError("webhook URL host is not allowed") + + try: + infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80)) + except OSError as exc: + raise NotificationError("webhook URL hostname could not be resolved") from exc + + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if ( + ip.is_loopback + or ip.is_private + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ): + raise NotificationError("webhook URL resolves to a blocked address") + return url + + +async def list_targets(user_id: str) -> list[dict]: + data = await _state(user_id) + visible = [] + for target in _targets(data): + if target.get("type") == "bot": + try: + await _ensure_user_bot(user_id, str((target.get("config") or {}).get("bot_id") or "")) + except NotificationError: + continue + visible.append(_public_target(target)) + return visible + + +async def get_bot_options(user_id: str, bot_manager=None) -> list[dict]: + from cptr.utils.bridge import get_bot_configs + + status = bot_manager.get_status() if bot_manager else {} + bots = [] + for bot in await get_bot_configs(): + if bot.get("user_id") != user_id: + continue + bots.append( + { + "id": bot.get("id"), + "name": bot.get("name"), + "platform": bot.get("platform"), + "is_active": bool(bot.get("is_active", True)), + "is_running": bool(status.get(bot.get("id"), False)), + } + ) + return bots + + +async def _ensure_user_bot(user_id: str, bot_id: str) -> None: + from cptr.utils.bridge import get_bot_by_id + + bot = await get_bot_by_id(bot_id) + if not bot or bot.get("user_id") != user_id: + raise NotificationError("bot target is not available") + + +def _validate_config(target_type: str, config: dict) -> dict: + if not isinstance(config, dict): + raise NotificationError("config must be an object") + if target_type == "webhook": + return {"url": validate_webhook_url(str(config.get("url") or ""))} + if target_type == "bot": + bot_id = str(config.get("bot_id") or "").strip() + destination = str(config.get("destination_chat_id") or "").strip() + if not bot_id: + raise NotificationError("bot target requires bot_id") + if not destination: + raise NotificationError("bot target requires destination_chat_id") + return {"bot_id": bot_id, "destination_chat_id": destination} + raise NotificationError("unsupported notification target type") + + +def _validate_target(payload: dict, existing: dict | None = None) -> dict: + merged = {**(existing or {}), **payload} + target_id = str(merged.get("id") or "").strip() + target_type = str(merged.get("type") or "").strip() + delivery = str(merged.get("delivery") or "away").strip() + if not target_id: + raise NotificationError("target id is required") + if target_type not in VALID_TYPES: + raise NotificationError("target type must be webhook or bot") + if delivery not in VALID_DELIVERY: + raise NotificationError("delivery must be away or always") + config = _validate_config(target_type, dict(merged.get("config") or {})) + now = _now() + return { + "id": target_id, + "type": target_type, + "enabled": bool(merged.get("enabled", True)), + "events": _validate_events(merged.get("events", [])), + "delivery": delivery, + "config": config, + "created_at": int(merged.get("created_at") or now), + "updated_at": now, + } + + +def _check_unique_id(targets: list[dict], new_id: str, old_id: str | None = None) -> None: + lowered = new_id.lower() + for target in targets: + if target.get("id") != old_id and str(target.get("id", "")).lower() == lowered: + raise NotificationError("notification target id already exists") + + +async def create_target(user_id: str, payload: dict) -> dict: + data = await _state(user_id) + targets = _targets(data) + target = _validate_target(payload) + if target["type"] == "bot": + await _ensure_user_bot(user_id, target["config"]["bot_id"]) + _check_unique_id(targets, target["id"]) + targets.append(target) + await _save_state(user_id, data) + return _public_target(target) + + +async def update_target(user_id: str, target_id: str, payload: dict) -> dict: + data = await _state(user_id) + targets = _targets(data) + for idx, current in enumerate(targets): + if current.get("id") == target_id: + target = _validate_target(payload, existing=current) + if target["type"] == "bot": + await _ensure_user_bot(user_id, target["config"]["bot_id"]) + _check_unique_id(targets, target["id"], target_id) + targets[idx] = target + await _save_state(user_id, data) + return _public_target(target) + raise NotificationError("notification target not found") + + +async def delete_target(user_id: str, target_id: str) -> bool: + data = await _state(user_id) + targets = _targets(data) + kept = [target for target in targets if target.get("id") != target_id] + if len(kept) == len(targets): + return False + data["notifications"]["targets"] = kept + await _save_state(user_id, data) + return True + + +async def _find_target(user_id: str, target_id: str) -> dict | None: + data = await _state(user_id) + needle = target_id.lower() + for target in _targets(data): + if str(target.get("id", "")).lower() == needle: + return target + return None + + +def _webhook_payload(event: str, title: str, message: str, context: dict) -> dict: + return { + "event": event, + "title": title, + "message": message, + "source": "computer", + "chat_id": context.get("chat_id"), + "workspace": context.get("workspace"), + "created_at": _now(), + } + + +def _sink_payload(url: str, payload: dict) -> dict: + title = payload["title"] + message = payload["message"] + if "hooks.slack.com" in url or "chat.googleapis.com" in url: + return {"text": f"*{title}*\n{message}"} + if "discord.com/api/webhooks" in url: + content = f"**{title}**\n{message}" + return {"content": content[:1997] + "..." if len(content) > 2000 else content} + if "webhook.office.com" in url: + return { + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "themeColor": "0076D7", + "summary": title, + "sections": [ + { + "activityTitle": title, + "activitySubtitle": "Computer", + "facts": [{"name": "Message", "value": message}], + "markdown": True, + } + ], + } + return payload + + +async def _send_webhook(target: dict, event: str, title: str, message: str, context: dict) -> None: + url = validate_webhook_url(str((target.get("config") or {}).get("url") or "")) + payload = _webhook_payload(event, title, message, context) + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: + response = await client.post(url, json=_sink_payload(url, payload)) + response.raise_for_status() + + +async def _send_bot(target: dict, title: str, message: str, context: dict) -> None: + from cptr.utils.bridge import get_current_bot_manager + + user_id = str(context.get("user_id") or "") + if user_id: + await _ensure_user_bot(user_id, str((target.get("config") or {}).get("bot_id") or "")) + + manager = get_current_bot_manager() + if not manager: + raise NotificationError("bot manager is not running") + workspace = (context.get("workspace") or {}).get("name") if isinstance(context.get("workspace"), dict) else "" + header = f"[{title}] {workspace}".strip() + text = f"{header}\n\n{message}".strip() + config = target.get("config") or {} + await manager.send_notification(config["bot_id"], config["destination_chat_id"], text) + + +async def _deliver(target: dict, event: str, title: str, message: str, context: dict) -> None: + if target.get("type") == "webhook": + await _send_webhook(target, event, title, message, context) + elif target.get("type") == "bot": + await _send_bot(target, title, message, context) + else: + raise NotificationError("unsupported notification target type") + + +async def test_target(user_id: str, target_id: str) -> dict: + target = await _find_target(user_id, target_id) + if not target: + raise NotificationError("notification target not found") + await _deliver( + target, + EVENTS.NOTIFICATION_TEST.name, + EVENTS.NOTIFICATION_TEST.label, + "Computer can reach this target.", + {"chat_id": None, "workspace": None, "user_id": user_id}, + ) + return {"ok": True} + + +async def send_chat_event(user_id: str, event: str, context: dict) -> None: + if event not in VALID_EVENTS: + return + data = await _state(user_id) + active = is_user_active(user_id) + for target in list(_targets(data)): + if not target.get("enabled", True): + continue + if event not in target.get("events", []): + continue + if target.get("delivery", "away") == "away" and active: + continue + try: + await _deliver( + target, + event, + str(context.get("title") or EVENT_LABELS[event]), + str(context.get("message") or ""), + {**context, "user_id": user_id}, + ) + except Exception: + logger.exception( + "[notifications] failed to deliver %s to %s", + event, + target.get("id"), + ) + + +async def notify_target( + user_id: str, + target_id: str, + message: str, + title: str | None = None, +) -> str: + target = await _find_target(user_id, target_id) + if not target: + raise NotificationError(f'notification target "{target_id}" was not found') + if not target.get("enabled", True): + raise NotificationError(f'notification target "{target_id}" is disabled') + await _deliver( + target, + EVENTS.NOTIFICATION_MANUAL.name, + title or "Notification", + message, + {"chat_id": None, "workspace": None, "user_id": user_id}, + ) + return f"Notification sent to {target.get('id')}." diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index cb1e8a0..e758b50 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -1887,6 +1887,26 @@ async def get_allowed_chat(cid: str): ) +async def notify(target: str, message: str, title: str = "", *, __context__: dict) -> str: + """Send a notification to a named user notification target. + + :param target: Notification target name from Settings > Notifications. + :param message: Message body to send. + :param title: Optional notification title. + """ + user_id = __context__.get("user_id") + if not user_id: + return "Error: authentication required." + try: + from cptr.utils.notifications import NotificationError, notify_target + + return await notify_target(user_id, target, message, title or None) + except NotificationError as exc: + return f"Error: {exc}" + except Exception as exc: + return f"Error: failed to send notification: {exc}" + + # ── Registry ──────────────────────────────────────────────── TOOLS: dict[str, dict] = { @@ -1912,6 +1932,7 @@ async def get_allowed_chat(cid: str): "update_automation": {"fn": update_automation, "auto": False}, "toggle_automation": {"fn": toggle_automation, "auto": False}, "delete_automation": {"fn": delete_automation, "auto": False}, + "notify": {"fn": notify, "auto": False}, "image_generate": {"fn": image_generate, "auto": False}, "update_memory": {"fn": update_memory, "auto": True}, } diff --git a/cptr/utils/webhook.py b/cptr/utils/webhook.py deleted file mode 100644 index b0b7e6d..0000000 --- a/cptr/utils/webhook.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Webhook notifications: POST to Slack, Discord, Teams, or generic JSON. - -Adapted from open-webui's webhook.py, using httpx instead of aiohttp. -""" - -from __future__ import annotations - -import logging - -import httpx - -logger = logging.getLogger(__name__) - - -async def post_webhook(url: str, title: str, message: str) -> bool: - """Send a notification to a webhook URL. - - Auto-detects Slack, Discord, Teams, and Google Chat webhook URLs - and formats the payload accordingly. Falls back to generic JSON. - - Returns True on success, False on failure (never raises). - """ - try: - payload: dict = {} - - # Slack and Google Chat - if "hooks.slack.com" in url or "chat.googleapis.com" in url: - payload["text"] = f"*{title}*\n{message}" - - # Discord (2000 char limit) - elif "discord.com/api/webhooks" in url: - content = f"**{title}**\n{message}" - if len(content) > 2000: - content = content[:1980] + "... (truncated)" - payload["content"] = content - - # Microsoft Teams - elif "webhook.office.com" in url: - payload = { - "@type": "MessageCard", - "@context": "http://schema.org/extensions", - "themeColor": "0076D7", - "summary": title, - "sections": [ - { - "activityTitle": title, - "activitySubtitle": "cptr", - "facts": [{"name": "Message", "value": message}], - "markdown": True, - } - ], - } - - # Generic JSON - else: - payload = { - "title": title, - "message": message, - "source": "cptr", - } - - async with httpx.AsyncClient(timeout=10) as client: - r = await client.post(url, json=payload) - r.raise_for_status() - - logger.info("[webhook] Sent notification to %s", url[:50]) - return True - - except Exception: - logger.exception("[webhook] Failed to send notification to %s", url[:50]) - return False -""", "Description": "Adapted from open-webui's webhook.py — uses httpx, supports Slack/Discord/Teams/generic JSON", "IsArtifact": false, "Overwrite": false, "TargetFile": "/Users/tim/Documents/workspace/computer/cptr/utils/webhook.py From 2a957a39dcb0652fc178b22757aab438c4d733e2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 16:30:22 -0500 Subject: [PATCH 13/20] refac --- cptr/events.py | 211 +++++++++++++++++- cptr/frontend/src/lib/apis/notifications.ts | 11 + .../components/Settings/Notifications.svelte | 35 ++- cptr/routers/notifications.py | 7 + cptr/utils/chat_task.py | 35 +-- cptr/utils/notifications.py | 87 +++++--- 6 files changed, 323 insertions(+), 63 deletions(-) diff --git a/cptr/events.py b/cptr/events.py index 2ea22ca..d871b95 100644 --- a/cptr/events.py +++ b/cptr/events.py @@ -1,26 +1,223 @@ -"""Application event definitions.""" +"""Application event definitions and internal event bus.""" from __future__ import annotations -from dataclasses import dataclass +import logging +import time +import uuid +from dataclasses import asdict, dataclass, field +from importlib.metadata import PackageNotFoundError, version as package_version +from typing import Any + +logger = logging.getLogger(__name__) + +MAX_STRING_LENGTH = 1000 @dataclass(frozen=True) class EventDefinition: name: str - label: str + description: str | None = None + message: str | None = None + + @property + def label(self) -> str: + return self.message or self.name.replace(".", " ").replace("_", " ").title() class EventDefinitions: - CHAT_FINISHED = EventDefinition("chat.finished", "Chat finished") - CHAT_FAILED = EventDefinition("chat.failed", "Chat failed") - NOTIFICATION_TEST = EventDefinition("notification.test", "Test notification") - NOTIFICATION_MANUAL = EventDefinition("manual.notify", "Notification") + CHAT_FINISHED = EventDefinition( + "chat.finished", + "A chat run finished successfully.", + "Chat finished", + ) + CHAT_FAILED = EventDefinition( + "chat.failed", + "A chat run failed.", + "Chat failed", + ) + NOTIFICATION_TEST = EventDefinition( + "notification.test", + "A notification target test was sent.", + "Test notification", + ) + NOTIFICATION_MANUAL = EventDefinition( + "manual.notify", + "A manual notification was sent.", + "Notification", + ) EVENTS = EventDefinitions() +EVENT_DEFINITIONS = tuple( + value for value in vars(EventDefinitions).values() if isinstance(value, EventDefinition) +) +EVENT_DEFINITIONS_BY_NAME = {definition.name: definition for definition in EVENT_DEFINITIONS} +EVENT_CATALOG = tuple(definition.name for definition in EVENT_DEFINITIONS) +EVENT_CATALOG_SET = set(EVENT_CATALOG) CHAT_NOTIFICATION_EVENTS = ( EVENTS.CHAT_FINISHED, EVENTS.CHAT_FAILED, ) + +SENSITIVE_KEYS = { + "password", + "hashed_password", + "token", + "access_token", + "refresh_token", + "id_token", + "api_key", + "secret", + "key", + "authorization", + "cookie", + "webhook_token", +} +SAFE_ACTOR_FIELDS = ("id", "name", "email", "role", "created_at", "updated_at") + + +@dataclass +class Event: + schema: str + id: str + event: str + resource: str + operation: str + created_at: int + source: str + actor: dict[str, Any] | None = None + subject: dict[str, Any] | None = None + data: dict[str, Any] = field(default_factory=dict) + message: str | None = None + + def model_dump(self) -> dict[str, Any]: + return asdict(self) + + +def _sanitize(value: Any) -> Any: + if hasattr(value, "model_dump"): + value = value.model_dump() + + if isinstance(value, dict): + sanitized = {} + for key, item in value.items(): + normalized = str(key).lower().replace("-", "_") + if ( + normalized in SENSITIVE_KEYS + or normalized.endswith("_token") + or normalized.endswith("_secret") + or normalized.endswith("_api_key") + or normalized.endswith("_key") + ): + continue + sanitized[key] = _sanitize(item) + return sanitized + + if isinstance(value, (list, tuple, set)): + return [_sanitize(item) for item in value] + + if isinstance(value, str) and len(value) > MAX_STRING_LENGTH: + return f"{value[:MAX_STRING_LENGTH]}..." + + return value + + +def build_event( + event: EventDefinition | str, + *, + actor: Any | None = None, + subject_id: Any | None = None, + subject_type: str | None = None, + source: str = "api", + data: dict | None = None, + message: str | None = None, +) -> Event: + event_name_value = event.name if isinstance(event, EventDefinition) else str(event) + if event_name_value not in EVENT_CATALOG_SET: + raise ValueError(f"Unknown event: {event_name_value}") + + parts = event_name_value.split(".") + resource = ".".join(parts[:-1]) + subject = ( + {"type": subject_type or resource, "id": subject_id} + if subject_id is not None or subject_type is not None + else None + ) + actor = _sanitize(actor) + actor_payload = None + if actor: + get = actor.get if isinstance(actor, dict) else lambda key: getattr(actor, key, None) + actor_payload = {field: get(field) for field in SAFE_ACTOR_FIELDS if get(field) is not None} + if actor_payload: + actor_payload["type"] = get("type") or "user" + + try: + schema = package_version("cptr") + except PackageNotFoundError: + schema = "dev" + + return Event( + schema=schema, + id=str(uuid.uuid4()), + event=event_name_value, + resource=resource, + operation=parts[-1], + created_at=int(time.time()), + source=source, + actor=actor_payload, + subject=_sanitize(subject) if subject else None, + data=_sanitize(data or {}), + message=message, + ) + + +def get_event_catalog() -> list[dict[str, str]]: + return [ + { + "event": definition.name, + "label": definition.label, + "description": definition.description or "", + } + for definition in EVENT_DEFINITIONS + ] + + +class NotificationEventSink: + async def handle_event(self, event: Event) -> None: + from cptr.utils.notifications import dispatch_notification_event + + await dispatch_notification_event(event) + + +EVENT_SINKS = [NotificationEventSink()] + + +async def publish_event( + event: EventDefinition | str, + *, + actor: Any | None = None, + subject_id: Any | None = None, + subject_type: str | None = None, + source: str = "api", + data: dict | None = None, + message: str | None = None, +) -> Event: + event_payload = build_event( + event, + actor=actor, + subject_id=subject_id, + subject_type=subject_type, + source=source, + data=data, + message=message, + ) + + for sink in EVENT_SINKS: + try: + await sink.handle_event(event_payload) + except Exception: + logger.exception("Event sink failed for %s", event_payload.event) + + return event_payload diff --git a/cptr/frontend/src/lib/apis/notifications.ts b/cptr/frontend/src/lib/apis/notifications.ts index 848d62c..e288091 100644 --- a/cptr/frontend/src/lib/apis/notifications.ts +++ b/cptr/frontend/src/lib/apis/notifications.ts @@ -10,6 +10,12 @@ export type ChatNotificationEvent = export type NotificationDelivery = 'away' | 'always'; export type NotificationTargetType = 'webhook' | 'bot'; +export interface NotificationEventOption { + event: ChatNotificationEvent; + label: string; + description: string; +} + export interface NotificationTarget { id: string; type: NotificationTargetType; @@ -47,6 +53,11 @@ export async function listNotificationTargets(): Promise { return data.targets; } +export async function listNotificationEvents(): Promise { + const data = await fetchJSON<{ events: NotificationEventOption[] }>('/api/notifications/events'); + return data.events; +} + export function createNotificationTarget(payload: NotificationTargetPayload) { return fetchJSON('/api/notifications/targets', jsonBody(payload)); } diff --git a/cptr/frontend/src/lib/components/Settings/Notifications.svelte b/cptr/frontend/src/lib/components/Settings/Notifications.svelte index 62cab56..a3e9dc8 100644 --- a/cptr/frontend/src/lib/components/Settings/Notifications.svelte +++ b/cptr/frontend/src/lib/components/Settings/Notifications.svelte @@ -8,11 +8,13 @@ CHAT_NOTIFICATION_EVENTS, deleteNotificationTarget, listNotificationBotOptions, + listNotificationEvents, listNotificationTargets, testNotificationTarget, updateNotificationTarget, type BotOption, type ChatNotificationEvent, + type NotificationEventOption, type NotificationDelivery, type NotificationTarget, type NotificationTargetType @@ -21,12 +23,21 @@ import Modal from '../Modal.svelte'; import ToggleSwitch from '../common/ToggleSwitch.svelte'; - const eventOptions: { value: ChatNotificationEvent; label: string }[] = [ - { value: CHAT_NOTIFICATION_EVENTS.FINISHED, label: 'general.chatFinished' }, - { value: CHAT_NOTIFICATION_EVENTS.FAILED, label: 'general.chatFailed' } + const fallbackEventOptions: NotificationEventOption[] = [ + { + event: CHAT_NOTIFICATION_EVENTS.FINISHED, + label: 'Chat finished', + description: 'A chat run finished successfully.' + }, + { + event: CHAT_NOTIFICATION_EVENTS.FAILED, + label: 'Chat failed', + description: 'A chat run failed.' + } ]; let targets = $state([]); + let eventOptions = $state(fallbackEventOptions); let botOptions = $state([]); let loadingTargets = $state(false); let savingTarget = $state(false); @@ -53,12 +64,14 @@ async function loadNotificationTargets() { loadingTargets = true; try { - const [nextTargets, bots] = await Promise.all([ + const [nextTargets, bots, events] = await Promise.all([ listNotificationTargets(), - listNotificationBotOptions() + listNotificationBotOptions(), + listNotificationEvents() ]); targets = nextTargets; botOptions = bots; + eventOptions = events.length ? events : fallbackEventOptions; } catch { toast.error($t('general.notificationTargetsLoadFailed')); } finally { @@ -256,15 +269,15 @@
{#if target.events.length}
- {#each eventOptions.filter((event) => target.events.includes(event.value)) as event} + {#each eventOptions.filter((event) => target.events.includes(event.event)) as event} {/each}
@@ -407,12 +420,12 @@ {#each eventOptions as event} {/each} diff --git a/cptr/routers/notifications.py b/cptr/routers/notifications.py index 0e0e4c4..54c6639 100644 --- a/cptr/routers/notifications.py +++ b/cptr/routers/notifications.py @@ -9,6 +9,7 @@ NotificationError, create_target, delete_target, + get_notification_event_catalog, get_bot_options, list_targets, test_target, @@ -51,6 +52,12 @@ async def api_list_targets(request: Request): return {"targets": await list_targets(_user_id(request))} +@router.get("/events") +async def api_list_events(request: Request): + _user_id(request) + return {"events": get_notification_event_catalog()} + + @router.post("/targets") async def api_create_target(request: Request, body: TargetPayload): try: diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index d6280c6..17b8c68 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -11,7 +11,7 @@ import re import uuid -from cptr.events import EVENTS +from cptr.events import EVENTS, publish_event from cptr.env import CHAT_MAX_ITERATIONS, CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS from cptr.utils.context import resolve_compact_token_threshold, should_compact from cptr.utils.skills import discover_skills, load_skill, format_skill_content @@ -1183,25 +1183,26 @@ async def _emit_done(): workspace_name=ws_name, ) - async def _notify_chat(event: str, message: str | None = None): + async def _publish_chat_event(event, message: str | None = None): try: - from cptr.utils.notifications import EVENT_LABELS, send_chat_event - ws_name = workspace.rstrip("/").rsplit("/", 1)[-1] if workspace else "" body = message if message is not None else content - await send_chat_event( - user_id, + preview = body[:300] if body else "" + await publish_event( event, - { - "title": EVENT_LABELS.get(event, "Chat"), - "message": body[:300] if body else "", - "chat_id": chat_id, + actor={"id": user_id}, + subject_id=chat_id, + subject_type="chat", + source="chat_task", + data={ "workspace": {"id": workspace, "name": ws_name} if workspace else None, + "preview": preview, }, + message=preview, ) except Exception: logger.debug( - "[notifications] Error sending chat notification for %s", chat_id[:8], exc_info=True + "[events] Error publishing chat event for %s", chat_id[:8], exc_info=True ) # Load existing state so continuations don't overwrite previous output @@ -1517,7 +1518,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() - await _notify_chat(EVENTS.CHAT_FINISHED.name) + await _publish_chat_event(EVENTS.CHAT_FINISHED) return flushed_item = _flush_text() @@ -1526,7 +1527,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): await _save_message("agent stream ended", content=content, output=output_items, done=True) _task_state.pop(message_id, None) await _emit_done() - await _notify_chat(EVENTS.CHAT_FINISHED.name) + await _publish_chat_event(EVENTS.CHAT_FINISHED) return try: @@ -1821,7 +1822,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() - await _notify_chat(EVENTS.CHAT_FINISHED.name) + await _publish_chat_event(EVENTS.CHAT_FINISHED) return # ── Process collected tool calls ──────────────────── @@ -2038,7 +2039,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() - await _notify_chat(EVENTS.CHAT_FINISHED.name) + await _publish_chat_event(EVENTS.CHAT_FINISHED) return # Max iterations reached @@ -2051,7 +2052,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() - await _notify_chat(EVENTS.CHAT_FAILED.name, "Max iterations reached.") + await _publish_chat_event(EVENTS.CHAT_FAILED, "Max iterations reached.") except asyncio.CancelledError: _flush_text() @@ -2093,7 +2094,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await emit(done=True, error=error_msg) - await _notify_chat(EVENTS.CHAT_FAILED.name, error_msg) + await _publish_chat_event(EVENTS.CHAT_FAILED, error_msg) finally: # Guarantee the gateway SSE stream terminates. If emit() # already pushed a done/error event the sentinel is harmless diff --git a/cptr/utils/notifications.py b/cptr/utils/notifications.py index 6530489..9f02c8a 100644 --- a/cptr/utils/notifications.py +++ b/cptr/utils/notifications.py @@ -10,7 +10,7 @@ import httpx -from cptr.events import CHAT_NOTIFICATION_EVENTS, EVENTS +from cptr.events import CHAT_NOTIFICATION_EVENTS, EVENT_DEFINITIONS_BY_NAME, EVENTS, Event from cptr.models import UserStates from cptr.socket.main import is_user_active @@ -20,8 +20,6 @@ VALID_TYPES = {"webhook", "bot"} VALID_DELIVERY = {"away", "always"} -EVENT_LABELS = {event.name: event.label for event in CHAT_NOTIFICATION_EVENTS} - class NotificationError(ValueError): pass @@ -330,32 +328,65 @@ async def test_target(user_id: str, target_id: str) -> dict: return {"ok": True} -async def send_chat_event(user_id: str, event: str, context: dict) -> None: - if event not in VALID_EVENTS: +def get_notification_event_catalog() -> list[dict[str, str]]: + return [ + { + "event": event.name, + "label": event.label, + "description": event.description or "", + } + for event in CHAT_NOTIFICATION_EVENTS + ] + + +async def dispatch_notification_event(event: Event) -> None: + if event.event not in VALID_EVENTS: return - data = await _state(user_id) - active = is_user_active(user_id) - for target in list(_targets(data)): - if not target.get("enabled", True): - continue - if event not in target.get("events", []): - continue - if target.get("delivery", "away") == "away" and active: - continue - try: - await _deliver( - target, - event, - str(context.get("title") or EVENT_LABELS[event]), - str(context.get("message") or ""), - {**context, "user_id": user_id}, - ) - except Exception: - logger.exception( - "[notifications] failed to deliver %s to %s", - event, - target.get("id"), - ) + definition = EVENT_DEFINITIONS_BY_NAME[event.event] + actor = event.actor or {} + subject = event.subject or {} + event_data = event.data or {} + user_ids = set() + if actor.get("id"): + user_ids.add(str(actor["id"])) + if subject.get("type") == "user" and subject.get("id"): + user_ids.add(str(subject["id"])) + if event_data.get("user_id"): + user_ids.add(str(event_data["user_id"])) + for user_id in event_data.get("user_ids") or []: + if user_id: + user_ids.add(str(user_id)) + + message = str(event.message or event_data.get("preview") or event_data.get("message") or "") + chat_id = event_data.get("chat_id") or ( + subject.get("id") if subject.get("type") == "chat" else None + ) + workspace = event_data.get("workspace") + + for user_id in user_ids: + data = await _state(user_id) + active = is_user_active(user_id) + for target in list(_targets(data)): + if not target.get("enabled", True): + continue + if event.event not in target.get("events", []): + continue + if target.get("delivery", "away") == "away" and active: + continue + try: + await _deliver( + target, + event.event, + definition.label, + message, + {"chat_id": chat_id, "workspace": workspace, "user_id": user_id}, + ) + except Exception: + logger.exception( + "[notifications] failed to deliver %s to %s", + event.event, + target.get("id"), + ) async def notify_target( From 0c6c837a9ebb6ce4a875ecbbcc84ec9c07a39bff Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 16:31:44 -0500 Subject: [PATCH 14/20] refac --- cptr/utils/chat_task.py | 88 ++++++++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 17b8c68..27a0961 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -1183,27 +1183,9 @@ async def _emit_done(): workspace_name=ws_name, ) - async def _publish_chat_event(event, message: str | None = None): - try: - ws_name = workspace.rstrip("/").rsplit("/", 1)[-1] if workspace else "" - body = message if message is not None else content - preview = body[:300] if body else "" - await publish_event( - event, - actor={"id": user_id}, - subject_id=chat_id, - subject_type="chat", - source="chat_task", - data={ - "workspace": {"id": workspace, "name": ws_name} if workspace else None, - "preview": preview, - }, - message=preview, - ) - except Exception: - logger.debug( - "[events] Error publishing chat event for %s", chat_id[:8], exc_info=True - ) + event_workspace = ( + {"id": workspace, "name": workspace.rstrip("/").rsplit("/", 1)[-1]} if workspace else None + ) # Load existing state so continuations don't overwrite previous output msg = await ChatMessage.get_by_id(message_id) @@ -1518,7 +1500,16 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() - await _publish_chat_event(EVENTS.CHAT_FINISHED) + preview = content[:300] if content else "" + await publish_event( + EVENTS.CHAT_FINISHED, + actor={"id": user_id}, + subject_id=chat_id, + subject_type="chat", + source="chat_task", + data={"workspace": event_workspace, "preview": preview}, + message=preview, + ) return flushed_item = _flush_text() @@ -1527,7 +1518,16 @@ async def _run_agent_target(agent_target: AgentModelTarget): await _save_message("agent stream ended", content=content, output=output_items, done=True) _task_state.pop(message_id, None) await _emit_done() - await _publish_chat_event(EVENTS.CHAT_FINISHED) + preview = content[:300] if content else "" + await publish_event( + EVENTS.CHAT_FINISHED, + actor={"id": user_id}, + subject_id=chat_id, + subject_type="chat", + source="chat_task", + data={"workspace": event_workspace, "preview": preview}, + message=preview, + ) return try: @@ -1822,7 +1822,16 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() - await _publish_chat_event(EVENTS.CHAT_FINISHED) + preview = content[:300] if content else "" + await publish_event( + EVENTS.CHAT_FINISHED, + actor={"id": user_id}, + subject_id=chat_id, + subject_type="chat", + source="chat_task", + data={"workspace": event_workspace, "preview": preview}, + message=preview, + ) return # ── Process collected tool calls ──────────────────── @@ -2039,7 +2048,16 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() - await _publish_chat_event(EVENTS.CHAT_FINISHED) + preview = content[:300] if content else "" + await publish_event( + EVENTS.CHAT_FINISHED, + actor={"id": user_id}, + subject_id=chat_id, + subject_type="chat", + source="chat_task", + data={"workspace": event_workspace, "preview": preview}, + message=preview, + ) return # Max iterations reached @@ -2052,7 +2070,15 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await _emit_done() - await _publish_chat_event(EVENTS.CHAT_FAILED, "Max iterations reached.") + await publish_event( + EVENTS.CHAT_FAILED, + actor={"id": user_id}, + subject_id=chat_id, + subject_type="chat", + source="chat_task", + data={"workspace": event_workspace, "preview": "Max iterations reached."}, + message="Max iterations reached.", + ) except asyncio.CancelledError: _flush_text() @@ -2094,7 +2120,15 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) _task_state.pop(message_id, None) await emit(done=True, error=error_msg) - await _publish_chat_event(EVENTS.CHAT_FAILED, error_msg) + await publish_event( + EVENTS.CHAT_FAILED, + actor={"id": user_id}, + subject_id=chat_id, + subject_type="chat", + source="chat_task", + data={"workspace": event_workspace, "preview": error_msg[:300] if error_msg else ""}, + message=error_msg[:300] if error_msg else "", + ) finally: # Guarantee the gateway SSE stream terminates. If emit() # already pushed a done/error event the sentinel is harmless From 516d6ffcfcde3da6c3f5e5879ce21384d32b06a7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 16:37:30 -0500 Subject: [PATCH 15/20] refac --- .../components/Settings/Notifications.svelte | 150 ++++++++---------- cptr/utils/notifications.py | 18 +++ 2 files changed, 80 insertions(+), 88 deletions(-) diff --git a/cptr/frontend/src/lib/components/Settings/Notifications.svelte b/cptr/frontend/src/lib/components/Settings/Notifications.svelte index a3e9dc8..8c3e1b6 100644 --- a/cptr/frontend/src/lib/components/Settings/Notifications.svelte +++ b/cptr/frontend/src/lib/components/Settings/Notifications.svelte @@ -238,100 +238,61 @@ {$t('general.noNotificationTargets')}

{:else} -
+
{#each targets as target} -
-
-
-
- - {target.id} - - - {target.type === 'webhook' ? $t('general.webhook') : $t('general.bot')} - -
-

- {target.type === 'webhook' - ? target.config.url_masked - : target.config.destination_chat_id} -

+ {@const targetDestination = + target.type === 'webhook' ? target.config.url_masked : target.config.destination_chat_id} + {@const alertLabels = eventOptions + .filter((event) => target.events.includes(event.event)) + .map((event) => event.label) + .join(', ')} +
+
+
+ + {target.id} + + + {target.type === 'webhook' ? $t('general.webhook') : $t('general.bot')} + +
+
+ {targetDestination} +
+
+ {alertLabels || $t('general.noChatAlerts')} + {#if target.events.length} + · {target.delivery === 'away' ? $t('general.onlyWhenAway') : $t('general.always')} + {/if}
- patchTarget(target, { enabled: v })} - />
-
-
- {$t('general.automaticEvents')} -
- {#if target.events.length} -
- {#each eventOptions.filter((event) => target.events.includes(event.event)) as event} - - {/each} -
- {:else} -

- {$t('general.noChatAlerts')} -

- {/if} +
+ + +
-
- {#if target.events.length} -
-
- {$t('general.automaticDelivery')} -
-
- {#each ['away', 'always'] as mode} - - {/each} -
-
- {:else} -
- {/if} -
- - - -
+
+ patchTarget(target, { enabled: v })} + />
{/each} @@ -471,3 +432,16 @@
{/if} + + diff --git a/cptr/utils/notifications.py b/cptr/utils/notifications.py index 9f02c8a..3824789 100644 --- a/cptr/utils/notifications.py +++ b/cptr/utils/notifications.py @@ -4,6 +4,7 @@ import ipaddress import logging +import re import socket import time from urllib.parse import urlparse @@ -198,6 +199,23 @@ def _check_unique_id(targets: list[dict], new_id: str, old_id: str | None = None async def create_target(user_id: str, payload: dict) -> dict: data = await _state(user_id) targets = _targets(data) + payload = dict(payload) + if not str(payload.get("id") or "").strip(): + config = payload.get("config") or {} + target_type = str(payload.get("type") or "target").strip() or "target" + if target_type == "webhook": + base = urlparse(str(config.get("url") or "")).hostname or "webhook" + elif target_type == "bot": + base = str(config.get("bot_id") or "bot") + else: + base = target_type + base = re.sub(r"[^a-zA-Z0-9_-]+", "-", base).strip("-").lower() or "target" + candidate = base + suffix = 2 + while any(str(target.get("id", "")).lower() == candidate.lower() for target in targets): + candidate = f"{base}-{suffix}" + suffix += 1 + payload["id"] = candidate target = _validate_target(payload) if target["type"] == "bot": await _ensure_user_bot(user_id, target["config"]["bot_id"]) From d403cd6bab0741998700be07426fd81721849c0d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 16:37:34 -0500 Subject: [PATCH 16/20] refac --- cptr/frontend/src/lib/components/SettingsModal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte index ff0ac0a..ce7a0bd 100644 --- a/cptr/frontend/src/lib/components/SettingsModal.svelte +++ b/cptr/frontend/src/lib/components/SettingsModal.svelte @@ -127,7 +127,7 @@
{targetDestination} @@ -274,6 +291,14 @@ > {$t('general.sendTest')} + {#if !target.is_default} + + {/if}