ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить selected-object follow-up по складу и включить разговорные/UI-варианты в обязательный domain-loop

This commit is contained in:
2026-04-14 14:18:38 +03:00
parent 9048632d3e
commit d41819eabd
67 changed files with 3800 additions and 195 deletions
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { AssistantConversationItem } from "../state/types";
import type { AssistantConversationItem, AssistantSelectionChip } from "../state/types";
import { buildConversationExportForCopy } from "../utils/conversationExport";
import { JsonView } from "./JsonView";
import { PanelFrame } from "./PanelFrame";
@@ -9,6 +9,9 @@ interface AssistantPanelProps {
conversation: AssistantConversationItem[];
inputValue: string;
onInputChange: (value: string) => void;
selectedContextChip: AssistantSelectionChip | null;
onSelectContextChip: (value: AssistantSelectionChip) => void;
onClearContextChip: () => void;
useMock: boolean;
onUseMockChange: (value: boolean) => void;
onSend: () => Promise<void> | void;
@@ -147,17 +150,134 @@ function lineClassName(line: string): string {
return "assistant-msg-line";
}
function renderAssistantMessageBody(text: string): JSX.Element[] {
const blocks = splitAssistantMessageBlocks(text);
function buildSelectionPreview(label: string, maxChars = 40): string {
const normalized = label.replace(/\s+/g, " ").trim();
if (normalized.length <= maxChars) {
return normalized;
}
const firstWords = normalized.split(" ").slice(0, 3).join(" ").trim();
if (firstWords.length >= 10 && firstWords.length <= maxChars) {
return `${firstWords}…`;
}
return `${normalized.slice(0, maxChars - 1).trimEnd()}…`;
}
function stripBlockSelectionPrefix(line: string): string {
return line.replace(/\*\*(.+?)\*\*/g, "$1").replace(/^\d+\.\s*/, "").trim();
}
function extractSelectionAnchorText(block: string): string {
const firstLine = block
.replace(/\r\n?/g, "\n")
.split("\n")
.map((line) => line.trim())
.find(Boolean);
const normalizedFirstLine = stripBlockSelectionPrefix(firstLine ?? "");
const primarySegment = normalizedFirstLine.split("|")[0]?.trim() ?? normalizedFirstLine;
return primarySegment.replace(/\s+/g, " ").trim();
}
function isSelectableEntityBlock(block: string): boolean {
const firstLine = block
.replace(/\r\n?/g, "\n")
.split("\n")
.map((line) => line.trim())
.find(Boolean);
if (!firstLine || !/^\d+\.\s/.test(firstLine)) {
return false;
}
const normalizedFirstLine = stripBlockSelectionPrefix(firstLine);
return normalizedFirstLine.includes("|");
}
function buildSelectionChipForBlock(item: AssistantConversationItem, block: string): AssistantSelectionChip | null {
const normalizedBlock = block
.replace(/\r\n?/g, "\n")
.replace(/\*\*(.+?)\*\*/g, "$1")
.split("\n")
.map((line, index) => {
const trimmed = line.trim();
if (index === 0) {
return trimmed.replace(/^\d+\.\s*/, "");
}
return trimmed;
})
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.trim();
if (!normalizedBlock) {
return null;
}
const anchorText = extractSelectionAnchorText(block) || normalizedBlock;
return {
message_id: item.message_id,
source_text: normalizedBlock,
anchor_text: anchorText,
preview_text: buildSelectionPreview(anchorText)
};
}
function renderAssistantMessageBody(
item: AssistantConversationItem,
selectedContextChip: AssistantSelectionChip | null,
onSelectContextChip: (value: AssistantSelectionChip) => void,
onClearContextChip: () => void
): JSX.Element[] {
const blocks = splitAssistantMessageBlocks(item.text);
return blocks.map((block, blockIndex) => {
const lines = block.split("\n");
const isSelectable = item.role === "assistant" && isSelectableEntityBlock(block);
const selectionChip = isSelectable ? buildSelectionChipForBlock(item, block) : null;
const selected =
Boolean(selectionChip) &&
selectedContextChip?.message_id === selectionChip?.message_id &&
selectedContextChip?.source_text === selectionChip?.source_text;
const body = lines.map((line, lineIndex) => (
<p key={`line-${blockIndex}-${lineIndex}`} className={lineClassName(line)}>
{renderInlineBold(line, `line-${blockIndex}-${lineIndex}`)}
</p>
));
if (!isSelectable || !selectionChip) {
return (
<div key={`block-${blockIndex}`} className="assistant-msg-block">
{body}
</div>
);
}
return (
<div key={`block-${blockIndex}`} className="assistant-msg-block">
{lines.map((line, lineIndex) => (
<p key={`line-${blockIndex}-${lineIndex}`} className={lineClassName(line)}>
{renderInlineBold(line, `line-${blockIndex}-${lineIndex}`)}
</p>
))}
<div
key={`block-${blockIndex}`}
className={selected ? "assistant-msg-block selectable active" : "assistant-msg-block selectable"}
role="button"
tabIndex={0}
onClick={() => {
if (selected) {
onClearContextChip();
return;
}
onSelectContextChip(selectionChip);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
if (selected) {
onClearContextChip();
return;
}
onSelectContextChip(selectionChip);
}}
>
{body}
</div>
);
});
@@ -168,6 +288,9 @@ export function AssistantPanel({
conversation,
inputValue,
onInputChange,
selectedContextChip,
onSelectContextChip,
onClearContextChip,
useMock,
onUseMockChange,
onSend,
@@ -316,7 +439,9 @@ export function AssistantPanel({
</div>
) : null}
</header>
<div className="assistant-msg-body">{renderAssistantMessageBody(item.text)}</div>
<div className="assistant-msg-body">
{renderAssistantMessageBody(item, selectedContextChip, onSelectContextChip, onClearContextChip)}
</div>
{item.role === "assistant" && item.debug ? (
<details className="assistant-debug">
<summary>Показать технический разбор</summary>
@@ -329,6 +454,23 @@ export function AssistantPanel({
</div>
<div className="assistant-compose">
{selectedContextChip ? (
<div className="assistant-compose-context">
<span className="assistant-compose-context-label">Выбранный объект</span>
<div className="assistant-compose-context-pill" title={selectedContextChip.source_text}>
<span className="assistant-compose-context-pill-text">{selectedContextChip.preview_text}</span>
<button
type="button"
className="assistant-compose-context-clear"
onClick={onClearContextChip}
aria-label="Убрать выбранный объект"
title="Убрать выбранный объект"
>
×
</button>
</div>
</div>
) : null}
<label className="full-width">
Сообщение
<textarea
@@ -336,7 +478,11 @@ export function AssistantPanel({
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
rows={4}
placeholder="Введите вопрос к данным компании..."
placeholder={
selectedContextChip
? "Продолжите вопрос по выбранному объекту..."
: "Введите вопрос к данным компании..."
}
/>
</label>
<div className="button-row assistant-send-row">
@@ -3,6 +3,7 @@ import { apiClient } from "../api/client";
import type {
AssistantConversationItem,
AssistantAnnotationRecord,
AssistantSelectionChip,
AsyncEvalRunJob,
AutoGenHistoryRecord,
AutoGenMode,
@@ -106,6 +107,25 @@ const AUTORUNS_UI_CONFIG_KEY = "ndc_autoruns_ui_config_v1";
const AUTORUNS_SAVE_EVENT = "ndc-autoruns-save";
const ASSISTANT_STAGES = ["Анализ запроса", "Получение данных", "Подготовка ответа"];
function buildAssistantLiveFollowupMessage(inputValue: string, selectedChip: AssistantSelectionChip | null): string {
const trimmedInput = inputValue.trim();
if (!trimmedInput) {
return "";
}
if (!selectedChip) {
return trimmedInput;
}
const normalizedInput = trimmedInput.toLowerCase();
const selectionAnchor = selectedChip.anchor_text.trim();
const normalizedSelection = selectionAnchor.toLowerCase();
if (normalizedSelection && normalizedInput.includes(normalizedSelection)) {
return trimmedInput;
}
return `По выбранному объекту "${selectionAnchor}": ${trimmedInput}`;
}
const AUTOGEN_PERSONALITIES: AutoGenPersonalityDefinition[] = [
{
id: "general",
@@ -496,6 +516,7 @@ export function AutoRunsHistoryPanel({
const [assistantLiveConversation, setAssistantLiveConversation] = useState<AssistantConversationItem[]>([]);
const [assistantLiveAnnotations, setAssistantLiveAnnotations] = useState<AssistantAnnotationRecord[]>([]);
const [assistantLiveInput, setAssistantLiveInput] = useState("");
const [assistantLiveSelectedChip, setAssistantLiveSelectedChip] = useState<AssistantSelectionChip | null>(null);
const [assistantLiveUseMock, setAssistantLiveUseMock] = useState(false);
const [assistantLiveBusy, setAssistantLiveBusy] = useState(false);
const [assistantLiveStatus, setAssistantLiveStatus] = useState("");
@@ -704,6 +725,7 @@ export function AutoRunsHistoryPanel({
setAssistantLiveConversation([]);
setAssistantLiveAnnotations([]);
setAssistantLiveInput("");
setAssistantLiveSelectedChip(null);
setAssistantLiveStatus("");
setAssistantLiveError("");
closeAssistantLiveCommentModal({ force: true });
@@ -711,7 +733,7 @@ export function AutoRunsHistoryPanel({
}, [closeAssistantLiveCommentModal, log]);
const sendAssistantLiveMessage = useCallback(async () => {
const userMessage = assistantLiveInput.trim();
const userMessage = buildAssistantLiveFollowupMessage(assistantLiveInput, assistantLiveSelectedChip);
if (!userMessage) {
return;
}
@@ -759,6 +781,7 @@ export function AutoRunsHistoryPanel({
}
}, [
assistantLiveInput,
assistantLiveSelectedChip,
assistantLiveSessionId,
assistantLiveUseMock,
assistantPromptVersion,
@@ -2302,6 +2325,9 @@ export function AutoRunsHistoryPanel({
conversation={assistantLiveConversation}
inputValue={assistantLiveInput}
onInputChange={setAssistantLiveInput}
selectedContextChip={assistantLiveSelectedChip}
onSelectContextChip={setAssistantLiveSelectedChip}
onClearContextChip={() => setAssistantLiveSelectedChip(null)}
useMock={assistantLiveUseMock}
onUseMockChange={setAssistantLiveUseMock}
onSend={sendAssistantLiveMessage}