Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/config-zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
| `llms[].embedding_model` | `string` | LLM 的 Embedding 模型。例如 `text-embedding-3-small`。如果用于 Embedding,则不能为空。如果此 LLM 被使用,则不能与 `model` 同时为空。**注意:** 初次使用后请勿直接修改,应添加新的 LLM 配置。 | | 条件性必需 |
| `llms[].tts_model` | `string` | LLM 的文本转语音 (TTS) 模型。 | | 否 |
| `llms[].temperature` | `float32` | LLM 的温度 (0-2)。 | `0.0` | 否 |
| `llms[].rpm` | `int` | 每分钟请求数限制 (Requests Per Minute)。用于控制 API 调用频率,避免超过提供商的速率限制。`0` 表示不限流。 | `0` | 否 |

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

避免是为了减少报错日志,还有啥


### Jina AI 配置 (`jina`)

Expand Down
1 change: 1 addition & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ This section defines the list of available Large Language Models. At least one L
| `llms[].embedding_model` | `string` | Embedding model of the LLM. E.g., `text-embedding-3-small`. Cannot be empty if used for embedding. If this LLM is used, cannot be empty along with `model`. **Note:** Do not modify directly after initial use; add a new LLM configuration instead. | | Conditionally Required |
| `llms[].tts_model` | `string` | The Text-to-Speech (TTS) model of the LLM. | | No |
| `llms[].temperature` | `float32` | Temperature of the LLM (0-2). | `0.0` | No |
| `llms[].rpm` | `int` | Requests per minute limit. Controls API call frequency to avoid exceeding provider rate limits. `0` means no rate limiting. | `0` | No |

### Jina AI Configuration (`jina`)

Expand Down
84 changes: 79 additions & 5 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,16 @@ type API interface {
) (resp *QueryRSSHubWebsitesResponse, err error)
QueryRSSHubRoutes(ctx context.Context, req *QueryRSSHubRoutesRequest) (resp *QueryRSSHubRoutesResponse, err error)

AddFeedSource(ctx context.Context, req *AddFeedSourceRequest) (resp *AddFeedSourceResponse, err error)

Write(ctx context.Context, req *WriteRequest) (resp *WriteResponse, err error) // WARN: beta!!!
Query(ctx context.Context, req *QueryRequest) (resp *QueryResponse, err error)
}

type Config struct {
RSSHubEndpoint string
LLM string
RSSHubEndpoint string
RSSHubAccessKey string
LLM string
}

func (c *Config) Validate() error {
Expand All @@ -78,6 +81,7 @@ func (c *Config) Validate() error {

func (c *Config) From(app *config.App) *Config {
c.RSSHubEndpoint = app.Scrape.RSSHubEndpoint
c.RSSHubAccessKey = app.Scrape.RSSHubAccessKey
c.LLM = app.API.LLM

return c
Expand Down Expand Up @@ -143,6 +147,12 @@ type RSSHubRoute struct {
Features map[string]any `json:"features,omitempty"`
}

type AddFeedSourceRequest struct {
Source config.ScrapeSource `json:"source"`
}

type AddFeedSourceResponse struct{}

type WriteRequest struct { // Beta.
Feeds []*model.Feed `json:"feeds"`
}
Expand Down Expand Up @@ -277,6 +287,19 @@ func (a *api) Reload(app *config.App) error {
return nil
}

// appendAccessKeyToURL adds the RSSHub access key to the URL if configured
func (a *api) appendAccessKeyToURL(url string) string {
if a.Config().RSSHubAccessKey != "" {
Comment on lines +290 to +292

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): 使用 URL 查询构造/转义,而不是手动字符串拼接 access key。

手动拼接查询字符串比较脆弱:可能错误处理已有的 query、需要 URL 转义的 key,以及之后新增的参数。建议使用 net/url 来解析基础 URL,通过 u.Query() 设置 key,然后重新编码,这样可以正确处理转义以及 ?/& 的位置。

Suggested implementation:

 // appendAccessKeyToURL adds the RSSHub access key to the URL if configured
func (a *api) appendAccessKeyToURL(rawURL string) string {
	if a.Config().RSSHubAccessKey == "" {
		return rawURL
	}

	u, err := url.Parse(rawURL)
	if err != nil {
		// If the URL cannot be parsed, fall back to returning the original string.
		return rawURL
	}

	q := u.Query()
	q.Set("key", a.Config().RSSHubAccessKey)
	u.RawQuery = q.Encode()

	return u.String()
}

要完整实现这个修改,还需要:

  1. pkg/api/api.go 的 import 区块中添加 net/url,例如:

    import (
        "context"
        // ...other imports...
        "net/url"
    )
  2. 如果 strings 在这个文件的其他地方已经不再使用,并且只在该函数中用到,那么可以移除 strings 的 import,以避免未使用导入的错误。

Original comment in English

suggestion (bug_risk): Use URL query construction/escaping instead of manual string concatenation for the access key.

Manual query concatenation is fragile: it can mis-handle existing queries, keys needing URL-escaping, and future parameters. Prefer using net/url to parse the base URL, set the key via u.Query(), then re-encode so escaping and ?/& placement are handled correctly.

Suggested implementation:

 // appendAccessKeyToURL adds the RSSHub access key to the URL if configured
func (a *api) appendAccessKeyToURL(rawURL string) string {
	if a.Config().RSSHubAccessKey == "" {
		return rawURL
	}

	u, err := url.Parse(rawURL)
	if err != nil {
		// If the URL cannot be parsed, fall back to returning the original string.
		return rawURL
	}

	q := u.Query()
	q.Set("key", a.Config().RSSHubAccessKey)
	u.RawQuery = q.Encode()

	return u.String()
}

To fully implement this change, you also need to:

  1. Add the net/url import to the import block in pkg/api/api.go, e.g.:

    import (
        "context"
        // ...other imports...
        "net/url"
    )
  2. If strings is no longer used elsewhere in this file and only existed for this function, you can remove the strings import to avoid an unused import error.

if strings.Contains(url, "?") {
return url + "&key=" + a.Config().RSSHubAccessKey
}

return url + "?key=" + a.Config().RSSHubAccessKey
}

return url
}

func (a *api) QueryAppConfigSchema(
ctx context.Context,
req *QueryAppConfigSchemaRequest,
Expand Down Expand Up @@ -313,7 +336,7 @@ func (a *api) QueryRSSHubCategories(
ctx context.Context,
req *QueryRSSHubCategoriesRequest,
) (resp *QueryRSSHubCategoriesResponse, err error) {
url := a.Config().RSSHubEndpoint + "/api/namespace"
url := a.appendAccessKeyToURL(a.Config().RSSHubEndpoint + "/api/namespace")

// New request.
forwardReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
Expand Down Expand Up @@ -357,7 +380,7 @@ func (a *api) QueryRSSHubWebsites(
return nil, ErrBadRequest(errors.New("category is required"))
}

url := a.Config().RSSHubEndpoint + "/api/category/" + req.Category
url := a.appendAccessKeyToURL(a.Config().RSSHubEndpoint + "/api/category/" + req.Category)

// New request.
forwardReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
Expand Down Expand Up @@ -407,7 +430,7 @@ func (a *api) QueryRSSHubRoutes(
return nil, ErrBadRequest(errors.New("website id is required"))
}

url := a.Config().RSSHubEndpoint + "/api/namespace/" + req.WebsiteID
url := a.appendAccessKeyToURL(a.Config().RSSHubEndpoint + "/api/namespace/" + req.WebsiteID)

// New request.
forwardReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
Expand Down Expand Up @@ -447,6 +470,48 @@ func (a *api) QueryRSSHubRoutes(
return resp, nil
}

func (a *api) AddFeedSource(
ctx context.Context,
req *AddFeedSourceRequest,
) (resp *AddFeedSourceResponse, err error) {
ctx = telemetry.StartWith(ctx, append(a.TelemetryLabels(), telemetrymodel.KeyOperation, "AddFeedSource")...)
defer func() { telemetry.End(ctx, err) }()

// Validate request.
if req.Source.Name == "" {
return nil, ErrBadRequest(errors.New("source name is required"))
}
if req.Source.RSS == nil {
return nil, ErrBadRequest(errors.New("rss config is required"))
}
if req.Source.RSS.URL == "" && req.Source.RSS.RSSHubRoutePath == "" {
return nil, ErrBadRequest(errors.New("either url or rsshub_route_path is required"))
}
if req.Source.RSS.URL != "" && req.Source.RSS.RSSHubRoutePath != "" {
return nil, ErrBadRequest(errors.New("url and rsshub_route_path cannot be set at the same time"))
}

// Get current config.
appConfig := a.Dependencies().ConfigManager.AppConfig()

// Check if source name already exists.
for _, source := range appConfig.Scrape.Sources {
if source.Name == req.Source.Name {
return nil, ErrBadRequest(errors.New("source name already exists"))
}
}

// Add new source.
appConfig.Scrape.Sources = append(appConfig.Scrape.Sources, req.Source)

// Save config.
if err := a.Dependencies().ConfigManager.SaveAppConfig(appConfig); err != nil {
return nil, ErrInternal(errors.Wrap(err, "save app config"))
}

return &AddFeedSourceResponse{}, nil
}

func (a *api) Write(ctx context.Context, req *WriteRequest) (resp *WriteResponse, err error) {
ctx = telemetry.StartWith(ctx, append(a.TelemetryLabels(), telemetrymodel.KeyOperation, "Write")...)
defer func() { telemetry.End(ctx, err) }()
Expand Down Expand Up @@ -584,6 +649,15 @@ func (m *mockAPI) QueryRSSHubRoutes(
return args.Get(0).(*QueryRSSHubRoutesResponse), args.Error(1)
}

func (m *mockAPI) AddFeedSource(
ctx context.Context,
req *AddFeedSourceRequest,
) (resp *AddFeedSourceResponse, err error) {
args := m.Called(ctx, req)

return args.Get(0).(*AddFeedSourceResponse), args.Error(1)
}

func (m *mockAPI) Query(ctx context.Context, req *QueryRequest) (resp *QueryResponse, err error) {
args := m.Called(ctx, req)

Expand Down
1 change: 1 addition & 0 deletions pkg/api/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ func new(instance string, app *config.App, dependencies Dependencies) (Server, e
router.Handle("/query_rsshub_categories", jsonrpc.API(api.QueryRSSHubCategories))
router.Handle("/query_rsshub_websites", jsonrpc.API(api.QueryRSSHubWebsites))
router.Handle("/query_rsshub_routes", jsonrpc.API(api.QueryRSSHubRoutes))
router.Handle("/add_feed_source", jsonrpc.API(api.AddFeedSource))
router.Handle("/query", jsonrpc.API(api.Query))
httpServer := &http.Server{Addr: config.Address, Handler: router}

Expand Down
59 changes: 59 additions & 0 deletions pkg/api/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func new(instance string, app *config.App, dependencies Dependencies) (Server, e
func registerTools(h *mcpserver.MCPServer, s *server) {
registerConfigTools(h, s)
registerRSSHubTools(h, s)
registerFeedSourceTools(h, s)

h.AddTool(mcp.NewTool("query",
mcp.WithDescription("Query feeds with semantic search. You can query any latest messages. "+
Expand Down Expand Up @@ -203,6 +204,26 @@ func registerRSSHubTools(h *mcpserver.MCPServer, s *server) {
), mcpserver.ToolHandlerFunc(s.queryRSSHubRoutes))
}

func registerFeedSourceTools(h *mcpserver.MCPServer, s *server) {
h.AddTool(mcp.NewTool("add_feed_source",
mcp.WithDescription("Add a new feed source to the app config. "+
"The source will be added to the scrape.sources list. "+
"You should confirm with the user before adding the source."),
mcp.WithString("name",
mcp.Required(),
mcp.Description("The name of the feed source. It must be unique."),
),
mcp.WithString("url",
mcp.Description("The direct RSS feed URL. e.g. https://tech.meituan.com/feed. "+
"Either url or rsshub_route_path must be provided, but not both."),
),
mcp.WithString("rsshub_route_path",
mcp.Description("The RSSHub route path. e.g. telegram/channel/zrj96. "+
"Either url or rsshub_route_path must be provided, but not both."),
),
), mcpserver.ToolHandlerFunc(s.addFeedSource))
}

// --- Implementation code block ---
type server struct {
*component.Base[Config, Dependencies]
Expand Down Expand Up @@ -334,6 +355,44 @@ func (s *server) queryRSSHubRoutes(ctx context.Context, req mcp.CallToolRequest)
return s.response(string(b)), nil
}

func (s *server) addFeedSource(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Parse arguments.
name, ok := req.Params.Arguments["name"].(string)
if !ok || name == "" {
return s.error(errors.New("name is required")), nil
}

url, _ := req.Params.Arguments["url"].(string)
rsshubRoutePath, _ := req.Params.Arguments["rsshub_route_path"].(string)

// Validate that either url or rsshub_route_path is provided.
if url == "" && rsshubRoutePath == "" {
return s.error(errors.New("either url or rsshub_route_path must be provided")), nil
}
if url != "" && rsshubRoutePath != "" {
return s.error(errors.New("url and rsshub_route_path cannot be set at the same time")), nil
}

// Build the source config.
source := config.ScrapeSource{
Name: name,
RSS: &config.ScrapeSourceRSS{},
}
if url != "" {
source.RSS.URL = url
} else {
source.RSS.RSSHubRoutePath = rsshubRoutePath
}

// Forward request to API.
_, err := s.Dependencies().API.AddFeedSource(ctx, &api.AddFeedSourceRequest{Source: source})
if err != nil {
return s.error(errors.Wrap(err, "add feed source")), nil
}

return s.response("Feed source added successfully. The source name is: " + name), nil
}

func (s *server) query(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Parse arguments.
query, ok := req.Params.Arguments["query"].(string)
Expand Down
1 change: 1 addition & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ type LLM struct {
EmbeddingModel string `yaml:"embedding_model,omitempty" json:"embedding_model,omitempty" desc:"The embedding model of the LLM. e.g. text-embedding-3-small. Can not be empty with model at same time when api.llm is set. NOTE: Once used, do not modify it directly, instead, add a new LLM configuration."`
TTSModel string `yaml:"tts_model,omitempty" json:"tts_model,omitempty" desc:"The TTS model of the LLM."`
Temperature float32 `yaml:"temperature,omitempty" json:"temperature,omitempty" desc:"The temperature (0-2) of the LLM. Default: 0.0"`
RPM int `yaml:"rpm,omitempty" json:"rpm,omitempty" desc:"Requests per minute limit for this LLM. 0 means no limit. Default: 0"`
}

type Scrape struct {
Expand Down
16 changes: 13 additions & 3 deletions pkg/llm/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ import (
type gemini struct {
*component.Base[Config, struct{}]
text
hc *http.Client
hc *http.Client
rateLimiter RateLimiter

embeddingSpliter embeddingSpliter
}
Expand All @@ -53,13 +54,17 @@ func newGemini(c *Config) LLM {
Config: c,
})

rateLimiter := NewRateLimiter(c.RPM)

return &gemini{
Base: base,
text: &openaiText{
Base: base,
client: client,
Base: base,
client: client,
rateLimiter: rateLimiter,
},
hc: &http.Client{},
rateLimiter: rateLimiter,
embeddingSpliter: embeddingSpliter,
}
}
Expand All @@ -72,6 +77,11 @@ func (g *gemini) WAV(ctx context.Context, text string, speakers []Speaker) (r io
return nil, errors.New("tts model is not set")
}

// 应用限流
if err := g.rateLimiter.Wait(ctx); err != nil {
return nil, errors.Wrap(err, "rate limiter wait")
}

reqPayload, err := buildWAVRequestPayload(text, speakers)
if err != nil {
return nil, errors.Wrap(err, "build wav request payload")
Expand Down
2 changes: 2 additions & 0 deletions pkg/llm/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type Config struct {
APIKey string
Model, EmbeddingModel, TTSModel string
Temperature float32
RPM int // Requests per minute limit
}

type ProviderType string
Expand Down Expand Up @@ -200,6 +201,7 @@ func (c *FactoryConfig) From(app *config.App) {
EmbeddingModel: llm.EmbeddingModel,
TTSModel: llm.TTSModel,
Temperature: llm.Temperature,
RPM: llm.RPM,
})
}
}
Expand Down
16 changes: 16 additions & 0 deletions pkg/llm/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
type openai struct {
*component.Base[Config, struct{}]
text
rateLimiter RateLimiter
}

func newOpenAI(c *Config) LLM {
Expand All @@ -47,13 +48,17 @@ func newOpenAI(c *Config) LLM {
Config: c,
})

rateLimiter := NewRateLimiter(c.RPM)

return &openai{
Base: base,
text: &openaiText{
Base: base,
client: client,
embeddingSpliter: embeddingSpliter,
rateLimiter: rateLimiter,
},
rateLimiter: rateLimiter,
}
}

Expand All @@ -66,6 +71,7 @@ type openaiText struct {

client *oai.Client
embeddingSpliter embeddingSpliter
rateLimiter RateLimiter
}

func (o *openaiText) String(ctx context.Context, messages []string) (value string, err error) {
Expand All @@ -90,6 +96,11 @@ func (o *openaiText) String(ctx context.Context, messages []string) (value strin
Temperature: config.Temperature,
}

// 应用限流
if err := o.rateLimiter.Wait(ctx); err != nil {
return "", errors.Wrap(err, "rate limiter wait")
}

resp, err := o.client.CreateChatCompletion(ctx, req)
if err != nil {
return "", errors.Wrap(err, "create chat completion")
Expand Down Expand Up @@ -140,6 +151,11 @@ func (o *openaiText) Embedding(ctx context.Context, s string) (value []float32,
if config.EmbeddingModel == "" {
return nil, errors.New("embedding model is not set")
}

if err := o.rateLimiter.Wait(ctx); err != nil {
return nil, errors.Wrap(err, "rate limiter wait")
}

vec, err := o.client.CreateEmbeddings(ctx, oai.EmbeddingRequest{
Input: []string{s},
Model: oai.EmbeddingModel(config.EmbeddingModel),
Expand Down
Loading