Реализация AI Copilot для пользователей веб-приложения
AI Copilot — это встроенный в интерфейс приложения интеллектуальный помощник, который работает в контексте текущего экрана, данных пользователя и его истории действий. Это не чат-бот в углу страницы и не всплывающее меню с подсказками — это компонент, который понимает, что пользователь делает прямо сейчас, и предлагает конкретные следующие шаги.
Разница между «добавить ChatGPT на сайт» и «реализовать AI Copilot» принципиальная. Первое — это обёртка над API с полем ввода. Второе требует проектирования контекстного протокола, архитектуры инструментов (tool calls), управления историей сессии и интеграции с доменной логикой приложения.
Архитектура: что внутри Copilot
Copilot состоит из нескольких слоёв:
Context layer — сбор и форматирование контекста перед каждым запросом к модели. Включает текущий маршрут, состояние UI, релевантные данные пользователя, последние действия.
Tool layer — набор функций, которые модель может вызывать: поиск по данным, выполнение операций в приложении, получение справочной информации.
Conversation layer — управление историей диалога, сжатие длинных сессий, разграничение контекстов между разными задачами.
UI layer — виджет, который интегрируется в существующий интерфейс без разрушения UX.
Реализация: стек и подход
Бэкенд строится на Node.js или Python. Модель — GPT-4o или Claude 3.5 Sonnet в зависимости от задачи. Коммуникация через Server-Sent Events для стриминга ответов.
// copilot/context-builder.ts
interface CopilotContext {
route: string;
pageData: Record<string, unknown>;
userProfile: {
id: string;
role: string;
recentActions: Action[];
};
selectedEntities?: Entity[];
}
export function buildContext(req: Request): CopilotContext {
return {
route: req.path,
pageData: req.body.pageData ?? {},
userProfile: {
id: req.user.id,
role: req.user.role,
recentActions: getRecentActions(req.user.id, 10),
},
selectedEntities: req.body.selectedEntities,
};
}
Tool calls — ключевая часть. Модель не просто отвечает текстом, она вызывает функции приложения:
// copilot/tools.ts
const tools: ChatCompletionTool[] = [
{
type: "function",
function: {
name: "search_records",
description: "Search records in the current module by query",
parameters: {
type: "object",
properties: {
query: { type: "string" },
module: { type: "string", enum: ["orders", "customers", "products"] },
limit: { type: "number", default: 10 },
},
required: ["query", "module"],
},
},
},
{
type: "function",
function: {
name: "create_record",
description: "Create a new record in the specified module",
parameters: {
type: "object",
properties: {
module: { type: "string" },
data: { type: "object" },
},
required: ["module", "data"],
},
},
},
{
type: "function",
function: {
name: "get_analytics",
description: "Get aggregated analytics for a date range",
parameters: {
type: "object",
properties: {
metric: { type: "string" },
from: { type: "string", format: "date" },
to: { type: "string", format: "date" },
},
required: ["metric", "from", "to"],
},
},
},
];
Обработчик запроса с поддержкой многошаговых вызовов инструментов:
// copilot/handler.ts
export async function handleCopilotRequest(
messages: Message[],
context: CopilotContext,
res: Response
) {
const systemPrompt = buildSystemPrompt(context);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
let currentMessages = [
{ role: "system", content: systemPrompt },
...messages,
];
// Цикл для multi-step tool calls
while (true) {
const stream = await openai.chat.completions.create({
model: "gpt-4o",
messages: currentMessages,
tools,
stream: true,
});
let toolCallAccumulator: ToolCall[] = [];
let textBuffer = "";
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (delta?.content) {
textBuffer += delta.content;
res.write(`data: ${JSON.stringify({ type: "text", content: delta.content })}\n\n`);
}
if (delta?.tool_calls) {
// Аккумулируем tool calls из стрима
mergeToolCallChunks(toolCallAccumulator, delta.tool_calls);
}
if (chunk.choices[0]?.finish_reason === "tool_calls") {
// Выполняем все запрошенные инструменты
const toolResults = await executeToolCalls(toolCallAccumulator, context);
currentMessages = [
...currentMessages,
{ role: "assistant", tool_calls: toolCallAccumulator },
...toolResults.map((r) => ({
role: "tool" as const,
tool_call_id: r.id,
content: JSON.stringify(r.result),
})),
];
// Сообщаем фронтенду о выполненных действиях
res.write(`data: ${JSON.stringify({ type: "tool_result", results: toolResults })}\n\n`);
break; // Продолжаем цикл
}
if (chunk.choices[0]?.finish_reason === "stop") {
res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
res.end();
return;
}
}
}
}
Системный промпт: контекстная привязка
Промпт не статический. Он собирается под каждый запрос и включает состояние приложения:
function buildSystemPrompt(context: CopilotContext): string {
return `
You are a Copilot assistant embedded in a business application. You help users work with their data efficiently.
Current context:
- Page: ${context.route}
- User role: ${context.userProfile.role}
- Recent actions: ${context.userProfile.recentActions.map(a => a.description).join(", ")}
${context.selectedEntities?.length ? `- Selected items: ${JSON.stringify(context.selectedEntities)}` : ""}
Page data summary:
${JSON.stringify(context.pageData, null, 2).slice(0, 2000)}
Rules:
- Be concise and action-oriented
- When you can take action via tools, prefer doing so over just describing how
- Confirm before destructive operations
- If you need more context, ask one targeted question
- Respond in the language the user writes in
`.trim();
}
Фронтенд: виджет Copilot
React-компонент, который живёт поверх основного интерфейса:
// components/Copilot/CopilotPanel.tsx
import { useState, useRef, useEffect } from "react";
import { useCopilotContext } from "./CopilotContext";
export function CopilotPanel() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const context = useCopilotContext();
const abortRef = useRef<AbortController | null>(null);
async function sendMessage() {
if (!input.trim() || isStreaming) return;
const userMessage = { role: "user" as const, content: input };
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput("");
setIsStreaming(true);
abortRef.current = new AbortController();
const response = await fetch("/api/copilot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: newMessages,
pageData: context.pageData,
selectedEntities: context.selectedEntities,
}),
signal: abortRef.current.signal,
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let assistantContent = "";
// Добавляем пустое сообщение ассистента для стриминга
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split("\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const event = JSON.parse(line.slice(6));
if (event.type === "text") {
assistantContent += event.content;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
role: "assistant",
content: assistantContent,
};
return updated;
});
}
if (event.type === "tool_result") {
// Показываем, какие действия были выполнены
context.onToolsExecuted?.(event.results);
}
}
}
setIsStreaming(false);
}
return (
<aside className="copilot-panel">
<div className="copilot-messages">
{messages.map((msg, i) => (
<MessageBubble key={i} message={msg} />
))}
</div>
<div className="copilot-input">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && sendMessage()}
placeholder="Спросите что-нибудь или попросите выполнить действие..."
/>
<button onClick={sendMessage} disabled={isStreaming}>
{isStreaming ? "Остановить" : "Отправить"}
</button>
</div>
</aside>
);
}
Контекстный провайдер отслеживает состояние страницы и передаёт его в Copilot:
// components/Copilot/CopilotContext.tsx
export function CopilotProvider({ children }: { children: React.ReactNode }) {
const location = useLocation();
const [pageData, setPageData] = useState({});
const [selectedEntities, setSelectedEntities] = useState<Entity[]>([]);
// Страницы регистрируют свой контекст через хук
const registerContext = useCallback((data: Record<string, unknown>) => {
setPageData(data);
}, []);
return (
<CopilotContext.Provider
value={{ pageData, selectedEntities, setSelectedEntities, registerContext }}
>
{children}
</CopilotContext.Provider>
);
}
// Хук для страниц
export function useCopilotRegister(data: Record<string, unknown>) {
const { registerContext } = useCopilotContext();
useEffect(() => {
registerContext(data);
}, [JSON.stringify(data)]);
}
Управление историей сессии
Длинные диалоги упираются в лимит контекстного окна. Решение — скользящее окно с суммаризацией:
async function compressHistory(messages: Message[]): Promise<Message[]> {
if (messages.length <= 20) return messages;
// Оставляем последние 10 сообщений как есть
const recent = messages.slice(-10);
const older = messages.slice(0, -10);
// Суммаризируем старую часть
const summary = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "user",
content: `Summarize this conversation history concisely, preserving key facts and decisions:\n\n${older.map((m) => `${m.role}: ${m.content}`).join("\n")}`,
},
],
});
return [
{
role: "system",
content: `Previous conversation summary: ${summary.choices[0].message.content}`,
},
...recent,
];
}
Авторизация инструментов
Инструменты должны выполняться с правами пользователя, а не системными:
async function executeToolCalls(
toolCalls: ToolCall[],
context: CopilotContext
): Promise<ToolResult[]> {
return Promise.all(
toolCalls.map(async (call) => {
const args = JSON.parse(call.function.arguments);
// Каждый инструмент проверяет права через тот же middleware что и REST API
switch (call.function.name) {
case "search_records":
return {
id: call.id,
result: await searchRecords(args, context.userProfile.id),
};
case "create_record":
// Проверяем право на создание
await assertPermission(context.userProfile, "create", args.module);
return {
id: call.id,
result: await createRecord(args, context.userProfile.id),
};
default:
return { id: call.id, result: { error: "Unknown tool" } };
}
})
);
}
Что нужно для старта
Перед реализацией нужно определить:
- Какие операции Copilot должен уметь выполнять (не только читать, но и писать?)
- Как выглядит контекст на каждой ключевой странице приложения
- Где хранить историю сессий (Redis для активных, PostgreSQL для архива)
- Нужна ли мультимодальность (анализ скриншотов, файлов)
Работа разбивается на итерации: сначала read-only Copilot (только отвечает на вопросы по данным), затем добавление инструментов записи по одному модулю. Так можно запустить что-то рабочее за 2–3 недели и расширять дальше.







