Zum Hauptinhalt springen

Einen KI-Assistenten mithilfe von Tool-Aufrufen integrieren

Dieses Leitfaden zeigt, wie man einen Chat-Assistenten mit einer DHTMLX Gantt-Anwendung über Tool-Aufrufe verbindet.

  • Backend: verarbeitet Modellaufrufe, speichert den Gesprächsstatus und entscheidet, welche Aktionen ausgeführt werden können.
  • Frontend: führt genehmigte Befehle aus und aktualisiert das Gantt-Diagramm im Browser.

Eine vollständige Beispielimplementierung finden Sie hier: Gantt Maker AI Demo.

Siehe den Abschnitt „Features“ in der Demo für eine vollständige Liste der unterstützten Fähigkeiten.

Die folgenden Abschnitte konzentrieren sich auf das minimale Integrationsmuster.

Voraussetzungen​

  • Node.js 20+
  • Einen OpenAI API-Schlüssel oder jeden Anbieter, der einen kompatiblen Chat Completions-Endpunkt mit Tool-Calling bereitstellt (richten Sie OPENAI_BASE_URL darauf aus)
  • Vertrautheit mit Socket.IO, dem OpenAI Chat Completions Tool-Calling-Format und der DHTMLX Gantt-API

Wie die Integration funktioniert​

Jede Benutzernachricht durchläuft diesen Ablauf:

user message
-> frontend sends the message to the backend
-> backend calls the model with tools
-> model returns a tool call
-> backend forwards the tool call to the frontend
-> frontend executes the Gantt command
-> frontend returns the result
-> backend saves the result
-> backend calls the model again
-> frontend receives the assistant response

Demo ausführen​

Die Demo unter DHTMLX/gantt-maker-ai-demo wird mit drei Laufmodi geliefert – Docker (produktion), Docker (Entwicklung mit heißem Reload) und normales npm – abgedeckt in ihrer README. Der kürzeste Weg:

git clone https://github.com/DHTMLX/gantt-maker-ai-demo.git
cd gantt-maker-ai-demo
cp .env.example .env
# bearbeiten Sie .env und setzen OPENAI_API_KEY
docker compose up --build

Öffnen Sie http://localhost. Das Frontend hört auf Port 80, das Backend auf Port 3001.

Der restliche Teil dieses Leitfadens erläutert das Integrationsmuster. Dateipfade beziehen sich auf das Demo-Layout: backend/server.ts, backend/helper.ts, backend/schemaList.ts, frontend/src/main.ts, frontend/src/chat-widget.ts, frontend/src/command-runner.ts.

Senden von Benutzernachrichten​

Das Frontend sendet Benutzernachrichten an das Backend über Socket.IO. Die Nachricht enthält lediglich die Benutzereingabe – zusätzliche Daten wie der aktuelle Zustand des Gantt wird separat bei Bedarf angefordert (siehe unten unter State-aware commands). Im Demo-Widget, das in #chat_panel eingebunden ist (frontend/src/chat-widget.ts), wird dieser Aufruf umschlossen.

function sendUserMessage(message: string): void {
if (!message) {
return;
}

socket.emit('user_msg', JSON.stringify({ message }));
}

Den Modellaufruf durchführen​

Das Backend empfängt Benutzernachrichten, speichert sie in der Gesprächshistorie und ruft das Modell mit den verfügbaren Tools auf.

Der OpenAI-Client liest OPENAI_API_KEY, OPENAI_BASE_URL und OPENAI_MODEL aus der Umgebung. Verwenden Sie OPENAI_BASE_URL, um auf jeden Anbieter zu verweisen, der die OpenAI Chat Completions API bereitstellt.

const history = new Map<string, ChatCompletionMessageParam[]>();

function getHistory(socketId: string) {
if (!history.has(socketId)) {
history.set(socketId, [
{
role: 'system',
content: `
You control a Gantt chart using tools.

Rules:
- Use tools to perform actions.
- Do not describe actions in text if a tool can be used.
- Prefer calling tools over explaining.
`
}
]);
}

return history.get(socketId);
}

socket.on('user_msg', async (payload: UserMsgPayload | string) => {
const { message } = typeof payload === 'string' ? JSON.parse(payload) : payload;

const history = getHistory(socket.id);

saveMessage(socket.id, {
role: 'user',
content: message,
});

const response = await openai.chat.completions.create({
model: MODEL,
messages: history,
tools: schemaList,
tool_choice: 'auto',
});

const assistantMessage = response.choices[0].message;
// assistantMessage may be a final reply or a request for tool calls.
// We process it in the Conversation loop section below.
});

Die Systemnachricht wird in der Gesprächshistorie gespeichert und nur einmal pro Sitzung hinzugefügt.

Hinweis

Die Systemprompt des Demos ist ausführlicher (backend/server.ts): erfordert, dass das Modell zuerst get_gantt_state aufruft, wenn eine Anfrage von bestehenden Tasks abhängt, gibt eine feste SKIP_MESSAGE zurück, wenn kein Tool übereinstimmt, und beschränkt endgültige Antworten auf 1–2 rein-textliche Sätze.

Tool-Schema​

Das Backend definiert Tools, die das Modell aufrufen kann. Jedes Tool beschreibt eine erlaubte Aktion und deren Parameter. Die Demo-Datei backend/schemaList.ts definiert 27 solcher Tools – Aufgaben-CRUD, Abhängigkeiten, Styling, Skalen, Planung, Exporte – aber das Integrationsmuster bleibt unabhängig von der Größe gleich. Wir beginnen mit zoom als dem einfachsten Beispiel und erweitern später auf update_tasks für den zustandsabhängigen Fall.

export const schemaList = [
{
type: 'function',
function: {
name: 'zoom',
description: 'Change the Gantt zoom level or fit the chart into view.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
level: {
type: 'string',
enum: ["hour", "day", "week", "month", "quarter", "year", "fit"],
},
},
required: ['level'],
},
},
},
];

Weiterleitung von Tool-Aufrufen​

Wenn das Modell einen Tool-Aufruf zurückgibt, analysiert das Backend dessen Argumente, leitet sie an das Frontend weiter und wartet auf das Ergebnis.

// Validate that the model returned a JSON object as arguments.
function parseToolArguments(rawArgs: string): Record<string, unknown> {
const parsed = JSON.parse(rawArgs);

if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Tool arguments must be a JSON object');
}

return parsed as Record<string, unknown>;
}

// Send the tool call to the frontend over Socket.IO and wait for the ack
// callback. Reject if no result arrives within the timeout.
function requestClientToolExecution(
socket: Socket,
payload: ClientToolRequest
): Promise<ClientToolResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Timed out waiting for tool result: ${payload.cmd}`));
}, 15000);

socket.emit('tool_call', payload, (result: ClientToolResult | undefined) => {
clearTimeout(timeout);

if (!result) {
reject(new Error(`No tool result received for: ${payload.cmd}`));
return;
}
resolve(result);
});
});
}

// parse the model's tool call and dispatch it to the frontend.
async function executeToolCall({
socket,
call,
}: {
socket: Socket;
call: ChatCompletionMessageToolCall;
}): Promise<ClientToolResult> {
return requestClientToolExecution(socket, {
toolCallId: call.id,
cmd: call.function.name,
params: parseToolArguments(call.function.arguments),
});
}
Hinweis

Die Demo extrahiert TIMEDOUT_SECONDS = 15_000 in backend/constants.ts und behandelt parseToolArguments und requestClientToolExecution als private Hilfsfunktionen – nur executeToolCall wird exportiert. Die 15-Sekunden-Grenze deckt Netzwerk- und Gantt-Render-Zeiten ab; ziehen Sie sie nur enger zusammen, wenn Sie eine schnellere Garantie für das Frontend haben.

Befehlsausführer (Command runner)​

Der Befehlsausführer definiert, wie Tool-Aufrufe in DHTMLX Gantt-API-Aufrufe übersetzt werden. Er bildet die Schranke zwischen Modell-Ausgabe und der Gantt-API.

Nur vordefinierte Befehle sollten ausgeführt werden. Jeder Backend-Tool-Name muss einen passenden Frontend-Befehl haben, unbekannte Befehle müssen geschlossen scheitern.

import type { GanttStatic } from '@dhx/trial-gantt';

export default function createCommandRunner(gantt: GanttStatic) {
return function runCommand(cmd: string, params: Record<string, unknown>): void {
switch (cmd) {
case 'zoom':
if (params.level === 'fit') {
gantt.ext.zoomToFit();
} else {
gantt.ext.zoom.setLevel(params.level as string);
}
break;

default:
throw new Error(`Unsupported command: ${cmd}`);
}
};
}

Der Runner wird beim Start einmal verbunden:

const runCommand = createCommandRunner(gantt);
Hinweis

Der Demo-Runner protokolliert console.warn("Unknown cmd:", cmd, args) statt im Default-Branch eine Ausnahme zu werfen. Das Werfen macht dem Modell den Fehler als Tool-Ergebnis zugänglich, sodass das LLM sich entschuldigen oder erneut versuchen kann. Das stille Warnen hält den Chat in Bewegung, verbirgt aber Abweichungen zwischen Backend- und Frontend-B Befehlslisten. Für eine echte Anwendung wählen Sie den Ansatz, der für Sie am besten funktioniert.

Befehle ausführen​

Das Frontend empfängt Tool-Aufrufe, führt den angeforderten Befehl aus und gibt das Ergebnis über den Socket.IO-Ack-Callback zurück. Das Ack signalisiert dem Backend, dass der Tool-Vorgang abgeschlossen ist – ohne ihn bleibt die Konversation bis zum Timeout hängen. Ein erfolgreicher Befehl gibt den aktuellen Diagrammzustand mit gantt.serialize() zurück; ein fehlgeschlagener Befehl gibt einen Fehler zurück.

socket.on('tool_call', (payload: ClientToolRequest, ack?: (result: ClientToolResult) => void) => {
try {
runCommand(payload.cmd, payload.params);

ack?.({
ok: true,
cmd: payload.cmd,
data: gantt.serialize(),
});
} catch (error) {
ack?.({
ok: false,
cmd: payload.cmd,
error: error instanceof Error ? error.message : String(error),
});
}
});

Konversationsschleife​

Tool-Aufrufe sind Teil der Konversation, nicht die endgültige Assistenten-Antwort. Nachdem ein Befehl ausgeführt wurde, wird das Ergebnis in der Gesprächshistorie gespeichert und dem Modell zurückgesendet.

Das Modell kann mehrere Tool-Aufrufe nacheinander anfordern: eine zustandsabhängige Aktualisierung, z. B. führt dies zu get_gantt_state, gefolgt von update_tasks. Der Backend wiederholt den Ausführungszyklus, bis das Modell eine Nachricht ohne Tool-Aufrufe zurückgibt – aber durch MAX_TURNS begrenzt, damit ein fehlverhaltendes Modell die Schleife nicht endlos aktiv halten kann.

const MAX_TURNS = 10;

for (let turn = 0; turn < MAX_TURNS; turn++) {
const response = await openai.chat.completions.create({
model: MODEL,
messages: getHistory(socket.id),
tools: schemaList,
tool_choice: 'auto',
});

const message = response.choices[0].message;

// No more tool calls → emit the final reply and stop.
if (!message.tool_calls?.length) {
socket.emit('assistant_msg', message.content ?? '');
saveMessage(socket.id, { role: 'assistant', content: message.content ?? '' });
return;
}

// Persist the assistant turn that triggered the tool calls.
saveMessage(socket.id, {
role: 'assistant',
content: null,
tool_calls: message.tool_calls,
});

// Run each tool call and persist the result back into history.
for (const call of message.tool_calls) {
try {
const result = await executeToolCall({ socket, call });
saveMessage(socket.id, {
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(result),
});
} catch (err) {
saveMessage(socket.id, {
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify({ ok: false, error: String(err) }),
});
}
}
}

// Loop exhausted without a final reply.
socket.emit('assistant_msg', 'Request required too many steps. Please try a simpler command.');

Der Ablauf umfasst hier den ersten Modellaufruf aus dem vorhergehenden Abschnitt – dieser Aufruf befindet sich jetzt bei turn === 0, sodass „Call the model“ und „Conversation loop“ dasselbe openai.chat.completions.create()-Aufruf aus zwei Perspektiven beschreiben.

Sicherheitsvorkehrungen​

Eine produktive Integration benötigt einige zusätzliche Absicherungen über der grundlegenden Schleife:

  • Begrenzte Schleife. MAX_TURNS (oben) begrenzt, wie viele Tool-/Antwort-Zyklen eine einzelne Benutzernachricht auslösen kann.
  • Sitzungsisolation pro Client. Halten Sie die Historie in einer Map<socketId, ChatMessages> mit dem Schlüssel socket.id, sodass mehrere gleichzeitige Benutzer sich nie denselben Kontext ansehen. Die Demo-Fateilung sessionMessagesByClient in backend/helper.ts erledigt dies.
  • Leerlauf-Timeout. Löschen Sie die Sitzungsverlauf nach einer Inaktivität (die Demo verwendet 30 Minuten), um Speicher zu binden und veraltete Zustände zu vermeiden, wenn ein lange nicht verbundener Client wiederkehrt.
  • Verlaufsbereinigung. Token-Budgets sind begrenzt. Trimmen Sie nach Blöcken – jede Benutzer-Nachricht und ihre vollständige Assistenz-Antwort (einschließlich jeglicher Tool-Aufruf-/Tool-Ergebnis-Kette) bildet einen Block – sodass Sie nie einen Tool-Aufruf-Zyklus über die Trennlinie hinweg umbrechen. Halten Sie die System-Nachricht fest an Index 0. Die Demo-trimHistory() in backend/helper.ts hält die letzten MAX_MESSAGES = 20 Blöcke.
  • Validierung von Tool-Argumenten. Die obige Helferfunktion parseToolArguments erzwingt nur „ist ein JSON-Objekt“. Darüber hinaus validieren Sie gegen das JSON-Schema, das Sie mit dem Modell registriert haben, oder akzeptieren Sie, dass der Runner fehlerhafte Argumente am Rand abfangen wird.

Zustandserfassende Befehle​

Einige Befehle hängen nicht vom aktuellen Diagrammzustand ab (z. B. zoom). Befehle, die vorhandene Tasks ändern, benötigen Zugriff auf den aktuellen Gantt-Zustand, damit das Modell Task-IDs referenzieren und Updates vorbereiten kann.

Das get_gantt_state-Tool liefert das aktuelle Ergebnis von gantt.serialize() ohne das Diagramm zu verändern. Das Modell kann dann update_tasks aufrufen, um basierend auf diesem Zustand Änderungen anzuwenden.

User: Move the QA task two days later
-> get_gantt_state
-> tool result (gantt.serialize())
-> update_tasks
-> tool result with updated gantt.serialize()
-> final assistant reply

Tool-Schema zum Lesen des aktuellen Zustands:

{
type: 'function',
function: {
name: 'get_gantt_state',
description: 'Return the current Gantt tasks and links.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {},
},
},
}

Tool-Schema zum Aktualisieren vorhandener Tasks:

{
type: 'function',
function: {
name: 'update_tasks',
description: 'Update existing Gantt tasks by id.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
tasks: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: ['string', 'number'] },
text: { type: 'string' },
start_date: { type: 'string', format: 'date' },
duration: { type: 'number' },
progress: {
type: 'number',
minimum: 0,
maximum: 1,
},
},
required: ['id'],
},
},
},
required: ['tasks'],
},
},
}

Die Fälle des Frontend-Befehlsausführers:

case 'get_gantt_state':
break;

case 'update_tasks':
gantt.batchUpdate(() => {
for (const task of params.tasks as Array<Record<string, unknown>>) {
const taskId = task.id as string | number;

if (!gantt.isTaskExists(taskId)) {
throw new Error(`Task does not exist: ${taskId}`);
}

const existingTask = gantt.getTask(taskId);
Object.assign(existingTask, task);
gantt.updateTask(taskId);
}
});
break;
Hinweis

Dieses update_tasks-Beispiel ist absichtlich minimal. Der Runner des Demos in frontend/src/command-runner.ts ergänzt Produktionsbestandteile: Er überspringt Tasks des Typs "project" (aus den Kind-Elementen neu berechnet), parsed eingehende Daten mit gantt.templates.parse_date, und ruft gantt.calculateEndDate auf, um sicherzustellen, dass start_date, end_date und duration konsistent bleiben, wenn das Modell nur einige von ihnen sendet. Übernehmen Sie diese Logik, wenn Sie dieses Muster an ein Live-Diagramm anschließen.

Gantt akzeptiert sowohl Date-Objekte als auch ISO 8601-Strings out of the box. Für andere String-Formate konfigurieren Sie gantt.config.date_format und überschreiben Sie gantt.templates.parse_date.

Troubleshooting​

  • Der Backend wartet auf ein Tool-Ergebnis bis zum Timeout. Das Frontend ruft den Ack-Callback nicht auf. Jedes Branch des socket.on('tool_call')-Handlers – inklusive des Catch-Blocks – muss ack() aufrufen.

  • Das Modell gibt Text statt eines Tool-Aufrufs zurück. Dem Backend werden keine tools beim Aufruf von chat.completions.create() übergeben, oder tool_choice ist nicht 'auto'. Kleinere Modelle (z. B. gpt-4.1-nano) neigen zudem zu Drift; versuchen Sie zuerst gpt-5-nano oder gpt-4.1-mini.

  • Ein Befehl scheint erfolgreich zu sein, aber das Gantt-Diagramm ändert sich nicht. Der Runner gibt { ok: true } für einen nicht unterstützten Befehl zurück. Machen Sie den Default-Branch des switch scheitern (werfen Sie eine Ausnahme oder geben Sie eine Warnung aus) und stellen Sie sicher, dass jeder Backend-Tool-Name einen passenden Frontend-Fall hat.

  • JSON.parse schlägt beim Lesen der Tool-Argumente fehl. Geben Sie einen deterministischen Fehler zurück oder speichern Sie { ok: false, error } als Tool-Ergebnis, damit das Modell wiederherstellen kann, statt zu stoppen.

  • Das Modell aktualisiert die falsche Aufgabe. Die Integration fehlt eine Zustand-lesende Schritt (get_gantt_state) oder eine Task-ID-Validierung im Frontend. Fügen Sie die Regel MUST call get_gantt_state first in die System-Prompt ein und prüfen Sie gantt.isTaskExists() im Runner.

Zusammenfassung​

Die Integration verbindet einen KI-Assistenten mit DHTMLX Gantt über Backend-Tool-Aufrufe und Frontend-Befehlsausführung.

Das Backend verarbeitet Modellaufrufe, Tool-Schemata und Gesprächshistorie. Das Frontend führt genehmigte Befehle auf der Gantt-Instanz aus und gibt den aktuellen Diagrammzustand zurück.

Der Befehlsausführer definiert die Grenze zwischen Modell-Ausgabe und der Gantt-API: Nur explizit unterstützte Befehle dürfen das Diagramm ändern.

Nach jedem Tool-Aufruf speichert das Backend das Ergebnis und ruft das Modell erneut auf, sodass die endgültige Assistenten-Antwort auf dem tatsächlichen Ausführungsergebnis basiert.

Verwandte Materialien​

Need help?
Got a question about the documentation? Reach out to our technical support team for help and guidance. For custom component solutions, visit the Services page.