FEAT - TASKER: support Codex agent labels and profiles

This commit is contained in:
DCCONSTRUCTIONS 2026-05-15 14:25:36 +03:00
parent b0a682b63b
commit 6962642614
3 changed files with 557 additions and 85 deletions

View File

@ -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"<p>{escape(text)}</p>" if text else "<p></p>"
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)

View File

@ -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/<uuid:project_id>/labels/ensure",
NodeDCAgentProjectLabelsEnsureEndpoint.as_view(),
name="nodedc-agent-project-labels-ensure",
),
path(
"api/internal/nodedc/agent/issues",
NodeDCAgentIssueListEndpoint.as_view(),

View File

@ -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 (
<section key={agent.id} className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
@ -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="Имя агента"
/>
</label>
@ -483,7 +499,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
loading={isCreatingToken}
onClick={() => void handleCreateToken(agent)}
>
Новый token
Новый токен
</Button>
<Button
variant="secondary"
@ -498,73 +514,32 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
{areSetupCardsLoading && agentTokens.length === 0 ? (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Загрузка token и Ops Agent.md...
Загрузка токена...
</div>
) : agentTokens.length > 0 ? (
<div className="grid gap-4">
{agentTokens.map((token) => {
const revealedToken = revealedTokens[token.id];
const tokenValue = revealedToken ?? maskToken(token);
const isTokenRevealed = Boolean(revealedToken);
return (
<div key={token.id} className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)]">
<div className="nodedc-settings-field p-4">
<div key={token.id} className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Agent token
</div>
<code className="nodedc-settings-input block min-h-12 px-3 py-3 text-12 break-all text-primary">
{tokenValue}
<div className="relative">
<code className="nodedc-settings-input flex h-12 w-full items-center overflow-hidden px-4 pr-14 text-12 text-primary">
<span className="truncate">{tokenValue}</span>
</code>
{isTokenRevealed && (
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(revealedToken, "Токен")}
<button
type="button"
aria-label="Скопировать токен"
className="absolute top-1/2 right-1.5 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] transition hover:opacity-90 disabled:bg-white/10 disabled:text-tertiary disabled:opacity-60"
disabled={!revealedToken}
onClick={() => revealedToken && void handleCopy(revealedToken, "Токен")}
>
Скопировать токен
</Button>
</div>
)}
</div>
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Ops Agent.md
</div>
{setup?.agents_md ? (
<>
<textarea
readOnly
className="nodedc-settings-input font-mono h-64 w-full resize-y px-3 py-3 text-12"
value={setup.agents_md}
/>
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(setup.agents_md ?? "", "Ops Agent.md")}
>
Скопировать Ops Agent.md
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => handleDownload(setup.agents_md ?? "", OPS_AGENT_FILENAME)}
>
Скачать
</Button>
</div>
</>
) : (
<div className="nodedc-settings-input flex min-h-24 items-center px-4 text-13 text-secondary">
Setup packet пока недоступен. Проверьте Gateway и grants агента.
</div>
)}
<Copy className="size-4" />
</button>
</div>
</div>
);
@ -572,7 +547,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
</div>
) : (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Token ещё не выпущен. Нажмите «Новый token», чтобы получить token и Ops Agent.md.
Токен ещё не выпущен. Нажмите «Новый токен», чтобы получить доступ для локального Codex.
</div>
)}
</section>
@ -580,9 +555,20 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
})
) : (
<div className="nodedc-settings-card px-5 py-5 text-center text-13 text-secondary">
Агентов пока нет. Создайте агента, выберите project и сразу получите token + Ops Agent.md.
Агентов пока нет. Создайте агента, выберите project и сразу получите токен доступа.
</div>
)}
{activeAgents.length > 0 && (
<section className="nodedc-settings-card px-5 py-5">
<CodexConnectionGuide
configSnippet={connectionGuideConfigSnippet}
mcpEndpoint={connectionGuideMcpEndpoint}
onCopyConfig={() => void handleCopy(connectionGuideConfigSnippet, "config.toml")}
onDownloadAgentsMd={() => handleDownload(connectionGuideOpsAgentMd, OPS_AGENT_FILENAME)}
/>
</section>
)}
</>
)}
</>
@ -591,6 +577,154 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
);
});
type TCodexConnectionGuideProps = {
configSnippet: string;
mcpEndpoint: string;
onCopyConfig: () => void;
onDownloadAgentsMd: () => void;
};
function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
return (
<div className="grid gap-4 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-14 font-semibold text-primary">Подключение локального Codex</div>
</div>
<span className="nodedc-settings-chip inline-flex min-h-11 w-fit items-center justify-center text-12">
MCP endpoint · {props.mcpEndpoint}
</span>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">1. Найдите config.toml</div>
<p>Windows: откройте файл через проводник или VS Code.</p>
<code className="mt-2 block break-all text-12 text-primary">
C:\Users\имя-пользователя\.codex\config.toml
</code>
<p className="mt-3">macOS / Linux:</p>
<code className="mt-2 block break-all text-12 text-primary">~/.codex/config.toml</code>
<p className="mt-3">Если файла нет создайте его.</p>
</div>
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">2. Сохраните токен</div>
<p>
Создайте пользовательскую переменную окружения <code>{CODEX_TOKEN_ENV_VAR}</code>, заменив токен из примера
на уникальный токен конкретного агента.
</p>
<code className="mt-3 block break-all rounded-2xl bg-black/20 px-3 py-3 text-12 text-primary">
{CODEX_TOKEN_ENV_VAR}=ndcag_...
</code>
</div>
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">3. Добавьте Ops Agent.md</div>
<p>
Скачайте <code>{OPS_AGENT_FILENAME}</code>. Если в проекте уже есть <code>AGENTS.md</code>, добавьте
содержимое Ops Agent.md в начало текущего файла. Если файла правил нет положите Ops Agent.md в корень
проекта.
</p>
<Button
variant="primary"
size="sm"
className="nodedc-settings-save-button mt-3"
onClick={props.onDownloadAgentsMd}
>
Скачать Ops Agent.md
</Button>
</div>
</div>
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">config.toml block</div>
<textarea
readOnly
className="nodedc-settings-input font-mono h-44 w-full resize-y px-3 py-3 text-12"
value={props.configSnippet}
/>
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="primary" size="sm" className="nodedc-settings-save-button" onClick={props.onCopyConfig}>
Скопировать config.toml
</Button>
</div>
</div>
</div>
);
}
function getMcpEndpoint(setup?: TCodexAgentSetupPacket): string {
return setup?.mcp_server?.url ?? DEFAULT_OPS_AGENT_MCP_ENDPOINT;
}
function buildCodexConfigSnippet(endpoint: string): string {
return `[mcp_servers.${CODEX_MCP_SERVER_NAME}]
url = "${endpoint}"
bearer_token_env_var = "${CODEX_TOKEN_ENV_VAR}"
enabled = true
required = true
startup_timeout_sec = 20
tool_timeout_sec = 60`;
}
function buildOpsAgentMarkdown(endpoint: string): string {
return `# NODE.DC Ops Agent Rules
MCP endpoint: ${endpoint}
## Startup
- Call \`tasker_get_agent_instructions\` before creating or changing Tasker cards.
- Call \`tasker_list_projects\` and \`tasker_get_project_context\` before writing into a project.
- Keep Tasker as the source of truth for project cards, checkers, status, labels, comments, and assignments.
## Write Safety
- Every write tool call must include a unique \`idempotency_key\`.
- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.
- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.
- Only assign existing project members returned by project context.
- If a needed label is missing, call \`tasker_ensure_labels\` first and then use returned ids with \`tasker_set_issue_labels\`.
## Card Writing
- Keep card titles concise and operational.
- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.
- Every structured text block must put its visible heading into the block \`title\` field.
- Do not put headings inside block body. Wrong: body starts with \`## Текущая архитектура\`. Correct: \`title: "Текущая архитектура"\`, body contains only content.
- Put short verifiable work items into checker blocks with explicit \`title\` fields.
- After code work, update the related card with factual files touched and validation performed.
## Labels
- Before assigning a new marker/label, check project context labels.
- If the label does not exist, call \`tasker_ensure_labels\` with the granted \`project_id\`.
- Use only label ids returned by project context or \`tasker_ensure_labels\` when calling \`tasker_set_issue_labels\`.
## Effective Grants
- Grants are bound to the local agent token.
- Load effective workspace/project grants through \`tasker_get_agent_instructions\` and \`tasker_list_projects\` after connecting.
- Do not assume access to projects, labels, states, or members that were not returned by NODE.DC MCP tools.
## Available Tools
- \`tasker_get_agent_instructions\`: Return effective NODE.DC Tasker card-writing rules, grants, scopes, and mode expectations.
- \`tasker_list_projects\`: List Tasker projects granted to the current agent.
- \`tasker_get_project_context\`: Return states, labels, members, and card-writing context for one granted project.
- \`tasker_search_issues\`: Search work items inside one granted Tasker project.
- \`tasker_create_issue\`: Create a Tasker card with optional NODE.DC structured text/checker blocks.
- \`tasker_update_issue\`: Patch allowed issue fields without delete, archive, or project transfer.
- \`tasker_update_structured_blocks\`: Replace NODE.DC structured text/checker blocks in an issue detail layout.
- \`tasker_move_issue\`: Move an issue to an existing state in the same granted project.
- \`tasker_append_comment\`: Append a comment to a granted issue.
- \`tasker_ensure_labels\`: Create missing labels in a granted project and return label ids.
- \`tasker_set_issue_labels\`: Replace issue labels with existing labels from the granted project.
- \`tasker_assign_issue\`: Replace issue assignees with existing members of the granted project.
`;
}
function getAgentDraftName(agentDraftNames: Record<string, string>, agent: TCodexAgent): string {
return agentDraftNames[agent.id] ?? agent.display_name;
}
@ -608,7 +742,7 @@ function maskToken(token: TCodexAgentToken): string {
function getAvatarSrc(avatarUrl?: string | null): string | null {
if (!avatarUrl) return null;
if (/^(data:|blob:|https?:\/\/)/.test(avatarUrl)) return avatarUrl;
return getFileURL(avatarUrl);
return getFileURL(avatarUrl) ?? null;
}
function AgentAvatar(props: { avatarUrl?: string | null; name: string; size?: "md" | "lg" }) {
@ -702,15 +836,73 @@ function readAvatarDataUrl(file: File): Promise<string> {
return Promise.reject(new Error("Поддерживаются только изображения PNG, JPG, WEBP или GIF."));
}
if (file.size > MAX_AGENT_AVATAR_BYTES) {
return Promise.reject(new Error("Аватар агента должен быть не больше 256 КБ."));
if (file.size > MAX_AGENT_AVATAR_SOURCE_BYTES) {
return Promise.reject(new Error("Исходный файл аватара должен быть не больше 50 МБ."));
}
return resizeAvatarFile(file);
}
async function resizeAvatarFile(file: File): Promise<string> {
const objectUrl = URL.createObjectURL(file);
try {
const image = await loadImageElement(objectUrl);
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
if (!sourceWidth || !sourceHeight) {
throw new Error("Не удалось определить размер изображения.");
}
const scale = Math.min(1, AGENT_AVATAR_RENDER_SIZE / Math.max(sourceWidth, sourceHeight));
const width = Math.max(1, Math.round(sourceWidth * scale));
const height = Math.max(1, Math.round(sourceHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Не удалось подготовить аватар.");
}
context.drawImage(image, 0, 0, width, height);
const blob = await canvasToBlob(canvas, "image/webp", AGENT_AVATAR_OUTPUT_QUALITY);
return blobToDataUrl(blob);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function loadImageElement(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image), { once: true });
image.addEventListener("error", () => reject(new Error("Не удалось открыть изображение аватара.")), { once: true });
image.src = src;
});
}
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Не удалось сжать аватар."));
},
type,
quality
);
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("Не удалось прочитать файл аватара."));
reader.readAsDataURL(file);
reader.addEventListener("load", () => resolve(String(reader.result)), { once: true });
reader.addEventListener("error", () => reject(new Error("Не удалось прочитать файл аватара.")), { once: true });
reader.readAsDataURL(blob);
});
}