diff --git a/examples/plugins/subtitles/main.go b/examples/plugins/subtitles/main.go new file mode 100644 index 000000000..bce45fbc2 --- /dev/null +++ b/examples/plugins/subtitles/main.go @@ -0,0 +1,130 @@ +// Command subtitles is an example parser plugin: it turns SRT and WebVTT +// subtitle files into a Markdown transcript, one paragraph per cue with its +// start time. It also keeps a per-workspace count of parsed files through +// the Host API key-value store, to show how a plugin keeps state without a +// database. +// +// Build a package with ./package.sh. +package main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "log" + "regexp" + "strings" + "time" + + "github.com/Tencent/WeKnora/pluginsdk" + "github.com/Tencent/WeKnora/pluginsdk/pluginapi" +) + +// Version must match plugin.yaml. +const Version = "1.0.0" + +func main() { + p := pluginsdk.New(pluginsdk.Info{ID: "weknora-examples.subtitles", Version: Version}) + p.Parser("subtitles", pluginsdk.ParserFunc(parse)) + if err := p.Serve(); err != nil { + log.Fatal(err) + } +} + +// timing matches a cue timing line: 00:01:02,500 --> 00:01:04,000 (SRT) or +// 01:02.500 --> 01:04.000 (WebVTT, hours optional). +var timing = regexp.MustCompile(`^\s*((?:\d+:)?\d{2}:\d{2}[.,]\d{3})\s*-->\s*`) + +// tags strips WebVTT / SRT inline markup such as or . +var tags = regexp.MustCompile(`<[^>]*>`) + +type cue struct { + start string + text []string +} + +func parse(ctx context.Context, call *pluginsdk.Call, in pluginapi.ParseInput) (*pluginapi.ParseOutput, error) { + if len(in.Content) == 0 { + return nil, pluginapi.Errorf(pluginapi.CodeInvalidConfig, "the subtitle file is empty") + } + cues := parseCues(in.Content) + if len(cues) == 0 { + return nil, pluginapi.Errorf( + pluginapi.CodeInvalidConfig, + "no subtitle cues found; is this an .srt or .vtt file?", + ) + } + title := strings.TrimSpace(in.Title) + if title == "" { + title = strings.TrimSuffix(in.FileName, "."+in.FileType) + } + var md strings.Builder + fmt.Fprintf(&md, "# %s\n\n", title) + for _, c := range cues { + fmt.Fprintf(&md, "**[%s]** %s\n\n", shortTime(c.start), strings.Join(c.text, " ")) + } + countParsed(ctx, call) + return &pluginapi.ParseOutput{ + Markdown: strings.TrimSpace(md.String()) + "\n", + Metadata: map[string]string{"cues": fmt.Sprint(len(cues))}, + }, nil +} + +func parseCues(data []byte) []cue { + data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")) + var out []cue + var cur *cue + sc := bufio.NewScanner(bytes.NewReader(data)) + sc.Buffer(make([]byte, 0, 64<<10), 1<<20) + for sc.Scan() { + line := strings.TrimRight(sc.Text(), "\r") + if m := timing.FindStringSubmatch(line); m != nil { + out = append(out, cue{start: m[1]}) + cur = &out[len(out)-1] + continue + } + if strings.TrimSpace(line) == "" { + cur = nil + continue + } + if cur == nil { + continue // cue numbers, WEBVTT header, NOTE blocks + } + if text := strings.TrimSpace(tags.ReplaceAllString(line, "")); text != "" { + cur.text = append(cur.text, text) + } + } + kept := out[:0] + for _, c := range out { + if len(c.text) > 0 { + kept = append(kept, c) + } + } + return kept +} + +// shortTime drops milliseconds and a zero hour: 00:01:02,500 → 01:02. +func shortTime(t string) string { + t = strings.NewReplacer(",", ".").Replace(t) + if i := strings.IndexByte(t, '.'); i >= 0 { + t = t[:i] + } + return strings.TrimPrefix(t, "00:") +} + +// countParsed bumps the workspace's parsed-file counter. It is best effort: +// a missing grant or an unreachable host must not fail the parse. +func countParsed(ctx context.Context, call *pluginsdk.Call) { + host := call.Host() + if host == nil { + return + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var n int + if _, err := host.KVGet(ctx, "stats/parsed", &n); err != nil { + return + } + _ = host.KVPut(ctx, "stats/parsed", n+1, 0) +} diff --git a/examples/plugins/subtitles/main_test.go b/examples/plugins/subtitles/main_test.go new file mode 100644 index 000000000..66ee0f3c9 --- /dev/null +++ b/examples/plugins/subtitles/main_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "bytes" + "context" + "go/parser" + "go/token" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Tencent/WeKnora/internal/application/repository" + "github.com/Tencent/WeKnora/internal/plugin/hostapi" + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/pluginsdk" + "github.com/Tencent/WeKnora/pluginsdk/client" + "github.com/Tencent/WeKnora/pluginsdk/conformance" + "github.com/Tencent/WeKnora/pluginsdk/pluginapi" +) + +const srt = "\ufeff1\r\n00:00:01,000 --> 00:00:03,500\r\nHello world.\r\n\r\n" + + "2\r\n00:01:02,250 --> 00:01:04,000\r\nSecond line\r\ncontinues here.\r\n" + +const vtt = `WEBVTT + +NOTE a comment + +01:02.500 --> 01:04.000 +Plugins run out of process. + +1:00:00.000 --> 1:00:02.000 align:start +Bye. +` + +func TestParseSRTAndVTT(t *testing.T) { + ctx := context.Background() + out, err := parse( + ctx, + &pluginsdk.Call{}, + pluginapi.ParseInput{FileName: "talk.srt", FileType: "srt", Content: []byte(srt)}, + ) + if err != nil { + t.Fatal(err) + } + want := "# talk\n\n**[00:01]** Hello world.\n\n**[01:02]** Second line continues here.\n" + if out.Markdown != want || out.Metadata["cues"] != "2" { + t.Fatalf("srt markdown = %q", out.Markdown) + } + out, err = parse( + ctx, + &pluginsdk.Call{}, + pluginapi.ParseInput{FileName: "t.vtt", FileType: "vtt", Title: "Talk", Content: []byte(vtt)}, + ) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.Markdown, "# Talk") || + !strings.Contains(out.Markdown, "**[01:02]** Plugins run out of process.") || + !strings.Contains(out.Markdown, "**[1:00:00]** Bye.") { + t.Fatalf("vtt markdown = %q", out.Markdown) + } + for _, bad := range []string{"", "just some text\nwith no cues\n"} { + _, err := parse( + ctx, + &pluginsdk.Call{}, + pluginapi.ParseInput{FileName: "x.srt", FileType: "srt", Content: []byte(bad)}, + ) + if e, ok := pluginapi.AsError(err); !ok || e.Code != pluginapi.CodeInvalidConfig || e.Retryable { + t.Errorf("%q: want a permanent invalid_config error, got %v", bad, err) + } + } +} + +func TestParseCountsThroughTheHostAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&types.PluginKV{}); err != nil { + t.Fatal(err) + } + iss := hostapi.NewIssuer([]byte("k")) + r := gin.New() + hostapi.NewHandler(iss, hostapi.NewKV(repository.NewPluginKVRepository(db))).Register(r) + srv := httptest.NewServer(r) + defer srv.Close() + + tok, _, _ := iss.Issue("weknora-examples.subtitles", Version, 3, []string{"kv"}) + call := &pluginsdk.Call{ + Context: pluginapi.Context{TenantID: 3, Host: &pluginapi.HostAccess{URL: srv.URL, Token: tok}}, + } + in := pluginapi.ParseInput{FileName: "a.srt", FileType: "srt", Content: []byte(srt)} + for range 2 { + if _, err := parse(context.Background(), call, in); err != nil { + t.Fatal(err) + } + } + var n int + if ok, err := call.Host().KVGet(context.Background(), "stats/parsed", &n); !ok || err != nil || n != 2 { + t.Fatalf("parsed count = %d (%v, %v)", n, ok, err) + } +} + +func TestConformsAndStaysExternal(t *testing.T) { + p := pluginsdk.New(pluginsdk.Info{ID: "weknora-examples.subtitles", Version: Version}) + p.Parser("subtitles", pluginsdk.ParserFunc(parse)) + srv := httptest.NewServer(p.Handler()) + defer srv.Close() + rep := conformance.Run(context.Background(), conformance.Target{ + Client: client.New(srv.URL, nil, nil), + Raw: func(ctx context.Context, path string, body []byte) (*http.Response, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+path, bytes.NewReader(body)) + return http.DefaultClient.Do(req) + }, + }) + for _, res := range rep.Results { + if !res.Passed { + t.Errorf("%s: %s", res.Name, res.Detail) + } + } + files, _ := filepath.Glob("*.go") + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, _ := os.ReadFile(f) + parsed, err := parser.ParseFile(token.NewFileSet(), f, src, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, imp := range parsed.Imports { + if strings.Contains(imp.Path.Value, "WeKnora/internal") { + t.Errorf("%s imports %s", f, imp.Path.Value) + } + } + } +} diff --git a/examples/plugins/subtitles/package.sh b/examples/plugins/subtitles/package.sh new file mode 100755 index 000000000..09f3d236e --- /dev/null +++ b/examples/plugins/subtitles/package.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Builds the subtitles example plugin for the platforms WeKnora runs on and zips it +# into a .wkp package: plugin.yaml and bin/-/subtitles. +set -euo pipefail +cd "$(dirname "$0")" +version=$(sed -n 's/^version: //p' plugin.yaml) +out=${1:-"weknora-examples-subtitles-${version}.wkp"} +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT +cp plugin.yaml "$stage"/ +for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do + os=${target%/*} + arch=${target#*/} + bin="$stage/bin/${os}-${arch}/subtitles" + [ "$os" = windows ] && bin="$bin.exe" + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath -ldflags="-s -w" -o "$bin" . +done +rm -f "$out" +(cd "$stage" && zip -qr - .) > "$out" +echo "wrote $out" diff --git a/examples/plugins/subtitles/plugin.yaml b/examples/plugins/subtitles/plugin.yaml new file mode 100644 index 000000000..86252e3fc --- /dev/null +++ b/examples/plugins/subtitles/plugin.yaml @@ -0,0 +1,23 @@ +schemaVersion: 1 +id: weknora-examples.subtitles +version: 1.0.0 +apiVersion: weknora.plugin/v1 +name: { en-US: Subtitles, zh-CN: 字幕解析 } +description: + en-US: Parse SRT and WebVTT subtitles into a searchable transcript. + zh-CN: 把 SRT、WebVTT 字幕解析为可检索的文字稿。 +publisher: { id: weknora-examples, name: WeKnora examples } +homepage: https://github.com/Tencent/WeKnora/tree/main/examples/plugins/subtitles +license: MIT +runtime: { type: host, kind: binary, entry: "bin/{os}-{arch}/subtitles" } +permissions: + # Counts parsed files per workspace in the Host API key-value store. + hostApi: [kv] +contributes: + parsers: + - id: subtitles + name: { en-US: Subtitles, zh-CN: 字幕解析 } + description: + en-US: SRT and WebVTT subtitles as a transcript with cue times + zh-CN: 带时间点的字幕文字稿 + fileTypes: [srt, vtt] diff --git a/frontend/src/api/system/index.ts b/frontend/src/api/system/index.ts index fa717354b..978564371 100644 --- a/frontend/src/api/system/index.ts +++ b/frontend/src/api/system/index.ts @@ -107,6 +107,10 @@ export interface ParserEngineInfo { FileTypes: string[] Available?: boolean UnavailableReason?: string + /** Set on engines an installed plugin provides (Name is then acme.ocr/ocr). */ + PluginID?: string + /** Localized names of plugin engines, keyed by locale; "default" as fallback. */ + DisplayNames?: Record } /** 解析引擎配置(引擎连接参数存空间;聊天附件解析策略在智能体中配置) */ diff --git a/frontend/src/views/knowledge/settings/KBParserSettings.vue b/frontend/src/views/knowledge/settings/KBParserSettings.vue index 107eb2dbd..7332b3dfd 100644 --- a/frontend/src/views/knowledge/settings/KBParserSettings.vue +++ b/frontend/src/views/knowledge/settings/KBParserSettings.vue @@ -66,6 +66,7 @@