ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить 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
+30 -1
View File
@@ -16,6 +16,7 @@ import { designConfig } from "../../../designconfig";
import type {
AssistantConversationItem,
AssistantAnnotationRecord,
AssistantSelectionChip,
ConnectionState,
HistoryItem,
NormalizeResultState,
@@ -60,6 +61,25 @@ function diffPrompts(current: PromptState, previous: PromptState | null): string
return `Changed fields: ${changed.length}. ${changed.join(" | ")}`;
}
function buildAssistantFollowupMessage(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}`;
}
export default function App() {
const [connection, setConnection] = useState<ConnectionState>(DEFAULT_CONNECTION);
const [prompts, setPrompts] = useState<PromptState>(DEFAULT_PROMPTS);
@@ -116,6 +136,7 @@ export default function App() {
const [assistantSessionId, setAssistantSessionId] = useState("");
const [assistantConversation, setAssistantConversation] = useState<AssistantConversationItem[]>([]);
const [assistantInput, setAssistantInput] = useState("");
const [assistantSelectedChip, setAssistantSelectedChip] = useState<AssistantSelectionChip | null>(null);
const [assistantBusy, setAssistantBusy] = useState(false);
const [assistantStatus, setAssistantStatus] = useState("");
const [assistantError, setAssistantError] = useState("");
@@ -140,6 +161,10 @@ export default function App() {
root.style.setProperty("--rgb-surface-main", colors.mainSurfaceRgb);
root.style.setProperty("--rgb-surface-horizontal", colors.horizontalSurfaceRgb);
root.style.setProperty("--rgb-surface-focus", colors.focusSurfaceRgb);
root.style.setProperty("--rgb-assistant-chip", colors.assistantChipRgb);
root.style.setProperty("--rgb-assistant-chip-hover", colors.assistantChipHoverRgb);
root.style.setProperty("--rgb-assistant-chip-selected", colors.assistantChipSelectedRgb);
root.style.setProperty("--rgb-assistant-chip-selected-text", colors.assistantChipSelectedTextRgb);
root.style.setProperty("--rgb-active", colors.activeRgb);
root.style.setProperty("--rgb-active-text", colors.activeTextRgb);
root.style.setProperty("--rgb-text-main", colors.textMainRgb);
@@ -785,6 +810,7 @@ export default function App() {
setAssistantSessionId("");
setAssistantConversation([]);
setAssistantInput("");
setAssistantSelectedChip(null);
setAssistantStatus("");
setAssistantError("");
setAssistantAnnotations([]);
@@ -793,7 +819,7 @@ export default function App() {
}
async function sendAssistantMessage() {
const userMessage = assistantInput.trim();
const userMessage = buildAssistantFollowupMessage(assistantInput, assistantSelectedChip);
if (!userMessage) {
return;
}
@@ -1035,6 +1061,9 @@ export default function App() {
conversation={assistantConversation}
inputValue={assistantInput}
onInputChange={setAssistantInput}
selectedContextChip={assistantSelectedChip}
onSelectContextChip={setAssistantSelectedChip}
onClearContextChip={() => setAssistantSelectedChip(null)}
useMock={useMock}
onUseMockChange={setUseMock}
onSend={sendAssistantMessage}
@@ -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}
@@ -486,6 +486,13 @@ export interface AssistantConversationItem {
debug: AssistantDebugState | null;
}
export interface AssistantSelectionChip {
message_id: string;
source_text: string;
anchor_text: string;
preview_text: string;
}
export interface AssistantMessageResultState {
ok: boolean;
session_id: string;
+93
View File
@@ -5,6 +5,10 @@
--rgb-surface-main: 26, 26, 31;
--rgb-surface-horizontal: 32, 32, 38;
--rgb-surface-focus: 40, 40, 47;
--rgb-assistant-chip: 18, 18, 18;
--rgb-assistant-chip-hover: 44, 44, 44;
--rgb-assistant-chip-selected: 228, 142, 92;
--rgb-assistant-chip-selected-text: 18, 18, 18;
--rgb-active: 228, 142, 92;
--rgb-active-text: 18, 18, 18;
--rgb-text-main: 240, 240, 240;
@@ -486,6 +490,7 @@ button:disabled {
padding: 8px 10px;
display: grid;
gap: 6px;
min-width: 0;
}
.assistant-msg.user {
@@ -552,17 +557,48 @@ button:disabled {
gap: 10px;
line-height: 1.35;
font-size: 0.84rem;
min-width: 0;
}
.assistant-msg-block {
display: grid;
gap: 4px;
min-width: 0;
}
.assistant-msg-block.selectable {
cursor: pointer;
padding: 8px 10px;
border-radius: 12px;
transition: background 0.18s ease, color 0.18s ease;
}
.assistant-msg-block.selectable:hover,
.assistant-msg-block.selectable:focus-visible {
background: rgba(var(--rgb-active), 0.18);
outline: none;
}
.assistant-msg-block.selectable.active {
background: rgb(var(--rgb-active));
color: rgb(var(--rgb-active-text));
}
.assistant-msg-block.selectable.active:hover,
.assistant-msg-block.selectable.active:focus-visible {
background: rgb(var(--rgb-active));
}
.assistant-msg-block.selectable.active .assistant-msg-line,
.assistant-msg-block.selectable.active .assistant-msg-line strong {
color: rgb(var(--rgb-active-text));
}
.assistant-msg-line {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
.assistant-msg-line.heading {
@@ -601,6 +637,63 @@ button:disabled {
flex: 0 0 auto;
}
.assistant-compose-context {
display: grid;
gap: 6px;
padding: 10px 12px;
border-radius: var(--radius-md);
background: rgb(var(--rgb-surface-horizontal));
}
.assistant-compose-context-label {
color: var(--text-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.assistant-compose-context-pill {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
width: fit-content;
min-height: 38px;
padding: 0 10px 0 12px;
border-radius: 999px;
background: rgb(var(--rgb-assistant-chip-selected));
color: rgb(var(--rgb-assistant-chip-selected-text));
}
.assistant-compose-context-pill-text {
min-width: 0;
max-width: min(100%, 460px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.82rem;
font-weight: 700;
}
.assistant-compose-context-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
min-width: 24px;
padding: 0;
border-radius: 999px;
background: rgba(0, 0, 0, 0.18);
color: inherit;
font-size: 0.96rem;
line-height: 1;
}
.assistant-compose-context-clear:hover {
background: rgba(0, 0, 0, 0.28);
}
.assistant-send-row {
align-items: center;
margin-top: 2px;