feat: implement streaming chat, skill routing, and SAFe PI planning tools

- Add /api/chat/stream endpoint with Server-Sent Events (SSE) for real-time message streaming
  * Implement StreamEvent types (thought, tool_call, tool_result, final, error)
  * Add StreamEventCallback mechanism for event propagation
  * Create StreamChatHandler in webui/bot with proper HTTP headers and flushing

- Implement LLM-based skill router for intelligent capability selection
  * Add optional routerLLM client for semantic routing
  * Implement routeSkillsWithLLM() to match user intent to available skills
  * Add matchSkillsByName() for fuzzy skill matching
  * Update buildUnifiedSystemPrompt() to use routed skills

- Add streaming support to ReAct pipeline
  * Implement runUnifiedReActStream() for streaming thought/action/observation
  * Emit StreamEvent at each ReAct step
  * Support callback error handling in streaming mode

- Integrate three new DevOps tools
  * tools/filedoc: Extract document content from file_id via OpenAI
  * tools/giteaticket: Create Gitea issues from PI plan items with SAFe metadata
  * tools/piplan: Publish PI planning blueprints with dependency tracking

- Add SAFe PI Planning skill
  * Implement PM/SA/RTE (iron triangle) workflow
  * Support for Feature, Enabler, and Dependency definition
  * Automatic task decomposition and Gitea integration

- Create frontend integration documentation
  * Complete SSE protocol specification
  * TypeScript fetch + ReadableStream example
  * LLM-ready refactoring template for other projects

- Simplify file handling
  * Remove legacy file context structures and dual-mode processing
  * Consolidate file operations into UploadAndCacheFiles()
  * Remove FilePromptMode configuration and related complexity

- Update configuration
  * Add Router model support (LLM_ROUTER_MODEL)
  * Add Gitea configuration (BaseURL, Token, Owner, Repo)
  * WebSearch and additional tool infrastructure

Tests: All 22 test packages passing, 8/8 webui tests including 3 new stream tests
This commit is contained in:
2026-03-11 17:58:19 +08:00
parent 0e1a800646
commit 8dc5354fa4
17 changed files with 3086 additions and 565 deletions

208
tools/filedoc/filedoc.go Normal file
View File

@@ -0,0 +1,208 @@
package filedoc
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"laodingbot/internal/logger"
openai "github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/openai/openai-go/shared"
)
type Config struct {
APIKey string
BaseURL string
Model string
Timeout time.Duration
}
type Tool struct {
client openai.Client
model string
maxOutputChars int
log *logger.Logger
}
func New(cfg Config, maxOutputChars int, log *logger.Logger) *Tool {
if strings.TrimSpace(cfg.Model) == "" {
cfg.Model = "gpt-4o-mini"
}
if cfg.Timeout <= 0 {
cfg.Timeout = 60 * time.Second
}
if maxOutputChars <= 0 {
maxOutputChars = 12000
}
opts := []option.RequestOption{
option.WithAPIKey(strings.TrimSpace(cfg.APIKey)),
option.WithRequestTimeout(cfg.Timeout),
}
if strings.TrimSpace(cfg.BaseURL) != "" {
opts = append(opts, option.WithBaseURL(strings.TrimSpace(cfg.BaseURL)))
}
return &Tool{
client: openai.NewClient(opts...),
model: cfg.Model,
maxOutputChars: maxOutputChars,
log: log,
}
}
func (t *Tool) Name() string { return "extract_file_document" }
func (t *Tool) Description() string {
return "Extract full document details from a file ID via OpenAI. Input: file_id (supports plain ID, fileid://ID, or JSON {\"file_id\":\"...\"})."
}
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
fileID, userFocus, err := parseInput(input)
if err != nil {
return "", err
}
prompt := buildExtractionPrompt(fileID, userFocus)
messages := []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage("fileid://" + fileID),
openai.UserMessage([]openai.ChatCompletionContentPartUnionParam{
openai.TextContentPart(prompt),
}),
}
params := openai.ChatCompletionNewParams{
Model: shared.ChatModel(t.model),
Messages: messages,
}
if t.log != nil {
t.log.Infof("filedoc tool request model=%s file_id=%s", t.model, fileID)
}
resp, err := t.client.Chat.Completions.New(ctx, params)
if err != nil {
return "", fmt.Errorf("filedoc request failed: %w", err)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("filedoc returned empty choices")
}
out := strings.TrimSpace(resp.Choices[0].Message.Content)
if out == "" {
out = "未提取到可读的文档内容。请确认 file_id 是否有效以及模型是否支持文件解析。"
}
if len(out) > t.maxOutputChars {
out = out[:t.maxOutputChars]
}
return out, nil
}
func buildExtractionPrompt(fileID, userFocus string) string {
focus := strings.TrimSpace(userFocus)
if focus == "" {
focus = "请输出完整文档信息,包括标题、主题、核心观点、结构大纲、关键术语、重要结论、风险点与后续建议。"
}
return strings.Join([]string{
"请基于所附文件输出完整文档信息。",
"file_id: " + fileID,
"",
"输出要求:",
"1) 文档基本信息:标题、文档类型、语言、可能作者/组织(若可判断)、时间线索(若可判断)。",
"2) 结构化摘要:按章节或逻辑段落给出要点,尽量保持原文顺序。",
"3) 关键数据与事实:列出关键数字、术语、专有名词、约束条件。",
"4) 风险与不确定性:明确哪些信息来源于文档,哪些是无法确认。",
"5) 面向执行的建议:给出可落地的后续行动项。",
"",
"补充关注点:",
focus,
}, "\n")
}
func parseInput(input string) (fileID string, userFocus string, err error) {
raw := strings.TrimSpace(input)
if raw == "" {
return "", "", fmt.Errorf("empty input: expected file_id")
}
if strings.HasPrefix(raw, "{") {
var payload map[string]any
if jsonErr := json.Unmarshal([]byte(raw), &payload); jsonErr == nil {
if id := firstNonEmptyString(payload, "file_id", "fileid", "id", "fileID"); id != "" {
return normalizeFileID(id), firstNonEmptyString(payload, "focus", "query", "instruction", "prompt"), nil
}
}
}
lines := strings.Split(raw, "\n")
for _, line := range lines {
candidate := extractFileIDToken(line)
if candidate != "" {
focus := strings.TrimSpace(strings.ReplaceAll(raw, line, ""))
return normalizeFileID(candidate), focus, nil
}
}
candidate := extractFileIDToken(raw)
if candidate == "" {
return "", "", fmt.Errorf("no file_id found in input")
}
return normalizeFileID(candidate), "", nil
}
func extractFileIDToken(s string) string {
fields := strings.FieldsFunc(s, func(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r', ',', ';', '|':
return true
default:
return false
}
})
for _, f := range fields {
tok := strings.TrimSpace(strings.Trim(f, "\"'()[]{}"))
if tok == "" {
continue
}
lower := strings.ToLower(tok)
if strings.HasPrefix(lower, "fileid://") {
return tok[len("fileid://"):]
}
if strings.HasPrefix(lower, "file_id=") || strings.HasPrefix(lower, "fileid=") {
idx := strings.Index(tok, "=")
if idx >= 0 && idx+1 < len(tok) {
return tok[idx+1:]
}
}
if strings.HasPrefix(lower, "file_") || strings.HasPrefix(lower, "file-") {
return tok
}
}
return ""
}
func normalizeFileID(id string) string {
id = strings.TrimSpace(strings.Trim(id, "\"'"))
if strings.HasPrefix(strings.ToLower(id), "fileid://") {
return strings.TrimSpace(id[len("fileid://"):])
}
return id
}
func firstNonEmptyString(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
}
return ""
}

View File

@@ -0,0 +1,74 @@
package filedoc
import (
"strings"
"testing"
)
func TestNameAndDescription(t *testing.T) {
tool := New(Config{APIKey: "k", Model: "gpt-4o-mini"}, 5000, nil)
if tool.Name() != "extract_file_document" {
t.Fatalf("unexpected tool name: %s", tool.Name())
}
if tool.Description() == "" {
t.Fatal("description should not be empty")
}
}
func TestParseInputPlainFileID(t *testing.T) {
id, focus, err := parseInput("file_ec_452e96aad38940229058f193f5c5b9c6_12553222")
if err != nil {
t.Fatalf("parseInput returned error: %v", err)
}
if id != "file_ec_452e96aad38940229058f193f5c5b9c6_12553222" {
t.Fatalf("unexpected id: %s", id)
}
if focus != "" {
t.Fatalf("expected empty focus, got: %q", focus)
}
}
func TestParseInputFileIDSchemeWithFocus(t *testing.T) {
input := "fileid://file_ec_12345\n重点关注风险与建议"
id, focus, err := parseInput(input)
if err != nil {
t.Fatalf("parseInput returned error: %v", err)
}
if id != "file_ec_12345" {
t.Fatalf("unexpected id: %s", id)
}
if focus == "" {
t.Fatal("expected non-empty focus")
}
}
func TestParseInputJSON(t *testing.T) {
input := `{"file_id":"file_ec_888", "focus":"提取关键结论"}`
id, focus, err := parseInput(input)
if err != nil {
t.Fatalf("parseInput returned error: %v", err)
}
if id != "file_ec_888" {
t.Fatalf("unexpected id: %s", id)
}
if focus != "提取关键结论" {
t.Fatalf("unexpected focus: %s", focus)
}
}
func TestParseInputInvalid(t *testing.T) {
_, _, err := parseInput("hello world")
if err == nil {
t.Fatal("expected parse error")
}
}
func TestBuildExtractionPrompt(t *testing.T) {
p := buildExtractionPrompt("file_ec_abc", "关注测试条目")
if p == "" {
t.Fatal("prompt should not be empty")
}
if p != "" && !(strings.Contains(p, "file_ec_abc") && strings.Contains(p, "关注测试条目")) {
t.Fatalf("unexpected prompt content: %s", p)
}
}