Fix: Chat scrollt nach Font-Loading nochmal nach unten

scrollDown() wartet jetzt auf document.fonts.ready und scrollt erneut.
chat-page.js wartet nach loadHistory() zusätzlich per requestAnimationFrame
auf den Repaint nach Font-Swap.
This commit is contained in:
Meleki committed 2026-02-28 15:54:47 +01:00
1 parent 3cb468f4e8
commit 76341dc42b
14 files changed
+349 -121

No files matched your search

+11 -2
View File
@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Models;
using Gott.Services;
@@ -22,7 +23,7 @@ public static class ChatEndpoint
app.MapPost("/api/chat", HandleChat);
}
private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService, DatabaseService db)
private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService, DatabaseService db, StreamingTracker streamingTracker, CancellationToken cancellationToken)
{
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
if (body == null || body.Messages == null || body.Messages.Count == 0)
@@ -180,6 +181,14 @@ Wenn der User auf eine bestimmte Zeile in einer Datei verweist (z.B. `[Datei: fo
Directory.CreateDirectory(workspace);
var anthropic = new AnthropicService(httpFactory, token, toolService, db);
await anthropic.StreamChat(res, modelId, systemPrompt, messages, chainId, workspace);
streamingTracker.Start(sessionId, chainId);
try
{
await anthropic.StreamChat(res, modelId, systemPrompt, messages, chainId, workspace, sessionId, cancellationToken: cancellationToken);
}
finally
{
streamingTracker.Stop(sessionId);
}
}
}
+20
View File
@@ -19,6 +19,7 @@ public static class HistoryEndpoint
app.MapGet("/api/archives", HandleListArchives);
app.MapGet("/api/archive/{archiveId}", HandleGetArchive);
app.MapGet("/api/attachments/{messageId}", HandleGetAttachments);
app.MapGet("/api/chains/{chainId}", HandleGetChain);
}
private static async Task HandleHistory(HttpContext ctx, DatabaseService db)
@@ -118,6 +119,25 @@ public static class HistoryEndpoint
await ctx.Response.WriteAsJsonAsync(result);
}
private static async Task HandleGetChain(HttpContext ctx, DatabaseService db)
{
var chainId = ctx.Request.RouteValues["chainId"]?.ToString();
if (string.IsNullOrEmpty(chainId))
{
ctx.Response.StatusCode = 400;
return;
}
var chain = await db.GetChain(chainId);
if (chain == null)
{
ctx.Response.StatusCode = 404;
return;
}
await ctx.Response.WriteAsJsonAsync(chain);
}
private static async Task HandleGetArchive(HttpContext ctx, DatabaseService db)
{
var archiveId = ctx.Request.RouteValues["archiveId"]?.ToString();
+15
View File
@@ -21,6 +21,7 @@ public static class SessionEndpoint
app.MapPut("/api/sessions/{sessionId}", HandleUpdate);
app.MapDelete("/api/sessions/{sessionId}", HandleDelete);
app.MapGet("/api/sessions/{sessionId}/system-prompt", HandleSystemPrompt);
app.MapGet("/api/sessions/{sessionId}/streaming", HandleStreaming);
app.MapPost("/api/sessions/{sessionId}/exec", HandleExec);
}
@@ -137,6 +138,20 @@ public static class SessionEndpoint
await ctx.Response.WriteAsync(systemPrompt);
}
private static async Task HandleStreaming(HttpContext ctx, StreamingTracker streamingTracker)
{
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var isStreaming = streamingTracker.IsStreaming(sessionId);
var chainId = streamingTracker.GetChainId(sessionId);
await ctx.Response.WriteAsJsonAsync(new { streaming = isStreaming, chainId });
}
private static async Task HandleExec(HttpContext ctx, DatabaseService db)
{
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
+1
View File
@@ -12,6 +12,7 @@ var dataDir = Path.Combine(AppContext.BaseDirectory, "data");
builder.Services.AddSingleton(new ToolService());
builder.Services.AddSingleton(new ImageService());
builder.Services.AddSingleton(new StreamingTracker());
builder.Services.AddSingleton(new DatabaseService(Path.Combine(dataDir, "gott.db")));
var app = builder.Build();
+144 -104
View File
@@ -5,6 +5,7 @@ using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Models;
using Microsoft.AspNetCore.Http;
@@ -34,7 +35,8 @@ public class AnthropicService
string chainId,
string workspace,
string sessionId = null,
int maxIterations = 25)
int maxIterations = 25,
CancellationToken cancellationToken = default)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
@@ -48,128 +50,152 @@ public class AnthropicService
Model = modelId
};
while (iterations < maxIterations)
try
{
iterations++;
var payload = new Dictionary<string, object>
while (iterations < maxIterations)
{
["model"] = modelId,
["max_tokens"] = 4096,
["system"] = systemPrompt,
["messages"] = messages,
["stream"] = true
};
iterations++;
if (!string.IsNullOrEmpty(workspace))
payload["tools"] = _toolService.GetToolDefinitions();
var client = CreateClient();
var json = JsonSerializer.Serialize(payload);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
HttpCompletionOption.ResponseHeadersRead
);
if (!httpResp.IsSuccessStatusCode)
{
var errBody = await httpResp.Content.ReadAsStringAsync();
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
await _db.SaveMessage(new MessageRecord
var payload = new Dictionary<string, object>
{
ChainId = chainId, Iteration = iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await WriteSseEvent(response, "error", new { error = errMsg });
await FinalizeChain(chain, usage, iterations, "error", sw);
await WriteSseEvent(response, "done", new { });
return;
}
["model"] = modelId,
["max_tokens"] = 16384,
["system"] = systemPrompt,
["messages"] = messages,
["stream"] = true
};
var (stopReason, assistantContent, executedTools) = await ProcessStream(response, httpResp, usage, workspace);
lastStopReason = stopReason;
if (!string.IsNullOrEmpty(workspace))
payload["tools"] = _toolService.GetToolDefinitions();
messages.Add(new { role = "assistant", content = assistantContent });
var client = CreateClient();
var json = JsonSerializer.Serialize(payload);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
// Assistant-Blöcke persistieren
foreach (var block in assistantContent)
{
var blockJson = JsonSerializer.Serialize(block);
var el = JsonSerializer.Deserialize<JsonElement>(blockJson);
var blockType = el.GetProperty("type").GetString() ?? "";
var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
HttpCompletionOption.ResponseHeadersRead,
cancellationToken
);
if (blockType == "text")
if (!httpResp.IsSuccessStatusCode)
{
var errBody = await httpResp.Content.ReadAsStringAsync(cancellationToken);
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await TryWriteSse(response, "error", new { error = errMsg }, cancellationToken);
await FinalizeChain(chain, usage, iterations, "error", sw);
await TryWriteSse(response, "done", new { }, cancellationToken);
return;
}
var (stopReason, assistantContent, executedTools) = await ProcessStream(response, httpResp, usage, workspace, cancellationToken);
lastStopReason = stopReason;
messages.Add(new { role = "assistant", content = assistantContent });
// Assistant-Blöcke persistieren
foreach (var block in assistantContent)
{
var blockJson = JsonSerializer.Serialize(block);
var el = JsonSerializer.Deserialize<JsonElement>(blockJson);
var blockType = el.GetProperty("type").GetString() ?? "";
if (blockType == "text")
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "text",
Content = el.GetProperty("text").GetString()
});
}
else if (blockType == "tool_use")
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "tool_use",
ToolName = el.GetProperty("name").GetString(),
ToolCallId = el.GetProperty("id").GetString(),
Content = el.GetProperty("input").ToString()
});
}
}
// Tool Results persistieren
foreach (var tr in executedTools)
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "text",
Content = el.GetProperty("text").GetString()
Role = "user", Type = "tool_result",
ToolCallId = tr.Id,
Content = tr.Result,
IsError = tr.IsError
});
}
else if (blockType == "tool_use")
if (stopReason != "tool_use")
{
await _db.SaveMessage(new MessageRecord
var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
await FinalizeChain(chain, usage, iterations, finalStopReason, sw);
await TryWriteSse(response, "done", new
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "tool_use",
ToolName = el.GetProperty("name").GetString(),
ToolCallId = el.GetProperty("id").GetString(),
Content = el.GetProperty("input").ToString()
model = modelId,
inputTokens = usage.InputTokens,
outputTokens = usage.OutputTokens,
iterations,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = finalStopReason
}, cancellationToken);
return;
}
var toolResults = new List<object>();
foreach (var tr in executedTools)
{
toolResults.Add(new
{
type = "tool_result",
tool_use_id = tr.Id,
content = tr.Result,
is_error = tr.IsError
});
}
messages.Add(new { role = "user", content = toolResults });
}
// Tool Results persistieren
foreach (var tr in executedTools)
await FinalizeChain(chain, usage, iterations, "max_iterations", sw);
await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, cancellationToken);
await TryWriteSse(response, "done", new { }, cancellationToken);
}
catch (OperationCanceledException)
{
// Client hat die Verbindung getrennt (z.B. Browser-Refresh)
await FinalizeChain(chain, usage, iterations, "client_disconnected", sw);
}
catch (Exception ex)
{
// Unerwarteter Fehler – in DB speichern und versuchen an Client zu senden
var errMsg = $"Stream-Fehler: {ex.Message}";
await _db.SaveMessage(new MessageRecord
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "user", Type = "tool_result",
ToolCallId = tr.Id,
Content = tr.Result,
IsError = tr.IsError
});
}
if (stopReason != "tool_use")
{
await FinalizeChain(chain, usage, iterations, stopReason, sw);
await WriteSseEvent(response, "done", new
{
model = modelId,
inputTokens = usage.InputTokens,
outputTokens = usage.OutputTokens,
iterations,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason
});
return;
}
var toolResults = new List<object>();
foreach (var tr in executedTools)
{
toolResults.Add(new
{
type = "tool_result",
tool_use_id = tr.Id,
content = tr.Result,
is_error = tr.IsError
});
}
messages.Add(new { role = "user", content = toolResults });
ChainId = chainId, Iteration = iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true
});
await FinalizeChain(chain, usage, iterations, "error", sw);
await TryWriteSse(response, "error", new { error = errMsg }, cancellationToken);
await TryWriteSse(response, "done", new { }, cancellationToken);
}
await FinalizeChain(chain, usage, iterations, "max_iterations", sw);
await WriteSseEvent(response, "error", new { error = "Max iterations erreicht" });
await WriteSseEvent(response, "done", new { });
}
private async Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw)
@@ -186,9 +212,10 @@ public class AnthropicService
HttpResponse response,
HttpResponseMessage httpResp,
TokenUsage usage,
string workspace)
string workspace,
CancellationToken cancellationToken = default)
{
var stream = await httpResp.Content.ReadAsStreamAsync();
var stream = await httpResp.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
var stopReason = "";
@@ -202,7 +229,7 @@ public class AnthropicService
var executedTools = new List<ToolResult>();
string line;
while ((line = await reader.ReadLineAsync()) != null)
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
{
if (!line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
@@ -357,6 +384,19 @@ public class AnthropicService
await response.WriteAsync($"event: {eventType}\ndata: {json}\n\n");
await response.Body.FlushAsync();
}
private static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct)
{
try
{
if (!ct.IsCancellationRequested)
await WriteSseEvent(response, eventType, data);
}
catch
{
// Client bereits disconnected – ignorieren
}
}
}
public record ToolResult(string Id, string Result, bool IsError);
+16
View File
@@ -0,0 +1,16 @@
using System.Collections.Concurrent;
namespace Gott.Services;
public class StreamingTracker
{
private readonly ConcurrentDictionary<string, string> _streaming = new ConcurrentDictionary<string, string>();
public void Start(string sessionId, string chainId) => _streaming[sessionId] = chainId;
public void Stop(string sessionId) => _streaming.TryRemove(sessionId, out _);
public bool IsStreaming(string sessionId) => _streaming.ContainsKey(sessionId);
public string GetChainId(string sessionId) => _streaming.TryGetValue(sessionId, out var chainId) ? chainId : null;
}
+3 -3
View File
@@ -8,9 +8,9 @@
- [ ] Größere Tasks brechen irgendwann ab, könnte an der Limitierung von Antrophic/Claude liegen. Jedoch sollte wird es scheinbar nicht behandelt: Der Status bleibt auf "Streaming..." aber es passiert nichts mehr
## Backend
- [ ] System-Prompt anpassen. Explizit auf Workspace-Pfad hinweisen, weil Model halluziniert oft und will in den nicht vorhandenen Pfad /workspace wechseln
- [x] System-Prompt anpassen. Explizit auf Workspace-Pfad hinweisen, weil Model halluziniert oft und will in den nicht vorhandenen Pfad /workspace wechseln
## Frontend
- [ ] Chat: Es scrollt beim Laden nicht ganz nach unten, ich denke weil im DOM noch etwas nachgeladen wird (ich denke es ist die Schriftart), und die Höhe sich beim Laden nochmal verändert
- [x] Chat: Es scrollt beim Laden nicht ganz nach unten, ich denke weil im DOM noch etwas nachgeladen wird (ich denke es ist die Schriftart), und die Höhe sich beim Laden nochmal verändert
- [x] Chat: User-Nachrichten mit dem Tag [Datei: pfat/zur/datei.xyz:1-10] wird nicht als HTML geparsed
- [ ] Chat: Wird zwar erkannt und ist klickbar, aber es öffnet sich einfach nur der File Explorer, ohne dass sich die Datei öffnet und die Darstellung in Monospace ist auch nicht schön
- [x] Chat: Wird zwar erkannt und ist klickbar, aber es öffnet sich einfach nur der File Explorer, ohne dass sich die Datei öffnet und die Darstellung in Monospace ist auch nicht schön
+7 -4
View File
@@ -331,14 +331,17 @@
/* File Ref Chips in User-Bubbles */
.file-ref-chip {
display: inline-block;
padding: var(--badge-padding);
display: inline-flex;
align-items: center;
gap: .3em;
padding: .15em .5em;
background: var(--f2-bg);
border: var(--border-width) solid var(--f2-border);
border-radius: var(--border-radius);
font-family: var(--mono);
font-size: .85em;
font-size: .9em;
white-space: nowrap;
vertical-align: baseline;
transition: background .15s, border-color .15s;
}
.file-ref-chip:hover {
+11
View File
@@ -111,3 +111,14 @@ async function apiExecCommand(sessionId, command) {
body: JSON.stringify({ command })
});
}
async function apiIsStreaming(sessionId) {
const resp = await fetch(`/api/sessions/${sessionId}/streaming`);
return resp.json();
}
async function apiGetChain(chainId) {
const resp = await fetch(`/api/chains/${chainId}`);
if (!resp.ok) return null;
return resp.json();
}
+14 -2
View File
@@ -149,10 +149,22 @@ async function sendMessage() {
} catch (e) {
endStreamBubble();
// Prüfen ob Backend noch streamt (z.B. bei Netzwerkfehler)
try {
const status = await apiIsStreaming(getCurrentSessionId());
if (status.streaming) {
// Backend streamt noch weiter – Recovery-Poll starten
setMetaHtml('<span>⏳ Streaming... (Verbindung wiederhergestellt)</span>');
startStreamingPoll(getCurrentSessionId(), btn, status.chainId);
return;
}
} catch (_) {}
addErrorBubble('Netzwerk-Fehler: ' + e.message);
} finally {
btn.disabled = false;
isStreaming = false;
if (!isStreaming || !_streamingPollInterval) {
btn.disabled = false;
isStreaming = false;
}
endStreamBubble();
// Erst neue aus DB laden, dann gestreamte Elemente ersetzen
+26 -2
View File
@@ -12,7 +12,8 @@ function openFileExplorer(opts = {}) {
onSelect: opts.onSelect || null,
currentPath: '',
history: [],
selectedFile: null
selectedFile: null,
initialFile: opts.initialFile || null // z.B. "src/foo.cs" – Datei direkt öffnen
};
const overlay = document.createElement('div');
@@ -55,7 +56,14 @@ function openFileExplorer(opts = {}) {
_feState._keyHandler = (e) => { if (e.key === 'Escape') closeFileExplorer(); };
document.addEventListener('keydown', _feState._keyHandler);
feLoadDir('');
// Zum Verzeichnis der initialFile navigieren, oder Root
if (_feState.initialFile) {
const filePath = _feState.initialFile;
const dir = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : '';
feLoadDir(dir);
} else {
feLoadDir('');
}
}
function closeFileExplorer() {
@@ -140,6 +148,22 @@ async function feLoadDir(path) {
}
backBtn.disabled = path === '';
// Wenn initialFile gesetzt, Datei automatisch selektieren
if (_feState && _feState.initialFile) {
const targetPath = _feState.initialFile;
_feState.initialFile = null; // Nur einmal triggern
// data-path Selektor sicher aufbauen
const allFiles = sidebar.querySelectorAll('.fe-file');
let targetItem = null;
allFiles.forEach(el => { if (el.dataset.path === targetPath) targetItem = el; });
if (targetItem) {
const entry = entries.find(e => e.path === targetPath);
if (entry) {
feSelectFile(entry, targetItem);
}
}
}
} catch (e) {
sidebar.innerHTML = '<div class="fe-error">Netzwerk-Fehler</div>';
}
+2 -2
View File
@@ -185,8 +185,8 @@ function stopHistoryPolling() {
}
}
async function pollHistory() {
if (isStreaming) return;
async function pollHistory(force = false) {
if (isStreaming && !force) return;
try {
const newMessages = await apiHistoryPoll(getCurrentSessionId(), lastKnownMessageId);
+67
View File
@@ -1,5 +1,50 @@
// ── Chat Page ──
let _streamingPollInterval = null;
let _recoveryChainId = null;
function startStreamingPoll(sessionId, sendBtn, chainId) {
_recoveryChainId = chainId;
if (_streamingPollInterval) clearInterval(_streamingPollInterval);
_streamingPollInterval = setInterval(async () => {
try {
// Neue Nachrichten laden (inkl. der noch streamenden)
await pollHistory(true);
const status = await apiIsStreaming(sessionId);
if (!status.streaming) {
clearInterval(_streamingPollInterval);
_streamingPollInterval = null;
isStreaming = false;
if (sendBtn) sendBtn.disabled = false;
// Finale Nachrichten nochmal laden
await pollHistory(true);
// Chain-Daten laden für Meta-Info
const cid = _recoveryChainId || status.chainId;
if (cid) {
const chain = await apiGetChain(cid);
if (chain) {
let meta = `Model: ${chain.model || '?'} | In: ${chain.inputTokens || 0} | Out: ${chain.outputTokens || 0}`;
if (chain.iterations > 1) meta += ` | Iterations: ${chain.iterations}`;
if (chain.durationMs) meta += ` | ${(chain.durationMs / 1000).toFixed(1)}s`;
if (chain.stopReason && chain.stopReason !== 'end_turn' && chain.stopReason !== 'tool_use') meta += ` | Stop: ${chain.stopReason}`;
setMeta(meta);
} else {
setMeta('Bereit');
}
} else {
setMeta('Bereit');
}
_recoveryChainId = null;
}
} catch (e) {
console.error('Streaming-Poll fehlgeschlagen:', e);
}
}, 1500);
}
registerPage('chat', {
async init(params) {
const sessionId = params.id;
@@ -152,6 +197,24 @@ registerPage('chat', {
isStreaming = false;
await loadHistory();
// Nach Font-Loading nochmal scrollen (Schriftart kann Höhe verändern)
document.fonts.ready.then(() => {
requestAnimationFrame(() => scrollDown());
});
// Prüfen ob Backend gerade noch streamt (z.B. nach Page-Refresh)
try {
const status = await apiIsStreaming(sessionId);
if (status.streaming) {
isStreaming = true;
if (sendBtn) sendBtn.disabled = true;
setMetaHtml('<span>⏳ Streaming...</span>');
startStreamingPoll(sessionId, sendBtn, status.chainId);
}
} catch (e) {
console.error('Streaming-Status prüfen fehlgeschlagen:', e);
}
},
destroy() {
@@ -159,6 +222,10 @@ registerPage('chat', {
document.removeEventListener('click', this._closeMenu);
this._closeMenu = null;
}
if (_streamingPollInterval) {
clearInterval(_streamingPollInterval);
_streamingPollInterval = null;
}
stopHistoryPolling();
messages.length = 0;
lastKnownMessageId = 0;
+12 -2
View File
@@ -15,7 +15,12 @@ function renderMarkdown(text) {
function scrollDown() {
const chat = chatEl();
if (!chat) return;
chat.scrollTop = chat.scrollHeight;
// Nach Font-Swap kann sich die Höhe ändern → nochmal scrollen
document.fonts.ready.then(() => {
chat.scrollTop = chat.scrollHeight;
});
}
function esc(s) {
@@ -45,13 +50,18 @@ function renderUserContent(container, text) {
}
const ref = match[1]; // z.B. "src/foo.cs:10-25"
// Pfad ohne Zeilenangabe extrahieren
const filePath = ref.replace(/:\d+(-\d+)?$/, '');
const chip = document.createElement('span');
chip.className = 'file-ref-chip';
chip.textContent = match[0];
// Schöne Darstellung: Icon + Kurzpfad
const fileName = filePath.includes('/') ? filePath.substring(filePath.lastIndexOf('/') + 1) : filePath;
const lineInfo = ref.replace(filePath, '').trim(); // z.B. ":10-25"
chip.textContent = '📄 ' + fileName + lineInfo;
chip.title = ref;
chip.style.cursor = 'pointer';
chip.addEventListener('click', () => {
openFileExplorer({ sessionId: getCurrentSessionId() });
openFileExplorer({ sessionId: getCurrentSessionId(), initialFile: filePath });
});
frag.appendChild(chip);
last = match.index + match[0].length;