Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

## [Unreleased]

### Fixed
- Assistant message `<think>...` blocks are now extracted into `m.reasoning` at persist time (both streaming inflight state and the SSE `done` finalization), instead of being stored inline in `m.content`. Reasoning-only providers such as `MiniMax-M3` (OpenAI-compat) previously left the thinking trace inside the assistant content, bloating persisted session files by 30-50% and bypassing the `m.reasoning` field that the thinking card reads on reload. A new `_splitThinkFromContent()` helper (in `static/messages.js`) handles all three known tag pairs (`<think>...`, `<|channel>thought\n...<channel|>`, `<|turn|>thinking\n...<turn|>`), preserves any pre-existing `m.reasoning` from a separate `on_reasoning` stream, leaves partial open blocks alone for the live renderer to hide, and supports multiple complete blocks in one message via bounded iteration. No rendering change — the streaming `_parseStreamState` and the persisted-state render path both already consume the split shape.
- The LLM Wiki status panel's `Last writer` field is now populated (was always `Not available` since the original panel shipped in #1257). The reader uses a 3-tier fallback: most-recent page frontmatter `updated_by`/`writer`/`author`, the most recent `log.md` action verb, and a static `ai-agent` fallback so the UI never shows `Not available` for a configured wiki. Reads only page frontmatter and `log.md` headings, never page bodies, so the private-safe status contract is preserved.

## [v0.51.222] — 2026-06-02 — Release GP (stage-p4 — backend bugfix batch: title language drift + orphaned CLI sidecar prune + pin-quota lineage)

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,7 @@ def _named_custom_provider_slug_for_base_url(
{"id": "kimi-k2.5", "label": "Kimi K2.5"},
],
"minimax": [
{"id": "MiniMax-M3", "label": "MiniMax M3"},
{"id": "MiniMax-M2.7", "label": "MiniMax M2.7"},
{"id": "MiniMax-M2.7-highspeed", "label": "MiniMax M2.7 Highspeed"},
{"id": "MiniMax-M2.5", "label": "MiniMax M2.5"},
Expand Down Expand Up @@ -2645,7 +2646,7 @@ def _current_webui_version() -> str | None:
# guarantees that even if a future release accidentally reuses the same
# WebUI version string (or a debug build doesn't have a version), a structural
# change still invalidates the cache.
_MODELS_CACHE_SCHEMA_VERSION = 3
_MODELS_CACHE_SCHEMA_VERSION = 4


_models_cache_path = STATE_DIR / "models_cache.json"
Expand Down
71 changes: 69 additions & 2 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3494,6 +3494,72 @@ def _llm_wiki_page_files(wiki_path: Path) -> list[Path]:
return pages


def _llm_wiki_last_writer(wiki_path: Path, page_files: list[Path]) -> str:
"""Best-effort last-writer detection for the LLM Wiki status card.

Closes the gap left by the original panel (commit 2684d6fa, Issue #1257):
the field was reserved as ``"last_writer": None`` with no reader wired up,
so the UI always rendered "Not available". This helper makes the field
useful without breaking the private-safe contract (reads only one line
of frontmatter and one line of log.md headings, never page bodies).

Priority:
1. Most-recently-modified page frontmatter ``updated_by`` / ``writer`` /
``author`` (case-insensitive).
2. Most recent ``log.md`` heading of the form
``## [YYYY-MM-DD] <action> | subject`` — returns
``"ai-agent (<action>)"`` so the user can see ingest vs update.
3. Static fallback ``"ai-agent"`` so the UI never shows "Not available"
for a configured wiki.
"""
# Priority 1: most recent page frontmatter
latest_page: Path | None = None
latest_mtime = -1.0
for candidate in page_files:
try:
mtime = candidate.stat().st_mtime
except Exception:
continue
if mtime > latest_mtime:
latest_mtime = mtime
latest_page = candidate
if latest_page is not None:
try:
content = latest_page.read_text(encoding="utf-8", errors="replace")
except Exception:
content = ""
if content.startswith("---"):
end = content.find("---", 3)
if end > 0:
for line in content[3:end].splitlines():
stripped = line.strip()
lower = stripped.lower()
for key in ("updated_by", "writer", "author"):
if lower.startswith(f"{key}:"):
value = stripped.split(":", 1)[1].strip()
if value:
return value

# Priority 2: log.md last entry action verb
log_path = wiki_path / "log.md"
if log_path.exists() and log_path.is_file():
try:
for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
stripped = line.strip()
if not stripped.startswith("## ["):
continue
if "|" not in stripped:
continue
tail = stripped.split("]", 1)[1].strip() if "]" in stripped else ""
action = tail.split()[0] if tail else "update"
return f"ai-agent ({action})"
except Exception:
pass

# Priority 3: never return None / "Not available" for a configured wiki
return "ai-agent"


def _build_llm_wiki_status() -> dict:
"""Return private-safe LLM Wiki status metadata without reading page bodies."""
try:
Expand All @@ -3506,7 +3572,7 @@ def _build_llm_wiki_status() -> dict:
"page_count": 0,
"raw_source_count": 0,
"last_updated": None,
"last_writer": None,
"last_writer": "ai-agent",
"path_configured": path_configured,
"path_source": path_source,
"toggle_available": False,
Expand Down Expand Up @@ -3538,6 +3604,7 @@ def _build_llm_wiki_status() -> dict:
"page_count": len(page_files),
"raw_source_count": _llm_wiki_count_files(wiki_path / "raw"),
"last_updated": _llm_wiki_safe_iso(latest),
"last_writer": _llm_wiki_last_writer(wiki_path, page_files),
})
return base
except Exception as exc:
Expand All @@ -3549,7 +3616,7 @@ def _build_llm_wiki_status() -> dict:
"page_count": 0,
"raw_source_count": 0,
"last_updated": None,
"last_writer": None,
"last_writer": "ai-agent",
"path_configured": False,
"path_source": "unknown",
"toggle_available": False,
Expand Down
76 changes: 73 additions & 3 deletions static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,62 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const clean=_stripLiveVisibleAssistantEchoFromThinking(liveReasoningText, visibleInterimSnippets);
return clean || 'Thinking…';
}
// Split a content string into {reasoning, content} by extracting any <think>...
// blocks (or other known reasoning-tag pairs). If reasoning is already
// populated on the message (e.g. from a separate on_reasoning stream), the
// inline blocks are stripped but the existing reasoning field is preserved.
// Provider-bug workaround: M3 (and similar reasoning models) emit the
// thinking inline in the OpenAI-compat content stream instead of a separate
// reasoning channel, which would otherwise bloat the persisted session
// message by 30-50% and miss the m.reasoning field used by the thinking card.
function _splitThinkFromContent(rawContent, existingReasoning){
const text=String(rawContent||'');
if(!text) return {reasoning:existingReasoning||'', content:text};
let extracted='';
let remaining=text;
let changed=true;
for(let safety=0; changed && safety<16; safety++){
changed=false;
// Pass 1: try to extract a think block at the start (after lstrip),
// matching the streaming renderer's _parseStreamState semantics.
const trimmed=remaining.trimStart();
const relOffset=remaining.length-trimmed.length;
for(const {open,close} of _thinkPairs){
if(!trimmed.startsWith(open)) continue;
const ci=trimmed.indexOf(close,open.length);
if(ci===-1) continue; // partial open — try the next pair
const block=trimmed.slice(open.length,ci);
extracted=extracted?extracted+'\n\n'+block:block;
// Drop leading whitespace if the think block was the very first thing;
// otherwise the prefix was real content before the think and stays.
const prefix=relOffset>0?remaining.slice(0,relOffset):'';
const leadingDecoration=prefix.trim()==='';
remaining=(leadingDecoration?'':prefix)+trimmed.slice(ci+close.length).replace(/^\s+/,'');
changed=true;
break;
}
if(changed) continue;
// Pass 2: scan the rest of the body for any remaining complete think block.
// Picks the earliest match across all known pairs.
let bestStart=Infinity, bestPair=null, bestEnd=-1;
for(const {open,close} of _thinkPairs){
const si=remaining.indexOf(open);
if(si===-1) continue;
const ei=remaining.indexOf(close, si+open.length);
if(ei===-1) continue;
if(si<bestStart){bestStart=si; bestPair={open,close}; bestEnd=ei;}
}
if(bestPair){
const block=remaining.slice(bestStart+bestPair.open.length, bestEnd);
extracted=extracted?extracted+'\n\n'+block:block;
remaining=remaining.slice(0,bestStart)+remaining.slice(bestEnd+bestPair.close.length);
changed=true;
}
}
if(!extracted) return {reasoning:existingReasoning||'', content:rawContent};
const finalReasoning=existingReasoning?existingReasoning+'\n\n'+extracted:extracted;
return {reasoning:finalReasoning, content:remaining};
}
function syncInflightAssistantMessage(){
const inflight=INFLIGHT[activeSid];
if(!inflight) return;
Expand All @@ -845,14 +901,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(msg&&msg.role==='assistant'&&msg._live){assistantIdx=i;break;}
}
const ts=Date.now()/1000;
// Split inline <think> blocks into m.reasoning so the persisted inflight
// state stays compact and the thinking card has a proper source field.
const split=_splitThinkFromContent(assistantText, reasoningText);
if(assistantIdx>=0){
inflight.messages[assistantIdx].content=assistantText;
inflight.messages[assistantIdx].reasoning=reasoningText||undefined;
inflight.messages[assistantIdx].content=split.content;
inflight.messages[assistantIdx].reasoning=split.reasoning||undefined;
inflight.messages[assistantIdx]._ts=inflight.messages[assistantIdx]._ts||ts;
_throttledPersist();
return;
}
inflight.messages.push({role:'assistant',content:assistantText,reasoning:reasoningText||undefined,_live:true,_ts:ts});
inflight.messages.push({role:'assistant',content:split.content,reasoning:split.reasoning||undefined,_live:true,_ts:ts});
_throttledPersist();
}
function ensureAssistantRow(force=false){
Expand Down Expand Up @@ -1927,6 +1986,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
// Persist reasoning trace so thinking card survives page reload
if(reasoningText&&lastAsst&&!lastAsst.reasoning) lastAsst.reasoning=reasoningText;
// Strip any inline <think> blocks still embedded in the server-side
// content (M3 OpenAI-compat doesn't separate reasoning). Move them
// to m.reasoning so the persisted session stays compact and the
// thinking card has a proper source field on reload.
if(lastAsst && typeof lastAsst.content === 'string' && lastAsst.content){
const split=_splitThinkFromContent(lastAsst.content, lastAsst.reasoning);
if(split.content!==lastAsst.content){
lastAsst.content=split.content;
if(split.reasoning) lastAsst.reasoning=split.reasoning;
}
}
// Stamp _ts on the last assistant message if it has no timestamp
if(lastAsst&&!lastAsst._ts&&!lastAsst.timestamp) lastAsst._ts=Date.now()/1000;
if(d.usage){
Expand Down
4 changes: 3 additions & 1 deletion tests/test_issue1257_llm_wiki_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ def test_llm_wiki_status_reads_synthetic_fixture_without_exposing_content(tmp_pa
assert status["page_count"] == 2
assert status["raw_source_count"] == 1
assert status["last_updated"] is not None
assert status["last_writer"] is None
# log.md in the fixture has a "## [2026-05-04] update | ..." heading,
# so the new last-writer reader must surface that action verb.
assert status["last_writer"] == "ai-agent (update)"
assert status["toggle_available"] is False
assert status["docs_url"].endswith("/research-llm-wiki")
serialized = repr(status)
Expand Down