Fixes O(n²) write amplification during wiki ingest on large KBs. Previously
every ingest/retract op re-wrote the single `slug='log'` row end-to-end and
every batch re-wrote the entire `slug='index'` directory markdown. On a 40k-
doc KB the log row grew to tens of MB and the index row to several MB, so
each batch triggered giant TOAST updates that dominated ingest wall time.
Log: new `wiki_log_entries` event table (`id DESC` indexed per KB) replaces
the single TEXT row. Batch ingest now collects entries and flushes them
once per batch via `AppendBatch`. Each entry stores `pages_affected` as
JSONB `[{slug,title}]` so the UI can render real titles; custom Scan falls
back to legacy `[]string` so older rows still deserialize.
Index: `wiki_pages[slug=index].content` keeps only the LLM-generated intro
(a few KB). The directory is now served by a structured paginated API
(`GetIndexView`) that reads `slug/title/summary` per type with cursor
pagination, so the agent and the frontend only pull the slice they need.
`RebuildIndexPage` degrades to a no-op; agent `wiki_read_page('index')`
synthesizes a small top-K overview and points callers at `wiki_search`.
Ingest resilience: LLM calls wrap with 3-attempt exponential backoff on
transient errors (5xx/408/429, transport resets/timeouts). Summary/extract
failures now bubble up so the batch's failed-op requeue path runs instead
of silently dropping the doc.
Frontend: sidebar Index/Log entries switch to dedicated views. Index view
streams intro → Summary → Entity → Concept → Synthesis → Comparison via
IntersectionObserver (with a nextTick re-check so small KBs still load
every section). Log view uses cursor pagination; pages_affected renders
titles with slug tooltip.
- new migration: migrations/versioned/000040_wiki_log_entries.{up,down}.sql
- tests: log repo pagination + legacy Scan, ListByTypeLight windowing,
renderIndexOverviewForAgent output, isTransientLLMError classifier
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Dockerfile invoked corepack + pnpm install, but the repo only
ships package-lock.json (no pnpm-lock.yaml). On node:24-alpine
corepack resolves to pnpm v10, whose default --strict-dep-builds
rejects unbuilt postinstall scripts (esbuild, vue-demi,
@vue-office/pptx) and aborts with ERR_PNPM_IGNORED_BUILDS, breaking
docker compose build.
Switching to npm ci / npm run build aligns the image build with
CLAUDE.md and the local dev workflow that already uses npm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pnpm v11 no longer supports onlyBuiltDependencies in package.json and does not
accept install-time --allow-build flags. Move build-script approvals to
pnpm-workspace.yaml and copy it before install in Dockerfile so CI Docker builds
can apply allowBuilds during pnpm install.
Some CI runners still fail with ERR_PNPM_IGNORED_BUILDS even with package-level
configuration. Pass explicit allow-build flags to `pnpm install` in Dockerfile so
required postinstall scripts are always permitted during image builds.
pnpm 10+ refuses to run dependency build scripts by default and exits
with ERR_PNPM_IGNORED_BUILDS, causing `pnpm install` to fail in the
frontend Docker image. Whitelist the packages that legitimately need
their install scripts (esbuild native binary, @vue-office/pptx,
vue-demi) via `pnpm.onlyBuiltDependencies`.
?tab=wiki used to call listWikiPages({page_size: 500}) in a loop up to
50 times, loading ~25k pages into memory before rendering the sidebar
on knowledge bases with tens of thousands of pages. All rows were then
materialized as DOM nodes inside collapsible groups stacked vertically.
This commit reshapes the sidebar around per-type buckets that are
fetched on demand and rendered through a single virtual scroller:
* pagesByType holds one bucket per page_type (summary / entity /
concept / synthesis / comparison), each with its own items list,
pagination cursor, KB-wide total, and loading flag. Initial load
fires one 50-item request per type in parallel.
* Index and Log are fetched directly via getWikiIndex / getWikiLog
instead of scanning the full page list to find them.
* Groups are surfaced as a horizontal tab bar; switching tabs swaps
the RecycleScroller's items. Only one bucket is visible at a time,
so we never stack multiple virtual viewports.
* The scroller runs in page-mode with the outer .wiki-page-list as the
scrollable ancestor; a 1px sentinel after the list is watched by
IntersectionObserver to trigger the next page fetch. After each
batch lands we call scroller.updateVisibleItems(true) on the next
tick so appended rows render in order without requiring the user
to jiggle the scroll.
* Switching tabs resets scrollTop so the retained position of the
previous tab can't re-trigger the sentinel and cascade loads on
the new (shorter) list.
* Search enters a dedicated flat-result mode (searchResults ref);
clearing the query flips back to the bucketed view without
refetching the buckets the user already had open.
Adds vue-virtual-scroller dependency and imports its stylesheet.
Wikis with tens of thousands of pages used to crash the browser trying
to render the entire link graph at once (30MB+ JSON, 100k+ SVG elements).
This change moves the graph viewer from "fetch everything, render
everything" to "fetch a slice, expand on demand" with several matching
UX improvements.
Backend
* GET /wiki/graph now accepts `mode` (overview | ego), `center`, `depth`,
`types`, `limit` query params. Default is overview top-500 by
link_count, capped at 2000. Response includes a Meta object so the
frontend can render a truncation hint and drive the UI.
* Pure helper `computeGraphSubset` extracted for testability; six unit
tests cover overview truncation / type filter / ego BFS / missing
center error.
* WikiLintService passes Limit=0 (uncapped) so link integrity checks
still walk every page.
* Repo `Search` adds CASE-based relevance ranking (title 4, slug 3,
summary 2, content 1) so full-text results put the most obvious
matches first instead of whatever was updated most recently.
Frontend
* Graph viewer fetches overview on entry; ego pivot on double-click,
search, wiki-link click, URL ?slug=, global issues jump.
* Shift+click or hover ⊕ button blooms neighbors onto the current
canvas additively. Bloom tracks generations and LRU-evicts oldest
when total exceeds 1500 nodes; ego center / selected node / latest
anchor are always protected.
* "Grow frontier (N)" legend action expands every dashed-ring node in
one click (6-way concurrency) while skipping Index/Log super-nodes
that would otherwise dump the whole wiki onto the canvas.
* Node dashed expansion ring + drawer "X/Y neighbors shown" hint tell
users which nodes still have neighbors to load.
* Search dropdown uses remote full-text search (debounced, sequence-
numbered to drop stale responses) and falls back to the overview
top-500 snapshot when the input is empty.
* Type filters now round-trip to the server so top-N is always
computed from the user's active type set rather than hiding nodes
client-side and shrinking the view.
* Status card replaces the cramped "centered on X · N hops · M nodes"
line with a structured focus/overview summary, resolving slugs to
page titles.
* Help popover in the legend lists every canvas shortcut.
i18n entries added for zh-CN / en-US / ko-KR / ru-RU.
- Fix global `--td-brand-color-disabled` to use correct muted green instead of bright green
- Unify `variant="text"` buttons (refresh, download, check dimension) in model settings to inherit standard TDesign styles
- Adjust WeKnora Cloud credential status layout and change reconfigure button to outline
- Fix font size of external doc links in model settings hint blocks
- Increase font weight of KB chunking debug trigger for better visibility
- Add global .doc-link utility in theme.css (brand color text + link icon, underline on hover).
- Convert all external doc/console links across settings to use .doc-link with a trailing <t-icon name="link" />, matching the Ollama browse-library style.
- Switch primary section-toolbar 'Add X' buttons (Model/WebSearch/VectorStore/MCP/DataSource) to outline variant for a lighter, more consistent look.
- Drop redundant per-component CSS for builtin-hint-link, weknoracloud-hint-link, engine-doc-link, engine-link, hint-link, section-doc-link, ds-doc-link-inline, model-link, etc.
- Add global t-radio-button outline styles in theme.css (brand fill when checked).
- Align ModelEditor, share KB, datasource, agent/MCP settings with t-radio-button.
- Remove redundant :deep overrides in AgentEditorModal, KB editor, FAQEntryManager, etc.
- Use brand disabled tone for disabled+checked radio buttons so selection stays visible.
The wiki page drawer opened from the chat view rendered tables without
borders or padding because the global `.wiki-graph-drawer .wiki-reader-body`
style block was missing GFM table rules. Add the same table styles already
used by `WikiBrowser.vue` so tables show consistent borders, header
background, and hover states across both entry points.
Some LLMs (e.g. qwen3 series) emit blank lines like "\n\n" before tool
calls. The backend faithfully forwards these as thought_chunk events
because streamThinkingToEventBus only filters chunk.Content == "" and
treats whitespace as content. The frontend then merges them into a
thinking event whose content is non-empty in JS truthiness terms but
visually empty, producing a "思考" card with a chevron and no text.
Filter such events out at the end of buildFullEventList so neither the
intermediate-steps tree nor the displayEvents fallback path renders
them.
The natural-stop path on the backend (observe.go Case 1) emits the same
content twice: once as streaming thinking chunks (from
streamThinkingToEventBus's per-chunk callback) which the frontend merges
into a single thinking event, and once as one big EventAgentFinalAnswer.
The result is that the intermediate-steps tree contains a thinking card
whose content is identical to the answer card rendered below it.
Detect this duplication and hide the offending thinking event from the
tree:
- New utils `normaliseForComparison` and `thinkingEqualsAnswer` perform
a tolerant comparison: strip <think>…</think> blocks (which leak into
raw thinking chunks but not into the post-StripThinkBlocks answer),
strip final-answer wrappers, collapse whitespace, then exact-equal or
±2% length with matching head/tail.
- `hiddenThinkingEventIds` (replacing the previous single-id
`promotedThinkingEventId`) now collects two cases of duplicate
thinking: the legacy promotion fallback (no answer event present) and
the natural-stop content-match case.
The final_answer-tool path is unaffected because reasoning thinking
events ("我找到了…让我整理…") and the answer payload have different
content and don't match the equivalence check.
Many models (especially smaller or non-OpenAI-style ones) do not reliably
call the structured final_answer tool, even when the system prompt
explicitly requires it. Two visible symptoms in the agent UI:
1. The natural-stop path emits the answer as a final_answer event whose
content is wrapped in <answer>...</answer>, <final_answer>...</final_answer>,
or prefixed with "Final Answer:" / "最终答案:". These wrappers were
rendered verbatim into the answer card.
2. When no answer event arrives at all, the trailing thinking event was
surfaced via the legacy fallback but kept its collapsed "思考" card
styling instead of rendering as a proper answer.
This change adds a small unwrap helper and wires it through the answer
rendering path:
- New util `unwrapFinalAnswerWrappers` strips conservative outer wrappers
(XML-style answer tags, fenced ```final_answer code blocks, and leading
"Final Answer:" / "最终答案:" prefixes) only when they cover the entire
trimmed content, so user markdown that happens to contain similar text
is left alone.
- `renderAnswerContent` and `getActualContent` apply the unwrap before the
markdown renderer / clipboard copy, so users see and copy clean text.
- The legacy "promote trailing thinking to final answer" fallback now
synthesizes a virtual answer event so it renders with the answer card
UI (expanded markdown + copy/add toolbar) instead of the collapsed
thinking card. The promoted thinking event is hidden from the
intermediate-steps tree to avoid duplication.
The font picker in 常规设置 silently failed in three distinct ways:
1. Font family changes were invisible on macOS because 6 of the 7 options
(Inter / Roboto / Segoe UI / Cascadia Code / JetBrains Mono / Fira
Code) aren't installed by default and fell back to system SF. Swap
the picker to a platform-aware list — Mac / Windows / Linux each see
only the fonts their OS actually ships. The "third sans slot" now
always serves a serif face (Georgia / Times New Roman / DejaVu
Serif) so the difference is unambiguously visible instead of being
three near-identical sans families.
2. Font size flipped --app-font-scale on <html>, but that variable was
only consumed by a handful of TDesign tokens and the body reset, so
the ~1000+ hard-coded font-size: NNpx rules across the frontend
stayed put. Apply the multiplier via `zoom` on <html> so the whole
document scales, then fix the 15 `100vh` rules that would overflow
under zoom (html / body / #app are already height: 100%, so the
descendants can use 100% too).
3. The monospace picker had no visible effect even on a working stack
because `<t-input style="font-family: ...">` is forwarded to the
inner <input>, but TDesign applies `font: var(--td-font-body-medium)`
— a shorthand that resets font-family. Reach the real <input> /
<textarea> via `:deep()` instead of inline style.
Additional polish:
- Add a live preview row under each picker so users can see the effect
without hunting for a code block or API Info screen
- Rename "等宽字体 / Monospace Font" → "代码字体 / Code Font" to match
VSCode / GitHub / Notion convention; the old label was jargon
- Rewrite descriptions to explain the actual purpose and scope
The chunking-settings debug trigger and strategy picker had two UX
issues reported by real usage:
- The test button used theme=primary + variant=outline, a combo that
appears nowhere else in the project. A green-bordered green-text
pill in the corner of the section header read as a misplaced
promotional element rather than a secondary action.
- The strategy select's placeholder was "选择策略(默认使用经典递归分块)"
/ "Select strategy (defaults to classic recursive splitting)". Naming
the fallback strategy inside the placeholder made the empty select
look as if the user had already picked "classic recursive" — even
though the value is actually empty.
- Move the debug trigger from the section header into the strategy
row's right column, stacked below the select. Users meet the test
action exactly when they're deciding which strategy to use.
- Switch the trigger to a text-style button and override TDesign's
default hover/focus/active surfaces so the button reads as an inline
link (text colour shift only, no filled background block).
- Rename the trigger to "测试分块效果" / "Preview chunking" so it
describes what will be inspected rather than the input source.
- Drop the reference to the fallback strategy inside the placeholder.
"不填则按长度切分" / "splits by length if left empty" preserves the
original information about empty-value behaviour without looking
like a selected option.
- Remove the now-unused 80px right padding the section header reserved
to keep the old inline trigger clear of the modal close button.
The chunking debug panel was an inline section that crowded the
strategy form, broke its own layout with negative margins, and crashed
on result.rejected being null. The trigger button required scrolling to
the form's bottom to reach.
- Move debug into a right-side t-drawer pinned above the modal stacking
context (attach=body, z-index 3000) so it overlays cleanly.
- Promote the trigger to a sticky header sibling next to the section
title, with right padding to clear the modal close button.
- Replace unicode arrows / + / − with tdesign-icons (Chevron, Play,
ErrorCircle) and rebuild the result layout: pill-shaped breadcrumb
tags, line-clamped previews with gradient fade on collapse, ordered
chunk list, structured error block.
- Drop custom char counter (t-textarea already has one).
- Add four sample fixtures (markdown / faq / chapter / plain) loadable
as preset chips so users have realistic content to test against.
- Guard `result.rejected` nullability that crashed the panel.
- Normalize the recursive tier label to legacy in the UI since both
tiers behave identically — pairs with the backend cleanup.
- Rename strategy labels in zh-CN/en-US/ko-KR/ru-RU from internal jargon
("auto", "heading", "heuristic", "legacy") to descriptive names
("Automatic", "Heading-aware", "Structure-aware", "Length-based") and
add presetLabel + sample i18n keys.
Three small follow-ups from the QA review of 13f57ca:
1. KBChunkingDebug error surfacing
Previously a 200 OK response with { success: false, error: "..." }
would have been swallowed under a generic "unexpected response shape"
message. The strict-shape check now distinguishes empty response,
success=false (surfaces resp.error directly), and missing data — so
any future backend-side validation message reaches the user.
2. Token approximation in docs/CHUNKING.md
Child Chunk Size default 384 ≈ 95 EN tokens (was rounded to 80).
384 / 4 chars-per-token = 96; "~95" is the honest figure.
3. API surface in docs/CHUNKING.md
The example only documented PUT /initialization/config/:kbId
(camelCase, documentSplitting envelope). Added explicit notes that
POST/PUT /knowledge-bases use snake_case under chunking_config, and
that POST /chunker/preview also uses the snake_case form plus a text
field. Readers picking the wrong endpoint won't be surprised by the
case mismatch anymore.
https://claude.ai/code/session_01XADhx6mtu2ZYW3DE9Lun6k
Three documentation passes around the adaptive chunking work:
UI
- Frontend ChunkOverlap default consolidated to 80 (was 100), matching
chunker.DefaultChunkOverlap on the backend. Both DEFAULT_CHUNKING_PRESET
and initFormData updated. The KB-load fallback also uses 80 when a
loaded KB has no chunk_overlap stored.
- All four locales (en-US, zh-CN, ko-KR, ru-RU) get rewritten chunking
setting descriptions: each now states the validated range, the default,
and the situations where you'd deviate (FAQ vs narrative, embedder
token limits, language-specific corpora).
Source code
- splitter.go: DefaultChunkSize / DefaultChunkOverlap constants get a
longer block-comment explaining the per-language token math and the
use-case sweet spots, plus the migration note on what the old
inconsistent defaults were.
- KBChunkingSettings.vue: new comment block above ChunkingConfig
documents the slider min/max for each setting, why those bounds
exist, and the recommended TokenLimit values per embedding model.
Repo docs
- New docs/CHUNKING.md: end-to-end guide covering why chunking matters,
the adaptive 3-tier architecture, per-setting reference with ranges
and sweet spots, parent-child explanation, the token-limit table per
embedder (OpenAI / Voyage / Cohere / BGE / MiniLM / Jina), 7 use-case
presets, the debug panel workflow, the API surface, and known
trade-offs (recursive strategy hidden from UI, no auto-reindex on
strategy switch, OCR limitations).
- CHANGELOG.md gets a new [Unreleased] section consolidating all the
adaptive-chunking work shipped on this branch: 5 features, 8
improvements, 6 fixes, 1 docs entry. The entry references
docs/CHUNKING.md for deeper explanation.
https://claude.ai/code/session_01XADhx6mtu2ZYW3DE9Lun6k
User report: clicking "Run preview" made the debug section appear to
disappear. Three independent problems contributed; fix all of them.
1. t-button rendered without explicit type="button". TDesign defaults
vary across versions; if the panel ever lives inside a parent
<form> (the editor modal does not currently, but this is defense
in depth), the click would submit the form and reload the page.
Added type="button" plus @click.prevent.stop on the run button.
2. No loading visual. While the request was in flight, result was
set back to null, so the result block disappeared with nothing
in its place. The textarea was still on screen but the spacing
shifted enough that users could read it as "section gone". Added
a loading panel with t-loading spinner + status text.
3. Error display was too quiet. A tiny one-line error block could be
missed entirely behind the modal's scrollable content. Made it
visually prominent (bold prefix, full-width error background) so
it can't be overlooked.
Bonus hardening:
- Stricter response-shape check (resp.success !== true || !resp.data
catches malformed responses, not just non-200s).
- console.error with [KBChunkingDebug] prefix so the failure is
debuggable from DevTools without enabling verbose logging.
- New i18n key chunking.debug.loading across all 4 locales.
https://claude.ai/code/session_01XADhx6mtu2ZYW3DE9Lun6k
Resolves issues from the review of be326aa..119f5e4. Each fix has a
regression test attached and overclaimed findings (parserEngineRules
defensive copy, runTier dead-code branch, init-vars architecture)
were intentionally not touched after re-evaluation.
Goroutine leak mitigation
- previewMaxChars dropped from 256k to 64k runes. The splitter does
not accept a context.Context, so when previewTimeout fires the
worker keeps running. Bounding input size keeps worst-case CPU
per request well under a second on commodity hardware. Sized so
10 concurrent timeouts don't pile up faster than they finish.
- Frontend MAX_CHARS lowered to match.
- Comment in handler explains the trade-off and points at the
follow-up: real cancellation needs the splitter to take a ctx.
Performance
- ApproxTokenCountFromRuneLen variant lets the preview handler
reuse a single rune-count per chunk for stats + size + token
estimation. Eliminates the previous triple []rune allocation per
chunk in the response loop.
- computeChunkSizeStats now takes []int (pre-computed rune lens)
instead of []chunker.Chunk; sumSq computed in float64 to avoid
the int*int overflow at l > ~46k.
Correctness / UX
- Preview panel sends strategy / token_limit / languages
unconditionally, mirroring the buildSubmitData convention so the
preview faithfully reflects what would happen on save.
- Empty-text returns a friendly 400 ("paste a sample…") instead of
gin's cryptic 'Field validation failed on the required tag'.
Tests
- TestSplit_DelegatesToSplitWithDiagnostics renamed to
TestSplit_AndDiagnostics_AgreeOnChunks (the post-audit refactor
made the original name a misnomer; the test still asserts the
right invariant under the new name).
- New TestSplitWithDiagnostics_ProfileSetForAuto and
TestSplitWithDiagnostics_ProfileNilForExplicit lock in the
profile-reuse contract that the preview endpoint depends on.
- New chunker_debug_test.go covers computeChunkSizeStats edge
cases (empty / single / varying / no-variance underflow) plus
PreviewChunking httptest scenarios (auto path, legacy strategy,
empty-text rejection, oversize rejection, chunk truncation with
full-set stats).
Doc cleanup
- runTier comment updated; the "stubbed in this scaffold" line was
obsolete since the heading and heuristic splitters shipped.
https://claude.ai/code/session_01XADhx6mtu2ZYW3DE9Lun6k
Resolves the issues surfaced by the post-Phase-3 self-review.
High severity
- Strategy / TokenLimit / Languages can now be reset. The KB-config
handler uses pointer DTOs so the absence of a field in the payload
means "no change" while an explicit empty string / 0 / [] clears
the value. The frontend now sends the fields unconditionally so
the user can revert to defaults via the clear icon — previously
these fields were write-once.
- DocProfile is no longer computed twice in the preview endpoint.
Diagnostics carries the profile that drove tier selection (when
auto-strategy ran); the handler only re-profiles for explicit
strategies that bypass the profiler.
- Chunk-size stats (avg / min / max / stddev) are now computed over
the FULL chunk set before the response is truncated to
previewMaxChunks. Avoids systematically misleading metrics for
large documents.
- SplitWithDiagnostics defaults SelectedTier to TierLegacy so an
empty-text request never leaves the diag with a blank tier string.
Medium severity
- Split() reverts to its own loop instead of delegating to
SplitWithDiagnostics. Diagnostics struct allocation is reserved
for callers that actually want the trace (preview endpoint).
Matters in SplitParentChild where Split runs once per parent.
- Preview endpoint runs the splitter on a goroutine wrapped in a
5s context.WithTimeout. CJK input at the 256k-rune ceiling
could otherwise tie up the worker for several seconds.
Low severity
- KBChunkingSettings emits arrays via spread copies so parent-side
mutation cannot drift the form's reactive state.
- KBChunkingDebug font-family includes a monospace fallback so the
chunk content stays monospaced even when the TDesign CSS variable
is missing.
Documentation
- DocProfile JSON shape is now annotated as part of the public
preview API; field renames would be breaking.
https://claude.ai/code/session_01XADhx6mtu2ZYW3DE9Lun6k
Adds a collapsible "Test with sample text" panel underneath the
chunking settings in the KB editor. Users paste a Markdown / plain-text
snippet, hit "Run preview", and see exactly how the current chunking
configuration would split it — without re-uploading any document.
The panel calls POST /api/v1/chunker/preview and renders:
- Selected tier (heading / heuristic / recursive / legacy) as a tag,
with a warning when the chain fell through to legacy fallback
- Rejected tiers + reasons (so users see why "auto" picked tier X)
- DocProfile stats grid: lines, chars, headings, page-breaks,
chapter markers, detected languages
- Chunk-size summary: count / avg / stddev / min / max
- Per-chunk cards with seq, size (chars + ~tokens), position range,
context header (when set), content preview (expand/collapse)
The panel reads its config from the live form state so edits to
strategy / chunk-size / overlap / etc. are reflected on the next
preview run — no save needed.
Pieces:
- types/chunker.ts: TS types mirroring the backend response shape
- api/chunker/index.ts: previewChunking(body) wrapper
- views/knowledge/settings/KBChunkingDebug.vue: the panel component
- KBChunkingSettings.vue: embeds the panel + exposes a debugConfig
computed prop
- i18n keys for en-US / zh-CN / ko-KR / ru-RU (8 keys each)
https://claude.ai/code/session_01XADhx6mtu2ZYW3DE9Lun6k
Surfaces the adaptive 3-tier chunking controls in the existing
"Chunking" section of the KB editor modal. Users can now pick a
strategy from a dropdown without crafting JSON for the API:
- Automatic (recommended) → backend profiler picks the tier
- Markdown-optimized → forces heading-aware splitting
- Smart structure detection → forces heuristic boundary detection
- Classic → original recursive splitter (legacy)
The fifth backend value `recursive` is intentionally hidden from the
UI — it's functionally near `legacy` and would dilute the choice.
Advanced options (collapsible, disabled when strategy=legacy):
- TokenLimit (0–8192 approx tokens; 0 = char-based budget)
- Languages multi-select (DE/EN/ZH; empty = auto-detect)
Behaviour:
- New KBs default to strategy="auto"
- Existing KBs without the field render with empty strategy and keep
classic behavior on save unless the user actively picks one
- Overlap warning when overlap >= chunkSize/2
Wired through:
- KBChunkingSettings.vue: strategy select + advanced collapsible +
ChunkingConfig interface extended
- KnowledgeBaseEditorModal.vue: form-state defaults, kb-load mapping,
buildSubmitData (chunking_config) + updateKBConfig (documentSplitting)
pass strategy/token_limit/languages
- api/initialization/index.ts: types extended
- i18n: en-US / zh-CN / ko-KR / ru-RU all carry strategy + advanced
keys (6 keys each)
https://claude.ai/code/session_01XADhx6mtu2ZYW3DE9Lun6k
DOMPurify was configured with USE_PROFILES.svg, which implicitly allows
<style> tags so that inline SVG styling works. User-authored markdown
rendered via v-html therefore let a pasted nginx error page's
<style>body { width: 35em; margin: 0 auto }</style> through the
sanitizer and apply as global CSS, squashing the entire app layout.
Add 'style' to FORBID_TAGS so top-level style elements from document
content are dropped regardless of the SVG profile.
Document list view relied on a redundant Type column (file extension is
already shown in the filename and icon) while hiding where a document
came from — external syncs from Feishu/Notion/Yuque, web URLs, manual
entries, and uploaded files were indistinguishable.
Swap Type for a Source column that resolves from `channel` first and
falls back to `type`, and surface `description` as a 12px subtitle under
the filename so the list view no longer requires hovering to read the
summary.
Reworks search from a siloed /platform/knowledge-search page into a
universal ⌘K palette plus natural, in-context entry points.
Why the old page felt out of place:
- "Search" was an action pinned next to entity routes (knowledge base,
agent, organization), mixing noun- and verb-level navigation items.
- Its two tabs (knowledge vs. chat messages) forced users to pick a
mental model before they knew what they wanted.
- It was also the only way to search at all — no global shortcut, no
context-aware entry.
What this replaces it with:
- A new ⌘K/Ctrl+K command palette (GlobalCommandPalette.vue) mounted
once in views/platform/index.vue. Results are grouped by type
(files, messages, knowledge bases, agents, chat titles, commands)
so users never need to pre-filter. Debounced, identity-keyed
selection survives async arrivals (slow KB results no longer yank
the highlight off the user's current row). ⌘1-9 jump shortcuts are
rendered inline on each row for discoverability.
- Sidebar gets a visible "Search" entry at the top — clicking it
opens the palette (no route change), with a platform-aware kbd hint
(⌘K on Mac, Ctrl K elsewhere).
- KB detail page: opening ⌘K there auto-scopes the search to the
current KB via a removable chip (Backspace in empty input also
clears it); no separate embedded search bar needed.
- The legacy route /platform/knowledge-search now redirects to the KB
list with ?cmdk= set, so existing bookmarks still work.
- KnowledgeBase.vue now handles both route-query changes and a
window event (weknora:open-knowledge) for intra-KB navigation,
fixing the silent-no-op when the palette jumps to a chunk in the
already-open KB.
Dropped:
- frontend/src/views/knowledge/KnowledgeSearch.vue (1163 lines).
- The exploratory SessionSearchBar mode-switching widget in the chat
submenu; ⌘K covers its use cases without a two-mode UI.
i18n: zh-CN / en-US / ko-KR / ru-RU updated together — new
commandPalette.*, kb.*, and menu.search keys; obsolete session.search.*
and menu.knowledgeSearch removed.
On the KB list page, pinned items already sort to the top but look
identical to the rest, so at a glance you can't tell where the pinned
group ends. Add lightweight section headers ("置顶" / "其他") that
span the grid when the list transitions from pinned to unpinned, in
both the "全部" and "我的" tabs. Shared KBs never enter the pinned
group (backend doesn't pin them).
No divider line, no backend change — purely a visual grouping on the
existing sort order.
When WeKnora is mounted under a sub-path (Kubernetes Ingress with a
path-prefix rule, an Nginx `location /weknora/` block, a Cloudflare
Tunnel route, or any other reverse-proxy gateway), the frontend's
`getApiBaseUrl()` currently returns `''` and axios issues `/api/v1/...`
requests at the origin root — bypassing the sub-path entirely and
404'ing every backend call.
This change makes `getApiBaseUrl()` honor Vite's
`import.meta.env.BASE_URL` so that, when the bundle was built with e.g.
`vite build --base /weknora/`, axios produces `/weknora/api/v1/...`.
Trailing-slash stripped to avoid double slashes (`/weknora//api/v1/...`).
For deployments at the origin root (the common case), `BASE_URL`
defaults to `'/'` which strips to `''` — behavior unchanged from
upstream. No new build flag required; no behavior change for existing
users.
Reproduce the original problem:
1. cd frontend && vite build --base /weknora/
2. Serve dist/ behind Nginx at `location /weknora/`, proxy
/weknora/api/* → backend.
3. Open https://example.com/weknora/ → 404 on every API request
because axios calls /api/v1/auth/test instead of
/weknora/api/v1/auth/test.
After the fix the same setup just works.
Vite raised `Duplicate key "liveIndicator" in object literal` at
ko-KR.ts:4107. Both entries mapped to the same Korean translation so
the second is simply redundant. Removing it silences the warning
without changing any user-visible string.
Verified no other duplicate keys remain in en-US / zh-CN / ko-KR /
ru-RU with a brace-scope-aware scan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QA review of the previous hardening commit identified two cross-user
infection paths created by the smart-fallback read chain (CRIT-1/2),
plus a UI-desync hazard from validation guards in setters (HIGH-1).
- Extracted localStorage helpers and migration logic into
composables/preferenceStorage.ts as a single source of truth.
- Reads now hit only the active user's namespace - no anon / legacy
fallback in the hot path. Pre-existing values are migrated **once**
per active user via migratePreferencesIntoUser, which adopts anon
and legacy keys into the user namespace and then deletes the source
keys, so a second user logging into the same browser cannot inherit
the previous user's preferences.
- Migration latch tracks which user has already been migrated this
session; auth store's reloadUserPreferences resets the latch on
setUser/logout so the next active user gets a fresh migration.
- setSansFont / setMonoFont / setFontSize / setTheme now return a
boolean; GeneralSettings.vue handlers roll the form back to the
canonical state when validation rejects a value, preventing the
UI from drifting out of sync with the source of truth.
- safeSetItem now console.warn on persistence failures so quota /
private-mode issues are at least diagnosable.
- applyFont is diff-aware: only the CSS variables that actually
changed are written to the root style, avoiding unnecessary style
recalculation when only one knob is flipped.
- applyTheme caches the last effective mode to skip redundant
attribute writes and Wails IPC calls.
https://claude.ai/code/session_01S1FVPf4jRfWXkTSnpMmMp7
Edge-case review identified several issues that would degrade UX or
silently break under unusual conditions:
- Anon settings chosen on the login screen were lost on first login
because the storage namespace switched from "anon" to the user id;
resolveStorageValue now falls through user -> anon -> legacy, so
pre-login choices carry over and v1 unscoped keys are still picked up.
- localStorage.setItem can throw under quota / private mode; setters now
swallow the error so the UI still updates for the session.
- applyFont reduced to String(undefined) when currentSize held an
invalid value; runtime fallbacks to the default scale guard against
manipulated storage.
- Added var(--app-font-scale, 1) defaults to all 32 calc() expressions
in theme.css and the body font-size, so a missing variable cannot
void the calc() value.
- Validation guards in setSansFont / setMonoFont / setFontSize / setTheme
reject invalid keys instead of writing them to storage.
- GeneralSettings.vue now watches the composable refs so the form stays
in sync if preferences change externally (e.g. user switch while on
the page).
- Dropped the unverified t-radio-group variant prop in favour of the
default style.
https://claude.ai/code/session_01S1FVPf4jRfWXkTSnpMmMp7
Build on the font picker with three additions per user feedback:
- Font size selector (Small / Normal / Large) wired through a new
--app-font-scale CSS variable; the body font-size and every
--td-font-size-* token are wrapped in calc(... * var(--app-font-scale))
so TDesign components scale together with the body
- localStorage keys for theme, font family, mono font and font size are
now namespaced as WeKnora_${userId}_${key}; falls back to "anon" before
login. Each user keeps their own preferences in the same browser
- auth store calls reloadFontFromStorage / reloadThemeFromStorage on
setUser (when the active user changes) and in logout, so a user switch
swaps the applied preferences without a reload
https://claude.ai/code/session_01S1FVPf4jRfWXkTSnpMmMp7
The UI used hardcoded "PingFang SC" in 50+ components, while the global
font stack in App.vue used a different fallback chain. Outside of mainland
China this caused visibly different fonts in the sidebar, breadcrumbs,
search and agent views compared to settings and document panes.
- Introduce --app-font-family / --app-font-family-mono CSS variables in
theme.css and route TDesign's --td-font-family through them
- Add a useFont composable that persists the user's choice in localStorage
and applies it via document.documentElement.style before mount
- Replace hardcoded font-family declarations across the app with the new
variables (TencentSans logo and the SVG attribute list are preserved)
- Add UI font and monospace font selectors in General Settings with
i18n strings for en-US, zh-CN, ko-KR and ru-RU
https://claude.ai/code/session_01S1FVPf4jRfWXkTSnpMmMp7
Add Kingsoft Cloud KS3 as a supported storage provider, including
backend service implementation, storage config UI, and provider
scheme recognition throughout the codebase.
Bump version to 0.5.1 across VERSION, frontend package files, and
Helm chart, and refresh CHANGELOG plus the four-language README
"Latest Updates" section to merge v0.5.1 highlights with the v0.5.0
core feature set.
- Apply the session-list additions to the SQLite init migration (Lite build)
and make QueryPaged dialect-aware: LOWER(..) LIKE on SQLite, ILIKE on
Postgres, drop NULLS LAST on SQLite. Escape LIKE wildcards in the keyword
via the existing escapeLikeKeyword helper.
- SetPinned now returns rowsAffected so the handler can respond 404 on
unknown / unauthorized session IDs instead of a misleading 200.
- GET /sessions always returns the enriched shape (pin state + IM origin
fields) so the frontend never needs a second roundtrip; the dual-path
legacy branch in the handler is gone.
- Register pin routes with the wildcard name that matches each verb's
existing radix tree (POST :session_id, DELETE :id) and accept either
param name in the handler; avoids gin's "wildcard conflicts" panic.
- Drop the redundant [platform] prefix from IM session titles now that
the list renders a platform icon alongside the title; add unit tests
for shortID / buildUserSessionTitle / buildThreadSessionTitle.
- Frontend: remove the submenu search input and its i18n/keyword wiring
(search lives elsewhere in the app); pin icon uses the TDesign `pin`
glyph and inherits color so the active session turns green; optimistic
pin moves the item to the top of the list so it shows up at the top
of the Pinned group; IM text badge replaced by the platform SVG icons
from assets/img/im, desaturated by default and full-color on hover
or when the session is active.
Sessions today are a flat list ordered by updated_at. Two gaps showed up in
practice:
- Users cannot find specific chats as the list grows beyond a screen.
- IM-created sessions (WeCom/Feishu/Slack/...) are indistinguishable:
every title was "IM-<platform>" or "IM-<platform>-<username>" and the
list API hid the underlying im_channel_sessions mapping, so admins had
no way to tell which Feishu group a session came from.
Backend
- Migration 000039 adds sessions.user_id (owner), is_pinned, pinned_at
plus a composite index for the list query. Existing rows keep user_id
NULL and stay visible at the tenant level for backward compatibility.
- CreateSession now writes the caller's user_id from auth context.
- GET /sessions accepts keyword / source / agent_id. When any filter is
set, the response switches to enriched items that LEFT JOIN
im_channel_sessions and expose im_platform / im_chat_id / im_thread_id
/ im_user_id / im_agent_id / im_channel_id. No filters => legacy shape,
existing clients unaffected.
- Ordering: is_pinned DESC, pinned_at DESC NULLS LAST, updated_at DESC.
- POST/DELETE /sessions/:id/pin for user-scoped pin/unpin.
- IM session titles: "[platform] <user|chat|thread>" with short ID
suffixes so group/DM/thread sessions are visually distinct without
needing a round-trip to fetch a display name from the IM adapter.
Frontend
- Search input debounced at 300ms drives the keyword filter.
- Pinned chats render in a dedicated group above the time-based groups,
with a pin icon and a pin/unpin entry in the per-chat dropdown.
- IM chats get a short [platform] badge in the list.
- Pin toggle is optimistic and guards against double-clicks.
- zh/en/ko/ru i18n keys added for the new strings.
Until now the IM channels a tenant has connected were only visible from
inside each agent's editor (AgentEditorModal → IMChannelPanel). Finding
out which bots are live across all agents meant clicking through every
one of them. Add a tenant-scoped overview so the set of connected IMs is
always one hop away from the avatar.
Backend
- im.Service.ListChannelsByTenant(tenantID) returns all non-deleted
channels in the tenant, LEFT JOIN'd with custom_agents.name so
built-in agents (which don't have rows in custom_agents) still show
up with an empty agent_name; the frontend substitutes a localized
"built-in agent" label.
- Credentials are intentionally stripped from this response — the list
view is read-only and the editor route (GET /agents/:id/im-channels)
remains the only source for secrets.
- New ChannelWithAgent DTO + IMHandler.ListAllIMChannels wired at
GET /api/v1/im-channels under the existing RegisterIMChannelRoutes
group, so auth middleware is inherited.
Frontend
- IMChannelsOverviewPanel.vue: floating submenu pane, stacked rows
(agent on top, channel below) with identical 20px avatars and 12px
labels so neither identity dominates. Each row is the whole click
target (jumps to the agent editor); the switch uses @click.stop to
toggle in place without triggering navigation.
- UserMenu.vue: new hover-driven submenu entry ("已接入的 IM" + link
icon). The pane is teleported to <body> because the sidebar container
has overflow:hidden that would otherwise clip right-flying content.
Position is computed from the menu item's rect and clamps/flips
against the viewport edges.
- "Live" indicator: a pulsing green dot on the menu item when at least
one channel is enabled. UserMenu prefetches the list once on mount;
the panel re-emits channels-changed after every load/toggle so the
dot stays in sync. Respects prefers-reduced-motion.
- AgentList.vue: supplement the existing onMounted-only
checkAndOpenEditModal with a watch(route.query.edit) so
router.push({ path: '/platform/agents', query: { edit, section } })
from an already-mounted AgentList (the common case when navigating
from the overview) opens the editor immediately instead of only
after a hard refresh.
Assets
- Seven platform SVGs under assets/img/im/, pulled from iconify
(simple-icons / logos / tdesign / icon-park / remix icon) and baked
with brand colors so monochrome sources (wecom/wechat/dingtalk/
feishu) don't render black.
i18n
- New imOverview namespace in zh-CN / en-US / ko-KR / ru-RU covering
the menu label, panel title/subtitle, column headers, builtin-agent
fallback, and the live-indicator tooltip.
Tested
- go build ./...
- go test ./internal/im/...
- vue-tsc --noEmit
- npm run build