DomainBot er en Blazor Web App, der hjælper brugere med at modne en webløsningsidé og foreslå passende, ledige domænenavne. Chatbotten taler med OpenAI's Responses API og bruger Simply.com's MCP-server til at tjekke domænetilgængelighed i realtid.
- Overblik og arkitektur
- Projektstruktur
- Konfiguration og opstart
- Datamodeller
- Services
- MCP-integrationen – en grundig gennemgang
- Brugergrænseflade – Chat.razor
- Dataflow fra ende til anden
- Samlet vurdering og udvidelsesmuligheder
DomainBot er bygget som en Blazor Server-applikation med interaktive serverkomponenter. Det betyder, at al logik kører på serveren, mens browseren modtager real-time UI-opdateringer via en WebSocket-forbindelse (SignalR). Der er ingen separate API-lag – servicesne kaldes direkte fra Razor-komponenterne.
Browser <──SignalR──> Blazor Server <──HTTPS──> OpenAI Responses API
│
(MCP tool call)
│
Simply.com MCP Server
https://mcp.simply.com/v1
Applikationens kerneflow er:
- Brugeren skriver en besked i chatten
- Blazor-komponenten kalder
OpenAiDomainChatService, som POST'er til OpenAI's Responses API - OpenAI modellen tænker og beslutter sig for at kalde Simply.com's MCP-server for at tjekke domænetilgængelighed
- Simply.com svarer med tilgængelighed for de forespurgte domæner
- Modellen formulerer et svar med kun ledige domæner og streamer det token for token tilbage
- Blazor-komponenten opdaterer UI'et i realtid via
StateHasChanged()
DomainBot/
├── Program.cs # App-opstart og dependency injection
├── appsettings.json # Standardkonfiguration (model, MCP-url)
├── appsettings.Development.json # Hemmelig konfiguration (API-nøgle, gitignored)
│
├── Configuration/
│ ├── OpenAiOptions.cs # Stærkt typede indstillinger for OpenAI
│ ├── SimplyOptions.cs # Stærkt typede indstillinger for Simply MCP
│ └── PadletOptions.cs # Stærkt typede indstillinger for Padlet (ApiKey, BoardId)
│
├── Models/
│ ├── ChatMessage.cs # En enkelt chatbesked (rolle, tekst, tidspunkt)
│ └── ChatSessionState.cs # Hele sessionens tilstand (til persistens)
│
├── Services/
│ ├── ChatSessionService.cs # In-memory sessionsstyring
│ ├── PromptFactory.cs # Bygger systemprompt og kontekst
│ ├── PadletService.cs # Opretter posts på Padlet-tavle via Padlet API
│ └── OpenAiDomainChatService.cs # Kalder OpenAI API med streaming, MCP og Padlet-function-tool
│
├── Components/
│ ├── App.razor # HTML shell med scripts og stylesheets
│ ├── _Imports.razor # Globale using-direktiver for Razor
│ ├── Layout/
│ │ └── MainLayout.razor # Simpelt layout uden sidemenu
│ └── Pages/
│ └── Chat.razor # Hele chatgrænsefladen
│
└── wwwroot/
├── app.css # Chat-UI styling (bobler, input, status)
└── js/chat.js # Auto-scroll hjælpefunktion
Den primære konfigurationsfil indeholder standardværdier, der er sikre at committe:
{
"OpenAI": {
"Model": "gpt-5.4-mini",
"ReasoningEffort": "low"
},
"Simply": {
"McpUrl": "https://mcp.simply.com/v1"
}
}ReasoningEffort styrer, hvor meget tid modellen bruger på intern ræsonnering inden svar. Mulige værdier er "low" og "medium" – "low" giver hurtigere svar, mens "medium" giver mere gennemtænkte svar (og bruges til o-modeller som o3 og o4-mini).
API-nøglen gemmes aldrig i kodebasen. Den sættes i appsettings.Development.json (gitignored) eller via .NET's User Secrets:
dotnet user-secrets set "OpenAI:ApiKey" "din-api-nøgle"Padlet (valgfri): For Padlet-opsummeringsfunktionen sættes Padlet:ApiKey og Padlet:BoardId (samme steder). Kræver et Padlet-abonnement med API-adgang.
Program.cs er det centrale sted, hvor hele applikationen sættes op:
var builder = WebApplication.CreateBuilder(args);
// Stærkt typede konfigurationsklasser bindes til appsettings
builder.Services.Configure<OpenAiOptions>(
builder.Configuration.GetSection(OpenAiOptions.SectionName));
builder.Services.Configure<SimplyOptions>(
builder.Configuration.GetSection(SimplyOptions.SectionName));
builder.Services.Configure<PadletOptions>(
builder.Configuration.GetSection(PadletOptions.SectionName));
// HttpClient til at kalde OpenAI REST API manuelt
builder.Services.AddHttpClient();
// Applikationens services registreres som Scoped
// (én instans per bruger/forbindelse i Blazor Server)
builder.Services.AddScoped<ChatSessionService>();
builder.Services.AddScoped<PromptFactory>();
builder.Services.AddScoped<PadletService>();
builder.Services.AddScoped<OpenAiDomainChatService>();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();Alle tre services er Scoped, hvilket i Blazor Server betyder, at hver bruger får sin egen isolerede instans – de deler ikke data.
Konfigurationen læses ind i stærkt typede klasser, så der ikke bruges magiske strings rundt i koden:
// Configuration/OpenAiOptions.cs
public class OpenAiOptions
{
public const string SectionName = "OpenAI";
public string ApiKey { get; set; } = string.Empty;
public string Model { get; set; } = "gpt-5.4-mini";
public string ReasoningEffort { get; set; } = "low";
}
// Configuration/SimplyOptions.cs
public class SimplyOptions
{
public const string SectionName = "Simply";
public string McpUrl { get; set; } = "https://mcp.simply.com/v1";
}Repræsenterer én enkelt besked i samtalen:
public class ChatMessage
{
public string Role { get; set; } = string.Empty; // "User" eller "Assistant"
public string Text { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public bool IsStreaming { get; set; }
}IsStreaming bruges til at indikere, om en assistentbesked stadig er ved at blive skrevet (dvs. om der stadig kommer tokens ind). Det giver mulighed for at vise en anden visuel tilstand, mens svaret streames.
En snapshot af hele sessionens tilstand, der bruges til persistens:
public class ChatSessionState
{
public List<ChatMessage> Messages { get; set; } = new();
public string LastResponseId { get; set; } = string.Empty;
public List<string> ShownDomainNames { get; set; } = new();
public string? IdeaSummary { get; set; }
public string Language { get; set; } = "da";
}LastResponseIder afgørende for multi-turn samtaler med OpenAI's Responses API – se afsnit 6.ShownDomainNameser en liste over alle domæner, der allerede er præsenteret for brugeren, så de ikke gentages.IdeaSummaryer en pladsholder til fremtidig brug (en kondenseret opsummering af brugerens idé).Languagegiver mulighed for at tilpasse sprogvalget.
ChatSessionService er sessionens in-memory "hukommelse". Den holder styr på alle beskeder, bot-status og hvilke domæner der er vist. Klassen eksponerer en ChatSessionState-instans indirekte via metoder, så resten af applikationen ikke direkte manipulerer state-objektet.
public class ChatSessionService
{
private readonly ChatSessionState _state = new();
public IReadOnlyList<ChatMessage> Messages => _state.Messages;
public string LastResponseId => _state.LastResponseId;
public IReadOnlyList<string> ShownDomainNames => _state.ShownDomainNames;
public string BotStatus { get; private set; } = string.Empty;De vigtigste metoder:
| Metode | Formål |
|---|---|
AddMessage(role, text) |
Tilføjer en ny besked til samtalen |
UpdateLastMessageText(text) |
Opdaterer den seneste beskeds tekst under streaming |
SetBotStatus(status) |
Sætter statuslinjen ("Tænker...", "Undersøger domæner...") |
AddShownDomains(domains) |
Registrerer viste domæner så de ikke gentages |
SetLastResponseId(id) |
Gemmer OpenAI's response ID til næste API-kald |
GetStateForPersistence() |
Returnerer en kopi af tilstanden til serialisering |
LoadFromPersisted(state) |
Gendanner tilstanden fra serialiseret data |
BotStatus er særligt interessant – den opdateres løbende under et streaming-kald og vises i UI'et som en grå statuslinje. Det giver brugeren feedback om, hvad botten laver, mens den tænker eller undersøger domæner.
PromptFactory har ansvaret for al prompt-konstruktion. Den indeholder systempromptet som en hardcoded konstant og bygger dynamisk kontekst baseret på sessions-state:
private const string SystemInstructions = """
Du er en dansk domænenavneassistent for webløsninger.
Din opgave er først at forstå brugerens idé og derefter foreslå stærke domænenavne.
Du må kun stille korte opklarende spørgsmål, når det er nødvendigt for at forbedre forslagene væsentligt.
Du må aldrig vise et domænenavn, før du har tjekket det med domænetilgængelighedsværktøjet.
Vis kun domæner, hvor domænet er ledigt og kan registreres (available og canregister begge true).
Prioritér .dk, derefter .com, .app, .io, og derefter andre relevante TLD'er.
Giv en blanding af brandbare, beskrivende og hybride forslag.
Returnér højst 3 forslag pr. batch.
Giv en kort forklaring til hvert forslag.
Undgå at gentage tidligere viste domæner i samme session.
Standardsproget er dansk, medmindre brugeren tydeligt vælger et andet sprog.
""";Systempromptet er formuleret meget præcist – det instruerer modellen om:
- Aldrig at vise et domæne uden at have tjekket det via MCP-værktøjet
- Kun at vise domæner med
available: trueogcanregister: true - At prioritere TLD'er i en bestemt rækkefølge
- At begrænse sig til 3 forslag ad gangen
Den anden metode bygger dynamisk kontekst, der fortæller modellen hvilke domæner der allerede er vist:
public string BuildContextForAlreadyShownDomains(ChatSessionState state)
{
if (state.ShownDomainNames.Count == 0) return string.Empty;
var list = string.Join(", ", state.ShownDomainNames.OrderBy(x => x));
return $"Allerede viste domæner i denne session: {list}. " +
"Fortsæt ud fra samtalen og giv nye forslag — gentag ikke disse domæner.";
}Denne tekst tilføjes til systempromptet, så modellen ved, at den ikke må gentage domæner som f.eks. minshop.dk selvom det er ledigt – brugeren har allerede set det.
Dette er applikationens hjerne og den mest komplekse klasse. Den håndterer:
- Konstruktion af API-request med MCP-værktøj tilknyttet
- Afsendelse via
HttpClientmed SSE-streaming - Parsing af SSE-hændelser og routing til korrekte handlinger
- Live statusopdateringer under MCP-kald
- Ekstraktion og registrering af viste domæner
- Håndtering af function-tool
create_padlet_noteog opfølgende request med tool output (Padlet)
Se en detaljeret gennemgang af MCP i afsnit 6.
DomainBot kan oprette en note på en Padlet-tavle med en opsummering af webtjeneste-ideen og de ønskede domæner. Det sker via et function-tool i OpenAI Responses API (ikke MCP): modellen kalder create_padlet_note med parametrene summary og domain_names, og backend udfører kaldet mod Padlets offentlige API.
Konfiguration: Padlet:ApiKey og Padlet:BoardId (fx i User Secrets eller appsettings.Development.json). Hvis de ikke er sat, returnerer tool’et en venlig fejl til modellen.
Flow: Brugeren beder f.eks. om at "gemme opsummeringen på Padlet". Modellen producerer et function call med opsummering og domæneliste (indholdet kommer fra modellen, ikke fra sessionens IdeaSummary). OpenAiDomainChatService fanger response.function_call_arguments.done, kalder PadletService.CreatePostAsync(summary, domainNames), sender tool output tilbage til API’et i et opfølgende request og streamer herefter modellens bekræftelsessvar.
PadletService sender en POST til https://api.padlet.dev/v1/boards/{board_id}/posts med header X-API-KEY og et JSON:API-body med content.subject og content.body. Resultatet (success eller fejlbesked) returneres som tool output til modellen.
MCP (Model Context Protocol) er en åben standard for, hvordan AI-modeller kan kalde eksterne services og værktøjer. Konceptet svarer til det, der tidligere hed "function calling" eller "tool use", men MCP standardiserer protokollen og gør det muligt at bruge tredjeparts MCP-servere direkte uden at skrive wrapper-kode.
OpenAI's Responses API understøtter MCP-servere som et tool-type direkte i API-kaldet. Modellen beslutter selv, hvornår den vil kalde MCP-serveren, med hvilke argumenter og fortolker svaret automatisk – alt dette sker server-side hos OpenAI.
Simply.com har udgivet en offentlig MCP-server på https://mcp.simply.com/v1, der eksponerer værktøjer til at slå domænetilgængelighed op. Det er denne server, DomainBot bruger.
I OpenAiDomainChatService.SendMessageStreamingAsync() bygges API-request-objektet. Det centrale element er tools-arrayet:
var request = new
{
model = _openAiOptions.Model,
stream = true,
input = new object[]
{
new { role = "system", content = new[] { new { type = "input_text", text = fullSystem } } },
new { role = "user", content = new[] { new { type = "input_text", text = userMessage } } }
},
tools = new[]
{
new
{
type = "mcp",
server_label = "simply",
server_description = "Domain availability and TLD lookup at Simply.com.",
server_url = _simplyOptions.McpUrl, // "https://mcp.simply.com/v1"
require_approval = "never"
}
},
reasoning = new
{
effort = _openAiOptions.ReasoningEffort?.ToLowerInvariant() == "medium" ? "medium" : "low"
},
previous_response_id = string.IsNullOrEmpty(_sessionService.LastResponseId)
? null
: _sessionService.LastResponseId
};Lad os gennemgå de vigtigste felter i tools-objektet:
| Felt | Værdi | Betydning |
|---|---|---|
type |
"mcp" |
Fortæller OpenAI, at dette er et MCP-baseret værktøj |
server_label |
"simply" |
Et kort navn, som modellen bruger til at referere til serveren |
server_description |
Fritekst | Hjælper modellen forstå, hvornår den skal bruge serveren |
server_url |
"https://mcp.simply.com/v1" |
URL'en på MCP-serveren – OpenAI kalder denne server direkte |
require_approval |
"never" |
Modellen må kalde serveren automatisk uden at bede om godkendelse |
require_approval: "never" er afgørende for en god brugeroplevelse – uden dette ville AI'en skulle sætte pauser og vente på godkendelse for hvert domænetjek.
previous_response_id er det, der gør samtalen multi-turn. OpenAI's Responses API er designet til at kæde svar sammen – ved at sende ID'et fra forrige svar behøver applikationen ikke at sende hele samtalens historik igen. OpenAI's servere husker konteksten. Dette sparer både tokens og latenstid.
Når stream: true er sat, svarer OpenAI med en Server-Sent Events (SSE) strøm. Hver linje starter med data: efterfulgt af JSON, og strømmen slutter med data: [DONE].
SSE-strømmen læses af en privat hjælpemetode:
private static async IAsyncEnumerable<string> ReadSseAsync(
Stream stream,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var reader = new StreamReader(stream);
while (true)
{
var line = await reader.ReadLineAsync(cancellationToken);
if (line == null) yield break;
if (line.StartsWith("data: ", StringComparison.Ordinal))
{
var data = line.AsSpan(6).Trim();
if (data.SequenceEqual("[DONE]"))
{
yield return "[DONE]";
yield break;
}
if (data.Length > 0)
yield return data.ToString();
}
}
}Metoden er en async IAsyncEnumerable<string> – en asynkron iterator der yields ét SSE-event ad gangen. Opkalderen kan iterere over den med await foreach.
Fra denne strøm modtages mange forskellige JSON-hændelsestyper. Her er de centrale:
| Hændelsestype | Hvornår | Handling i koden |
|---|---|---|
response.output_item.added med item.type == "reasoning" |
Modellen begynder at tænke | Status → "Tænker..." |
response.reasoning_text.delta |
Et stykke af modelens indre ræsonnering | Status → "Tænker..." |
response.output_item.added med item.type == "mcp_call" |
Modellen har besluttet at kalde MCP | Status → "Undersøger domæner..." |
response.mcp_call.in_progress |
MCP-kaldet er i gang | Status → "Undersøger domæner..." |
response.mcp_call_arguments.delta |
Argumenterne til MCP-kaldet streames (domænenavne) | Status → "Undersøger domæner: minshop.dk, webshop.com" |
response.mcp_call_arguments.done |
Alle argumenter er sendt | Status opdateres med komplette domænenavne |
response.output_text.delta |
Et stykke af det endelige tekstsvar | Status ryddes, teksten vises i chat-boblen |
response.completed |
Hele responsen er færdig | Response ID gemmes, viste domæner registreres |
Hændelserne håndteres i én stor if-kæde inde i await foreach-løkken:
await foreach (var evt in ReadSseAsync(stream, cancellationToken))
{
if (string.IsNullOrEmpty(evt)) continue;
if (evt == "[DONE]") break;
using var doc = JsonDocument.Parse(evt);
var root = doc.RootElement;
// Udtræk response ID så snart det dukker op
if (root.TryGetProperty("response", out var responseEl))
if (responseEl.TryGetProperty("id", out var idEl))
responseId = idEl.GetString();
if (root.TryGetProperty("type", out var typeEl))
{
var type = typeEl.GetString();
// Ræsonnering i gang
if (type == "response.reasoning_text.delta")
{
_sessionService.SetBotStatus("Tænker...");
yield return "";
}
// MCP-kald påbegyndt
if (type == "response.mcp_call.in_progress")
{
_sessionService.SetBotStatus("Undersøger domæner...");
yield return "";
}
// MCP-argumenter streames – udtræk domænenavne til status
if (type == "response.mcp_call_arguments.delta" &&
root.TryGetProperty("delta", out var mcpDeltaEl))
{
var delta = mcpDeltaEl.GetString();
if (!string.IsNullOrEmpty(delta))
{
mcpArgumentsBuffer.Append(delta);
var domains = ParseDomainsFromText(mcpArgumentsBuffer.ToString());
_sessionService.SetBotStatus(domains.Count > 0
? "Undersøger domæner: " + string.Join(", ", domains)
: "Undersøger domæner...");
yield return "";
}
}
// Tekstsvar streames – vis token for token i chat-boblen
if (type == "response.output_text.delta" &&
root.TryGetProperty("delta", out var deltaEl))
{
var text = deltaEl.GetString();
if (!string.IsNullOrEmpty(text))
{
_sessionService.SetBotStatus("");
buffer.Add(text);
yield return text;
}
}
// Hele svaret er færdigt
if (type == "response.completed" && root.TryGetProperty("response", out var completedResp))
{
_sessionService.SetBotStatus(null);
if (completedResp.TryGetProperty("id", out var idEl))
_sessionService.SetLastResponseId(idEl.GetString() ?? "");
var fullText = string.Concat(buffer);
ExtractAndAddShownDomains(fullText);
}
}
}Bemærk brugen af yield return "" (tom streng) – disse yields giver Chat.razor mulighed for at kalde StateHasChanged() og opdatere UI'et, selvom der ikke er ny tekstindhold. Det er den mekanisme, der gør, at statuslinjen opdateres i realtid.
Et særlig elegant aspekt er, at applikationen udtrækker domænenavne fra MCP-argumenterne mens de streames, og viser dem i realtid i statuslinjen. Dette gøres med en regex:
private static List<string> ParseDomainsFromText(string text)
{
var regex = new System.Text.RegularExpressions.Regex(
@"\b([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.[a-z]{2,})\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var domains = new List<string>();
foreach (var m in regex.Matches(text))
{
var domain = m.Groups[1].Value.ToLowerInvariant();
if (domain.Length > 3 && !domains.Contains(domain))
domains.Add(domain);
}
return domains;
}Regex-mønstret \b([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.[a-z]{2,})\b matcher gyldige domænenavne. Da MCP-argumenterne streames gradvist (f.eks. {"domain":"min → shop → .dk"}), bufres de i mcpArgumentsBuffer og parses for hvert delta.
Resultatet er, at brugeren ser:
Undersøger domæner: minshop.dk, webshop.com, netbutik.dk
... mens AI'en stadig er ved at undersøge. Det giver en levende og responsiv brugeroplevelse.
Den samme regex bruges efter svaret er færdigt til at registrere de endelig viste domæner i ShownDomainNames:
private void ExtractAndAddShownDomains(string assistantText)
{
// Samme regex som ParseDomainsFromText
// Alle fundne domæner tilføjes til sessionens ShownDomainNames
_sessionService.AddShownDomains(domains);
}MCP-integrationen via OpenAI's Responses API åbner for en række interessante muligheder:
Tilføje flere MCP-servere
Man kan tilføje flere objekter til tools-arrayet. Eksempel med en hypotetisk prisserver:
tools = new[]
{
new
{
type = "mcp",
server_label = "simply",
server_description = "Domain availability at Simply.com.",
server_url = "https://mcp.simply.com/v1",
require_approval = "never"
},
new
{
type = "mcp",
server_label = "domainprices",
server_description = "Look up domain registration prices.",
server_url = "https://example.com/domain-prices/mcp/v1",
require_approval = "never"
}
}Modellen ville selv beslutte, hvornår den vil bruge hvilken server.
Kræve godkendelse for visse kald
require_approval kan sættes til "always" i stedet for "never". Så vil modellen sætte en pause og bede brugeren bekræfte, inden den kalder serveren. Dette er nyttigt, hvis MCP-kaldet har sideeffekter (f.eks. faktisk at registrere et domæne).
Begrænsning til specifikke værktøjer
Mange MCP-servere eksponerer mange værktøjer. Man kan tilføje en allowed_tools-liste for at begrænse, hvilke af serverens værktøjer modellen må kalde:
new
{
type = "mcp",
server_label = "simply",
server_url = "https://mcp.simply.com/v1",
require_approval = "never",
allowed_tools = new[] { "check_domain_availability" }
}Custom MCP-headers til autentifikation Hvis en MCP-server kræver autentifikation, kan headers tilføjes:
new
{
type = "mcp",
server_label = "myservice",
server_url = "https://api.myservice.com/mcp/v1",
require_approval = "never",
headers = new Dictionary<string, string>
{
{ "Authorization", "Bearer " + myServiceApiKey }
}
}Skifte til previous_response_id-fri tilstand
Hvis man ønsker at sende hele samtalehistorikken selv (f.eks. for at gemme konteksten i en database), kan man fjerne previous_response_id og i stedet sende hele input-arrayet med alle tidligere beskeder. Herved behøver OpenAI ikke at huske noget server-side.
Reasoning effort
Parameteren reasoning.effort kan sættes til "low", "medium" eller "high" (for o-modeller som o3). Et højere effort giver bedre svar til komplekse opgaver, men er langsommere og dyrere.
Chat.razor er den eneste side i applikationen (ruten er /). Den er erklæret med @rendermode InteractiveServer, hvilket aktiverer Blazor Server's realtids-interaktivitet via SignalR.
@page "/"
@rendermode InteractiveServer
@inject ChatSessionService SessionService
@inject OpenAiDomainChatService OpenAiService
@inject IJSRuntime JSKomponentens HTML-struktur er opdelt i tre zoner:
- Chat-beskeder – en scrollbar liste med bruger- og assistentbobler
- Fejlbanner – vises kun, hvis der sker en fejl under AI-kaldet
- Inputfelt – tekstfelt og Send-knap, der deaktiveres under streaming
Samtalen gemmes i browserens sessionStorage efter hvert svar. Det betyder, at brugeren kan opdatere siden og fortsætte samtalen – men hvis fanen lukkes, mistes historikken.
Genoprettelse sker i OnAfterRenderAsync ved første render:
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await RestoreStateFromStorageAsync();
await ScrollToBottomAsync();
}
private async Task RestoreStateFromStorageAsync()
{
var json = await JS.InvokeAsync<string?>("sessionStorage.getItem", "DomainBotChatState");
if (string.IsNullOrEmpty(json)) return;
var state = System.Text.Json.JsonSerializer.Deserialize<ChatSessionState>(json);
if (state != null)
{
SessionService.LoadFromPersisted(state);
StateHasChanged();
}
}Og gemning sker efter hvert vellykket API-kald:
private async Task PersistStateToStorageAsync()
{
var state = SessionService.GetStateForPersistence();
var json = System.Text.Json.JsonSerializer.Serialize(state);
await JS.InvokeVoidAsync("sessionStorage.setItem", "DomainBotChatState", json);
}Vigtig detalje: LastResponseId gemmes også i sessionStorage. Det betyder, at også kæden til OpenAI's server-side kontekst overlever en side-refresh – brugeren kan fortsætte præcis, hvor de slap.
SendMessage() er den centrale metode, der orkestrerer hele brugerens besked:
private async Task SendMessage()
{
var text = _inputText?.Trim() ?? "";
if (string.IsNullOrEmpty(text) || _isLoading) return;
_inputText = "";
_errorMessage = "";
SessionService.AddMessage("User", text);
SessionService.AddMessage("Assistant", ""); // Tom boble – udfyldes under streaming
SessionService.SetBotStatus("Tænker...");
_isLoading = true;
StateHasChanged();
var assistantText = "";
try
{
await foreach (var delta in OpenAiService.SendMessageStreamingAsync(text))
{
if (!string.IsNullOrEmpty(delta))
{
assistantText += delta;
SessionService.UpdateLastMessageText(assistantText);
}
StateHasChanged(); // Opdaterer UI for hvert token
await ScrollToBottomAsync(); // Auto-scroller ned
}
SessionService.SetLastMessageStreaming(false);
}
catch (Exception)
{
_errorMessage = "Noget gik galt med AI-svaret. Prøv igen.";
// Fjern den tomme assistentboble ved fejl
if (SessionService.Messages[^1].Role == "Assistant" &&
string.IsNullOrEmpty(SessionService.Messages[^1].Text))
SessionService.RemoveLastMessage();
}
finally
{
SessionService.SetBotStatus(null);
_isLoading = false;
await PersistStateToStorageAsync();
StateHasChanged();
await ScrollToBottomAsync();
}
}Mønstret er:
- Tilføj brugerens besked og en tom assistentboble
- Iterer over tokens fra
SendMessageStreamingAsyncmedawait foreach - For hvert token (eller tom streng for statusopdatering): kald
StateHasChanged() - Brug
finallytil at rydde op uanset om der skete en fejl
Assistentens svar er typisk formateret med fed tekst (**ord**) for at fremhæve domænenavne og begrundelser. Da Blazor HTML-enkoder al tekst som standard, er der implementeret en simpel inline Markdown-parser:
private static string MarkdownToHtml(string text)
{
if (string.IsNullOrEmpty(text)) return "";
// Først HTML-enkodér for at forhindre XSS
var encoded = System.Net.WebUtility.HtmlEncode(text);
// Konvertér **fed** til <strong>
encoded = System.Text.RegularExpressions.Regex.Replace(
encoded, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
// Konvertér linjeskift til <br/>
return encoded.Replace("\n", "<br/>");
}Resultatet indsættes som MarkupString for at undgå dobbelt-enkodning:
<div class="message-text">@((MarkupString)(MarkdownToHtml(msg.Text)))</div>Rækkefølgen er vigtig: HTML-enkodning sker først, derefter tilføjes de sikre HTML-tags. Dette forhindrer XSS-angreb, selvom modellen skulle returnere tekst med HTML-tegn.
Her er en komplet gennemgang af, hvad der sker, fra brugeren trykker Enter til svaret er vist:
1. Bruger skriver "Jeg vil lave en online plantebutik" og trykker Enter
2. Chat.razor / SendMessage():
- Tilføjer brugerbesked til SessionService
- Tilføjer tom assistentboble
- Sætter _isLoading = true
- Kalder StateHasChanged() → UI viser beskeden og spinner
3. OpenAiDomainChatService.SendMessageStreamingAsync():
- Henter systemprompt fra PromptFactory
- Tilføjer liste over allerede viste domæner til prompten
- Bygger JSON-request med model, input, MCP-tool og previous_response_id
- POST'er til https://api.openai.com/v1/responses med streaming
4. OpenAI's server modtager requestet:
- Modellen begynder at ræsonnere (reasoning)
- Sender SSE-hændelse: response.output_item.added (type: reasoning)
→ BotStatus: "Tænker..."
5. Modellen beslutter at tjekke domæner:
- Sender SSE-hændelse: response.output_item.added (type: mcp_call)
→ BotStatus: "Undersøger domæner..."
- Sender SSE-hændelse: response.mcp_call_arguments.delta
med delta: '{"domain":"planteverden'
→ BotStatus: "Undersøger domæner..."
- Sender SSE-hændelse: response.mcp_call_arguments.delta
med delta: '.dk"}'
→ BotStatus: "Undersøger domæner: planteverden.dk"
6. OpenAI kalder Simply.com's MCP-server:
- GET https://mcp.simply.com/v1/... med domænenavnet
- Simply svarer: { available: true, canregister: true }
7. Modellen formulerer sit svar og streamer det:
- Sender SSE-hændelser: response.output_text.delta
med successive tekstbidder
→ BotStatus ryddes, tekst vises i assistentboblen token for token
8. Streaming slutter:
- Sender SSE-hændelse: response.completed med response.id
- response.id gemmes i SessionService.LastResponseId
- Alle domænenavne i svaret ekstrahereres og gemmes i ShownDomainNames
- Sender SSE: [DONE] → loop afslutter
9. Chat.razor / finally-blok:
- _isLoading = false
- SessionService gemmes i sessionStorage (inkl. LastResponseId)
- StateHasChanged() → UI viser det færdige svar
10. Næste gang brugeren skriver, sendes previous_response_id med,
og OpenAI husker hele samtalen server-side.