ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов Stage 3.8 - юи

This commit is contained in:
dctouch 2026-04-12 00:54:55 +03:00
parent d65969d2ff
commit 8167bd228d
18 changed files with 1408 additions and 70 deletions

View File

@ -2592,9 +2592,26 @@ Acceptance (Stage 3):
1. LLM outputs strictly validated schema for extraction/decomposition (no free-form).
2. Deterministic guards can block or downgrade answers when evidence insufficient.
3. False route drifts and generic responses reduced in regression packs.
4. Manual markup shows increase in correct/grounded labels.
4. Manual markup shows increase in correct/grounded labels.
Status: In validation (functional gates green; manual re-markup trend confirmation pending)
### Stage 3 Exit Audit (2026-04-11)
1. Functional gate:
- `npm --prefix llm_normalizer/backend run build` passed.
2. Regression gate:
- Stage 3 focused suite passed: `9 files / 356 tests`.
- (`addressQueryRuntimeM23`, `assistantWave17RunRegression20260411`, `assistantWave18ManualCommentsRegression`,
`assistantLivingRouter`, `assistantLivingChatMode`, `assistantSoftPolicyReply`,
`assistantBoundaryFallbackReply`, `assistantAnswerPolicyV11`, `assistantSemanticExtractionContract`).
3. Runtime stability gate:
- address lane includes protective fallback for legacy MCP schema error (`SubcontoDt1`) and avoids hard execution drop for debt-lifecycle receivables prompts.
4. Manual smoke:
- reviewer confirmed fixed questions on latest spot checks; domain breadth expansion is tracked separately and is not a Stage 3 blocker.
5. Full backend suite note (non-blocking for Stage 4 kickoff):
- `npm --prefix llm_normalizer/backend run test` reports red integration groups with `5000ms` timeout budget (`assistantEvalHarness`, `assistantP0EvalHarness`, `assistantStage2EvalHarness`, `assistantStage3LifecycleAcceptanceProbe`, part of `assistantEndpoint`/`assistantBroadGuard`).
- one user-facing policy assertion currently diverges when live MCP returns aborted fetch (`This operation was aborted`), producing execution-error soft reply instead of expected clarification template.
- these failures are tracked as stability/infra debt and do not block Stage 4 answer-layer kickoff for already green Stage 3 focused contract pack.
Status: Completed (Stage 3 gate approved; domain expansion moved to Stage 5 backlog)
## Stage 4 (P2): Human-Centric Answer Layer
@ -2607,7 +2624,12 @@ Goal:
- best next step.
2. Keep claim-to-evidence binding strict.
Status: Planned
Stage 4 kickoff preconditions:
1. Stage 3 functional + regression gates are green.
2. No route/MCP interface changes are required to start Stage 4.
3. Remaining acknowledged risk: domain coverage is still limited and will be expanded iteratively.
Status: Ready to start (kickoff approved on 2026-04-11)
## Stage 5 (P3): Quality Loop Driven By GUI Markup

View File

@ -3,8 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.AUTORUN_GENERATOR_HISTORY_FILE = exports.AUTORUN_GENERATOR_DIR = exports.AUTORUN_ANNOTATIONS_FILE = exports.AUTORUN_ANNOTATIONS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
exports.MANUAL_CASE_DECISION_SCHEMA_FILE = exports.ASSISTANT_CAPABILITIES_REGISTRY_FILE = exports.ASSISTANT_CANON_FILE = void 0;
exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.AUTORUN_GENERATOR_HISTORY_FILE = exports.AUTORUN_GENERATOR_DIR = exports.AUTORUN_ANNOTATIONS_FILE = exports.AUTORUN_ANNOTATIONS_DIR = exports.ASSISTANT_ANNOTATIONS_FILE = exports.ASSISTANT_ANNOTATIONS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
exports.MANUAL_CASE_DECISION_SCHEMA_FILE = exports.ASSISTANT_CAPABILITIES_REGISTRY_FILE = exports.ASSISTANT_CANON_FILE = exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = void 0;
const path_1 = __importDefault(require("path"));
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
@ -72,6 +72,8 @@ exports.TRACES_DIR = path_1.default.resolve(exports.DATA_DIR, "traces");
exports.PRESETS_DIR = path_1.default.resolve(exports.DATA_DIR, "presets");
exports.EVAL_CASES_DIR = path_1.default.resolve(exports.DATA_DIR, "eval_cases");
exports.ASSISTANT_SESSIONS_DIR = path_1.default.resolve(exports.DATA_DIR, "assistant_sessions");
exports.ASSISTANT_ANNOTATIONS_DIR = path_1.default.resolve(exports.DATA_DIR, "assistant_annotations");
exports.ASSISTANT_ANNOTATIONS_FILE = path_1.default.resolve(exports.ASSISTANT_ANNOTATIONS_DIR, "annotations.json");
exports.AUTORUN_ANNOTATIONS_DIR = path_1.default.resolve(exports.DATA_DIR, "autorun_annotations");
exports.AUTORUN_ANNOTATIONS_FILE = path_1.default.resolve(exports.AUTORUN_ANNOTATIONS_DIR, "annotations.json");
exports.AUTORUN_GENERATOR_DIR = path_1.default.resolve(exports.DATA_DIR, "autorun_generators");

View File

@ -1,8 +1,118 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildAssistantRouter = buildAssistantRouter;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const express_1 = require("express");
const config_1 = require("../config");
const http_1 = require("../utils/http");
function toStringSafe(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toNumberSafe(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function clampInt(value, min, max, fallback) {
if (value === null || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.trunc(value);
if (rounded < min)
return min;
if (rounded > max)
return max;
return rounded;
}
function parseComment(value) {
if (typeof value !== "string")
return "";
return value.trim().slice(0, 4000);
}
function parseAnnotationAuthor(value) {
const normalized = toStringSafe(value);
if (!normalized)
return null;
return normalized.slice(0, 80);
}
function toRecord(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value;
}
function generateAnnotationId() {
return `asann-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function ensureAnnotationStoreDir() {
const dir = path_1.default.dirname(config_1.ASSISTANT_ANNOTATIONS_FILE);
if (!fs_1.default.existsSync(dir)) {
fs_1.default.mkdirSync(dir, { recursive: true });
}
}
function readAssistantAnnotations() {
if (!fs_1.default.existsSync(config_1.ASSISTANT_ANNOTATIONS_FILE)) {
return [];
}
try {
const parsed = JSON.parse(fs_1.default.readFileSync(config_1.ASSISTANT_ANNOTATIONS_FILE, "utf-8"));
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.map((item) => toRecord(item))
.filter((item) => item !== null)
.map((item) => {
const context = toRecord(item.context);
const technicalContext = toRecord(item.technical_context);
const createdAt = toStringSafe(item.created_at) ?? new Date().toISOString();
const updatedAt = toStringSafe(item.updated_at) ?? createdAt;
return {
annotation_id: toStringSafe(item.annotation_id) ?? "",
session_id: toStringSafe(item.session_id) ?? "",
message_id: toStringSafe(item.message_id) ?? "",
message_index: clampInt(toNumberSafe(item.message_index), 0, 100_000, 0),
rating: clampInt(toNumberSafe(item.rating), 1, 5, 3),
comment: parseComment(item.comment),
annotation_author: parseAnnotationAuthor(item.annotation_author),
created_at: createdAt,
updated_at: updatedAt,
context: {
trace_id: toStringSafe(context?.trace_id),
reply_type: toStringSafe(context?.reply_type),
question_text: toStringSafe(context?.question_text),
answer_text: toStringSafe(context?.answer_text)
},
technical_context: technicalContext
};
})
.filter((item) => item.annotation_id && item.session_id && item.message_id && item.comment.length > 0)
.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
}
catch {
return [];
}
}
function writeAssistantAnnotations(items) {
ensureAnnotationStoreDir();
fs_1.default.writeFileSync(config_1.ASSISTANT_ANNOTATIONS_FILE, JSON.stringify(items, null, 2), "utf-8");
}
function annotationKey(sessionId, messageIndex) {
return `${sessionId}::${messageIndex}`;
}
function buildAssistantRouter(services) {
const router = (0, express_1.Router)();
router.post("/api/assistant/message", async (req, res, next) => {
@ -45,5 +155,103 @@ function buildAssistantRouter(services) {
next(error);
}
});
router.get("/api/assistant/annotations", (req, res, next) => {
try {
const query = req.query;
const sessionIdFilter = toStringSafe(query.session_id);
const limit = clampInt(toNumberSafe(query.limit), 1, 1000, 300);
const items = readAssistantAnnotations()
.filter((item) => (sessionIdFilter ? item.session_id === sessionIdFilter : true))
.slice(0, limit);
(0, http_1.ok)(res, {
ok: true,
generated_at: new Date().toISOString(),
filters_applied: {
session_id: sessionIdFilter ?? null,
limit
},
items
});
}
catch (error) {
next(error);
}
});
router.post("/api/assistant/annotations", (req, res, next) => {
try {
const body = toRecord(req.body);
if (!body) {
throw new http_1.ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "JSON body is required", 400);
}
const sessionId = toStringSafe(body.session_id);
const messageIndexRaw = toNumberSafe(body.message_index);
const ratingRaw = toNumberSafe(body.rating);
const comment = parseComment(body.comment);
const annotationAuthor = parseAnnotationAuthor(body.annotation_author);
if (!sessionId) {
throw new http_1.ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "session_id is required", 400);
}
if (messageIndexRaw === null) {
throw new http_1.ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "message_index is required", 400);
}
const messageIndex = clampInt(messageIndexRaw, 0, 100_000, 0);
if (ratingRaw === null) {
throw new http_1.ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "rating is required", 400);
}
const rating = clampInt(ratingRaw, 1, 5, 3);
if (!comment) {
throw new http_1.ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "comment is required", 400);
}
const session = services.assistantService.getSession(sessionId);
if (!session) {
throw new http_1.ApiError("ASSISTANT_SESSION_NOT_FOUND", `Session not found: ${sessionId}`, 404);
}
if (messageIndex >= session.items.length) {
throw new http_1.ApiError("ASSISTANT_MESSAGE_NOT_FOUND", `Message index ${messageIndex} out of range`, 400);
}
const targetMessage = session.items[messageIndex];
if (!targetMessage || targetMessage.role !== "assistant") {
throw new http_1.ApiError("ASSISTANT_MESSAGE_NOT_ASSISTANT", "Only assistant answers can be annotated", 400);
}
const pairedUserQuestion = [...session.items.slice(0, messageIndex)].reverse().find((item) => item.role === "user") ?? null;
const nowIso = new Date().toISOString();
const annotations = readAssistantAnnotations();
const key = annotationKey(sessionId, messageIndex);
const existingIndex = annotations.findIndex((item) => annotationKey(item.session_id, item.message_index) === key);
const existing = existingIndex >= 0 ? annotations[existingIndex] : null;
const annotation = {
annotation_id: existing?.annotation_id ?? generateAnnotationId(),
session_id: sessionId,
message_id: targetMessage.message_id,
message_index: messageIndex,
rating,
comment,
annotation_author: annotationAuthor,
created_at: existing?.created_at ?? nowIso,
updated_at: nowIso,
context: {
trace_id: toStringSafe(targetMessage.trace_id),
reply_type: toStringSafe(targetMessage.reply_type),
question_text: toStringSafe(pairedUserQuestion?.text),
answer_text: toStringSafe(targetMessage.text)
},
technical_context: toRecord(targetMessage.debug)
};
if (existingIndex >= 0) {
annotations[existingIndex] = annotation;
}
else {
annotations.push(annotation);
}
writeAssistantAnnotations(annotations);
(0, http_1.ok)(res, {
ok: true,
annotation
});
}
catch (error) {
next(error);
}
});
return router;
}

View File

@ -31,6 +31,7 @@ function createApp() {
(0, files_1.ensureDir)(config_1.EVAL_CASES_DIR);
(0, files_1.ensureDir)(config_1.REPORTS_DIR);
(0, files_1.ensureDir)(config_1.ASSISTANT_SESSIONS_DIR);
(0, files_1.ensureDir)(config_1.ASSISTANT_ANNOTATIONS_DIR);
(0, files_1.ensureDir)(config_1.AUTORUN_ANNOTATIONS_DIR);
(0, files_1.ensureDir)(config_1.AUTORUN_GENERATOR_DIR);
const app = (0, express_1.default)();

View File

@ -134,6 +134,8 @@ export const TRACES_DIR = path.resolve(DATA_DIR, "traces");
export const PRESETS_DIR = path.resolve(DATA_DIR, "presets");
export const EVAL_CASES_DIR = path.resolve(DATA_DIR, "eval_cases");
export const ASSISTANT_SESSIONS_DIR = path.resolve(DATA_DIR, "assistant_sessions");
export const ASSISTANT_ANNOTATIONS_DIR = path.resolve(DATA_DIR, "assistant_annotations");
export const ASSISTANT_ANNOTATIONS_FILE = path.resolve(ASSISTANT_ANNOTATIONS_DIR, "annotations.json");
export const AUTORUN_ANNOTATIONS_DIR = path.resolve(DATA_DIR, "autorun_annotations");
export const AUTORUN_ANNOTATIONS_FILE = path.resolve(AUTORUN_ANNOTATIONS_DIR, "annotations.json");
export const AUTORUN_GENERATOR_DIR = path.resolve(DATA_DIR, "autorun_generators");

View File

@ -1,8 +1,140 @@
import fs from "fs";
import path from "path";
import { Router } from "express";
import { ASSISTANT_ANNOTATIONS_FILE } from "../config";
import type { AppServices } from "../serverContext";
import type { AssistantMessageRequestPayload } from "../types/assistant";
import { ApiError, ok } from "../utils/http";
interface AssistantAnnotationRecord {
annotation_id: string;
session_id: string;
message_id: string;
message_index: number;
rating: number;
comment: string;
annotation_author: string | null;
created_at: string;
updated_at: string;
context: {
trace_id: string | null;
reply_type: string | null;
question_text: string | null;
answer_text: string | null;
};
technical_context: Record<string, unknown> | null;
}
function toStringSafe(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toNumberSafe(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function clampInt(value: number | null, min: number, max: number, fallback: number): number {
if (value === null || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.trunc(value);
if (rounded < min) return min;
if (rounded > max) return max;
return rounded;
}
function parseComment(value: unknown): string {
if (typeof value !== "string") return "";
return value.trim().slice(0, 4000);
}
function parseAnnotationAuthor(value: unknown): string | null {
const normalized = toStringSafe(value);
if (!normalized) return null;
return normalized.slice(0, 80);
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function generateAnnotationId(): string {
return `asann-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function ensureAnnotationStoreDir(): void {
const dir = path.dirname(ASSISTANT_ANNOTATIONS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function readAssistantAnnotations(): AssistantAnnotationRecord[] {
if (!fs.existsSync(ASSISTANT_ANNOTATIONS_FILE)) {
return [];
}
try {
const parsed = JSON.parse(fs.readFileSync(ASSISTANT_ANNOTATIONS_FILE, "utf-8")) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.map((item) => toRecord(item))
.filter((item): item is Record<string, unknown> => item !== null)
.map((item) => {
const context = toRecord(item.context);
const technicalContext = toRecord(item.technical_context);
const createdAt = toStringSafe(item.created_at) ?? new Date().toISOString();
const updatedAt = toStringSafe(item.updated_at) ?? createdAt;
return {
annotation_id: toStringSafe(item.annotation_id) ?? "",
session_id: toStringSafe(item.session_id) ?? "",
message_id: toStringSafe(item.message_id) ?? "",
message_index: clampInt(toNumberSafe(item.message_index), 0, 100_000, 0),
rating: clampInt(toNumberSafe(item.rating), 1, 5, 3),
comment: parseComment(item.comment),
annotation_author: parseAnnotationAuthor(item.annotation_author),
created_at: createdAt,
updated_at: updatedAt,
context: {
trace_id: toStringSafe(context?.trace_id),
reply_type: toStringSafe(context?.reply_type),
question_text: toStringSafe(context?.question_text),
answer_text: toStringSafe(context?.answer_text)
},
technical_context: technicalContext
} satisfies AssistantAnnotationRecord;
})
.filter((item) => item.annotation_id && item.session_id && item.message_id && item.comment.length > 0)
.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
} catch {
return [];
}
}
function writeAssistantAnnotations(items: AssistantAnnotationRecord[]): void {
ensureAnnotationStoreDir();
fs.writeFileSync(ASSISTANT_ANNOTATIONS_FILE, JSON.stringify(items, null, 2), "utf-8");
}
function annotationKey(sessionId: string, messageIndex: number): string {
return `${sessionId}::${messageIndex}`;
}
export function buildAssistantRouter(services: AppServices): Router {
const router = Router();
@ -48,5 +180,111 @@ export function buildAssistantRouter(services: AppServices): Router {
}
});
router.get("/api/assistant/annotations", (req, res, next) => {
try {
const query = req.query as Record<string, unknown>;
const sessionIdFilter = toStringSafe(query.session_id);
const limit = clampInt(toNumberSafe(query.limit), 1, 1000, 300);
const items = readAssistantAnnotations()
.filter((item) => (sessionIdFilter ? item.session_id === sessionIdFilter : true))
.slice(0, limit);
ok(res, {
ok: true,
generated_at: new Date().toISOString(),
filters_applied: {
session_id: sessionIdFilter ?? null,
limit
},
items
});
} catch (error) {
next(error);
}
});
router.post("/api/assistant/annotations", (req, res, next) => {
try {
const body = toRecord(req.body);
if (!body) {
throw new ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "JSON body is required", 400);
}
const sessionId = toStringSafe(body.session_id);
const messageIndexRaw = toNumberSafe(body.message_index);
const ratingRaw = toNumberSafe(body.rating);
const comment = parseComment(body.comment);
const annotationAuthor = parseAnnotationAuthor(body.annotation_author);
if (!sessionId) {
throw new ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "session_id is required", 400);
}
if (messageIndexRaw === null) {
throw new ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "message_index is required", 400);
}
const messageIndex = clampInt(messageIndexRaw, 0, 100_000, 0);
if (ratingRaw === null) {
throw new ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "rating is required", 400);
}
const rating = clampInt(ratingRaw, 1, 5, 3);
if (!comment) {
throw new ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "comment is required", 400);
}
const session = services.assistantService.getSession(sessionId);
if (!session) {
throw new ApiError("ASSISTANT_SESSION_NOT_FOUND", `Session not found: ${sessionId}`, 404);
}
if (messageIndex >= session.items.length) {
throw new ApiError("ASSISTANT_MESSAGE_NOT_FOUND", `Message index ${messageIndex} out of range`, 400);
}
const targetMessage = session.items[messageIndex];
if (!targetMessage || targetMessage.role !== "assistant") {
throw new ApiError("ASSISTANT_MESSAGE_NOT_ASSISTANT", "Only assistant answers can be annotated", 400);
}
const pairedUserQuestion = [...session.items.slice(0, messageIndex)].reverse().find((item) => item.role === "user") ?? null;
const nowIso = new Date().toISOString();
const annotations = readAssistantAnnotations();
const key = annotationKey(sessionId, messageIndex);
const existingIndex = annotations.findIndex((item) => annotationKey(item.session_id, item.message_index) === key);
const existing = existingIndex >= 0 ? annotations[existingIndex] : null;
const annotation: AssistantAnnotationRecord = {
annotation_id: existing?.annotation_id ?? generateAnnotationId(),
session_id: sessionId,
message_id: targetMessage.message_id,
message_index: messageIndex,
rating,
comment,
annotation_author: annotationAuthor,
created_at: existing?.created_at ?? nowIso,
updated_at: nowIso,
context: {
trace_id: toStringSafe(targetMessage.trace_id),
reply_type: toStringSafe(targetMessage.reply_type),
question_text: toStringSafe(pairedUserQuestion?.text),
answer_text: toStringSafe(targetMessage.text)
},
technical_context: toRecord(targetMessage.debug)
};
if (existingIndex >= 0) {
annotations[existingIndex] = annotation;
} else {
annotations.push(annotation);
}
writeAssistantAnnotations(annotations);
ok(res, {
ok: true,
annotation
});
} catch (error) {
next(error);
}
});
return router;
}

View File

@ -9,6 +9,7 @@ import {
REPORTS_DIR,
TIMEZONE,
ASSISTANT_SESSIONS_DIR,
ASSISTANT_ANNOTATIONS_DIR,
AUTORUN_ANNOTATIONS_DIR,
AUTORUN_GENERATOR_DIR
} from "./config";
@ -37,6 +38,7 @@ export function createApp(): express.Express {
ensureDir(EVAL_CASES_DIR);
ensureDir(REPORTS_DIR);
ensureDir(ASSISTANT_SESSIONS_DIR);
ensureDir(ASSISTANT_ANNOTATIONS_DIR);
ensureDir(AUTORUN_ANNOTATIONS_DIR);
ensureDir(AUTORUN_GENERATOR_DIR);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-DMMD5-xN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfGkpjEM.css">
<script type="module" crossorigin src="/assets/index-B_Dz87Mp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4dtBq8A.css">
</head>
<body>
<div id="root"></div>

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "./api/client";
import { AssistantSamPanel } from "./components/AssistantSamPanel";
import { AutoRunsHistoryPanel } from "./components/AutoRunsHistoryPanel";
@ -7,6 +7,7 @@ import { ConnectionPanel } from "./components/ConnectionPanel";
import { HistoryPanel } from "./components/HistoryPanel";
import { MetricsPanel } from "./components/MetricsPanel";
import { OutputPanel } from "./components/OutputPanel";
import { PanelFrame } from "./components/PanelFrame";
import { PromptPanel } from "./components/PromptPanel";
import { QueryPanel } from "./components/QueryPanel";
import { RuntimePanel } from "./components/RuntimePanel";
@ -14,6 +15,7 @@ import { DEFAULT_CONNECTION, DEFAULT_PROMPTS, DEFAULT_QUERY } from "./state/defa
import { designConfig } from "../../../designconfig";
import type {
AssistantConversationItem,
AssistantAnnotationRecord,
ConnectionState,
HistoryItem,
NormalizeResultState,
@ -32,6 +34,17 @@ const DEFAULT_UI_MODE: UiMode = "assistant";
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1";
const TAB_KEYS: TabKey[] = ["normalized", "fragments", "scope", "flags", "route", "raw", "validation", "logs"];
const DEFAULT_ASSISTANT_ANNOTATION_AUTHOR = "manual_reviewer";
interface AssistantCommentModalState {
open: boolean;
messageIndex: number;
rating: number;
comment: string;
annotationAuthor: string;
saving: boolean;
error: string;
}
function withTs(message: string): string {
return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`;
@ -91,6 +104,7 @@ export default function App() {
const [showAssistantConnectionMode, setShowAssistantConnectionMode] = useState(true);
const [showAssistantPromptMode, setShowAssistantPromptMode] = useState(true);
const [showAssistantChatMode, setShowAssistantChatMode] = useState(true);
const [showAssistantCommentsMode, setShowAssistantCommentsMode] = useState(true);
const [showAssistantSamMode, setShowAssistantSamMode] = useState(true);
const [showDecompositionConnectionMode, setShowDecompositionConnectionMode] = useState(true);
const [showDecompositionPromptMode, setShowDecompositionPromptMode] = useState(true);
@ -105,6 +119,17 @@ export default function App() {
const [assistantBusy, setAssistantBusy] = useState(false);
const [assistantStatus, setAssistantStatus] = useState("");
const [assistantError, setAssistantError] = useState("");
const [assistantAnnotations, setAssistantAnnotations] = useState<AssistantAnnotationRecord[]>([]);
const [assistantAnnotationsBusy, setAssistantAnnotationsBusy] = useState(false);
const [assistantCommentModal, setAssistantCommentModal] = useState<AssistantCommentModalState>({
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
});
const presetAutoloadDoneRef = useRef(false);
const skipPresetAutoloadRef = useRef(false);
@ -172,6 +197,7 @@ export default function App() {
showAssistantConnectionMode?: boolean;
showAssistantPromptMode?: boolean;
showAssistantChatMode?: boolean;
showAssistantCommentsMode?: boolean;
showAssistantSamMode?: boolean;
showDecompositionConnectionMode?: boolean;
showDecompositionPromptMode?: boolean;
@ -209,6 +235,9 @@ export default function App() {
if (typeof parsed.showAssistantChatMode === "boolean") {
setShowAssistantChatMode(parsed.showAssistantChatMode);
}
if (typeof parsed.showAssistantCommentsMode === "boolean") {
setShowAssistantCommentsMode(parsed.showAssistantCommentsMode);
}
if (typeof parsed.showAssistantSamMode === "boolean") {
setShowAssistantSamMode(parsed.showAssistantSamMode);
}
@ -331,6 +360,7 @@ export default function App() {
showAssistantConnectionMode,
showAssistantPromptMode,
showAssistantChatMode,
showAssistantCommentsMode,
showAssistantSamMode,
showDecompositionConnectionMode,
showDecompositionPromptMode,
@ -619,12 +649,146 @@ export default function App() {
}
}
const assistantAnnotationsByMessageId = useMemo(() => {
const map = new Map<string, AssistantAnnotationRecord>();
for (const item of assistantAnnotations) {
if (item.message_id) {
map.set(item.message_id, item);
}
}
return map;
}, [assistantAnnotations]);
const assistantCommentModalMessage =
assistantCommentModal.messageIndex >= 0 ? assistantConversation[assistantCommentModal.messageIndex] ?? null : null;
const assistantCommentModalQuestion = useMemo(() => {
if (assistantCommentModal.messageIndex < 0) return null;
for (let index = assistantCommentModal.messageIndex - 1; index >= 0; index -= 1) {
const candidate = assistantConversation[index];
if (candidate?.role === "user") {
return candidate;
}
}
return null;
}, [assistantCommentModal.messageIndex, assistantConversation]);
async function loadAssistantAnnotationsForSession(sessionId: string): Promise<void> {
if (!sessionId.trim()) {
setAssistantAnnotations([]);
return;
}
setAssistantAnnotationsBusy(true);
try {
const payload = await apiClient.loadAssistantAnnotations({
session_id: sessionId,
limit: 400
});
setAssistantAnnotations(payload.items ?? []);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Assistant annotations load error: ${message}`);
} finally {
setAssistantAnnotationsBusy(false);
}
}
function closeAssistantCommentModal(options?: { force?: boolean }) {
setAssistantCommentModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
};
});
}
function openAssistantCommentModal(item: AssistantConversationItem, index: number): void {
if (item.role !== "assistant") return;
const sessionIdFromState = assistantSessionId.trim();
const sessionIdFromItem = String(item.session_id ?? "").trim();
const resolvedSessionId = sessionIdFromState || sessionIdFromItem;
if (!resolvedSessionId) {
setAssistantError("Сначала получите ответ ассистента в активной сессии.");
return;
}
if (!sessionIdFromState && sessionIdFromItem) {
setAssistantSessionId(sessionIdFromItem);
}
const existing = assistantAnnotationsByMessageId.get(item.message_id) ?? null;
setAssistantCommentModal({
open: true,
messageIndex: index,
rating: existing?.rating ?? 3,
comment: existing?.comment ?? "",
annotationAuthor: existing?.annotation_author ?? DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
});
}
function canCommentAssistantMessage(item: AssistantConversationItem): boolean {
return item.role === "assistant";
}
function isAssistantMessageCommented(item: AssistantConversationItem): boolean {
return item.role === "assistant" && assistantAnnotationsByMessageId.has(item.message_id);
}
async function submitAssistantCommentModal(): Promise<void> {
if (!assistantSessionId.trim()) {
setAssistantCommentModal((prev) => ({ ...prev, error: "Сессия ассистента не найдена." }));
return;
}
if (assistantCommentModal.messageIndex < 0) {
return;
}
if (!assistantCommentModal.comment.trim()) {
setAssistantCommentModal((prev) => ({ ...prev, error: "Добавьте комментарий." }));
return;
}
setAssistantCommentModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.saveAssistantAnnotation({
session_id: assistantSessionId,
message_index: assistantCommentModal.messageIndex,
rating: assistantCommentModal.rating,
comment: assistantCommentModal.comment.trim(),
annotation_author: assistantCommentModal.annotationAuthor.trim() || undefined
});
setAssistantAnnotations((prev) => {
const next = [...prev];
const index = next.findIndex((item) => item.annotation_id === payload.annotation.annotation_id);
if (index >= 0) {
next[index] = payload.annotation;
} else {
next.unshift(payload.annotation);
}
return next.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
});
closeAssistantCommentModal({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantCommentModal((prev) => ({ ...prev, saving: false, error: message }));
}
}
function resetAssistantSession() {
setAssistantSessionId("");
setAssistantConversation([]);
setAssistantInput("");
setAssistantStatus("");
setAssistantError("");
setAssistantAnnotations([]);
closeAssistantCommentModal({ force: true });
log("Assistant session reset.");
}
@ -664,6 +828,7 @@ export default function App() {
setAssistantSessionId(response.session_id);
setAssistantConversation(response.conversation);
setAssistantStatus("Ответ готов");
await loadAssistantAnnotationsForSession(response.session_id);
log(`Assistant reply received: trace=${response.debug.trace_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@ -676,6 +841,14 @@ export default function App() {
}
}
useEffect(() => {
if (!assistantSessionId.trim()) {
setAssistantAnnotations([]);
return;
}
void loadAssistantAnnotationsForSession(assistantSessionId);
}, [assistantSessionId]);
useEffect(() => {
if (!selectedRunId) {
setRunTrace([]);
@ -728,6 +901,13 @@ export default function App() {
<button type="button" className={showAssistantChatMode ? "tab active" : "tab"} onClick={() => setShowAssistantChatMode((prev) => !prev)}>
Режим ассистента
</button>
<button
type="button"
className={showAssistantCommentsMode ? "tab active" : "tab"}
onClick={() => setShowAssistantCommentsMode((prev) => !prev)}
>
Комментарии ассистента
</button>
<button type="button" className={showAssistantSamMode ? "tab active" : "tab"} onClick={() => setShowAssistantSamMode((prev) => !prev)}>
SAM
</button>
@ -862,10 +1042,57 @@ export default function App() {
busy={assistantBusy}
statusText={assistantStatus}
errorMessage={assistantError}
showCommentAction
onCommentAssistantMessage={openAssistantCommentModal}
isAssistantMessageCommented={isAssistantMessageCommented}
canCommentAssistantMessage={canCommentAssistantMessage}
/>
</div>
) : null}
{showAssistantCommentsMode ? (
<div className="mode-col">
<PanelFrame className="assistant-comments-frame" title="Комментарии ассистента">
<div className="assistant-comments-shell">
<div className="assistant-comments-toolbar">
<span className="muted">
{assistantSessionId ? `session: ${assistantSessionId}` : "Сессия не запущена"}
</span>
<button
type="button"
className="tab"
onClick={() => void loadAssistantAnnotationsForSession(assistantSessionId)}
disabled={!assistantSessionId || assistantAnnotationsBusy}
>
{assistantAnnotationsBusy ? "Обновляю..." : "Обновить"}
</button>
</div>
<div className="assistant-comments-list">
{!assistantSessionId ? <p className="muted">Появится после первого ответа ассистента.</p> : null}
{assistantSessionId && assistantAnnotations.length === 0 && !assistantAnnotationsBusy ? (
<p className="muted">Комментариев по этой сессии пока нет.</p>
) : null}
{assistantAnnotations.map((item) => (
<article key={item.annotation_id} className="assistant-comment-item">
<div className="assistant-comment-head">
<strong>{`${"●".repeat(Math.max(1, Math.min(5, Math.round(item.rating))))}${"○".repeat(Math.max(0, 5 - Math.round(item.rating)))}`}</strong>
<span>{new Date(item.updated_at).toLocaleString("ru-RU")}</span>
</div>
{item.context.question_text ? <p>Q: {item.context.question_text}</p> : null}
{item.context.answer_text ? <p>A: {item.context.answer_text}</p> : null}
<p>{item.comment}</p>
<div className="assistant-comment-meta">
{item.context.trace_id ? <span>{`trace=${item.context.trace_id}`}</span> : null}
{item.context.reply_type ? <span>{`reply_type=${item.context.reply_type}`}</span> : null}
</div>
</article>
))}
</div>
</div>
</PanelFrame>
</div>
) : null}
{showAssistantSamMode ? (
<div className="mode-col">
<AssistantSamPanel
@ -879,7 +1106,11 @@ export default function App() {
</div>
) : null}
{!showAssistantConnectionMode && !showAssistantPromptMode && !showAssistantChatMode && !showAssistantSamMode ? (
{!showAssistantConnectionMode &&
!showAssistantPromptMode &&
!showAssistantChatMode &&
!showAssistantCommentsMode &&
!showAssistantSamMode ? (
<div className="mode-columns-empty">Все панели режима ассистента скрыты. Включите нужные блоки справа в шапке.</div>
) : null}
</div>
@ -999,6 +1230,89 @@ export default function App() {
/>
</div>
)}
{assistantCommentModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAssistantCommentModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Комментарий к ответу ассистента</h3>
<p className="muted">Эта разметка хранится отдельно от комментариев автопрогонов.</p>
{assistantCommentModalQuestion ? (
<details className="autoruns-prompt-details" open>
<summary>Вопрос пользователя</summary>
<p className="autoruns-comment-quote">{assistantCommentModalQuestion.text}</p>
</details>
) : null}
{assistantCommentModalMessage ? (
<details className="autoruns-prompt-details" open>
<summary>Ответ ассистента</summary>
<p className="autoruns-comment-quote">{assistantCommentModalMessage.text}</p>
</details>
) : null}
<div className="autoruns-rating-row" role="group" aria-label="Рейтинг ответа">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
className={assistantCommentModal.rating >= value ? "autoruns-rating-dot active" : "autoruns-rating-dot"}
onClick={() => setAssistantCommentModal((prev) => ({ ...prev, rating: value }))}
disabled={assistantCommentModal.saving}
aria-label={`Оценка ${value}`}
>
{assistantCommentModal.rating >= value ? "●" : "○"}
</button>
))}
</div>
<div className="autoruns-form-grid">
<label>
Автор комментария
<input
value={assistantCommentModal.annotationAuthor}
onChange={(event) => setAssistantCommentModal((prev) => ({ ...prev, annotationAuthor: event.target.value }))}
placeholder="manual_reviewer"
disabled={assistantCommentModal.saving}
/>
</label>
</div>
<label>
Комментарий
<textarea
value={assistantCommentModal.comment}
onChange={(event) => setAssistantCommentModal((prev) => ({ ...prev, comment: event.target.value }))}
placeholder="Что именно не так в ответе и что проверить."
rows={4}
disabled={assistantCommentModal.saving}
/>
</label>
{assistantCommentModal.error ? <p className="error-text">{assistantCommentModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAssistantCommentModal()} disabled={assistantCommentModal.saving}>
{assistantCommentModal.saving ? "Сохраняю..." : "Готово"}
</button>
<button
type="button"
className="tab"
onClick={() => closeAssistantCommentModal()}
disabled={assistantCommentModal.saving}
>
Отмена
</button>
</div>
</div>
</div>
) : null}
</main>
);
}

View File

@ -10,6 +10,8 @@ import type {
AutoRunDialogResponse,
AutoRunHistoryResponse,
AutoRunPostAnalysisResponse,
AssistantAnnotationsResponse,
AssistantAnnotationRecord,
AssistantMessageResultState,
AssistantConversationItem,
ConnectionState,
@ -314,6 +316,30 @@ export const apiClient = {
return request(`/assistant/session/${sessionId}`);
},
async loadAssistantAnnotations(input?: {
session_id?: string;
limit?: number;
}): Promise<AssistantAnnotationsResponse> {
const params = new URLSearchParams();
if (input?.session_id) params.set("session_id", input.session_id);
if (typeof input?.limit === "number") params.set("limit", String(input.limit));
const query = params.toString();
return request(`/assistant/annotations${query ? `?${query}` : ""}`);
},
async saveAssistantAnnotation(input: {
session_id: string;
message_index: number;
rating: number;
comment: string;
annotation_author?: string;
}): Promise<{ ok: boolean; annotation: AssistantAnnotationRecord }> {
return request("/assistant/annotations", {
method: "POST",
body: JSON.stringify(input)
});
},
async loadAutoRunsHistory(input?: {
from?: string;
to?: string;

View File

@ -16,6 +16,10 @@ interface AssistantPanelProps {
busy: boolean;
statusText: string;
errorMessage: string;
showCommentAction?: boolean;
onCommentAssistantMessage?: (item: AssistantConversationItem, index: number) => void;
isAssistantMessageCommented?: (item: AssistantConversationItem, index: number) => boolean;
canCommentAssistantMessage?: (item: AssistantConversationItem, index: number) => boolean;
}
function roleLabel(role: AssistantConversationItem["role"]): string {
@ -61,6 +65,15 @@ async function copyTextToClipboard(text: string): Promise<boolean> {
return copied;
}
function CommentBubbleIcon({ commented }: { commented: boolean }) {
const className = commented ? "comment-icon-svg commented" : "comment-icon-svg";
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 6.5h14v9H11.5l-4.5 3v-3H5z" />
</svg>
);
}
export function AssistantPanel({
sessionId,
conversation,
@ -72,7 +85,11 @@ export function AssistantPanel({
onClear,
busy,
statusText,
errorMessage
errorMessage,
showCommentAction = false,
onCommentAssistantMessage,
isAssistantMessageCommented,
canCommentAssistantMessage
}: AssistantPanelProps) {
const listRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(true);
@ -162,11 +179,45 @@ export function AssistantPanel({
</div>
<div ref={listRef} className="assistant-chat-list" onScroll={handleChatScroll}>
{conversation.map((item) => (
{conversation.map((item, index) => {
const commentEnabled =
item.role === "assistant" &&
showCommentAction &&
typeof onCommentAssistantMessage === "function" &&
(typeof canCommentAssistantMessage === "function" ? canCommentAssistantMessage(item, index) : true);
const commented =
item.role === "assistant" && typeof isAssistantMessageCommented === "function"
? isAssistantMessageCommented(item, index)
: false;
return (
<article key={item.message_id} className={`assistant-msg ${item.role}`}>
<header className="assistant-msg-head">
<strong>{roleLabel(item.role)}</strong>
<span>{shortTime(item.created_at)}</span>
<div className="assistant-msg-head-main">
<strong>{roleLabel(item.role)}</strong>
<span>{shortTime(item.created_at)}</span>
</div>
{item.role === "assistant" && showCommentAction ? (
<div className="assistant-msg-head-actions">
<button
type="button"
className={commented ? "autoruns-comment-icon assistant-comment-btn commented" : "autoruns-comment-icon assistant-comment-btn"}
onClick={() => onCommentAssistantMessage?.(item, index)}
disabled={!commentEnabled}
title={
commentEnabled
? "Комментировать ответ ассистента"
: "Комментарий недоступен для этого сообщения"
}
aria-label={
commentEnabled
? "Комментировать ответ ассистента"
: "Комментарий недоступен для этого сообщения"
}
>
<CommentBubbleIcon commented={commented} />
</button>
</div>
) : null}
</header>
<div className="assistant-msg-body">{item.text}</div>
{item.role === "assistant" && item.debug ? (
@ -176,13 +227,15 @@ export function AssistantPanel({
</details>
) : null}
</article>
))}
);
})}
</div>
<div className="assistant-compose">
<label className="full-width">
Сообщение
<textarea
className="assistant-input-textarea"
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
rows={4}

View File

@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type SyntheticEvent } from "react";
import { apiClient } from "../api/client";
import type {
AssistantConversationItem,
AssistantAnnotationRecord,
AsyncEvalRunJob,
AutoGenHistoryRecord,
AutoGenMode,
@ -68,6 +69,16 @@ interface CommentModalState {
error: string;
}
interface AssistantLiveCommentModalState {
open: boolean;
messageIndex: number;
rating: number;
comment: string;
annotationAuthor: string;
saving: boolean;
error: string;
}
interface AutoGenSettingsState {
mode: AutoGenMode;
count: number;
@ -117,6 +128,7 @@ function buildDefaultPersonalityPrompts(
interface AutoRunsUiConfig {
filters?: Partial<AutoRunsFilters>;
analysisDate?: string;
autogenPersonalityPromptHeight?: number;
autoGenSettings?: {
mode?: AutoGenMode;
count?: number;
@ -143,6 +155,11 @@ function normalizeAnalysisDateInput(value: string): string {
return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : "";
}
function clampAutogenPromptHeight(value: number | null | undefined): number {
const numeric = typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 160;
return Math.max(110, Math.min(520, numeric));
}
function dateToInputValue(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@ -391,9 +408,6 @@ function CommentBubbleIcon({ commented }: { commented: boolean }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 6.5h14v9H11.5l-4.5 3v-3H5z" />
<circle className="comment-icon-dot" cx="9" cy="11" r="1.05" />
<circle className="comment-icon-dot" cx="12" cy="11" r="1.05" />
<circle className="comment-icon-dot" cx="15" cy="11" r="1.05" />
</svg>
);
}
@ -462,6 +476,7 @@ export function AutoRunsHistoryPanel({
const [errorText, setErrorText] = useState("");
const [assistantLiveSessionId, setAssistantLiveSessionId] = useState("");
const [assistantLiveConversation, setAssistantLiveConversation] = useState<AssistantConversationItem[]>([]);
const [assistantLiveAnnotations, setAssistantLiveAnnotations] = useState<AssistantAnnotationRecord[]>([]);
const [assistantLiveInput, setAssistantLiveInput] = useState("");
const [assistantLiveUseMock, setAssistantLiveUseMock] = useState(false);
const [assistantLiveBusy, setAssistantLiveBusy] = useState(false);
@ -469,6 +484,7 @@ export function AutoRunsHistoryPanel({
const [assistantLiveError, setAssistantLiveError] = useState("");
const [limitInput, setLimitInput] = useState(String(DEFAULT_FILTERS.limit));
const [autogenCountInput, setAutogenCountInput] = useState(String(DEFAULT_AUTOGEN_SETTINGS.count));
const [autogenPersonalityPromptHeight, setAutogenPersonalityPromptHeight] = useState(160);
const [commentModal, setCommentModal] = useState<CommentModalState>({
open: false,
caseId: "",
@ -481,6 +497,15 @@ export function AutoRunsHistoryPanel({
saving: false,
error: ""
});
const [assistantLiveCommentModal, setAssistantLiveCommentModal] = useState<AssistantLiveCommentModalState>({
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: "manual_reviewer",
saving: false,
error: ""
});
const initialLoadDoneRef = useRef(false);
const asyncJobPollTimerRef = useRef<number | null>(null);
@ -510,6 +535,27 @@ export function AutoRunsHistoryPanel({
}
return null;
}, [commentModal.messageIndex, dialog]);
const assistantLiveAnnotationsByMessageId = useMemo(() => {
const map = new Map<string, AssistantAnnotationRecord>();
for (const item of assistantLiveAnnotations) {
if (item.message_id) {
map.set(item.message_id, item);
}
}
return map;
}, [assistantLiveAnnotations]);
const assistantLiveCommentModalMessage =
assistantLiveCommentModal.messageIndex >= 0 ? assistantLiveConversation[assistantLiveCommentModal.messageIndex] ?? null : null;
const assistantLiveCommentModalQuestion = useMemo(() => {
if (assistantLiveCommentModal.messageIndex < 0) return null;
for (let index = assistantLiveCommentModal.messageIndex - 1; index >= 0; index -= 1) {
const candidate = assistantLiveConversation[index];
if (candidate?.role === "user") {
return candidate;
}
}
return null;
}, [assistantLiveCommentModal.messageIndex, assistantLiveConversation]);
const annotationsAverageRating = useMemo(() => {
if (visibleAnnotations.length === 0) return null;
@ -535,6 +581,44 @@ export function AutoRunsHistoryPanel({
[onLog]
);
const loadAssistantLiveAnnotationsForSession = useCallback(
async (sessionIdRaw: string) => {
const sessionId = String(sessionIdRaw ?? "").trim();
if (!sessionId) {
setAssistantLiveAnnotations([]);
return;
}
try {
const payload = await apiClient.loadAssistantAnnotations({
session_id: sessionId,
limit: 400
});
setAssistantLiveAnnotations(payload.items ?? []);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Assistant live annotations load error: ${message}`);
}
},
[log]
);
const closeAssistantLiveCommentModal = useCallback((options?: { force?: boolean }) => {
setAssistantLiveCommentModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: "manual_reviewer",
saving: false,
error: ""
};
});
}, []);
const copyRunIdToClipboard = useCallback(
async (event: React.SyntheticEvent, runId: string) => {
event.stopPropagation();
@ -580,11 +664,13 @@ export function AutoRunsHistoryPanel({
const resetAssistantLiveSession = useCallback(() => {
setAssistantLiveSessionId("");
setAssistantLiveConversation([]);
setAssistantLiveAnnotations([]);
setAssistantLiveInput("");
setAssistantLiveStatus("");
setAssistantLiveError("");
closeAssistantLiveCommentModal({ force: true });
log("Live-чат ассистента в истории автопрогонов сброшен.");
}, [log]);
}, [closeAssistantLiveCommentModal, log]);
const sendAssistantLiveMessage = useCallback(async () => {
const userMessage = assistantLiveInput.trim();
@ -621,6 +707,7 @@ export function AutoRunsHistoryPanel({
});
setAssistantLiveSessionId(response.session_id);
setAssistantLiveConversation(response.conversation);
await loadAssistantLiveAnnotationsForSession(response.session_id);
setAssistantLiveStatus("Ответ готов");
log(`Live-ответ ассистента получен: trace=${response.debug.trace_id}`);
} catch (error) {
@ -638,6 +725,7 @@ export function AutoRunsHistoryPanel({
assistantLiveUseMock,
assistantPromptVersion,
connection,
loadAssistantLiveAnnotationsForSession,
log,
prompts
]);
@ -692,6 +780,20 @@ export function AutoRunsHistoryPanel({
[autoGenSettings.count]
);
const commitAutogenPromptHeight = useCallback((height: number) => {
setAutogenPersonalityPromptHeight(clampAutogenPromptHeight(height));
}, []);
const captureAutogenPromptHeight = useCallback(
(event: SyntheticEvent<HTMLTextAreaElement>) => {
const nextHeight = event.currentTarget.offsetHeight;
if (Number.isFinite(nextHeight) && nextHeight > 0) {
commitAutogenPromptHeight(nextHeight);
}
},
[commitAutogenPromptHeight]
);
const loadAnnotations = useCallback(async () => {
setAnnotationsBusy(true);
try {
@ -1202,6 +1304,94 @@ export function AutoRunsHistoryPanel({
selectedRunId
]);
const canCommentAssistantLiveMessage = useCallback((item: AssistantConversationItem): boolean => item.role === "assistant", []);
const isAssistantLiveMessageCommented = useCallback(
(item: AssistantConversationItem): boolean => item.role === "assistant" && assistantLiveAnnotationsByMessageId.has(item.message_id),
[assistantLiveAnnotationsByMessageId]
);
const openAssistantLiveCommentModal = useCallback(
(item: AssistantConversationItem, index: number) => {
if (item.role !== "assistant") {
return;
}
const sessionIdFromState = assistantLiveSessionId.trim();
const sessionIdFromItem = String(item.session_id ?? "").trim();
const resolvedSessionId = sessionIdFromState || sessionIdFromItem;
if (!resolvedSessionId) {
setAssistantLiveError("Сначала получите ответ ассистента в активной сессии.");
return;
}
if (!sessionIdFromState && sessionIdFromItem) {
setAssistantLiveSessionId(sessionIdFromItem);
}
const existing = assistantLiveAnnotationsByMessageId.get(item.message_id) ?? null;
setAssistantLiveError("");
setAssistantLiveCommentModal({
open: true,
messageIndex: index,
rating: existing?.rating ?? 3,
comment: existing?.comment ?? "",
annotationAuthor: existing?.annotation_author ?? "manual_reviewer",
saving: false,
error: ""
});
},
[assistantLiveAnnotationsByMessageId, assistantLiveSessionId]
);
const submitAssistantLiveCommentModal = useCallback(async () => {
if (assistantLiveCommentModal.messageIndex < 0) {
return;
}
if (!assistantLiveCommentModal.comment.trim()) {
setAssistantLiveCommentModal((prev) => ({ ...prev, error: "Добавьте комментарий." }));
return;
}
const modalMessage = assistantLiveConversation[assistantLiveCommentModal.messageIndex] ?? null;
const sessionId =
assistantLiveSessionId.trim() || (modalMessage?.role === "assistant" ? String(modalMessage.session_id ?? "").trim() : "");
if (!sessionId) {
setAssistantLiveCommentModal((prev) => ({ ...prev, error: "Сессия ассистента не найдена." }));
return;
}
setAssistantLiveCommentModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.saveAssistantAnnotation({
session_id: sessionId,
message_index: assistantLiveCommentModal.messageIndex,
rating: assistantLiveCommentModal.rating,
comment: assistantLiveCommentModal.comment.trim(),
annotation_author: assistantLiveCommentModal.annotationAuthor.trim() || undefined
});
setAssistantLiveAnnotations((prev) => {
const next = [...prev];
const index = next.findIndex((item) => item.annotation_id === payload.annotation.annotation_id);
if (index >= 0) {
next[index] = payload.annotation;
} else {
next.unshift(payload.annotation);
}
return next.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
});
closeAssistantLiveCommentModal({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantLiveCommentModal((prev) => ({ ...prev, saving: false, error: message }));
}
}, [
assistantLiveCommentModal.annotationAuthor,
assistantLiveCommentModal.comment,
assistantLiveCommentModal.messageIndex,
assistantLiveCommentModal.rating,
assistantLiveConversation,
assistantLiveSessionId,
closeAssistantLiveCommentModal
]);
const applyLocalAnnotationPatch = useCallback((annotation: AutoRunAnnotationRecord) => {
setAnnotations((prev) =>
prev.map((item) =>
@ -1316,6 +1506,14 @@ export function AutoRunsHistoryPanel({
setAutogenCountInput(String(autoGenSettings.count));
}, [autoGenSettings.count]);
useEffect(() => {
if (!assistantLiveSessionId.trim()) {
setAssistantLiveAnnotations([]);
return;
}
void loadAssistantLiveAnnotationsForSession(assistantLiveSessionId);
}, [assistantLiveSessionId, loadAssistantLiveAnnotationsForSession]);
useEffect(() => {
if (!activeAsyncJob) return;
const liveRunId = toLiveRunId(activeAsyncJob.job_id);
@ -1375,6 +1573,9 @@ export function AutoRunsHistoryPanel({
if (typeof parsed.analysisDate === "string") {
setAnalysisDate(normalizeAnalysisDateInput(parsed.analysisDate));
}
if (typeof parsed.autogenPersonalityPromptHeight === "number") {
setAutogenPersonalityPromptHeight(clampAutogenPromptHeight(parsed.autogenPersonalityPromptHeight));
}
if (parsed.autoGenSettings) {
setAutoGenSettings((prev) => {
const nextPrompts: Record<string, string> = {
@ -1431,6 +1632,7 @@ export function AutoRunsHistoryPanel({
const payload: AutoRunsUiConfig = {
filters,
analysisDate,
autogenPersonalityPromptHeight,
autoGenSettings: {
mode: autoGenSettings.mode,
count: autoGenSettings.count,
@ -1443,7 +1645,7 @@ export function AutoRunsHistoryPanel({
hideResolvedAnnotations
};
localStorage.setItem(AUTORUNS_UI_CONFIG_KEY, JSON.stringify(payload));
}, [analysisDate, annotationDecisionFilter, autoGenSettings, filters, hideResolvedAnnotations]);
}, [analysisDate, annotationDecisionFilter, autoGenSettings, autogenPersonalityPromptHeight, filters, hideResolvedAnnotations]);
useEffect(() => {
const onSave = () => {
@ -1596,9 +1798,6 @@ export function AutoRunsHistoryPanel({
</div>
<h4>Автогенерация вопросов</h4>
<p className="muted">
`qwen_seed` использует текущую LLM-модель из активного контура подключения (та же модель, что и для ответов ассистента).
</p>
<div className="autoruns-form-grid">
<label>
Режим генерации
@ -1660,6 +1859,7 @@ export function AutoRunsHistoryPanel({
<label className="full-width">
Промпт личности
<textarea
className="autoruns-personality-prompt"
value={autoGenSettings.personalityPrompts[autoGenSettings.personalityId] ?? ""}
onChange={(event) =>
setAutoGenSettings((prev) => ({
@ -1671,6 +1871,9 @@ export function AutoRunsHistoryPanel({
}))
}
placeholder="Текст промпта для выбранной личности автогенерации"
style={{ height: `${autogenPersonalityPromptHeight}px` }}
onMouseUp={captureAutogenPromptHeight}
onTouchEnd={captureAutogenPromptHeight}
/>
</label>
<label className="checkbox-row">
@ -1698,9 +1901,6 @@ export function AutoRunsHistoryPanel({
</button>
</div>
</div>
<p className="muted">
Если дата среза задана, автопрогон анализирует данные на эту дату. Если поле пустое, используется текущее состояние.
</p>
<div className="button-row">
<button type="button" disabled={autoGenBusy} onClick={() => void generateAutogenBatch()}>
@ -1711,7 +1911,7 @@ export function AutoRunsHistoryPanel({
</button>
<button
type="button"
className="tab"
className="autoruns-run-launch-btn"
disabled={autogenRunBusy || editableGeneratedQuestions.length === 0}
onClick={() => void runAutogenCampaign()}
>
@ -1764,7 +1964,7 @@ export function AutoRunsHistoryPanel({
title="Удалить вопрос из запуска"
aria-label="Удалить вопрос из запуска"
>
X
+
</button>
</div>
))}
@ -2071,6 +2271,10 @@ export function AutoRunsHistoryPanel({
busy={assistantLiveBusy}
statusText={assistantLiveStatus}
errorMessage={assistantLiveError}
showCommentAction
onCommentAssistantMessage={openAssistantLiveCommentModal}
isAssistantMessageCommented={isAssistantLiveMessageCommented}
canCommentAssistantMessage={canCommentAssistantLiveMessage}
/>
</div>
) : null}
@ -2359,6 +2563,89 @@ export function AutoRunsHistoryPanel({
) : null}
</div>
{assistantLiveCommentModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAssistantLiveCommentModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Комментарий к ответу ассистента</h3>
<p className="muted">Комментарий сохраняется отдельно от комментариев автопрогонов.</p>
{assistantLiveCommentModalQuestion ? (
<details className="autoruns-prompt-details" open>
<summary>Вопрос пользователя</summary>
<p className="autoruns-comment-quote">{assistantLiveCommentModalQuestion.text}</p>
</details>
) : null}
{assistantLiveCommentModalMessage ? (
<details className="autoruns-prompt-details" open>
<summary>Ответ ассистента</summary>
<p className="autoruns-comment-quote">{assistantLiveCommentModalMessage.text}</p>
</details>
) : null}
<div className="autoruns-rating-row" role="group" aria-label="Рейтинг ответа ассистента">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
className={assistantLiveCommentModal.rating >= value ? "autoruns-rating-dot active" : "autoruns-rating-dot"}
onClick={() => setAssistantLiveCommentModal((prev) => ({ ...prev, rating: value }))}
disabled={assistantLiveCommentModal.saving}
aria-label={`Оценка ${value}`}
>
{assistantLiveCommentModal.rating >= value ? "●" : "○"}
</button>
))}
</div>
<div className="autoruns-form-grid">
<label>
Автор комментария
<input
value={assistantLiveCommentModal.annotationAuthor}
onChange={(event) => setAssistantLiveCommentModal((prev) => ({ ...prev, annotationAuthor: event.target.value }))}
placeholder="manual_reviewer"
disabled={assistantLiveCommentModal.saving}
/>
</label>
</div>
<label>
Комментарий
<textarea
value={assistantLiveCommentModal.comment}
onChange={(event) => setAssistantLiveCommentModal((prev) => ({ ...prev, comment: event.target.value }))}
placeholder="Что именно не так в ответе и что нужно исправить."
rows={4}
disabled={assistantLiveCommentModal.saving}
/>
</label>
{assistantLiveCommentModal.error ? <p className="error-text">{assistantLiveCommentModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAssistantLiveCommentModal()} disabled={assistantLiveCommentModal.saving}>
{assistantLiveCommentModal.saving ? "Сохраняю..." : "Готово"}
</button>
<button
type="button"
className="tab"
onClick={() => closeAssistantLiveCommentModal()}
disabled={assistantLiveCommentModal.saving}
>
Отмена
</button>
</div>
</div>
</div>
) : null}
{commentModal.open ? (
<div
className="autoruns-comment-modal-backdrop"

View File

@ -495,3 +495,32 @@ export interface AssistantMessageResultState {
debug: AssistantDebugState;
conversation: AssistantConversationItem[];
}
export interface AssistantAnnotationRecord {
annotation_id: string;
session_id: string;
message_id: string;
message_index: number;
rating: number;
comment: string;
annotation_author: string | null;
created_at: string;
updated_at: string;
context: {
trace_id: string | null;
reply_type: string | null;
question_text: string | null;
answer_text: string | null;
};
technical_context: Record<string, unknown> | null;
}
export interface AssistantAnnotationsResponse {
ok: boolean;
generated_at: string;
filters_applied: {
session_id: string | null;
limit: number;
};
items: AssistantAnnotationRecord[];
}

View File

@ -363,6 +363,22 @@ textarea:focus {
textarea {
resize: vertical;
min-height: 86px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.assistant-input-textarea,
.autoruns-personality-prompt {
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable both-edges;
border-bottom-right-radius: 6px;
}
.assistant-input-textarea::-webkit-scrollbar-corner,
.autoruns-personality-prompt::-webkit-scrollbar-corner {
background: rgb(var(--rgb-surface-horizontal));
}
button {
@ -494,6 +510,7 @@ button:disabled {
.assistant-msg-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 0;
@ -501,6 +518,35 @@ button:disabled {
color: var(--text-muted);
}
.assistant-msg-head-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.assistant-msg-head-actions {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.assistant-comment-btn {
cursor: pointer;
}
.assistant-comment-btn:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.assistant-msg.user .assistant-comment-btn {
color: rgba(var(--rgb-active-text), 0.92);
}
.assistant-msg-body {
white-space: pre-wrap;
line-height: 1.35;
@ -539,6 +585,66 @@ button:disabled {
margin-left: auto;
}
.assistant-comments-frame .panel-body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.assistant-comments-shell {
display: grid;
gap: 8px;
min-height: 0;
height: 100%;
}
.assistant-comments-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.assistant-comments-list {
display: grid;
gap: 8px;
overflow: auto;
min-height: 0;
padding-right: 2px;
}
.assistant-comment-item {
border: none;
border-radius: 10px;
background: rgb(var(--rgb-surface-horizontal));
padding: 8px;
display: grid;
gap: 6px;
}
.assistant-comment-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 0.75rem;
}
.assistant-comment-item p {
margin: 0;
white-space: pre-wrap;
font-size: 0.8rem;
}
.assistant-comment-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--text-muted);
font-size: 0.74rem;
}
.app-root-autoruns .assistant-panel-frame .panel-header {
position: sticky;
top: -12px;
@ -733,7 +839,8 @@ button:disabled {
width: var(--mode-column-width);
height: 100%;
min-height: 0;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
border: none;
border-radius: 14px;
background: rgb(var(--rgb-surface-main));
@ -1063,10 +1170,10 @@ button:disabled {
background: transparent;
color: rgb(var(--rgb-text-main));
border-radius: 0;
min-width: 20px;
min-height: 20px;
width: 20px;
height: 20px;
min-width: 16px;
min-height: 16px;
width: 16px;
height: 16px;
padding: 0;
line-height: 1;
box-shadow: none;
@ -1074,24 +1181,37 @@ button:disabled {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0.92;
}
.autoruns-comment-icon:hover {
background: transparent;
color: rgb(var(--rgb-active));
color: rgb(var(--rgb-text-main));
opacity: 1;
box-shadow: none;
transform: none;
}
.autoruns-comment-icon.commented {
color: rgb(var(--rgb-active-text));
color: rgb(var(--rgb-active));
background: transparent;
box-shadow: none;
}
.autoruns-comment-icon:focus-visible {
outline: 1px solid rgba(var(--rgb-text-main), 0.7);
outline-offset: 1px;
}
.autoruns-comment-icon:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.comment-icon-svg {
width: 20px;
height: 20px;
width: 0.82rem;
height: 0.82rem;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
@ -1099,17 +1219,9 @@ button:disabled {
fill: none;
}
.comment-icon-svg .comment-icon-dot {
fill: currentColor;
}
.comment-icon-svg.commented {
fill: rgb(var(--rgb-active));
stroke: rgb(var(--rgb-active));
}
.autoruns-comment-icon.commented .comment-icon-dot {
fill: rgb(var(--rgb-active-text));
fill: none;
}
.autoruns-resolve-toggle {
@ -1224,6 +1336,21 @@ button:disabled {
font-size: 0.8rem;
}
.autoruns-run-launch-btn {
background: rgb(var(--rgb-active));
color: rgb(var(--rgb-active-text));
}
.autoruns-run-launch-btn:hover {
background: rgb(var(--rgb-surface-focus));
color: rgb(var(--rgb-text-main));
}
.autoruns-run-launch-btn:disabled {
background: rgba(var(--rgb-active), 0.38);
color: rgba(var(--rgb-active-text), 0.88);
}
.autoruns-generated-questions {
border: none;
border-radius: 10px;
@ -1249,31 +1376,58 @@ button:disabled {
}
.autoruns-generated-question-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
position: relative;
display: block;
gap: 8px;
border: none;
border-radius: 9px;
background: rgb(var(--rgb-surface-focus));
padding: 6px 8px;
padding: 7px 30px 7px 8px;
font-size: 0.78rem;
}
.autoruns-generated-question-item span {
display: block;
white-space: pre-wrap;
}
.autoruns-remove-question-btn {
position: absolute;
top: 6px;
right: 6px;
flex: 0 0 auto;
border: none;
border-radius: 7px;
background: rgb(var(--rgb-surface-horizontal));
color: var(--text-main);
min-width: 24px;
height: 24px;
font-size: 0.75rem;
border-radius: 0;
background: transparent;
color: rgb(var(--rgb-text-main));
min-width: 16px;
width: 16px;
height: 16px;
padding: 0;
font-size: 1rem;
font-weight: 700;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transform: rotate(45deg);
box-shadow: none;
transition: color 0.15s ease;
}
.autoruns-remove-question-btn:hover {
background: transparent;
color: rgb(var(--rgb-active-text));
box-shadow: none;
}
.autoruns-remove-question-btn:focus-visible {
outline: none;
}
.autoruns-personality-prompt {
resize: vertical;
min-height: 110px;
}
.autoruns-comment-item {