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:
@@ -33,21 +33,13 @@ type MessageChatClient interface {
|
||||
GenerateMessages(ctx context.Context, messages []PromptMessage) (string, error)
|
||||
}
|
||||
|
||||
type FileChatClient interface {
|
||||
GenerateWithFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string, appendFileIDText bool) (string, error)
|
||||
}
|
||||
|
||||
type FileMessageChatClient interface {
|
||||
GenerateMessagesWithFiles(ctx context.Context, messages []PromptMessage, fileIDs []string, appendFileIDText bool) (string, error)
|
||||
}
|
||||
|
||||
type FileUploader interface {
|
||||
UploadFile(ctx context.Context, file InputFile, purpose string) (string, error)
|
||||
}
|
||||
|
||||
// ToolCallChatClient 支持原生 function calling 的 LLM 客户端接口。
|
||||
type ToolCallChatClient interface {
|
||||
GenerateWithTools(ctx context.Context, messages []PromptMessage, tools []ToolDefinition, fileIDs []string, appendFileIDText bool) (*ChatCompletion, error)
|
||||
GenerateWithTools(ctx context.Context, messages []PromptMessage, tools []ToolDefinition) (*ChatCompletion, error)
|
||||
}
|
||||
|
||||
// ToolDefinition 描述一个可供 LLM 调用的工具函数定义。
|
||||
@@ -89,11 +81,9 @@ type InputFile struct {
|
||||
}
|
||||
|
||||
type OpenAICompatibleClient struct {
|
||||
client openai.Client
|
||||
model string
|
||||
fileModel string
|
||||
filePromptMode string
|
||||
log *logger.Logger
|
||||
client openai.Client
|
||||
model string
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAICompatibleClient {
|
||||
@@ -105,11 +95,9 @@ func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAI
|
||||
opts = append(opts, option.WithBaseURL(cfg.BaseURL))
|
||||
}
|
||||
return &OpenAICompatibleClient{
|
||||
client: openai.NewClient(opts...),
|
||||
model: cfg.Model,
|
||||
fileModel: cfg.FileModel,
|
||||
filePromptMode: cfg.FilePromptMode,
|
||||
log: log,
|
||||
client: openai.NewClient(opts...),
|
||||
model: cfg.Model,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,38 +106,22 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, systemPrompt, use
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
}
|
||||
return c.generateWithMessagesInternal(ctx, messages, nil, false)
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) GenerateWithFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string, appendFileIDText bool) (string, error) {
|
||||
messages := []PromptMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
}
|
||||
return c.generateWithMessagesInternal(ctx, messages, fileIDs, appendFileIDText)
|
||||
return c.generateWithMessagesInternal(ctx, messages)
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) GenerateMessages(ctx context.Context, messages []PromptMessage) (string, error) {
|
||||
return c.generateWithMessagesInternal(ctx, messages, nil, false)
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) GenerateMessagesWithFiles(ctx context.Context, messages []PromptMessage, fileIDs []string, appendFileIDText bool) (string, error) {
|
||||
return c.generateWithMessagesInternal(ctx, messages, fileIDs, appendFileIDText)
|
||||
return c.generateWithMessagesInternal(ctx, messages)
|
||||
}
|
||||
|
||||
// GenerateWithTools 使用原生 function calling 发送请求,返回结构化的 ChatCompletion。
|
||||
func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages []PromptMessage, tools []ToolDefinition, fileIDs []string, appendFileIDText bool) (*ChatCompletion, error) {
|
||||
func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages []PromptMessage, tools []ToolDefinition) (*ChatCompletion, error) {
|
||||
model := c.model
|
||||
ids := nonEmptyIDs(fileIDs)
|
||||
if len(ids) > 0 && strings.TrimSpace(c.fileModel) != "" {
|
||||
model = c.fileModel
|
||||
}
|
||||
|
||||
sdkMessages := buildSDKMessages(messages, ids, c.normalizedFilePromptMode(), appendFileIDText)
|
||||
sdkMessages := buildSDKMessages(messages)
|
||||
sdkTools := toSDKTools(tools)
|
||||
|
||||
if c.log != nil {
|
||||
c.log.Debugf("llm tool-call request start model=%s messages=%d tools=%d files=%d", model, len(sdkMessages), len(sdkTools), len(ids))
|
||||
c.log.Debugf("llm tool-call request start model=%s messages=%d tools=%d", model, len(sdkMessages), len(sdkTools))
|
||||
}
|
||||
|
||||
params := openai.ChatCompletionNewParams{
|
||||
@@ -188,12 +160,8 @@ func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Context, messages []PromptMessage, fileIDs []string, appendFileIDText bool) (string, error) {
|
||||
func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Context, messages []PromptMessage) (string, error) {
|
||||
model := c.model
|
||||
ids := nonEmptyIDs(fileIDs)
|
||||
if len(ids) > 0 && strings.TrimSpace(c.fileModel) != "" {
|
||||
model = c.fileModel
|
||||
}
|
||||
|
||||
baseMessages := normalizePromptMessages(messages)
|
||||
if len(baseMessages) == 0 {
|
||||
@@ -202,10 +170,10 @@ func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Contex
|
||||
|
||||
systemLen, userLen := promptMessageLengths(baseMessages)
|
||||
if c.log != nil {
|
||||
c.log.Debugf("llm request start model=%s system_len=%d user_len=%d file_count=%d file_prompt_mode=%s", model, systemLen, userLen, len(ids), c.normalizedFilePromptMode())
|
||||
c.log.Debugf("llm request start model=%s system_len=%d user_len=%d", model, systemLen, userLen)
|
||||
}
|
||||
|
||||
sdkMessages := buildSDKMessages(baseMessages, ids, c.normalizedFilePromptMode(), appendFileIDText)
|
||||
sdkMessages := buildSDKMessages(baseMessages)
|
||||
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Model: shared.ChatModel(model),
|
||||
@@ -234,10 +202,9 @@ func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Contex
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// buildSDKMessages 将 PromptMessage 列表转换为 openai SDK 的消息格式,并注入 file_id(如需要)。
|
||||
func buildSDKMessages(base []PromptMessage, fileIDs []string, mode string, appendFileIDText bool) []openai.ChatCompletionMessageParamUnion {
|
||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||
out := make([]openai.ChatCompletionMessageParamUnion, 0, len(base)+2)
|
||||
// buildSDKMessages 将 PromptMessage 列表转换为 openai SDK 的消息格式。
|
||||
func buildSDKMessages(base []PromptMessage) []openai.ChatCompletionMessageParamUnion {
|
||||
out := make([]openai.ChatCompletionMessageParamUnion, 0, len(base))
|
||||
|
||||
for _, m := range base {
|
||||
role := normalizeRole(m.Role)
|
||||
@@ -247,34 +214,6 @@ func buildSDKMessages(base []PromptMessage, fileIDs []string, mode string, appen
|
||||
out = append(out, toSDKMessage(m, role))
|
||||
}
|
||||
|
||||
if len(fileIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
if appendFileIDText {
|
||||
// WebUI 场景:将首个 fileID 作为 text part 追加到最后一个 user 消息。
|
||||
firstFileID := strings.TrimSpace(fileIDs[0])
|
||||
if firstFileID == "" {
|
||||
return out
|
||||
}
|
||||
for i := len(out) - 1; i >= 0; i-- {
|
||||
if r := out[i].GetRole(); r != nil && *r == "user" {
|
||||
out[i] = buildUserMessageWithFileIDText(out[i], firstFileID)
|
||||
return out
|
||||
}
|
||||
}
|
||||
out = append(out, buildUserMessageWithFileIDText(openai.UserMessage(""), firstFileID))
|
||||
return out
|
||||
}
|
||||
|
||||
// 非 WebUI 场景:保持原有 file content part 方式。
|
||||
for i := len(out) - 1; i >= 0; i-- {
|
||||
if r := out[i].GetRole(); r != nil && *r == "user" {
|
||||
out[i] = buildUserMessageWithFiles(out[i], fileIDs)
|
||||
return out
|
||||
}
|
||||
}
|
||||
out = append(out, buildUserMessageWithFiles(openai.UserMessage(""), fileIDs))
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -309,53 +248,6 @@ func toSDKMessage(m PromptMessage, role string) openai.ChatCompletionMessagePara
|
||||
}
|
||||
}
|
||||
|
||||
// buildUserMessageWithFileIDText 为 user 消息追加一个 text part,内容为 fileID。
|
||||
func buildUserMessageWithFileIDText(msg openai.ChatCompletionMessageParamUnion, fileID string) openai.ChatCompletionMessageParamUnion {
|
||||
// 提取已有的文本内容
|
||||
text := ""
|
||||
if s, ok := msg.GetContent().AsAny().(*string); ok && s != nil {
|
||||
text = *s
|
||||
}
|
||||
fileID = strings.TrimSpace(fileID)
|
||||
if fileID == "" {
|
||||
return msg
|
||||
}
|
||||
|
||||
parts := make([]openai.ChatCompletionContentPartUnionParam, 0, 2)
|
||||
if strings.TrimSpace(text) != "" {
|
||||
parts = append(parts, openai.TextContentPart(text))
|
||||
}
|
||||
parts = append(parts, openai.TextContentPart(fileID))
|
||||
if len(parts) == 0 {
|
||||
return msg
|
||||
}
|
||||
return openai.UserMessage(parts)
|
||||
}
|
||||
|
||||
// buildUserMessageWithFiles 为 user 消息追加 file content parts。
|
||||
func buildUserMessageWithFiles(msg openai.ChatCompletionMessageParamUnion, fileIDs []string) openai.ChatCompletionMessageParamUnion {
|
||||
text := ""
|
||||
if s, ok := msg.GetContent().AsAny().(*string); ok && s != nil {
|
||||
text = *s
|
||||
}
|
||||
|
||||
parts := make([]openai.ChatCompletionContentPartUnionParam, 0, len(fileIDs)+1)
|
||||
if strings.TrimSpace(text) != "" {
|
||||
parts = append(parts, openai.TextContentPart(text))
|
||||
}
|
||||
for _, id := range fileIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, openai.FileContentPart(openai.ChatCompletionContentPartFileFileParam{FileID: param.NewOpt(id)}))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return msg
|
||||
}
|
||||
return openai.UserMessage(parts)
|
||||
}
|
||||
|
||||
// toSDKTools 将内部 ToolDefinition 列表转换为 openai SDK 的 ChatCompletionToolParam 列表。
|
||||
func toSDKTools(tools []ToolDefinition) []openai.ChatCompletionToolParam {
|
||||
if len(tools) == 0 {
|
||||
@@ -397,6 +289,46 @@ func fromSDKToolCalls(sdkCalls []openai.ChatCompletionMessageToolCall) []ToolCal
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizePromptMessages(messages []PromptMessage) []PromptMessage {
|
||||
out := make([]PromptMessage, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
role := normalizeRole(m.Role)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, PromptMessage{
|
||||
Role: role,
|
||||
Content: m.Content,
|
||||
ToolCalls: m.ToolCalls,
|
||||
ToolCallID: m.ToolCallID,
|
||||
Name: m.Name,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRole(role string) string {
|
||||
r := strings.ToLower(strings.TrimSpace(role))
|
||||
if r != "system" && r != "user" && r != "assistant" && r != "tool" {
|
||||
return ""
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func promptMessageLengths(messages []PromptMessage) (int, int) {
|
||||
systemLen := 0
|
||||
userLen := 0
|
||||
for _, m := range messages {
|
||||
switch normalizeRole(m.Role) {
|
||||
case "system":
|
||||
systemLen += len(m.Content)
|
||||
case "user":
|
||||
userLen += len(m.Content)
|
||||
}
|
||||
}
|
||||
return systemLen, userLen
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) UploadFile(ctx context.Context, file InputFile, purpose string) (string, error) {
|
||||
if strings.TrimSpace(file.FileName) == "" {
|
||||
return "", fmt.Errorf("empty file name")
|
||||
@@ -460,71 +392,3 @@ func appendIfMissing(items []string, value string) []string {
|
||||
}
|
||||
return append(items, value)
|
||||
}
|
||||
|
||||
func nonEmptyIDs(ids []string) []string {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(ids))
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizePromptMessages(messages []PromptMessage) []PromptMessage {
|
||||
out := make([]PromptMessage, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
role := normalizeRole(m.Role)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, PromptMessage{
|
||||
Role: role,
|
||||
Content: m.Content,
|
||||
ToolCalls: m.ToolCalls,
|
||||
ToolCallID: m.ToolCallID,
|
||||
Name: m.Name,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRole(role string) string {
|
||||
r := strings.ToLower(strings.TrimSpace(role))
|
||||
if r != "system" && r != "user" && r != "assistant" && r != "tool" {
|
||||
return ""
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func promptMessageLengths(messages []PromptMessage) (int, int) {
|
||||
systemLen := 0
|
||||
userLen := 0
|
||||
for _, m := range messages {
|
||||
switch normalizeRole(m.Role) {
|
||||
case "system":
|
||||
systemLen += len(m.Content)
|
||||
case "user":
|
||||
userLen += len(m.Content)
|
||||
}
|
||||
}
|
||||
return systemLen, userLen
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) normalizedFilePromptMode() string {
|
||||
mode := strings.ToLower(strings.TrimSpace(c.filePromptMode))
|
||||
if mode == "system_fileid" || mode == "system_fileid_url" || mode == "system_fileid_uri" {
|
||||
return "system_fileid_uri"
|
||||
}
|
||||
return "user_content_file_parts"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user