diff --git a/Frontend/app.vue b/Frontend/app.vue index 7c84fd9..b551a27 100644 --- a/Frontend/app.vue +++ b/Frontend/app.vue @@ -190,7 +190,6 @@ type PilotMessage = { at: string; }> | null; createdAt?: string; - pending?: boolean; }; type ChatConversation = { @@ -222,23 +221,6 @@ const loginPassword = ref(""); const loginError = ref(null); const loginBusy = ref(false); -const pilotTimeline = computed(() => { - if (!pilotSending.value) return pilotMessages.value; - return [ - ...pilotMessages.value, - { - id: "__pilot_pending__", - role: "assistant", - text: "Working on it...", - thinking: ["Reading your request", "Planning the next actions", "Preparing the final answer"], - tools: [], - toolRuns: [], - createdAt: new Date().toISOString(), - pending: true, - }, - ]; -}); - const activeChatConversation = computed(() => { const activeId = authMe.value?.conversation.id; if (!activeId) return null; @@ -414,12 +396,17 @@ async function sendPilotMessage() { pilotSending.value = true; pilotInput.value = ""; + await loadPilotMessages().catch(() => {}); + const pollId = setInterval(() => { + loadPilotMessages().catch(() => {}); + }, 450); try { await gqlFetch<{ sendPilotMessage: { ok: boolean } }>(sendPilotMessageMutation, { text }); await Promise.all([loadPilotMessages(), loadChatConversations()]); } catch { pilotInput.value = text; } finally { + clearInterval(pollId); pilotSending.value = false; } } @@ -1258,13 +1245,12 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {{ thread.title }} -

Loading threads...

@@ -1276,7 +1262,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
{{ pilotRoleName(message.role) }} {{ formatPilotStamp(message.createdAt) }} - Live
@@ -1284,11 +1269,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
+
+

Plan

+
    +
  1. {{ step }}
  2. +
+
+
-

Thinking

+

Trace

  1. {{ step }}
@@ -1317,6 +1309,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
+
@@ -1328,7 +1321,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") @keyup.enter="sendPilotMessage" >
@@ -2316,18 +2309,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") color: rgba(255, 255, 255, 0.45); } -.pilot-live-dot { - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: 700; - color: #c6ceff; - border-radius: 999px; - border: 1px solid rgba(124, 144, 255, 0.55); - padding: 1px 7px; -} - .pilot-message-text { margin-top: 2px; font-size: 13px; diff --git a/Frontend/prisma/seed.mjs b/Frontend/prisma/seed.mjs index d94544f..ab93868 100644 --- a/Frontend/prisma/seed.mjs +++ b/Frontend/prisma/seed.mjs @@ -360,30 +360,6 @@ async function main() { ], }); - await prisma.chatMessage.createMany({ - data: [ - { - teamId: team.id, - conversationId: conversation.id, - authorUserId: null, - role: "ASSISTANT", - text: "Workspace is ready. I connected contacts, communications, calendar, deals, and recommendations.", - planJson: { - steps: ["Open any contact", "Review chat + pinned tasks", "Confirm next event or message"], - tools: ["contacts", "communications", "calendar", "feed"], - }, - }, - { - teamId: team.id, - conversationId: conversation.id, - authorUserId: null, - role: "ASSISTANT", - text: "Dataset loaded with 220 contacts and linked timeline activity.", - planJson: { steps: ["Filter by country/company", "Open active threads", "Apply one recommendation"], tools: ["search", "pins"] }, - }, - ], - }); - console.log("Seed completed."); console.log(`Login phone: ${LOGIN_PHONE}`); console.log(`Login password: ${LOGIN_PASSWORD}`); diff --git a/Frontend/server/agent/crmAgent.ts b/Frontend/server/agent/crmAgent.ts index 38e44a1..399b285 100644 --- a/Frontend/server/agent/crmAgent.ts +++ b/Frontend/server/agent/crmAgent.ts @@ -30,6 +30,17 @@ export type AgentReply = { dbWrites?: Array<{ kind: string; detail: string }>; }; +export type AgentTraceEvent = { + text: string; + toolRun?: { + name: string; + status: "ok" | "error"; + input: string; + output: string; + at: string; + }; +}; + function normalize(s: string) { return s.trim().toLowerCase(); } @@ -81,7 +92,12 @@ export async function runCrmAgent(userText: string): Promise { } export async function runCrmAgentFor( - input: { teamId: string; userId: string; userText: string }, + input: { + teamId: string; + userId: string; + userText: string; + onTrace?: (event: AgentTraceEvent) => Promise | void; + }, ): Promise { const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase(); const llmApiKey = diff --git a/Frontend/server/agent/langgraphCrmAgent.ts b/Frontend/server/agent/langgraphCrmAgent.ts index 3cca67b..1827f05 100644 --- a/Frontend/server/agent/langgraphCrmAgent.ts +++ b/Frontend/server/agent/langgraphCrmAgent.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { AgentReply } from "./crmAgent"; +import type { AgentReply, AgentTraceEvent } from "./crmAgent"; import { prisma } from "../utils/prisma"; import { ensureDataset } from "../dataset/exporter"; import { createReactAgent } from "@langchain/langgraph/prebuilt"; @@ -322,6 +322,7 @@ export async function runLangGraphCrmAgentFor(input: { teamId: string; userId: string; userText: string; + onTrace?: (event: AgentTraceEvent) => Promise | void; }): Promise { const genericApiKey = process.env.LLM_API_KEY || @@ -388,6 +389,15 @@ export async function runLangGraphCrmAgentFor(input: { const toolRuns: NonNullable = []; const pendingChanges: PendingChange[] = []; + async function emitTrace(event: AgentTraceEvent) { + if (!input.onTrace) return; + try { + await input.onTrace(event); + } catch { + // Trace transport errors must not break the agent response. + } + } + function compact(value: unknown, max = 240) { const text = typeof value === "string" ? value : JSON.stringify(value); if (!text) return ""; @@ -510,6 +520,7 @@ export async function runLangGraphCrmAgentFor(input: { const toolName = `crm:${raw.action}`; const startedAt = new Date().toISOString(); toolsUsed.push(toolName); + await emitTrace({ text: `Tool started: ${toolName}` }); const executeAction = async () => { if (raw.action === "get_snapshot") { @@ -753,21 +764,31 @@ export async function runLangGraphCrmAgentFor(input: { try { const result = await executeAction(); - toolRuns.push({ + const run = { name: toolName, status: "ok", input: compact(raw), output: compact(result), at: startedAt, + } as const; + toolRuns.push(run); + await emitTrace({ + text: `Tool finished: ${toolName}`, + toolRun: run, }); return result; } catch (error: any) { - toolRuns.push({ + const run = { name: toolName, status: "error", input: compact(raw), output: compact(error?.message || String(error)), at: startedAt, + } as const; + toolRuns.push(run); + await emitTrace({ + text: `Tool failed: ${toolName}`, + toolRun: run, }); throw error; } @@ -796,14 +817,19 @@ export async function runLangGraphCrmAgentFor(input: { : {}), }); - const agent = createReactAgent({ - llm: model, - tools: [crmTool], - responseFormat: z.object({ - answer: z.string().describe("Final assistant answer for the user."), - plan: z.array(z.string()).min(1).max(10).describe("Short plan (3-8 steps)."), - }), - }); + const agent = useGigachat + ? createReactAgent({ + llm: model, + tools: [crmTool], + }) + : createReactAgent({ + llm: model, + tools: [crmTool], + responseFormat: z.object({ + answer: z.string().describe("Final assistant answer for the user."), + plan: z.array(z.string()).min(1).max(10).describe("Short plan (3-8 steps)."), + }), + }); const system = [ "You are Pilot, a CRM assistant.", @@ -831,13 +857,34 @@ export async function runLangGraphCrmAgentFor(input: { ); const structured = res?.structuredResponse as { answer?: string; plan?: string[] } | undefined; - const text = structured?.answer?.trim() || "Готово."; - const plan = Array.isArray(structured?.plan) ? structured!.plan : ["Собрать данные", "Сформировать ответ"]; + const fallbackText = (() => { + const messages = Array.isArray(res?.messages) ? res.messages : []; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const msg = messages[i]; + const type = String(msg?.type ?? "").toLowerCase(); + if (type !== "ai") continue; + const content = msg?.content; + if (typeof content === "string" && content.trim()) return content.trim(); + if (Array.isArray(content)) { + const text = content + .map((part: any) => (typeof part?.text === "string" ? part.text : "")) + .filter(Boolean) + .join("\n") + .trim(); + if (text) return text; + } + } + return ""; + })(); + const text = structured?.answer?.trim() || fallbackText || "Готово."; + const plan = Array.isArray(structured?.plan) && structured.plan.length + ? structured.plan + : ["Собрать данные", "Сформировать ответ"]; return { text, plan, - thinking: plan, + thinking: [], tools: toolsUsed, toolRuns, dbWrites: dbWrites.length ? dbWrites : undefined, diff --git a/Frontend/server/graphql/schema.ts b/Frontend/server/graphql/schema.ts index 488f038..d56c277 100644 --- a/Frontend/server/graphql/schema.ts +++ b/Frontend/server/graphql/schema.ts @@ -5,6 +5,7 @@ import { clearAuthSession, setSession } from "../utils/auth"; import { prisma } from "../utils/prisma"; import { normalizePhone, verifyPassword } from "../utils/password"; import { persistChatMessage, runCrmAgentFor } from "../agent/crmAgent"; +import type { AgentTraceEvent } from "../agent/crmAgent"; type GraphQLContext = { auth: AuthContext | null; @@ -222,7 +223,7 @@ async function getChatMessages(auth: AuthContext | null) { role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system", text: m.text, plan: Array.isArray(debug.steps) ? (debug.steps as string[]) : [], - thinking: Array.isArray(debug.thinking) ? (debug.thinking as string[]) : Array.isArray(debug.steps) ? (debug.steps as string[]) : [], + thinking: Array.isArray(debug.thinking) ? (debug.thinking as string[]) : [], tools: Array.isArray(debug.tools) ? (debug.tools as string[]) : [], toolRuns: Array.isArray(debug.toolRuns) ? (debug.toolRuns as any[]) @@ -510,7 +511,24 @@ async function sendPilotMessage(auth: AuthContext | null, textInput: string) { text, }); - const reply = await runCrmAgentFor({ teamId: ctx.teamId, userId: ctx.userId, userText: text }); + const reply = await runCrmAgentFor({ + teamId: ctx.teamId, + userId: ctx.userId, + userText: text, + onTrace: async (event: AgentTraceEvent) => { + await persistChatMessage({ + teamId: ctx.teamId, + conversationId: ctx.conversationId, + authorUserId: null, + role: "SYSTEM", + text: event.text, + plan: [], + thinking: [], + tools: event.toolRun ? [event.toolRun.name] : [], + toolRuns: event.toolRun ? [event.toolRun] : [], + }); + }, + }); await persistChatMessage({ teamId: ctx.teamId, conversationId: ctx.conversationId, @@ -518,7 +536,7 @@ async function sendPilotMessage(auth: AuthContext | null, textInput: string) { role: "ASSISTANT", text: reply.text, plan: reply.plan, - thinking: reply.thinking ?? reply.plan, + thinking: reply.thinking ?? [], tools: reply.tools, toolRuns: reply.toolRuns ?? [], }); diff --git a/compose.yaml b/compose.yaml index 8cc1128..9ff7202 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,9 +12,10 @@ services: REDIS_URL: "redis://redis:6379" CF_AGENT_MODE: "langgraph" OPENAI_MODEL: "gpt-4o-mini" - GIGACHAT_AUTH_KEY: "${GIGACHAT_AUTH_KEY:-}" - GIGACHAT_MODEL: "${GIGACHAT_MODEL:-GigaChat-2-Max}" - GIGACHAT_SCOPE: "${GIGACHAT_SCOPE:-GIGACHAT_API_PERS}" + GIGACHAT_AUTH_KEY: "MDE5YzQwNmQtMDM0NC03MTVlLTg4MTAtOWZlYjlmNzQwY2E3OmNhZTg5NmM1LWZiOGEtNGZkZS04ODA0LWZkYjYyYzVlMTI0OQ==" + GIGACHAT_MODEL: "GigaChat-2" + GIGACHAT_SCOPE: "GIGACHAT_API_PERS" + NODE_TLS_REJECT_UNAUTHORIZED: "0" # Set this in your shell or a compose override: # OPENAI_API_KEY: "..." command: >