From 6962642614e4069af8965cae896a79632f4b4e7d Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 15 May 2026 14:25:36 +0300 Subject: [PATCH] FEAT - TASKER: support Codex agent labels and profiles --- .../views/nodedc_agent_adapter.py | 300 +++++++++++++++- plane-src/apps/api/plane/urls.py | 6 + .../settings/codex-agent-api-settings.tsx | 336 ++++++++++++++---- 3 files changed, 557 insertions(+), 85 deletions(-) diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py b/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py index 0f6c011..52f4711 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py @@ -1,7 +1,8 @@ +import re from html import escape from django.core.exceptions import ValidationError -from django.db import transaction +from django.db import IntegrityError, transaction from django.http import JsonResponse from django.utils import timezone from django.utils.decorators import method_decorator @@ -30,6 +31,11 @@ NODEDC_STRUCTURED_BLOCKS_KEY = "nodedc_structured_blocks" AGENT_EMAIL_DOMAIN = "agents.nodedc.local" AGENT_BOT_TYPE = "nodedc_codex_agent" ALLOWED_PRIORITIES = ["none", "low", "medium", "high", "urgent"] +DEFAULT_AGENT_LABEL_COLOR = "#4E5355" +MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000 +MARKDOWN_HEADING_PATTERN = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*$") +HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$") +DATA_IMAGE_PATTERN = re.compile(r"^data:image/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$") def unauthorized_response(): @@ -169,24 +175,80 @@ def get_agent_identity(request): } -def ensure_agent_actor(request, workspace, project=None): +def get_agent_payload_metadata(payload): + if not isinstance(payload, dict): + return {} + + metadata = payload.get("_agent") + return metadata if isinstance(metadata, dict) else {} + + +def normalize_agent_display_name(value, fallback): + if isinstance(value, str): + display_name = value.strip() + if display_name: + return display_name[:120] + + return fallback + + +def normalize_agent_avatar_url(value): + if not isinstance(value, str): + return "" + + avatar_url = value.strip() + if not avatar_url or len(avatar_url) > MAX_AGENT_AVATAR_URL_LENGTH: + return "" + + if avatar_url.startswith("data:image/"): + return avatar_url if DATA_IMAGE_PATTERN.match(avatar_url) else "" + + if avatar_url.startswith("http://") or avatar_url.startswith("https://"): + return avatar_url + + return "" + + +def get_agent_profile(request, payload=None): + identity = get_agent_identity(request) + if identity is None: + return None + + short_id = identity["agent_id"].replace("-", "")[:12] + fallback_display_name = f"Codex Agent {short_id}" + metadata = get_agent_payload_metadata(payload) + + return { + "display_name": normalize_agent_display_name(metadata.get("display_name"), fallback_display_name), + "avatar_url": normalize_agent_avatar_url(metadata.get("avatar_url")), + "short_id": short_id, + } + + +def ensure_agent_actor(request, workspace, project=None, payload=None): identity = get_agent_identity(request) if identity is None: return None agent_id = identity["agent_id"] - short_id = agent_id.replace("-", "")[:12] + profile = get_agent_profile(request, payload) + if profile is None: + return None + + short_id = profile["short_id"] email = f"agent+{agent_id}@{AGENT_EMAIL_DOMAIN}" username = f"nodedc_agent_{short_id}" - display_name = f"Codex Agent {short_id}" + display_name = profile["display_name"] + avatar_url = profile["avatar_url"] user, _ = User.objects.get_or_create( email=email, defaults={ "username": username, "display_name": display_name, - "first_name": "Codex", - "last_name": f"Agent {short_id}", + "first_name": display_name, + "last_name": "", + "avatar": avatar_url, "is_bot": True, "bot_type": AGENT_BOT_TYPE, "is_active": True, @@ -206,6 +268,18 @@ def ensure_agent_actor(request, workspace, project=None): if user.display_name != display_name: user.display_name = display_name update_fields.append("display_name") + if user.first_name != display_name: + user.first_name = display_name + update_fields.append("first_name") + if user.last_name: + user.last_name = "" + update_fields.append("last_name") + if avatar_url and user.avatar != avatar_url: + user.avatar = avatar_url + update_fields.append("avatar") + if avatar_url and user.avatar_asset_id: + user.avatar_asset = None + update_fields.append("avatar_asset") if update_fields: update_fields.append("updated_at") @@ -278,6 +352,100 @@ def html_from_text(value): return f"

{escape(text)}

" if text else "

" +def normalize_structured_title(value, fallback): + title = value.strip() if isinstance(value, str) else "" + if not title: + return fallback + + heading_match = MARKDOWN_HEADING_PATTERN.match(title) + if heading_match: + return heading_match.group(1).strip() or fallback + + return title + + +def split_leading_markdown_heading(value): + if not isinstance(value, str): + return None, "" + + lines = value.splitlines() + leading_blank_count = 0 + for line in lines: + if line.strip(): + break + leading_blank_count += 1 + + if leading_blank_count >= len(lines): + return None, "" + + first_content_line = lines[leading_blank_count] + heading_match = MARKDOWN_HEADING_PATTERN.match(first_content_line) + if not heading_match: + return None, value.strip() + + body_lines = lines[leading_blank_count + 1 :] + while body_lines and not body_lines[0].strip(): + body_lines.pop(0) + + return heading_match.group(1).strip(), "\n".join(body_lines).strip() + + +def normalize_structured_block(block): + if not isinstance(block, dict): + return None + + block_type = block.get("type") + if block_type == "text": + extracted_title, body = split_leading_markdown_heading(block.get("body")) + title = normalize_structured_title(block.get("title") or extracted_title, "Раздел") + + return { + "id": str(block.get("id") or f"text-{title.lower().replace(' ', '-')}"), + "type": "text", + "title": title, + "body": body, + } + + if block_type == "checker": + title = normalize_structured_title(block.get("title"), "Чеклист") + raw_items = block.get("items") + items = [] + if isinstance(raw_items, list): + for item in raw_items: + if not isinstance(item, dict): + continue + text = item.get("text") + if not isinstance(text, str) or not text.strip(): + continue + items.append( + { + "id": str(item.get("id") or f"item-{len(items) + 1}"), + "text": text.strip(), + "checked": item.get("checked") is True, + } + ) + + return { + "id": str(block.get("id") or f"checker-{title.lower().replace(' ', '-')}"), + "type": "checker", + "title": title, + "items": items, + } + + return None + + +def normalize_structured_blocks(structured_blocks): + normalized_blocks = [] + for block in structured_blocks: + normalized_block = normalize_structured_block(block) + if normalized_block is None: + return None + normalized_blocks.append(normalized_block) + + return normalized_blocks + + def merge_structured_blocks(detail_layout, structured_blocks): if structured_blocks is None: return detail_layout or {} @@ -285,8 +453,12 @@ def merge_structured_blocks(detail_layout, structured_blocks): if not isinstance(structured_blocks, list): return None + normalized_blocks = normalize_structured_blocks(structured_blocks) + if normalized_blocks is None: + return None + updated_layout = dict(detail_layout or {}) - updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = structured_blocks + updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = normalized_blocks return updated_layout @@ -301,6 +473,72 @@ def update_issue_labels(issue, label_ids): return True +def normalize_label_color(value): + if isinstance(value, str) and HEX_COLOR_PATTERN.match(value.strip()): + return value.strip() + return DEFAULT_AGENT_LABEL_COLOR + + +def normalize_label_input(raw_label): + if isinstance(raw_label, str): + name = raw_label.strip() + color = DEFAULT_AGENT_LABEL_COLOR + elif isinstance(raw_label, dict): + name = raw_label.get("name").strip() if isinstance(raw_label.get("name"), str) else "" + color = normalize_label_color(raw_label.get("color")) + else: + return None + + if not name: + return None + + return { + "name": name[:255], + "color": color, + } + + +def ensure_project_labels(project, raw_labels, actor, agent_id): + if not isinstance(raw_labels, list) or not raw_labels or len(raw_labels) > 50: + return None + + ensured_labels = [] + for raw_label in raw_labels: + label_input = normalize_label_input(raw_label) + if label_input is None: + return None + + label = Label.objects.filter( + project=project, + name__iexact=label_input["name"], + deleted_at__isnull=True, + ).first() + + if label is None: + try: + label = Label( + project=project, + workspace=project.workspace, + name=label_input["name"], + color=label_input["color"], + external_source=AGENT_BOT_TYPE, + external_id=agent_id, + ) + label.save(created_by_id=actor.id) + except IntegrityError: + label = Label.objects.filter( + project=project, + name__iexact=label_input["name"], + deleted_at__isnull=True, + ).first() + if label is None: + return None + + ensured_labels.append(label) + + return ensured_labels + + def update_issue_assignees(issue, member_ids): project_members = list( ProjectMember.objects.filter( @@ -413,6 +651,42 @@ class NodeDCAgentProjectContextEndpoint(View): ) +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCAgentProjectLabelsEnsureEndpoint(View): + def post(self, request, project_id): + error_response = validate_internal_request(request) + if error_response is not None: + return error_response + + payload = parse_json_body(request) + if payload is None: + return invalid_json_response() + + project = resolve_project(project_id, payload.get("workspace_slug")) + if project is None: + return validation_error("project_not_found", status=404) + + entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug) + if entitlement_error is not None: + return entitlement_error + + actor = ensure_agent_actor(request, project.workspace, project, payload) + if actor is None: + return validation_error("missing_agent_headers") + + identity = get_agent_identity(request) + ensured_labels = ensure_project_labels( + project, + payload.get("labels"), + actor, + identity["agent_id"] if identity else "", + ) + if ensured_labels is None: + return validation_error("invalid_labels") + + return JsonResponse({"ok": True, "labels": [serialize_label(label) for label in ensured_labels]}, status=201) + + @method_decorator(csrf_exempt, name="dispatch") class NodeDCAgentIssueListEndpoint(View): def get(self, request): @@ -473,7 +747,7 @@ class NodeDCAgentIssueListEndpoint(View): return validation_error("invalid_structured_blocks") with transaction.atomic(): - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is None: return validation_error("missing_agent_headers") @@ -539,7 +813,7 @@ class NodeDCAgentIssueUpdateEndpoint(View): update_fields.append("detail_layout") if update_fields: - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is None: return validation_error("missing_agent_headers") issue.updated_by = actor @@ -575,7 +849,7 @@ class NodeDCAgentIssueMoveEndpoint(View): if state is None: return validation_error("state_not_found", status=404) - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is None: return validation_error("missing_agent_headers") @@ -615,7 +889,7 @@ class NodeDCAgentIssueCommentEndpoint(View): if not isinstance(body, str) or not body.strip(): return validation_error("body_required") - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is None: return validation_error("missing_agent_headers") @@ -659,7 +933,7 @@ class NodeDCAgentIssueLabelsEndpoint(View): if not update_issue_labels(issue, label_ids): return validation_error("label_not_found", status=404) - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is not None: issue.updated_by = actor issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True) @@ -696,7 +970,7 @@ class NodeDCAgentIssueAssigneesEndpoint(View): if not update_issue_assignees(issue, member_ids): return validation_error("member_not_found", status=404) - actor = ensure_agent_actor(request, project.workspace, project) + actor = ensure_agent_actor(request, project.workspace, project, payload) if actor is not None: issue.updated_by = actor issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True) diff --git a/plane-src/apps/api/plane/urls.py b/plane-src/apps/api/plane/urls.py index 8cffc77..96d2b2b 100644 --- a/plane-src/apps/api/plane/urls.py +++ b/plane-src/apps/api/plane/urls.py @@ -23,6 +23,7 @@ from plane.authentication.views.nodedc_agent_adapter import ( NodeDCAgentIssueMoveEndpoint, NodeDCAgentIssueUpdateEndpoint, NodeDCAgentProjectContextEndpoint, + NodeDCAgentProjectLabelsEnsureEndpoint, NodeDCAgentProjectResolveEndpoint, ) from plane.authentication.views.nodedc_workspace_adapter import ( @@ -94,6 +95,11 @@ urlpatterns = [ NodeDCAgentProjectContextEndpoint.as_view(), name="nodedc-agent-project-context", ), + path( + "api/internal/nodedc/agent/projects//labels/ensure", + NodeDCAgentProjectLabelsEnsureEndpoint.as_view(), + name="nodedc-agent-project-labels-ensure", + ), path( "api/internal/nodedc/agent/issues", NodeDCAgentIssueListEndpoint.as_view(), diff --git a/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx b/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx index 31513ae..d56c463 100644 --- a/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx @@ -6,7 +6,7 @@ import { type ChangeEvent, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; -import { Bot, Check, KeyRound, Route, ShieldCheck } from "lucide-react"; +import { Bot, Check, Copy, KeyRound, Route, ShieldCheck } from "lucide-react"; import useSWR from "swr"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; @@ -40,8 +40,13 @@ const TASK_AUTHOR_SCOPES = [ ]; const AGENT_AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif"; -const MAX_AGENT_AVATAR_BYTES = 256 * 1024; +const MAX_AGENT_AVATAR_SOURCE_BYTES = 50 * 1024 * 1024; +const AGENT_AVATAR_RENDER_SIZE = 512; +const AGENT_AVATAR_OUTPUT_QUALITY = 0.86; const OPS_AGENT_FILENAME = "OPS_AGENT.md"; +const CODEX_MCP_SERVER_NAME = "nodedc-ops-agent"; +const CODEX_TOKEN_ENV_VAR = "NODEDC_OPS_AGENT_TOKEN"; +const DEFAULT_OPS_AGENT_MCP_ENDPOINT = "https://ops-agents.nodedc.ru/mcp"; const codexAgentService = new WorkspaceCodexAgentService(); const projectService = new ProjectService(); @@ -123,6 +128,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti () => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards), [createdSetupCards, persistedSetupCards] ); + const connectionGuideMcpEndpoint = getMcpEndpoint(setupCards.find((card) => card.setup)?.setup); + const connectionGuideConfigSnippet = buildCodexConfigSnippet(connectionGuideMcpEndpoint); + const connectionGuideOpsAgentMd = buildOpsAgentMarkdown(connectionGuideMcpEndpoint); const handleCopy = async (value: string, label: string) => { await navigator.clipboard.writeText(value); @@ -241,8 +249,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti await mutateSetupCards(); setToast({ type: TOAST_TYPE.SUCCESS, - title: "Новый token выпущен", - message: "Скопируйте token сейчас. После перезахода backend вернет только masked suffix.", + title: "Новый токен выпущен", + message: "Скопируйте токен сейчас. После перезахода backend вернет только masked suffix.", }); } catch (error: any) { setToast({ @@ -258,6 +266,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti const handleSaveAgentName = async (agent: TCodexAgent) => { const displayName = getAgentDraftName(agentDraftNames, agent).trim(); if (!displayName) return; + if (displayName === agent.display_name) return; setUpdatingAgentIds((current) => ({ ...current, [agent.id]: true })); try { @@ -422,7 +431,6 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti const isAgentDirty = draftName.trim() !== agent.display_name; const setupCard = setupCards.find((card) => card.agent.id === agent.id); const agentTokens = setupCard?.tokens ?? []; - const setup = setupCard?.setup; return (
@@ -456,6 +464,14 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti [agent.id]: event.target.value, })) } + onBlur={() => { + if (isAgentDirty && !isUpdatingAgent) void handleSaveAgentName(agent); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} placeholder="Имя агента" /> @@ -483,7 +499,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti loading={isCreatingToken} onClick={() => void handleCreateToken(agent)} > - Новый token + Новый токен - - )} +
+
+ Agent token
- -
-
- Ops Agent.md -
- {setup?.agents_md ? ( - <> -