WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
package text
// KnowledgeDisplayName picks the most informative human label for a
// knowledge entry. File-uploads use FileName; URL / text entries fall
// back to Title; the ID is the last-resort placeholder so a table cell
// is never empty. Single source for the ordering - `weknora doc list`
// and `weknora search docs` both call this so a Knowledge renders
// identically in either command.
//
// Takes the three fields directly rather than `sdk.Knowledge` so this
// package stays free of SDK imports.
func KnowledgeDisplayName(fileName, title, id string) string {
if fileName != "" {
return fileName
}
if title != "" {
return title
}
return id
}
+17
View File
@@ -0,0 +1,17 @@
package text
import "strings"
// ContainsFold reports whether any of fields contains needle, comparing
// case-insensitively. Avoids the inline `strings.Contains(strings.ToLower
// (field), needle)` triple-pattern when callers want to OR-match across
// several columns of the same record. The caller passes the needle in
// lowercase form so we don't lowercase the same string per call site.
func ContainsFold(lowerNeedle string, fields ...string) bool {
for _, f := range fields {
if strings.Contains(strings.ToLower(f), lowerNeedle) {
return true
}
}
return false
}
+13
View File
@@ -0,0 +1,13 @@
package text
import "strings"
// OneLine collapses newlines/carriage-returns/tabs in s to single spaces,
// then truncates to maxDisplayWidth columns (UTF-8 safe via Truncate).
// Use for human-readable preview rows where multiline content would
// break tabular layout.
func OneLine(maxDisplayWidth int, s string) string {
return Truncate(maxDisplayWidth, lineReplacer.Replace(s))
}
var lineReplacer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ")
+29
View File
@@ -0,0 +1,29 @@
package text_test
import (
"testing"
"github.com/Tencent/WeKnora/cli/internal/text"
)
func TestOneLine(t *testing.T) {
cases := []struct {
name string
max int
in, want string
}{
{"empty", 10, "", ""},
{"no-collapse", 10, "hello", "hello"},
{"collapse-newline", 20, "hello\nworld", "hello world"},
{"collapse-cr-tab", 20, "hello\r\tworld", "hello world"},
{"truncate", 8, "hello world long", "hello w…"},
{"truncate-after-collapse", 8, "hello\nworld\nlong", "hello w…"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := text.OneLine(c.max, c.in); got != c.want {
t.Errorf("OneLine(%d, %q) = %q, want %q", c.max, c.in, got, c.want)
}
})
}
}
+14
View File
@@ -0,0 +1,14 @@
// Package text holds string helpers for human-readable CLI output.
// All functions are pure (no I/O, no time.Now), making them trivially testable.
package text
import "fmt"
// Pluralize returns "<n> <thing>" or "<n> <thing>s". Simple suffix "s" only;
// irregular forms (person/people) are not supported.
func Pluralize(n int, thing string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, thing)
}
return fmt.Sprintf("%d %ss", n, thing)
}
+27
View File
@@ -0,0 +1,27 @@
package text_test
import (
"testing"
"github.com/Tencent/WeKnora/cli/internal/text"
)
func TestPluralize(t *testing.T) {
tests := []struct {
n int
thing string
want string
}{
{0, "doc", "0 docs"},
{1, "doc", "1 doc"},
{2, "doc", "2 docs"},
{5, "chunk", "5 chunks"},
{1, "chunk", "1 chunk"},
}
for _, tt := range tests {
got := text.Pluralize(tt.n, tt.thing)
if got != tt.want {
t.Errorf("Pluralize(%d, %q) = %q, want %q", tt.n, tt.thing, got, tt.want)
}
}
}
+38
View File
@@ -0,0 +1,38 @@
package text
import (
"fmt"
"math"
"time"
)
// FuzzyAgo formats t relative to now as a short relative-time string.
//
// Examples:
//
// FuzzyAgo(now, now-5m) → "about 5 minutes ago"
// FuzzyAgo(now, now-3h) → "about 3 hours ago"
// FuzzyAgo(now, now-2d) → "about 2 days ago"
// FuzzyAgo(now, now+5m) → "about 5 minutes from now"
func FuzzyAgo(now, t time.Time) string {
d := now.Sub(t)
suffix := "ago"
if d < 0 {
d = -d
suffix = "from now"
}
switch {
case d < time.Minute:
return "less than a minute " + suffix
case d < time.Hour:
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Minutes())), "minute"), suffix)
case d < 24*time.Hour:
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours())), "hour"), suffix)
case d < 30*24*time.Hour:
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours()/24)), "day"), suffix)
case d < 365*24*time.Hour:
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours()/(24*30))), "month"), suffix)
default:
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours()/(24*365))), "year"), suffix)
}
}
+25
View File
@@ -0,0 +1,25 @@
package text
import "time"
// FuzzyAgoStr is the string-input variant of FuzzyAgo for SDK types that
// carry timestamps as RFC3339 strings (sdk.Chunk.UpdatedAt, sdk.Session.
// UpdatedAt, ...) rather than time.Time.
//
// Behavior:
// - ts == "" → "-" (server has no timestamp; render placeholder)
// - parse error → ts (surface the raw value rather than hide it)
// - parse OK → FuzzyAgo(now, parsed)
//
// The parse-error fallback is deliberate: if the server starts emitting a
// new format we want it visible in the table, not silently replaced by "-".
func FuzzyAgoStr(now time.Time, ts string) string {
if ts == "" {
return "-"
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
return ts
}
return FuzzyAgo(now, t)
}
+31
View File
@@ -0,0 +1,31 @@
package text_test
import (
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/text"
)
func TestFuzzyAgoStr(t *testing.T) {
now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
ts string
want string
}{
{"empty renders dash", "", "-"},
{"valid RFC3339 5m ago", now.Add(-5 * time.Minute).Format(time.RFC3339), "about 5 minutes ago"},
{"valid RFC3339 2d ago", now.Add(-2 * 24 * time.Hour).Format(time.RFC3339), "about 2 days ago"},
{"unparseable returned verbatim", "not-a-date", "not-a-date"},
{"wrong format returned verbatim", "2026-05-15 12:00:00", "2026-05-15 12:00:00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := text.FuzzyAgoStr(now, tt.ts)
if got != tt.want {
t.Errorf("FuzzyAgoStr(%q) = %q, want %q", tt.ts, got, tt.want)
}
})
}
}
+31
View File
@@ -0,0 +1,31 @@
package text_test
import (
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/text"
)
func TestFuzzyAgo(t *testing.T) {
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
t time.Time
want string
}{
{"5 minutes ago", now.Add(-5 * time.Minute), "about 5 minutes ago"},
{"1 hour ago", now.Add(-1 * time.Hour), "about 1 hour ago"},
{"3 hours ago", now.Add(-3 * time.Hour), "about 3 hours ago"},
{"2 days ago", now.Add(-2 * 24 * time.Hour), "about 2 days ago"},
{"30 days ago", now.Add(-30 * 24 * time.Hour), "about 1 month ago"},
{"5 minutes from now", now.Add(5 * time.Minute), "about 5 minutes from now"},
{"less than 1m ago", now.Add(-30 * time.Second), "less than a minute ago"},
}
for _, tt := range tests {
got := text.FuzzyAgo(now, tt.t)
if got != tt.want {
t.Errorf("FuzzyAgo(%s) = %q, want %q", tt.name, got, tt.want)
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package text
import "github.com/mattn/go-runewidth"
const ellipsis = "…"
// Truncate cuts s to at most maxWidth display columns (CJK / emoji occupy
// 2 columns; ASCII 1), appending "…" if truncated. Display width, not rune count.
//
// Edge cases:
//
// maxWidth <= 0 -> ""
// s already fits -> s returned unchanged
// ellipsis is 1 column, so truncate budget = maxWidth - 1
func Truncate(maxWidth int, s string) string {
if maxWidth <= 0 {
return ""
}
if runewidth.StringWidth(s) <= maxWidth {
return s
}
if maxWidth == 1 {
return ellipsis
}
budget := maxWidth - 1 // reserve for ellipsis
w := 0
for i, r := range s {
rw := runewidth.RuneWidth(r)
if w+rw > budget {
return s[:i] + ellipsis
}
w += rw
}
return s + ellipsis
}
+31
View File
@@ -0,0 +1,31 @@
package text_test
import (
"testing"
"github.com/Tencent/WeKnora/cli/internal/text"
)
func TestTruncate(t *testing.T) {
tests := []struct {
name string
maxWidth int
s string
want string
}{
{"no truncate ASCII", 20, "hello", "hello"},
{"truncate ASCII", 5, "hello world", "hell…"},
{"no truncate CJK", 8, "中文测试", "中文测试"},
{"truncate CJK", 5, "中文测试", "中文…"},
{"empty", 5, "", ""},
{"single char fits", 1, "a", "a"},
{"truncate to width 1", 1, "abc", "…"},
{"maxWidth zero", 0, "x", ""},
}
for _, tt := range tests {
got := text.Truncate(tt.maxWidth, tt.s)
if got != tt.want {
t.Errorf("Truncate(%d, %q) = %q, want %q", tt.maxWidth, tt.s, got, tt.want)
}
}
}