FEAT - TASKER: support Codex agent labels and profiles
This commit is contained in:
parent
b0a682b63b
commit
6962642614
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 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}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
<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, "Токен")}
|
||||
>
|
||||
<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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue