Mimir: Web Search feature & impoving the CPU budget usage (#202)
* game-launcher: add launcher toggles, keyboard nav, auto-refresh * game-launcher: update README with runner toggle settings * fix: remove duplicate translation keys after upstream merge * fix(game-launcher): add ~/.local/share/Steam to steam roots for NixOS support * chore(game-launcher): bump version to 1.1.1 * feat(mimir): AI companion plugin with LLM chat panel Mimir is an AI companion for Noctalia — an LLM-powered chat interface with model selection, conversation history, and a bar widget. - Service-based architecture: service (brain) handles HTTP API calls, panel (chat) renders the UI, widget (status) shows bar indicator - OpenAI-compatible chat completions with dynamic model discovery - Floating side panel (center_right) with message history and simple markdown rendering (code blocks) - Model selection dropdown populated from API /models endpoint - Bar widget with brain icon to toggle chat panel - i18n via translations/en.json - Auto-detection of OpenCode Go API key from auth.json - Full-height floating panel layout matching oficial notes plugin .gitignore: add editor files, OS junk, auth secrets, compiled binary * mimir: rename author leo->Alexander, strip scrollBottom, update README with plans - plugin.toml: author leo -> Alexander, widget panel-toggle id -> alexander/mimir - widget.luau: togglePanel id -> alexander/mimir:chat - panel.luau: remove scrollBottom/dynamic key (unstable), remove setUpdateInterval - README.md: add future plans (commands, file search, etc.) - thumbnail.webp: removed (replaced by mimir-thumbnail.webp) * added thumbnail * added thumbnail.webp * mimir: bump 0.1.0 → 0.3.0, update README with tools + copy + editable approval * mimir: copy-to-clipboard toggle, editable command approval, unicode bold rendering * mimir: fix review findings — conditional auth, dedupe user msg, apply max_history * mimir: fix manifest validation — use select setting type, add README Plugin section * mimir: multi-tool queue support, plain approve/deny, better tool-use prompt * mimir: add full markdown rendering — headings, lists, quotes, hr, bold/italic * mimir: bump 0.3.2 — fix Lua pattern quantifiers, multi-tool queue, markdown rendering * mimir: fix security review findings * mimir: clarify selectable message text * mimir: add command history display * mimir: add web search * mimir: align README with template * mimir: fix CPU budget error with many messages --------- Co-authored-by: Ahmed5Emad <ahmed5emad@users.noreply.github.com>
This commit is contained in:
+77
-58
@@ -3,35 +3,44 @@ local inputKey = 0
|
||||
local copyTarget = nil
|
||||
local modelIdx = 0
|
||||
|
||||
local markdownCache = {}
|
||||
local parseCache = {}
|
||||
local CACHE_MAX = 100
|
||||
|
||||
local function cacheGet(cache, key)
|
||||
return cache[key]
|
||||
end
|
||||
|
||||
local function cacheSet(cache, key, value)
|
||||
if cache[key] then return end
|
||||
cache[key] = value
|
||||
local count = 0
|
||||
for _ in pairs(cache) do count += 1 end
|
||||
if count > CACHE_MAX then
|
||||
for k in pairs(cache) do
|
||||
cache[k] = nil
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
noctalia.state.watch("mimir.copy_target", function(value)
|
||||
copyTarget = (value == -1) and nil or value
|
||||
render()
|
||||
end)
|
||||
|
||||
local function mapUnicode(text, lowerBase, upperBase)
|
||||
local out = ""
|
||||
for i = 1, #text do
|
||||
local c = text:sub(i, i)
|
||||
local code = c:byte()
|
||||
if code >= 97 and code <= 122 then
|
||||
out = out .. utf8.char(code - 97 + lowerBase)
|
||||
elseif code >= 65 and code <= 90 then
|
||||
out = out .. utf8.char(code - 65 + upperBase)
|
||||
else
|
||||
out = out .. c
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function inlineMarkdown(text)
|
||||
text = text:gsub("%*%*%*([^%*]-)%*%*%*", function(s) return mapUnicode(s, 0x1D482, 0x1D468) end)
|
||||
text = text:gsub("%*%*([^%*]-)%*%*", function(s) return mapUnicode(s, 0x1D41A, 0x1D400) end)
|
||||
text = text:gsub("%*([^%*]-)%*", function(s) return mapUnicode(s, 0x1D44E, 0x1D434) end)
|
||||
text = text:gsub("~~([^~]-)~~", "%1")
|
||||
text = text:gsub("`(.-)`", "%1")
|
||||
text = text:gsub("%[([^%]]*)%]%(([^%)]+)%)", "%1 (%2)")
|
||||
return text
|
||||
local cached = cacheGet(markdownCache, text)
|
||||
if cached then return cached end
|
||||
local out = text
|
||||
out = out:gsub("%*%*%*([^%*]-)%*%*%*", "%1")
|
||||
out = out:gsub("%*%*([^%*]-)%*%*", "%1")
|
||||
out = out:gsub("%*([^%*]-)%*", "%1")
|
||||
out = out:gsub("~~([^~]-)~~", "%1")
|
||||
out = out:gsub("`(.-)`", "%1")
|
||||
out = out:gsub("%[([^%]]*)%]%(([^%)]+)%)", "%1 (%2)")
|
||||
cacheSet(markdownCache, text, out)
|
||||
return out
|
||||
end
|
||||
|
||||
local function renderText(text, color, fontSize, fontWeight)
|
||||
@@ -49,6 +58,8 @@ local function toolCommand(toolCall)
|
||||
end
|
||||
|
||||
local function parseMessage(content)
|
||||
local cached = cacheGet(parseCache, content)
|
||||
if cached then return cached end
|
||||
local nodes = {}
|
||||
local lines = {}
|
||||
for line in (content .. "\n"):gmatch("(.-)\n") do
|
||||
@@ -119,6 +130,7 @@ local function parseMessage(content)
|
||||
end
|
||||
end
|
||||
end
|
||||
cacheSet(parseCache, content, nodes)
|
||||
return nodes
|
||||
end
|
||||
|
||||
@@ -133,41 +145,45 @@ function render()
|
||||
commandsByToolCall[entry.tool_call_id] = entry.command
|
||||
end
|
||||
|
||||
local statusColors = { idle = "on_surface/0.4", thinking = "primary", running_tool = "primary", error = "error" }
|
||||
local statusTexts = { idle = "Ready", thinking = "Thinking...", running_tool = "Running command...", error = "Error" }
|
||||
local statusColors = { idle = "on_surface/0.4", thinking = "primary", running_tool = "primary", searching = "primary", fetching = "primary", error = "error" }
|
||||
local statusTexts = { idle = "Ready", thinking = "Thinking...", running_tool = "Running command...", searching = "Searching web...", fetching = "Fetching page...", error = "Error" }
|
||||
|
||||
local displayMsgs = {}
|
||||
local copyContent = nil
|
||||
for i, msg in ipairs(messages) do
|
||||
if msg.role ~= "system" and msg.role ~= "tool" and msg.content then
|
||||
if msg.role ~= "system" and msg.role ~= "tool" then
|
||||
local isUser = msg.role == "user"
|
||||
local isCopying = copyTarget == i
|
||||
if isCopying then copyContent = msg.content end
|
||||
local nodes = parseMessage(msg.content or "")
|
||||
local content = msg.content
|
||||
local hasText = type(content) == "string" and content:match("%S") ~= nil
|
||||
local contentNodes = {}
|
||||
for _, node in ipairs(nodes) do
|
||||
if node.type == "text" then
|
||||
table.insert(contentNodes, renderText(node.content, isUser and "primary" or "on_surface"))
|
||||
elseif node.type == "code" then
|
||||
table.insert(contentNodes, ui.column({ fill = "surface_variant", padding = 8, radius = 6 }, {
|
||||
ui.label({ text = node.content, fontSize = 12, color = "on_surface_variant" }),
|
||||
}))
|
||||
elseif node.type == "heading" then
|
||||
local sizes = { 17, 15, 14, 13, 13, 13 }
|
||||
table.insert(contentNodes, renderText(node.content, isUser and "primary" or "on_surface", sizes[node.level] or 13, "bold"))
|
||||
elseif node.type == "quote" then
|
||||
table.insert(contentNodes, ui.column({ fill = "surface_variant", padding = 8, radius = 4 }, {
|
||||
renderText(node.content, isUser and "primary" or "on_surface_variant", 12),
|
||||
}))
|
||||
elseif node.type == "hr" then
|
||||
table.insert(contentNodes, ui.separator({ thickness = 1, color = "surface_variant" }))
|
||||
elseif node.type == "list" then
|
||||
local listChildren = {}
|
||||
for n, item in ipairs(node.items) do
|
||||
local prefix = item.ordered and (n .. ". ") or "• "
|
||||
table.insert(listChildren, renderText(prefix .. item.content, isUser and "primary" or "on_surface"))
|
||||
if hasText then
|
||||
local isCopying = copyTarget == i
|
||||
if isCopying then copyContent = content end
|
||||
local nodes = parseMessage(content)
|
||||
for _, node in ipairs(nodes) do
|
||||
if node.type == "text" then
|
||||
table.insert(contentNodes, renderText(node.content, isUser and "primary" or "on_surface"))
|
||||
elseif node.type == "code" then
|
||||
table.insert(contentNodes, ui.column({ fill = "surface_variant", padding = 8, radius = 6 }, {
|
||||
ui.label({ text = node.content, fontSize = 12, color = "on_surface_variant" }),
|
||||
}))
|
||||
elseif node.type == "heading" then
|
||||
local sizes = { 17, 15, 14, 13, 13, 13 }
|
||||
table.insert(contentNodes, renderText(node.content, isUser and "primary" or "on_surface", sizes[node.level] or 13, "bold"))
|
||||
elseif node.type == "quote" then
|
||||
table.insert(contentNodes, ui.column({ fill = "surface_variant", padding = 8, radius = 4 }, {
|
||||
renderText(node.content, isUser and "primary" or "on_surface_variant", 12),
|
||||
}))
|
||||
elseif node.type == "hr" then
|
||||
table.insert(contentNodes, ui.separator({ thickness = 1, color = "surface_variant" }))
|
||||
elseif node.type == "list" then
|
||||
local listChildren = {}
|
||||
for n, item in ipairs(node.items) do
|
||||
local prefix = item.ordered and (n .. ". ") or "• "
|
||||
table.insert(listChildren, renderText(prefix .. item.content, isUser and "primary" or "on_surface"))
|
||||
end
|
||||
table.insert(contentNodes, ui.column({ gap = 2 }, listChildren))
|
||||
end
|
||||
table.insert(contentNodes, ui.column({ gap = 2 }, listChildren))
|
||||
end
|
||||
end
|
||||
if msg.tool_calls then
|
||||
@@ -181,13 +197,16 @@ function render()
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(displayMsgs, ui.column({ key = "m" .. i, gap = 4, paddingH = 4, align = isUser and "end" or "start" }, {
|
||||
ui.row({ gap = 4, align = "center" }, {
|
||||
ui.label({ text = isUser and "You" or "Mimir", fontSize = 11, color = "on_surface/0.5" }),
|
||||
ui.button({ glyph = isCopying and "close" or "copy", variant = "ghost", onClick = function() noctalia.state.set("mimir.copy_target", isCopying and -1 or i) end }),
|
||||
}),
|
||||
table.unpack(contentNodes),
|
||||
}))
|
||||
if #contentNodes > 0 then
|
||||
local isCopying = copyTarget == i
|
||||
table.insert(displayMsgs, ui.column({ key = "m" .. i, gap = 4, paddingH = 4, align = isUser and "end" or "start" }, {
|
||||
ui.row({ gap = 4, align = "center" }, {
|
||||
ui.label({ text = isUser and "You" or "Mimir", fontSize = 11, color = "on_surface/0.5" }),
|
||||
ui.button({ glyph = isCopying and "close" or "copy", variant = "ghost", onClick = function() noctalia.state.set("mimir.copy_target", isCopying and -1 or i) end }),
|
||||
}),
|
||||
table.unpack(contentNodes),
|
||||
}))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user