mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-30 16:53:21 +08:00
feat(agent): add question suggestions with after-answer follow-ups
Enable agents to configure starter prompts and async follow-up questions after completed answers, with durable caching, analytics events, and chat/embed UI integration.
This commit is contained in:
+79
-53
@@ -71,59 +71,85 @@ func AllKBSelectionModes() []KBSelectionMode {
|
||||
// AgentConfig represents the configuration for an agent.
|
||||
// Field names and JSON tags mirror internal/types.CustomAgentConfig.
|
||||
type AgentConfig struct {
|
||||
AgentMode string `json:"agent_mode"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
SystemPromptID string `json:"system_prompt_id,omitempty"`
|
||||
ContextTemplate string `json:"context_template"`
|
||||
ContextTemplateID string `json:"context_template_id,omitempty"`
|
||||
ModelID string `json:"model_id"`
|
||||
RerankModelID string `json:"rerank_model_id"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens"`
|
||||
Thinking *bool `json:"thinking"`
|
||||
MaxIterations int `json:"max_iterations"`
|
||||
LLMCallTimeout int `json:"llm_call_timeout,omitempty"`
|
||||
AllowedTools []string `json:"allowed_tools"`
|
||||
MCPSelectionMode string `json:"mcp_selection_mode"`
|
||||
MCPServices []string `json:"mcp_services"`
|
||||
SkillsSelectionMode string `json:"skills_selection_mode"`
|
||||
SelectedSkills []string `json:"selected_skills"`
|
||||
KBSelectionMode string `json:"kb_selection_mode"`
|
||||
KnowledgeBases []string `json:"knowledge_bases"`
|
||||
RetrieveKBOnlyWhenMentioned bool `json:"retrieve_kb_only_when_mentioned"`
|
||||
RetainRetrievalHistory bool `json:"retain_retrieval_history"`
|
||||
ImageUploadEnabled bool `json:"image_upload_enabled"`
|
||||
VLMModelID string `json:"vlm_model_id"`
|
||||
AudioUploadEnabled bool `json:"audio_upload_enabled"`
|
||||
ASRModelID string `json:"asr_model_id"`
|
||||
ImageStorageProvider string `json:"image_storage_provider"`
|
||||
SupportedFileTypes []string `json:"supported_file_types"`
|
||||
DataAnalysisEnabled bool `json:"data_analysis_enabled"`
|
||||
FAQPriorityEnabled bool `json:"faq_priority_enabled"`
|
||||
FAQDirectAnswerThreshold float64 `json:"faq_direct_answer_threshold"`
|
||||
FAQScoreBoost float64 `json:"faq_score_boost"`
|
||||
WebSearchEnabled bool `json:"web_search_enabled"`
|
||||
WebSearchMaxResults int `json:"web_search_max_results"`
|
||||
WebSearchProviderID string `json:"web_search_provider_id,omitempty"`
|
||||
WebFetchEnabled bool `json:"web_fetch_enabled"`
|
||||
WebFetchTopN int `json:"web_fetch_top_n,omitempty"`
|
||||
MultiTurnEnabled bool `json:"multi_turn_enabled"`
|
||||
HistoryTurns int `json:"history_turns"`
|
||||
EmbeddingTopK int `json:"embedding_top_k"`
|
||||
KeywordThreshold float64 `json:"keyword_threshold"`
|
||||
VectorThreshold float64 `json:"vector_threshold"`
|
||||
RerankTopK int `json:"rerank_top_k"`
|
||||
RerankThreshold float64 `json:"rerank_threshold"`
|
||||
EnableQueryExpansion bool `json:"enable_query_expansion"`
|
||||
EnableRewrite bool `json:"enable_rewrite"`
|
||||
RewritePromptSystem string `json:"rewrite_prompt_system"`
|
||||
RewritePromptUser string `json:"rewrite_prompt_user"`
|
||||
QueryUnderstandModelID string `json:"query_understand_model_id,omitempty"`
|
||||
FallbackStrategy string `json:"fallback_strategy"`
|
||||
FallbackResponse string `json:"fallback_response"`
|
||||
FallbackPrompt string `json:"fallback_prompt"`
|
||||
SuggestedPrompts []string `json:"suggested_prompts,omitempty"`
|
||||
AgentMode string `json:"agent_mode"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
SystemPromptID string `json:"system_prompt_id,omitempty"`
|
||||
ContextTemplate string `json:"context_template"`
|
||||
ContextTemplateID string `json:"context_template_id,omitempty"`
|
||||
ModelID string `json:"model_id"`
|
||||
RerankModelID string `json:"rerank_model_id"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens"`
|
||||
Thinking *bool `json:"thinking"`
|
||||
MaxIterations int `json:"max_iterations"`
|
||||
LLMCallTimeout int `json:"llm_call_timeout,omitempty"`
|
||||
AllowedTools []string `json:"allowed_tools"`
|
||||
MCPSelectionMode string `json:"mcp_selection_mode"`
|
||||
MCPServices []string `json:"mcp_services"`
|
||||
SkillsSelectionMode string `json:"skills_selection_mode"`
|
||||
SelectedSkills []string `json:"selected_skills"`
|
||||
KBSelectionMode string `json:"kb_selection_mode"`
|
||||
KnowledgeBases []string `json:"knowledge_bases"`
|
||||
RetrieveKBOnlyWhenMentioned bool `json:"retrieve_kb_only_when_mentioned"`
|
||||
RetainRetrievalHistory bool `json:"retain_retrieval_history"`
|
||||
ImageUploadEnabled bool `json:"image_upload_enabled"`
|
||||
VLMModelID string `json:"vlm_model_id"`
|
||||
AudioUploadEnabled bool `json:"audio_upload_enabled"`
|
||||
ASRModelID string `json:"asr_model_id"`
|
||||
ImageStorageProvider string `json:"image_storage_provider"`
|
||||
SupportedFileTypes []string `json:"supported_file_types"`
|
||||
DataAnalysisEnabled bool `json:"data_analysis_enabled"`
|
||||
FAQPriorityEnabled bool `json:"faq_priority_enabled"`
|
||||
FAQDirectAnswerThreshold float64 `json:"faq_direct_answer_threshold"`
|
||||
FAQScoreBoost float64 `json:"faq_score_boost"`
|
||||
WebSearchEnabled bool `json:"web_search_enabled"`
|
||||
WebSearchMaxResults int `json:"web_search_max_results"`
|
||||
WebSearchProviderID string `json:"web_search_provider_id,omitempty"`
|
||||
WebFetchEnabled bool `json:"web_fetch_enabled"`
|
||||
WebFetchTopN int `json:"web_fetch_top_n,omitempty"`
|
||||
MultiTurnEnabled bool `json:"multi_turn_enabled"`
|
||||
HistoryTurns int `json:"history_turns"`
|
||||
EmbeddingTopK int `json:"embedding_top_k"`
|
||||
KeywordThreshold float64 `json:"keyword_threshold"`
|
||||
VectorThreshold float64 `json:"vector_threshold"`
|
||||
RerankTopK int `json:"rerank_top_k"`
|
||||
RerankThreshold float64 `json:"rerank_threshold"`
|
||||
EnableQueryExpansion bool `json:"enable_query_expansion"`
|
||||
EnableRewrite bool `json:"enable_rewrite"`
|
||||
RewritePromptSystem string `json:"rewrite_prompt_system"`
|
||||
RewritePromptUser string `json:"rewrite_prompt_user"`
|
||||
QueryUnderstandModelID string `json:"query_understand_model_id,omitempty"`
|
||||
FallbackStrategy string `json:"fallback_strategy"`
|
||||
FallbackResponse string `json:"fallback_response"`
|
||||
FallbackPrompt string `json:"fallback_prompt"`
|
||||
QuestionSuggestions *QuestionSuggestionConfig `json:"question_suggestions,omitempty"`
|
||||
}
|
||||
|
||||
type QuestionSuggestionConfig struct {
|
||||
Starters StarterSuggestionConfig `json:"starters"`
|
||||
FollowUps FollowUpSuggestionConfig `json:"follow_ups"`
|
||||
}
|
||||
|
||||
type StarterSuggestionConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Items []string `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type FollowUpSuggestionConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Count int `json:"count"`
|
||||
ModelID string `json:"model_id,omitempty"`
|
||||
AdditionalInstruction string `json:"additional_instruction,omitempty"`
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
MaxContextTurns int `json:"max_context_turns"`
|
||||
SuppressOnFallback bool `json:"suppress_on_fallback"`
|
||||
SuppressWhenAnswerAsksQuestion bool `json:"suppress_when_answer_asks_question"`
|
||||
KnowledgeFallback bool `json:"knowledge_fallback"`
|
||||
AllowRegenerate bool `json:"allow_regenerate"`
|
||||
}
|
||||
|
||||
// CreateAgentRequest represents the request to create an agent.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type MessageSuggestionItem struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Source string `json:"source"`
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"`
|
||||
}
|
||||
|
||||
type MessageSuggestionSet struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
AssistantMessageID string `json:"assistant_message_id"`
|
||||
Status string `json:"status"`
|
||||
AllowRegenerate bool `json:"allow_regenerate"`
|
||||
SuppressionReason string `json:"suppression_reason,omitempty"`
|
||||
Questions []MessageSuggestionItem `json:"questions"`
|
||||
}
|
||||
|
||||
type messageSuggestionResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data MessageSuggestionSet `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) EnsureMessageSuggestions(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
messageID string,
|
||||
regenerate bool,
|
||||
) (*MessageSuggestionSet, error) {
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s/messages/%s/suggestions", url.PathEscape(sessionID), url.PathEscape(messageID))
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, map[string]bool{"regenerate": regenerate}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response messageSuggestionResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMessageSuggestions(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
messageID string,
|
||||
) (*MessageSuggestionSet, error) {
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s/messages/%s/suggestions", url.PathEscape(sessionID), url.PathEscape(messageID))
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response messageSuggestionResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) RecordMessageSuggestionEvent(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
setID string,
|
||||
questionID string,
|
||||
eventType string,
|
||||
) error {
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s/suggestion-events", url.PathEscape(sessionID))
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, map[string]string{
|
||||
"suggestion_set_id": setID,
|
||||
"question_id": questionID,
|
||||
"event_type": eventType,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
+19
-1
@@ -493,9 +493,27 @@ curl --location 'http://localhost:8080/api/v1/agents/placeholders' \
|
||||
|
||||
### 推荐问题设置
|
||||
|
||||
`question_suggestions` 是智能体拥有的统一策略。网页嵌入等渠道只能关闭展示,不能覆盖内容或生成规则。
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `suggested_prompts` | []string | - | 推荐问题列表,用于在前端对话面板展示快捷提问 |
|
||||
| `question_suggestions.starters.enabled` | bool | true | 是否在首次提问前展示开场问题 |
|
||||
| `question_suggestions.starters.mode` | string | `hybrid` | `curated`、`knowledge` 或 `hybrid` |
|
||||
| `question_suggestions.starters.items` | []string | `[]` | 运营配置的开场问题 |
|
||||
| `question_suggestions.starters.count` | int | 6 | 展示数量,范围 1-8 |
|
||||
| `question_suggestions.follow_ups.enabled` | bool | false | 是否在每次完整回答后异步生成追问 |
|
||||
| `question_suggestions.follow_ups.mode` | string | `hybrid` | `generated`、`knowledge` 或 `hybrid` |
|
||||
| `question_suggestions.follow_ups.count` | int | 3 | 生成数量,范围 1-5 |
|
||||
| `question_suggestions.follow_ups.model_id` | string | - | 独立生成模型;为空使用本轮对话模型 |
|
||||
| `question_suggestions.follow_ups.categories` | []string | `clarify,deepen,action` | 允许的问题类型 |
|
||||
| `question_suggestions.follow_ups.max_context_turns` | int | 2 | 生成时使用的最近对话轮数,范围 1-5 |
|
||||
| `question_suggestions.follow_ups.additional_instruction` | string | - | 智能体作者的附加生成要求 |
|
||||
| `question_suggestions.follow_ups.suppress_on_fallback` | bool | true | 兜底回答后不展示 |
|
||||
| `question_suggestions.follow_ups.suppress_when_answer_asks_question` | bool | true | 回答本身以问题结尾时不展示 |
|
||||
| `question_suggestions.follow_ups.knowledge_fallback` | bool | true | 模型失败时使用知识库候选补位 |
|
||||
| `question_suggestions.follow_ups.allow_regenerate` | bool | false | 是否允许用户换一批 |
|
||||
|
||||
旧 `suggested_prompts` 会在数据库迁移时一次性写入 `starters.items`,API 不再接受该字段。
|
||||
|
||||
### 高级设置
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
| POST | `/knowledge-chat/:session_id` | 基于知识库的问答 |
|
||||
| POST | `/agent-chat/:session_id` | 基于 Agent 的智能问答 |
|
||||
| POST | `/knowledge-search` | 基于知识库的搜索知识 |
|
||||
| GET | `/sessions/:session_id/messages/:message_id/suggestions` | 获取已生成的回答后推荐 |
|
||||
| POST | `/sessions/:session_id/messages/:message_id/suggestions` | 确保生成或换一批推荐 |
|
||||
| POST | `/sessions/:session_id/suggestion-events` | 上报曝光、点击、关闭事件 |
|
||||
|
||||
## POST `/knowledge-chat/:session_id` - 基于知识库的问答
|
||||
|
||||
@@ -26,6 +29,7 @@
|
||||
| `enable_memory` | bool | 否 | 是否启用记忆功能 |
|
||||
| `images` | object[] | 否 | 附带的图片(base64 格式),需要 Agent 启用图片上传 |
|
||||
| `channel` | string | 否 | 来源渠道标识:`web`、`api`、`im`、`browser_extension` |
|
||||
| `suggestion_attribution` | object | 否 | 用户从推荐问题发起本轮时传入 `{suggestion_set_id, question_id}`;服务端会校验归属 |
|
||||
|
||||
**请求**:
|
||||
|
||||
@@ -76,6 +80,33 @@ Agent 模式支持更智能的问答,包括工具调用、网络搜索、多
|
||||
| `enable_memory` | bool | 否 | 是否启用记忆功能 |
|
||||
| `images` | object[] | 否 | 附带的图片(base64 格式),需要 Agent 启用图片上传 |
|
||||
| `channel` | string | 否 | 来源渠道标识:`web`、`api`、`im`、`browser_extension` |
|
||||
| `suggestion_attribution` | object | 否 | 用户从推荐问题发起本轮时传入 `{suggestion_set_id, question_id}`;服务端会校验归属 |
|
||||
|
||||
## 回答后推荐问题
|
||||
|
||||
回答主消息完成后,服务端会异步生成推荐问题,不阻塞 SSE 的 `complete`/`done` 事件。生成结果按“租户、助手消息、位置、配置快照、语言”持久化并去重。
|
||||
|
||||
```http
|
||||
POST /api/v1/sessions/{session_id}/messages/{message_id}/suggestions
|
||||
Content-Type: application/json
|
||||
|
||||
{"regenerate": false}
|
||||
```
|
||||
|
||||
状态包括 `generating`、`ready`、`suppressed`、`failed`。`ready` 时的每个问题都有稳定 `id`,点击后应先上报事件,并在下一次聊天请求中携带 `suggestion_attribution`。
|
||||
|
||||
```http
|
||||
POST /api/v1/sessions/{session_id}/suggestion-events
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"suggestion_set_id": "...",
|
||||
"question_id": "...",
|
||||
"event_type": "click"
|
||||
}
|
||||
```
|
||||
|
||||
网页嵌入提供同构接口:`/api/v1/embed/{channel_id}/sessions/{session_id}/...`,继续使用嵌入令牌和 `X-Embed-Session`。
|
||||
|
||||
**mentioned_items 结构**:
|
||||
|
||||
|
||||
+276
-7
@@ -10674,6 +10674,111 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/messages/{message_id}/suggestions": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
},
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"会话"
|
||||
],
|
||||
"summary": "获取回答后推荐问题",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "会话 ID",
|
||||
"name": "session_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "助手消息 ID",
|
||||
"name": "message_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
},
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "对已完成的助手消息异步生成或重新生成推荐问题;相同配置快照会复用持久化结果",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"会话"
|
||||
],
|
||||
"summary": "确保生成回答后推荐问题",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "会话 ID",
|
||||
"name": "session_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "助手消息 ID",
|
||||
"name": "message_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "生成选项",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handler.EnsureMessageSuggestionsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"202": {
|
||||
"description": "Accepted",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/pin": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -10774,6 +10879,49 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/suggestion-events": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
},
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "记录曝光、点击或关闭事件",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"会话"
|
||||
],
|
||||
"summary": "上报推荐问题事件",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "会话 ID",
|
||||
"name": "session_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "事件",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handler.SuggestionEventRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/title": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -14481,6 +14629,14 @@ const docTemplate = `{
|
||||
"description": "Dedicated chat model ID for the query-understanding (rewrite + intent) step.\nWhen empty, the main conversation ModelID is used as a fallback.",
|
||||
"type": "string"
|
||||
},
|
||||
"question_suggestions": {
|
||||
"description": "===== Conversation Question Suggestions =====\nQuestionSuggestions owns both the static/knowledge-backed prompts shown\nbefore the first user turn and the contextual follow-up questions shown\nafter a completed assistant answer.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.QuestionSuggestionConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rerank_model_id": {
|
||||
"description": "ReRank model ID for retrieval",
|
||||
"type": "string"
|
||||
@@ -14520,13 +14676,6 @@ const docTemplate = `{
|
||||
"description": "===== Skills Settings (only for smart-reasoning mode) =====\nSkills selection mode: \"all\" = all preloaded skills, \"selected\" = specific skills, \"none\" = no skills",
|
||||
"type": "string"
|
||||
},
|
||||
"suggested_prompts": {
|
||||
"description": "===== Suggested Prompts =====\n推荐问题列表,用于在前端对话面板展示快捷提问",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"supported_file_types": {
|
||||
"description": "===== File Type Restriction Settings =====\nSupported file types for this agent (e.g., [\"csv\", \"xlsx\", \"xls\"])\nEmpty means all file types are supported\nWhen set, only files with matching extensions can be used with this agent",
|
||||
"type": "array",
|
||||
@@ -14921,6 +15070,47 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.FollowUpSuggestionConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"additional_instruction": {
|
||||
"type": "string"
|
||||
},
|
||||
"allow_regenerate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"knowledge_fallback": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_context_turns": {
|
||||
"type": "integer"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
},
|
||||
"model_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"suppress_on_fallback": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"suppress_when_answer_asks_question": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.GraphNode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16123,6 +16313,10 @@ const docTemplate = `{
|
||||
"description": "Agent total execution duration in milliseconds (from query start to answer start)",
|
||||
"type": "integer"
|
||||
},
|
||||
"agent_id": {
|
||||
"description": "AgentID is the agent used for this individual assistant turn. Unlike the\nsession's last_request_state it remains stable when users switch agents.",
|
||||
"type": "string"
|
||||
},
|
||||
"agent_steps": {
|
||||
"description": "Agent execution steps (only for assistant messages generated by agent)\nThis contains the detailed reasoning process and tool calls made by the agent\nStored for user history display, but NOT included in LLM context to avoid redundancy",
|
||||
"type": "array",
|
||||
@@ -16194,6 +16388,10 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.MentionedItem"
|
||||
}
|
||||
},
|
||||
"model_id": {
|
||||
"description": "ModelID is the requested/effective chat model binding captured for this\nturn. It is useful for reproducibility and suggestion generation.",
|
||||
"type": "string"
|
||||
},
|
||||
"request_id": {
|
||||
"description": "Request identifier for tracking API requests",
|
||||
"type": "string"
|
||||
@@ -16784,6 +16982,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.QuestionSuggestionConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"follow_ups": {
|
||||
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.FollowUpSuggestionConfig"
|
||||
},
|
||||
"starters": {
|
||||
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.StarterSuggestionConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.QueueStat": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17424,6 +17633,26 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.StarterSuggestionConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.StorageConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17536,6 +17765,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.SuggestionAttribution": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"suggestion_set_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.SyncLog": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19060,6 +19300,14 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handler.EnsureMessageSuggestionsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"regenerate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handler.EvaluationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20015,6 +20263,24 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handler.SuggestionEventRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"event_type",
|
||||
"suggestion_set_id"
|
||||
],
|
||||
"properties": {
|
||||
"event_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"question_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"suggestion_set_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handler.TestProviderRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -20533,6 +20799,9 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"suggestion_attribution": {
|
||||
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.SuggestionAttribution"
|
||||
},
|
||||
"summary_model_id": {
|
||||
"description": "Optional summary model ID for this request (overrides session default)",
|
||||
"type": "string"
|
||||
|
||||
+276
-7
@@ -10667,6 +10667,111 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/messages/{message_id}/suggestions": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
},
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"会话"
|
||||
],
|
||||
"summary": "获取回答后推荐问题",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "会话 ID",
|
||||
"name": "session_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "助手消息 ID",
|
||||
"name": "message_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
},
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "对已完成的助手消息异步生成或重新生成推荐问题;相同配置快照会复用持久化结果",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"会话"
|
||||
],
|
||||
"summary": "确保生成回答后推荐问题",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "会话 ID",
|
||||
"name": "session_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "助手消息 ID",
|
||||
"name": "message_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "生成选项",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handler.EnsureMessageSuggestionsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"202": {
|
||||
"description": "Accepted",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/pin": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -10767,6 +10872,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/suggestion-events": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
},
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "记录曝光、点击或关闭事件",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"会话"
|
||||
],
|
||||
"summary": "上报推荐问题事件",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "会话 ID",
|
||||
"name": "session_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "事件",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handler.SuggestionEventRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/title": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -14474,6 +14622,14 @@
|
||||
"description": "Dedicated chat model ID for the query-understanding (rewrite + intent) step.\nWhen empty, the main conversation ModelID is used as a fallback.",
|
||||
"type": "string"
|
||||
},
|
||||
"question_suggestions": {
|
||||
"description": "===== Conversation Question Suggestions =====\nQuestionSuggestions owns both the static/knowledge-backed prompts shown\nbefore the first user turn and the contextual follow-up questions shown\nafter a completed assistant answer.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.QuestionSuggestionConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rerank_model_id": {
|
||||
"description": "ReRank model ID for retrieval",
|
||||
"type": "string"
|
||||
@@ -14513,13 +14669,6 @@
|
||||
"description": "===== Skills Settings (only for smart-reasoning mode) =====\nSkills selection mode: \"all\" = all preloaded skills, \"selected\" = specific skills, \"none\" = no skills",
|
||||
"type": "string"
|
||||
},
|
||||
"suggested_prompts": {
|
||||
"description": "===== Suggested Prompts =====\n推荐问题列表,用于在前端对话面板展示快捷提问",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"supported_file_types": {
|
||||
"description": "===== File Type Restriction Settings =====\nSupported file types for this agent (e.g., [\"csv\", \"xlsx\", \"xls\"])\nEmpty means all file types are supported\nWhen set, only files with matching extensions can be used with this agent",
|
||||
"type": "array",
|
||||
@@ -14914,6 +15063,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.FollowUpSuggestionConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"additional_instruction": {
|
||||
"type": "string"
|
||||
},
|
||||
"allow_regenerate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"knowledge_fallback": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_context_turns": {
|
||||
"type": "integer"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
},
|
||||
"model_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"suppress_on_fallback": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"suppress_when_answer_asks_question": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.GraphNode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16116,6 +16306,10 @@
|
||||
"description": "Agent total execution duration in milliseconds (from query start to answer start)",
|
||||
"type": "integer"
|
||||
},
|
||||
"agent_id": {
|
||||
"description": "AgentID is the agent used for this individual assistant turn. Unlike the\nsession's last_request_state it remains stable when users switch agents.",
|
||||
"type": "string"
|
||||
},
|
||||
"agent_steps": {
|
||||
"description": "Agent execution steps (only for assistant messages generated by agent)\nThis contains the detailed reasoning process and tool calls made by the agent\nStored for user history display, but NOT included in LLM context to avoid redundancy",
|
||||
"type": "array",
|
||||
@@ -16187,6 +16381,10 @@
|
||||
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.MentionedItem"
|
||||
}
|
||||
},
|
||||
"model_id": {
|
||||
"description": "ModelID is the requested/effective chat model binding captured for this\nturn. It is useful for reproducibility and suggestion generation.",
|
||||
"type": "string"
|
||||
},
|
||||
"request_id": {
|
||||
"description": "Request identifier for tracking API requests",
|
||||
"type": "string"
|
||||
@@ -16777,6 +16975,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.QuestionSuggestionConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"follow_ups": {
|
||||
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.FollowUpSuggestionConfig"
|
||||
},
|
||||
"starters": {
|
||||
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.StarterSuggestionConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.QueueStat": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17417,6 +17626,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.StarterSuggestionConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.StorageConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17529,6 +17758,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.SuggestionAttribution": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"suggestion_set_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_Tencent_WeKnora_internal_types.SyncLog": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19053,6 +19293,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handler.EnsureMessageSuggestionsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"regenerate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handler.EvaluationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20008,6 +20256,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handler.SuggestionEventRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"event_type",
|
||||
"suggestion_set_id"
|
||||
],
|
||||
"properties": {
|
||||
"event_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"question_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"suggestion_set_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handler.TestProviderRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -20526,6 +20792,9 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"suggestion_attribution": {
|
||||
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.SuggestionAttribution"
|
||||
},
|
||||
"summary_model_id": {
|
||||
"description": "Optional summary model ID for this request (overrides session default)",
|
||||
"type": "string"
|
||||
|
||||
+183
-7
@@ -713,6 +713,14 @@ definitions:
|
||||
Dedicated chat model ID for the query-understanding (rewrite + intent) step.
|
||||
When empty, the main conversation ModelID is used as a fallback.
|
||||
type: string
|
||||
question_suggestions:
|
||||
allOf:
|
||||
- $ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.QuestionSuggestionConfig'
|
||||
description: |-
|
||||
===== Conversation Question Suggestions =====
|
||||
QuestionSuggestions owns both the static/knowledge-backed prompts shown
|
||||
before the first user turn and the contextual follow-up questions shown
|
||||
after a completed assistant answer.
|
||||
rerank_model_id:
|
||||
description: ReRank model ID for retrieval
|
||||
type: string
|
||||
@@ -747,13 +755,6 @@ definitions:
|
||||
===== Skills Settings (only for smart-reasoning mode) =====
|
||||
Skills selection mode: "all" = all preloaded skills, "selected" = specific skills, "none" = no skills
|
||||
type: string
|
||||
suggested_prompts:
|
||||
description: |-
|
||||
===== Suggested Prompts =====
|
||||
推荐问题列表,用于在前端对话面板展示快捷提问
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
supported_file_types:
|
||||
description: |-
|
||||
===== File Type Restriction Settings =====
|
||||
@@ -1045,6 +1046,33 @@ definitions:
|
||||
required:
|
||||
- query_text
|
||||
type: object
|
||||
github_com_Tencent_WeKnora_internal_types.FollowUpSuggestionConfig:
|
||||
properties:
|
||||
additional_instruction:
|
||||
type: string
|
||||
allow_regenerate:
|
||||
type: boolean
|
||||
categories:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
count:
|
||||
type: integer
|
||||
enabled:
|
||||
type: boolean
|
||||
knowledge_fallback:
|
||||
type: boolean
|
||||
max_context_turns:
|
||||
type: integer
|
||||
mode:
|
||||
type: string
|
||||
model_id:
|
||||
type: string
|
||||
suppress_on_fallback:
|
||||
type: boolean
|
||||
suppress_when_answer_asks_question:
|
||||
type: boolean
|
||||
type: object
|
||||
github_com_Tencent_WeKnora_internal_types.GraphNode:
|
||||
properties:
|
||||
attributes:
|
||||
@@ -1938,6 +1966,11 @@ definitions:
|
||||
description: Agent total execution duration in milliseconds (from query start
|
||||
to answer start)
|
||||
type: integer
|
||||
agent_id:
|
||||
description: |-
|
||||
AgentID is the agent used for this individual assistant turn. Unlike the
|
||||
session's last_request_state it remains stable when users switch agents.
|
||||
type: string
|
||||
agent_steps:
|
||||
description: |-
|
||||
Agent execution steps (only for assistant messages generated by agent)
|
||||
@@ -1997,6 +2030,11 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.MentionedItem'
|
||||
type: array
|
||||
model_id:
|
||||
description: |-
|
||||
ModelID is the requested/effective chat model binding captured for this
|
||||
turn. It is useful for reproducibility and suggestion generation.
|
||||
type: string
|
||||
request_id:
|
||||
description: Request identifier for tracking API requests
|
||||
type: string
|
||||
@@ -2447,6 +2485,13 @@ definitions:
|
||||
10)'
|
||||
type: integer
|
||||
type: object
|
||||
github_com_Tencent_WeKnora_internal_types.QuestionSuggestionConfig:
|
||||
properties:
|
||||
follow_ups:
|
||||
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.FollowUpSuggestionConfig'
|
||||
starters:
|
||||
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.StarterSuggestionConfig'
|
||||
type: object
|
||||
github_com_Tencent_WeKnora_internal_types.QueueStat:
|
||||
properties:
|
||||
active:
|
||||
@@ -2928,6 +2973,19 @@ definitions:
|
||||
- organization_id
|
||||
- permission
|
||||
type: object
|
||||
github_com_Tencent_WeKnora_internal_types.StarterSuggestionConfig:
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
enabled:
|
||||
type: boolean
|
||||
items:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
mode:
|
||||
type: string
|
||||
type: object
|
||||
github_com_Tencent_WeKnora_internal_types.StorageConfig:
|
||||
properties:
|
||||
app_id:
|
||||
@@ -3006,6 +3064,13 @@ definitions:
|
||||
required:
|
||||
- invite_code
|
||||
type: object
|
||||
github_com_Tencent_WeKnora_internal_types.SuggestionAttribution:
|
||||
properties:
|
||||
question_id:
|
||||
type: string
|
||||
suggestion_set_id:
|
||||
type: string
|
||||
type: object
|
||||
github_com_Tencent_WeKnora_internal_types.SyncLog:
|
||||
properties:
|
||||
created_at:
|
||||
@@ -4179,6 +4244,11 @@ definitions:
|
||||
type: integer
|
||||
type: array
|
||||
type: object
|
||||
internal_handler.EnsureMessageSuggestionsRequest:
|
||||
properties:
|
||||
regenerate:
|
||||
type: boolean
|
||||
type: object
|
||||
internal_handler.EvaluationRequest:
|
||||
properties:
|
||||
chat_id:
|
||||
@@ -4848,6 +4918,18 @@ definitions:
|
||||
description: '"local", "minio", "cos", "tos", "s3", "oss", "ks3"'
|
||||
type: string
|
||||
type: object
|
||||
internal_handler.SuggestionEventRequest:
|
||||
properties:
|
||||
event_type:
|
||||
type: string
|
||||
question_id:
|
||||
type: string
|
||||
suggestion_set_id:
|
||||
type: string
|
||||
required:
|
||||
- event_type
|
||||
- suggestion_set_id
|
||||
type: object
|
||||
internal_handler.TestProviderRequest:
|
||||
properties:
|
||||
parameters:
|
||||
@@ -5213,6 +5295,8 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
suggestion_attribution:
|
||||
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.SuggestionAttribution'
|
||||
summary_model_id:
|
||||
description: Optional summary model ID for this request (overrides session
|
||||
default)
|
||||
@@ -12017,6 +12101,72 @@ paths:
|
||||
summary: 知识问答
|
||||
tags:
|
||||
- 问答
|
||||
/sessions/{session_id}/messages/{message_id}/suggestions:
|
||||
get:
|
||||
parameters:
|
||||
- description: 会话 ID
|
||||
in: path
|
||||
name: session_id
|
||||
required: true
|
||||
type: string
|
||||
- description: 助手消息 ID
|
||||
in: path
|
||||
name: message_id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- Bearer: []
|
||||
- ApiKeyAuth: []
|
||||
summary: 获取回答后推荐问题
|
||||
tags:
|
||||
- 会话
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 对已完成的助手消息异步生成或重新生成推荐问题;相同配置快照会复用持久化结果
|
||||
parameters:
|
||||
- description: 会话 ID
|
||||
in: path
|
||||
name: session_id
|
||||
required: true
|
||||
type: string
|
||||
- description: 助手消息 ID
|
||||
in: path
|
||||
name: message_id
|
||||
required: true
|
||||
type: string
|
||||
- description: 生成选项
|
||||
in: body
|
||||
name: request
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handler.EnsureMessageSuggestionsRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"202":
|
||||
description: Accepted
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- Bearer: []
|
||||
- ApiKeyAuth: []
|
||||
summary: 确保生成回答后推荐问题
|
||||
tags:
|
||||
- 会话
|
||||
/sessions/{session_id}/pin:
|
||||
post:
|
||||
description: 将指定会话置顶(用户维度)
|
||||
@@ -12079,6 +12229,32 @@ paths:
|
||||
summary: 停止生成
|
||||
tags:
|
||||
- 问答
|
||||
/sessions/{session_id}/suggestion-events:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 记录曝光、点击或关闭事件
|
||||
parameters:
|
||||
- description: 会话 ID
|
||||
in: path
|
||||
name: session_id
|
||||
required: true
|
||||
type: string
|
||||
- description: 事件
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handler.SuggestionEventRequest'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
security:
|
||||
- Bearer: []
|
||||
- ApiKeyAuth: []
|
||||
summary: 上报推荐问题事件
|
||||
tags:
|
||||
- 会话
|
||||
/sessions/{session_id}/title:
|
||||
post:
|
||||
consumes:
|
||||
|
||||
@@ -8,6 +8,28 @@ import { get, post, put, del } from "../../utils/request";
|
||||
// 'custom' : 完全自定义(不应用预设)
|
||||
export type AgentType = 'rag-qa' | 'wiki-qa' | 'hybrid-rag-wiki' | 'data-analysis' | 'custom';
|
||||
|
||||
export interface QuestionSuggestionConfig {
|
||||
starters: {
|
||||
enabled: boolean;
|
||||
mode: 'curated' | 'knowledge' | 'hybrid';
|
||||
items: string[];
|
||||
count: number;
|
||||
};
|
||||
follow_ups: {
|
||||
enabled: boolean;
|
||||
mode: 'generated' | 'knowledge' | 'hybrid';
|
||||
count: number;
|
||||
model_id?: string;
|
||||
additional_instruction?: string;
|
||||
categories: Array<'clarify' | 'deepen' | 'action'>;
|
||||
max_context_turns: number;
|
||||
suppress_on_fallback: boolean;
|
||||
suppress_when_answer_asks_question: boolean;
|
||||
knowledge_fallback: boolean;
|
||||
allow_regenerate: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomAgentConfig {
|
||||
// ===== 基础设置 =====
|
||||
agent_mode?: 'quick-answer' | 'smart-reasoning'; // 运行模式:quick-answer=RAG模式, smart-reasoning=ReAct Agent模式
|
||||
@@ -92,7 +114,7 @@ export interface CustomAgentConfig {
|
||||
|
||||
// ===== 已废弃字段(保留兼容)=====
|
||||
welcome_message?: string;
|
||||
suggested_prompts?: string[];
|
||||
question_suggestions?: QuestionSuggestionConfig;
|
||||
}
|
||||
|
||||
// 智能体
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useStream() {
|
||||
let renderTimer: number | null = null
|
||||
|
||||
// 启动流式请求
|
||||
const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; tag_ids?: string[]; agent_enabled?: boolean; agent_id?: string; web_search_enabled?: boolean; enable_memory?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; skill_names?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; method: string; url: string; embed_token?: string; embed_session_sig?: string; embed_visitor_id?: string }) => {
|
||||
const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; tag_ids?: string[]; agent_enabled?: boolean; agent_id?: string; web_search_enabled?: boolean; enable_memory?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; skill_names?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; suggestion_attribution?: { suggestion_set_id: string; question_id: string }; method: string; url: string; embed_token?: string; embed_session_sig?: string; embed_visitor_id?: string }) => {
|
||||
const myGeneration = ++streamGeneration
|
||||
// 重置状态
|
||||
output.value = '';
|
||||
@@ -133,6 +133,9 @@ export function useStream() {
|
||||
if (params.attachment_uploads !== undefined && params.attachment_uploads.length > 0) {
|
||||
postBody.attachment_uploads = params.attachment_uploads;
|
||||
}
|
||||
if (params.suggestion_attribution) {
|
||||
postBody.suggestion_attribution = params.suggestion_attribution;
|
||||
}
|
||||
postBody.channel = embedToken ? "embed" : "web";
|
||||
|
||||
lastStreamRequest.value = {
|
||||
|
||||
@@ -170,6 +170,20 @@ export interface SuggestedQuestion {
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface EmbedMessageSuggestionItem {
|
||||
id: string
|
||||
text: string
|
||||
category?: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface EmbedMessageSuggestionSet {
|
||||
id: string
|
||||
status: 'generating' | 'ready' | 'suppressed' | 'failed'
|
||||
allow_regenerate: boolean
|
||||
questions: EmbedMessageSuggestionItem[]
|
||||
}
|
||||
|
||||
export async function getEmbedChunkById(channelId: string, token: string, chunkId: string) {
|
||||
return get<{ success: boolean; data: { content?: string } }>(
|
||||
`/api/v1/embed/${channelId}/chunks/${chunkId}`,
|
||||
@@ -184,6 +198,53 @@ export async function getEmbedSuggestedQuestions(channelId: string, token: strin
|
||||
)
|
||||
}
|
||||
|
||||
export async function ensureEmbedMessageSuggestions(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
regenerate = false,
|
||||
) {
|
||||
return post<{ success: boolean; data: EmbedMessageSuggestionSet }>(
|
||||
`/api/v1/embed/${channelId}/sessions/${sessionId}/messages/${messageId}/suggestions`,
|
||||
{ regenerate },
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getEmbedMessageSuggestions(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
) {
|
||||
return get<{ success: boolean; data: EmbedMessageSuggestionSet }>(
|
||||
`/api/v1/embed/${channelId}/sessions/${sessionId}/messages/${messageId}/suggestions`,
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
}
|
||||
|
||||
export async function recordEmbedMessageSuggestionEvent(
|
||||
channelId: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
sessionSig: string,
|
||||
visitorId: string,
|
||||
suggestionSetId: string,
|
||||
eventType: 'impression' | 'click' | 'dismiss',
|
||||
questionId = '',
|
||||
) {
|
||||
return post(
|
||||
`/api/v1/embed/${channelId}/sessions/${sessionId}/suggestion-events`,
|
||||
{ suggestion_set_id: suggestionSetId, question_id: questionId, event_type: eventType },
|
||||
{ headers: embedSessionHeaders(token, sessionSig, visitorId) },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getEmbedConfig(channelId: string, token: string) {
|
||||
return get<{ success: boolean; data: EmbedChannelPublicConfig }>(
|
||||
`/api/v1/embed/${channelId}/config`,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { get, post } from '@/utils/request'
|
||||
|
||||
export interface MessageSuggestionItem {
|
||||
id: string
|
||||
text: string
|
||||
category?: 'clarify' | 'deepen' | 'action'
|
||||
source: 'model' | 'faq' | 'document' | 'wiki' | string
|
||||
knowledge_base_ids?: string[]
|
||||
}
|
||||
|
||||
export interface MessageSuggestionSet {
|
||||
id: string
|
||||
session_id: string
|
||||
assistant_message_id: string
|
||||
status: 'generating' | 'ready' | 'suppressed' | 'failed'
|
||||
allow_regenerate: boolean
|
||||
suppression_reason?: string
|
||||
questions: MessageSuggestionItem[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
export function ensureMessageSuggestions(sessionId: string, messageId: string, regenerate = false) {
|
||||
return post<{ data: MessageSuggestionSet }>(
|
||||
`/api/v1/sessions/${sessionId}/messages/${messageId}/suggestions`,
|
||||
{ regenerate },
|
||||
)
|
||||
}
|
||||
|
||||
export function getMessageSuggestions(sessionId: string, messageId: string) {
|
||||
return get<{ data: MessageSuggestionSet }>(
|
||||
`/api/v1/sessions/${sessionId}/messages/${messageId}/suggestions`,
|
||||
)
|
||||
}
|
||||
|
||||
export function recordMessageSuggestionEvent(
|
||||
sessionId: string,
|
||||
suggestionSetId: string,
|
||||
eventType: 'impression' | 'click' | 'dismiss',
|
||||
questionId = '',
|
||||
) {
|
||||
return post(
|
||||
`/api/v1/sessions/${sessionId}/suggestion-events`,
|
||||
{ suggestion_set_id: suggestionSetId, question_id: questionId, event_type: eventType },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div v-if="loading || suggestionSet?.status === 'ready'" class="follow-ups" aria-live="polite">
|
||||
<div class="follow-ups__header">
|
||||
<span>{{ t('chat.followUpQuestions') }}</span>
|
||||
<div class="follow-ups__actions">
|
||||
<button v-if="allowRegenerate" type="button" :disabled="loading" @click="emit('regenerate')">
|
||||
<t-icon :name="loading ? 'loading' : 'refresh'" :class="{ 'is-spinning': loading }" />
|
||||
<span>{{ t('chat.refreshSuggestedQuestions') }}</span>
|
||||
</button>
|
||||
<button type="button" :aria-label="t('common.close')" @click="dismiss">
|
||||
<t-icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading && !suggestionSet?.questions?.length" class="follow-ups__skeletons">
|
||||
<span v-for="n in 3" :key="n" :style="{ width: skeletonWidths[n - 1] }" />
|
||||
</div>
|
||||
<div v-else class="follow-ups__list">
|
||||
<button v-for="item in suggestionSet?.questions || []" :key="item.id" type="button"
|
||||
class="follow-ups__item" @click="emit('select', item)">
|
||||
<span>{{ item.text }}</span>
|
||||
<t-icon name="arrow-up-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MessageSuggestionItem, MessageSuggestionSet } from '@/api/message-suggestion'
|
||||
|
||||
const props = defineProps<{
|
||||
suggestionSet?: MessageSuggestionSet | null
|
||||
loading?: boolean
|
||||
allowRegenerate?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(event: 'select', item: MessageSuggestionItem): void
|
||||
(event: 'regenerate'): void
|
||||
(event: 'impression', set: MessageSuggestionSet): void
|
||||
(event: 'dismiss', set: MessageSuggestionSet): void
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const impressed = new Set<string>()
|
||||
const skeletonWidths = ['92%', '78%', '85%']
|
||||
|
||||
watch(
|
||||
() => props.suggestionSet,
|
||||
(set) => {
|
||||
if (set?.status === 'ready' && set.questions.length > 0 && !impressed.has(set.id)) {
|
||||
impressed.add(set.id)
|
||||
emit('impression', set)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const dismiss = () => {
|
||||
if (props.suggestionSet) emit('dismiss', props.suggestionSet)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.follow-ups {
|
||||
max-width: 760px;
|
||||
margin: -4px 0 28px 46px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: 12px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
.follow-ups__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.follow-ups__actions { display: flex; gap: 4px; }
|
||||
.follow-ups__actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--td-text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color .2s, color .2s;
|
||||
}
|
||||
.follow-ups__actions button:hover:not(:disabled) {
|
||||
background: var(--td-bg-color-container-hover, rgba(0, 0, 0, .06));
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
.follow-ups__actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .6;
|
||||
}
|
||||
.follow-ups__list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.follow-ups__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: var(--td-bg-color-container);
|
||||
color: var(--td-text-color-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.follow-ups__item { transition: border-color .2s, box-shadow .2s, transform .2s; }
|
||||
.follow-ups__item:hover {
|
||||
border-color: var(--td-brand-color);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, .12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.follow-ups__item:hover .t-icon { color: var(--td-brand-color); }
|
||||
.follow-ups__skeletons { display: flex; flex-direction: column; gap: 6px; }
|
||||
.follow-ups__skeletons span {
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
var(--td-bg-color-component) 30%,
|
||||
var(--td-bg-color-container-hover, rgba(255, 255, 255, .35)) 50%,
|
||||
var(--td-bg-color-component) 70%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
}
|
||||
.is-spinning { animation: spin 1s linear infinite; }
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ export interface UseChatStreamHandlerOptions {
|
||||
isAgentStreamSession: () => boolean
|
||||
scrollToBottom: (force?: boolean) => void
|
||||
onReplyComplete?: (content: string) => void
|
||||
onTurnComplete?: (message: ChatMessage) => void
|
||||
onError?: (message: string) => void
|
||||
/** Main chat: keep the last incomplete message reactive for continue-stream. */
|
||||
preserveIncompleteStreamReactive?: boolean
|
||||
@@ -42,6 +43,7 @@ export function useChatStreamHandler(options: UseChatStreamHandlerOptions) {
|
||||
isAgentStreamSession,
|
||||
scrollToBottom,
|
||||
onReplyComplete,
|
||||
onTurnComplete,
|
||||
onError,
|
||||
preserveIncompleteStreamReactive = false,
|
||||
isFirstEnter,
|
||||
@@ -808,6 +810,7 @@ export function useChatStreamHandler(options: UseChatStreamHandlerOptions) {
|
||||
isReplying.value = false
|
||||
message.is_completed = true
|
||||
onReplyComplete?.(String(message.content || ''))
|
||||
onTurnComplete?.(message)
|
||||
fullContent.value = ''
|
||||
currentAssistantMessageId.value = ''
|
||||
if (message.agentEventStream) {
|
||||
@@ -1003,6 +1006,10 @@ export function useChatStreamHandler(options: UseChatStreamHandlerOptions) {
|
||||
currentAssistantMessageId.value = ''
|
||||
}
|
||||
updateAssistantSession(obj)
|
||||
if (data.done) {
|
||||
const completed = resolveActiveAssistantMessage(data) || obj
|
||||
onTurnComplete?.(completed)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -35,6 +35,8 @@ export function useEmbedChatSession(options: {
|
||||
hostContext?: Ref<Record<string, unknown>>
|
||||
onMessagesChange?: (has: boolean) => void
|
||||
onSessionTitle?: (title: string) => void
|
||||
onTurnComplete?: (message: Record<string, unknown>) => void
|
||||
onMessagesLoaded?: (messages: Record<string, unknown>[]) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const { onChunk, error, startStream, stopStream } = useStream()
|
||||
@@ -59,6 +61,7 @@ export function useEmbedChatSession(options: {
|
||||
const hasMoreHistory = ref(true)
|
||||
const created_at = ref('')
|
||||
const fullContent = ref('')
|
||||
let pendingSuggestionAttribution: { suggestion_set_id: string; question_id: string } | null = null
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const userHasScrolledUp = ref(false)
|
||||
const SCROLL_BOTTOM_THRESHOLD = 80
|
||||
@@ -130,6 +133,8 @@ export function useEmbedChatSession(options: {
|
||||
isAgentStreamSession,
|
||||
scrollToBottom,
|
||||
onReplyComplete: notifyEmbedReceived,
|
||||
onTurnComplete: options.onTurnComplete,
|
||||
onAfterMsgList: () => options.onMessagesLoaded?.(messagesList),
|
||||
onError: embedToast,
|
||||
isFirstEnter,
|
||||
scrollContainer,
|
||||
@@ -298,6 +303,8 @@ export function useEmbedChatSession(options: {
|
||||
? `/api/v1/embed/${options.channelId}/agent-chat`
|
||||
: `/api/v1/embed/${options.channelId}/knowledge-chat`
|
||||
|
||||
const suggestionAttribution = pendingSuggestionAttribution
|
||||
pendingSuggestionAttribution = null
|
||||
await startStream({
|
||||
session_id: options.sessionId.value,
|
||||
knowledge_base_ids: options.kbIds,
|
||||
@@ -312,6 +319,7 @@ export function useEmbedChatSession(options: {
|
||||
images: imageAttachments.length > 0 ? imageAttachments : undefined,
|
||||
attachment_uploads: attachmentUploads.length > 0 ? attachmentUploads : undefined,
|
||||
query: outboundQuery,
|
||||
suggestion_attribution: suggestionAttribution || undefined,
|
||||
method: 'POST',
|
||||
url: endpoint,
|
||||
embed_token: options.token,
|
||||
@@ -391,5 +399,8 @@ export function useEmbedChatSession(options: {
|
||||
onClickScrollToBottom,
|
||||
sendMsg,
|
||||
handleStopGeneration,
|
||||
setSuggestionAttribution: (suggestionSetId: string, questionId: string) => {
|
||||
pendingSuggestionAttribution = { suggestion_set_id: suggestionSetId, question_id: questionId }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ const messages = {
|
||||
"newChat": "新对话",
|
||||
"suggestedQuestions": "你可以这样问我",
|
||||
"suggestedQuestionsLoading": "正在加载推荐问题...",
|
||||
"followUpQuestions": "接下来可以继续问",
|
||||
"refreshSuggestedQuestions": "换一批推荐问题",
|
||||
"inputPlaceholder": "请输入您的消息...",
|
||||
"send": "发送",
|
||||
"thinking": "思考中...",
|
||||
@@ -550,6 +552,8 @@ const messages = {
|
||||
"newChat": "New Chat",
|
||||
"suggestedQuestions": "You can ask me",
|
||||
"suggestedQuestionsLoading": "Loading suggestions...",
|
||||
"followUpQuestions": "Continue with a follow-up",
|
||||
"refreshSuggestedQuestions": "Refresh suggestions",
|
||||
"inputPlaceholder": "Enter your message...",
|
||||
"send": "Send",
|
||||
"thinking": "Thinking...",
|
||||
@@ -1008,6 +1012,8 @@ const koEmbedPublish = {
|
||||
},
|
||||
chat: {
|
||||
suggestedQuestions: '이렇게 물어보세요',
|
||||
followUpQuestions: '이어서 질문해 보세요',
|
||||
refreshSuggestedQuestions: '추천 질문 새로고침',
|
||||
imageTooMany: '이미지는 최대 5장까지 업로드할 수 있습니다',
|
||||
imageTypeSizeError: 'JPG/PNG/GIF/WEBP만 지원하며, 각 파일은 10MB 이하여야 합니다',
|
||||
imageReadFailed: '이미지를 읽지 못했습니다',
|
||||
@@ -1091,6 +1097,8 @@ const ruEmbedPublish = {
|
||||
},
|
||||
chat: {
|
||||
suggestedQuestions: 'Вы можете спросить так',
|
||||
followUpQuestions: 'Продолжите уточняющим вопросом',
|
||||
refreshSuggestedQuestions: 'Обновить предложения',
|
||||
imageTooMany: 'Можно загрузить не более 5 изображений',
|
||||
imageTypeSizeError: 'Поддерживаются только JPG/PNG/GIF/WEBP, каждый файл до 10 МБ',
|
||||
imageReadFailed: 'Не удалось прочитать изображение',
|
||||
|
||||
@@ -3081,6 +3081,7 @@ export default {
|
||||
title: 'Chat',
|
||||
newChat: 'New Chat',
|
||||
suggestedQuestions: 'You can ask me',
|
||||
followUpQuestions: 'Continue with a follow-up',
|
||||
suggestedQuestionsLoading: 'Loading suggestions...',
|
||||
refreshSuggestedQuestions: 'Refresh suggestions',
|
||||
inputPlaceholder: 'Enter your message...',
|
||||
@@ -5487,6 +5488,41 @@ export default {
|
||||
capability: 'Extensions',
|
||||
integration: 'Publish & Integrations',
|
||||
},
|
||||
questionSuggestions: {
|
||||
navLabel: 'Question suggestions',
|
||||
title: 'Conversation question suggestions',
|
||||
description: 'Configure starters and contextual follow-ups in one agent-owned policy. Channels may hide them but cannot override the policy.',
|
||||
startersTitle: 'Conversation starters',
|
||||
followUpsTitle: 'After-answer follow-ups',
|
||||
enableStarters: 'Show starter questions',
|
||||
enableStartersDesc: 'Shown before the first user message from curated, knowledge, or mixed sources.',
|
||||
enableFollowUps: 'Generate follow-up questions',
|
||||
enableFollowUpsDesc: 'Generated asynchronously after every completed answer. Enabling this adds model usage.',
|
||||
sourceMode: 'Source mode',
|
||||
count: 'Question count',
|
||||
curatedItems: 'Curated questions',
|
||||
curatedItemsDesc: 'Used for starters and prioritized in hybrid mode.',
|
||||
addItem: 'Add question',
|
||||
model: 'Generation model',
|
||||
modelDesc: 'Uses the model from the completed turn when empty.',
|
||||
advancedSettings: 'Advanced generation settings',
|
||||
advancedSettingsDesc: 'Context, question types, instructions, and display rules',
|
||||
displayRules: 'Display and fallback rules',
|
||||
contextTurns: 'Context turns',
|
||||
categories: 'Question types',
|
||||
instruction: 'Additional instruction',
|
||||
suppressFallback: 'Hide after fallback answers',
|
||||
suppressQuestion: 'Hide when the answer ends with a question',
|
||||
knowledgeFallback: 'Use knowledge candidates if generation fails',
|
||||
allowRegenerate: 'Allow users to regenerate',
|
||||
modeCurated: 'Curated',
|
||||
modeKnowledge: 'Knowledge',
|
||||
modeGenerated: 'Generated',
|
||||
modeHybrid: 'Hybrid',
|
||||
categoryClarify: 'Clarify',
|
||||
categoryDeepen: 'Deepen',
|
||||
categoryAction: 'Next step',
|
||||
},
|
||||
placeholders: {
|
||||
available: 'Available variables: ',
|
||||
clickToInsert: '(click to insert)',
|
||||
|
||||
@@ -895,6 +895,7 @@ export default {
|
||||
title: "대화",
|
||||
newChat: "새 대화",
|
||||
suggestedQuestions: "이렇게 물어보세요",
|
||||
followUpQuestions: "이어서 질문해 보세요",
|
||||
suggestedQuestionsLoading: "추천 질문 로딩 중...",
|
||||
refreshSuggestedQuestions: "추천 질문 새로고침",
|
||||
inputPlaceholder: "메시지를 입력하세요...",
|
||||
@@ -5498,6 +5499,24 @@ export default {
|
||||
capability: '기능 확장',
|
||||
integration: '게시 및 통합',
|
||||
},
|
||||
questionSuggestions: {
|
||||
navLabel: '질문 추천', title: '대화 질문 추천',
|
||||
description: '대화 시작 질문과 답변 후 후속 질문을 에이전트 정책으로 통합 설정합니다.',
|
||||
startersTitle: '시작 질문', followUpsTitle: '답변 후 후속 질문',
|
||||
enableStarters: '시작 질문 표시', enableStartersDesc: '첫 질문 전에 운영 설정 또는 지식 기반 질문을 표시합니다.',
|
||||
enableFollowUps: '후속 질문 생성', enableFollowUpsDesc: '완료된 답변마다 비동기로 생성하며 추가 모델 사용량이 발생합니다.',
|
||||
sourceMode: '콘텐츠 소스', count: '표시 개수', curatedItems: '운영 설정 질문',
|
||||
curatedItemsDesc: '혼합 모드에서 우선 표시됩니다.', addItem: '질문 추가',
|
||||
model: '생성 모델', modelDesc: '비워 두면 현재 대화 모델을 사용합니다.',
|
||||
advancedSettings: '고급 생성 설정',
|
||||
advancedSettingsDesc: '컨텍스트, 질문 유형, 생성 지침 및 표시 규칙',
|
||||
displayRules: '표시 및 폴백 규칙',
|
||||
contextTurns: '컨텍스트 턴 수', categories: '질문 유형', instruction: '추가 생성 지침',
|
||||
suppressFallback: '대체 답변 뒤에는 숨기기', suppressQuestion: '답변이 질문으로 끝나면 숨기기',
|
||||
knowledgeFallback: '생성 실패 시 지식 후보 사용', allowRegenerate: '새 질문 묶음 허용',
|
||||
modeCurated: '운영 설정', modeKnowledge: '지식 기반', modeGenerated: '모델 생성', modeHybrid: '혼합',
|
||||
categoryClarify: '명확화', categoryDeepen: '심화', categoryAction: '다음 단계',
|
||||
},
|
||||
placeholders: {
|
||||
available: '사용 가능한 변수: ',
|
||||
clickToInsert: '(클릭하여 삽입)',
|
||||
|
||||
@@ -3663,6 +3663,7 @@ export default {
|
||||
title: 'Диалог',
|
||||
newChat: 'Новый чат',
|
||||
suggestedQuestions: 'Вы можете спросить меня',
|
||||
followUpQuestions: 'Продолжите уточняющим вопросом',
|
||||
suggestedQuestionsLoading: 'Загрузка предложений...',
|
||||
refreshSuggestedQuestions: 'Обновить предложения',
|
||||
inputPlaceholder: 'Введите ваше сообщение...',
|
||||
@@ -4998,6 +4999,24 @@ export default {
|
||||
capability: 'Расширения',
|
||||
integration: 'Публикация и интеграция',
|
||||
},
|
||||
questionSuggestions: {
|
||||
navLabel: 'Рекомендуемые вопросы', title: 'Рекомендации вопросов в диалоге',
|
||||
description: 'Единая политика агента для стартовых и контекстных вопросов после ответа.',
|
||||
startersTitle: 'Стартовые вопросы', followUpsTitle: 'Вопросы после ответа',
|
||||
enableStarters: 'Показывать стартовые вопросы', enableStartersDesc: 'Показываются до первого сообщения пользователя.',
|
||||
enableFollowUps: 'Генерировать уточнения', enableFollowUpsDesc: 'Создаются асинхронно после каждого полного ответа и расходуют модель.',
|
||||
sourceMode: 'Источник', count: 'Количество', curatedItems: 'Редакторские вопросы',
|
||||
curatedItemsDesc: 'Имеют приоритет в гибридном режиме.', addItem: 'Добавить вопрос',
|
||||
model: 'Модель генерации', modelDesc: 'Если не выбрана, используется модель текущего ответа.',
|
||||
advancedSettings: 'Расширенные настройки генерации',
|
||||
advancedSettingsDesc: 'Контекст, типы вопросов, инструкции и правила показа',
|
||||
displayRules: 'Правила показа и резерва',
|
||||
contextTurns: 'Ходы контекста', categories: 'Типы вопросов', instruction: 'Дополнительная инструкция',
|
||||
suppressFallback: 'Скрывать после резервного ответа', suppressQuestion: 'Скрывать, если ответ заканчивается вопросом',
|
||||
knowledgeFallback: 'Использовать базу знаний при ошибке', allowRegenerate: 'Разрешить обновление',
|
||||
modeCurated: 'Редакторские', modeKnowledge: 'База знаний', modeGenerated: 'Модель', modeHybrid: 'Гибрид',
|
||||
categoryClarify: 'Уточнение', categoryDeepen: 'Углубление', categoryAction: 'Следующий шаг',
|
||||
},
|
||||
placeholders: {
|
||||
available: 'Доступные переменные: ',
|
||||
clickToInsert: '(нажмите для вставки)',
|
||||
|
||||
@@ -893,6 +893,7 @@ export default {
|
||||
title: "对话",
|
||||
newChat: "新对话",
|
||||
suggestedQuestions: "你可以这样问我",
|
||||
followUpQuestions: "接下来可以继续问",
|
||||
suggestedQuestionsLoading: "正在加载推荐问题...",
|
||||
refreshSuggestedQuestions: "换一批推荐问题",
|
||||
inputPlaceholder: "请输入您的消息...",
|
||||
@@ -5504,6 +5505,41 @@ export default {
|
||||
capability: "能力扩展",
|
||||
integration: "发布集成",
|
||||
},
|
||||
questionSuggestions: {
|
||||
navLabel: "问题推荐",
|
||||
title: "对话问题推荐",
|
||||
description: "统一配置开场问题与回答后的上下文追问;渠道可关闭展示,但不能改写智能体策略。",
|
||||
startersTitle: "开场推荐",
|
||||
followUpsTitle: "回答后推荐",
|
||||
enableStarters: "展示开场问题",
|
||||
enableStartersDesc: "在用户首次提问前展示,可使用运营配置、知识库或混合来源。",
|
||||
enableFollowUps: "生成回答后推荐",
|
||||
enableFollowUpsDesc: "每次完整回答结束后异步生成,不阻塞主回答。启用后会产生额外模型调用。",
|
||||
sourceMode: "内容来源",
|
||||
count: "展示数量",
|
||||
curatedItems: "运营配置问题",
|
||||
curatedItemsDesc: "用于开场推荐;混合模式下优先展示。",
|
||||
addItem: "添加问题",
|
||||
model: "生成模型",
|
||||
modelDesc: "留空时使用本轮对话模型。",
|
||||
advancedSettings: "高级生成设置",
|
||||
advancedSettingsDesc: "上下文、问题类型、生成要求与展示规则",
|
||||
displayRules: "展示与兜底规则",
|
||||
contextTurns: "上下文轮数",
|
||||
categories: "问题类型",
|
||||
instruction: "附加生成要求",
|
||||
suppressFallback: "兜底回答后不展示",
|
||||
suppressQuestion: "回答本身以提问结尾时不展示",
|
||||
knowledgeFallback: "模型生成失败时使用知识库候选",
|
||||
allowRegenerate: "允许用户换一批",
|
||||
modeCurated: "运营配置",
|
||||
modeKnowledge: "知识库",
|
||||
modeGenerated: "模型生成",
|
||||
modeHybrid: "混合",
|
||||
categoryClarify: "澄清",
|
||||
categoryDeepen: "深入",
|
||||
categoryAction: "下一步",
|
||||
},
|
||||
placeholders: {
|
||||
available: "可用变量:",
|
||||
clickToInsert: "(点击插入)",
|
||||
|
||||
@@ -825,6 +825,187 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话问题推荐 -->
|
||||
<div v-show="currentSection === 'suggestions'" class="section">
|
||||
<div class="section-header">
|
||||
<h2>{{ $t('agentEditor.questionSuggestions.title') }}</h2>
|
||||
<p class="section-description">{{ $t('agentEditor.questionSuggestions.description') }}</p>
|
||||
</div>
|
||||
|
||||
<t-tabs v-model="suggestionTab" class="suggestion-tabs">
|
||||
<t-tab-panel value="starters"
|
||||
:label="$t('agentEditor.questionSuggestions.startersTitle')" />
|
||||
<t-tab-panel value="followUps"
|
||||
:label="$t('agentEditor.questionSuggestions.followUpsTitle')" />
|
||||
</t-tabs>
|
||||
|
||||
<div v-show="suggestionTab === 'starters'" class="settings-group">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.enableStarters') }}</label>
|
||||
<p class="desc">{{ $t('agentEditor.questionSuggestions.enableStartersDesc') }}</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<t-switch v-model="formData.config.question_suggestions.starters.enabled"
|
||||
:aria-label="$t('agentEditor.questionSuggestions.enableStarters')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.config.question_suggestions.starters.enabled" class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.sourceMode') }}</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<t-select v-model="formData.config.question_suggestions.starters.mode"
|
||||
:options="starterSuggestionModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.config.question_suggestions.starters.enabled" class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.count') }}</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<t-input-number v-model="formData.config.question_suggestions.starters.count"
|
||||
:min="1" :max="8" theme="column" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formData.config.question_suggestions.starters.enabled && ['curated', 'hybrid'].includes(formData.config.question_suggestions.starters.mode)"
|
||||
class="setting-row setting-row-vertical">
|
||||
<div class="setting-info">
|
||||
<div class="setting-info-header setting-info-header--inline">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.curatedItems') }}</label>
|
||||
<span class="curated-items-count">
|
||||
{{ formData.config.question_suggestions.starters.items.length }}/8
|
||||
</span>
|
||||
</div>
|
||||
<p class="desc">{{ $t('agentEditor.questionSuggestions.curatedItemsDesc') }}</p>
|
||||
</div>
|
||||
<div class="setting-control setting-control-full">
|
||||
<div class="suggested-prompts-list">
|
||||
<div v-for="(_prompt, index) in formData.config.question_suggestions.starters.items"
|
||||
:key="index" class="prompt-item">
|
||||
<t-input v-model="formData.config.question_suggestions.starters.items[index]"
|
||||
:maxlength="200" />
|
||||
<t-button variant="text" theme="danger" shape="square"
|
||||
:aria-label="$t('common.delete')" @click="removeStarterSuggestion(Number(index))">
|
||||
<t-icon name="delete" />
|
||||
</t-button>
|
||||
</div>
|
||||
<t-button variant="dashed"
|
||||
:disabled="formData.config.question_suggestions.starters.items.length >= 8"
|
||||
@click="addStarterSuggestion">
|
||||
<template #icon><t-icon name="add" /></template>
|
||||
{{ $t('agentEditor.questionSuggestions.addItem') }}
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="suggestionTab === 'followUps'" class="settings-group">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.enableFollowUps') }}</label>
|
||||
<p class="desc">{{ $t('agentEditor.questionSuggestions.enableFollowUpsDesc') }}</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<t-switch v-model="formData.config.question_suggestions.follow_ups.enabled"
|
||||
:aria-label="$t('agentEditor.questionSuggestions.enableFollowUps')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="formData.config.question_suggestions.follow_ups.enabled">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.sourceMode') }}</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<t-select v-model="formData.config.question_suggestions.follow_ups.mode"
|
||||
:options="followUpSuggestionModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.count') }}</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<t-input-number v-model="formData.config.question_suggestions.follow_ups.count"
|
||||
:min="1" :max="5" theme="column" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.config.question_suggestions.follow_ups.mode !== 'knowledge'"
|
||||
class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.model') }}</label>
|
||||
<p class="desc">{{ $t('agentEditor.questionSuggestions.modelDesc') }}</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<ModelSelector model-type="KnowledgeQA"
|
||||
:selected-model-id="formData.config.question_suggestions.follow_ups.model_id"
|
||||
:all-models="allModels"
|
||||
@update:selected-model-id="(val: string) => formData.config.question_suggestions.follow_ups.model_id = val"
|
||||
@add-model="handleAddModel('summary')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="suggestion-advanced-divider">
|
||||
<span>{{ $t('agentEditor.questionSuggestions.advancedSettings') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.contextTurns') }}</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<t-input-number
|
||||
v-model="formData.config.question_suggestions.follow_ups.max_context_turns"
|
||||
:min="1" :max="5" theme="column" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row setting-row-vertical">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.categories') }}</label>
|
||||
</div>
|
||||
<div class="setting-control setting-control-full">
|
||||
<t-checkbox-group v-model="formData.config.question_suggestions.follow_ups.categories"
|
||||
:options="followUpCategoryOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row setting-row-vertical">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.instruction') }}</label>
|
||||
</div>
|
||||
<div class="setting-control setting-control-full">
|
||||
<t-textarea
|
||||
v-model="formData.config.question_suggestions.follow_ups.additional_instruction"
|
||||
:maxlength="2000" :autosize="{ minRows: 3, maxRows: 8 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row setting-row-vertical">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.questionSuggestions.displayRules') }}</label>
|
||||
</div>
|
||||
<div class="setting-control setting-control-full">
|
||||
<div class="suggestion-checkboxes">
|
||||
<t-checkbox v-model="formData.config.question_suggestions.follow_ups.suppress_on_fallback">{{ $t('agentEditor.questionSuggestions.suppressFallback') }}</t-checkbox>
|
||||
<t-checkbox v-model="formData.config.question_suggestions.follow_ups.suppress_when_answer_asks_question">{{ $t('agentEditor.questionSuggestions.suppressQuestion') }}</t-checkbox>
|
||||
<t-checkbox v-model="formData.config.question_suggestions.follow_ups.knowledge_fallback">{{ $t('agentEditor.questionSuggestions.knowledgeFallback') }}</t-checkbox>
|
||||
<t-checkbox v-model="formData.config.question_suggestions.follow_ups.allow_regenerate">{{ $t('agentEditor.questionSuggestions.allowRegenerate') }}</t-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具配置(仅 Agent 模式) -->
|
||||
<div v-show="currentSection === 'tools' && isAgentMode" class="section">
|
||||
<div class="section-header">
|
||||
@@ -1487,6 +1668,7 @@ const copyAgentId = async () => {
|
||||
};
|
||||
|
||||
const currentSection = ref(props.initialSection || 'basic');
|
||||
const suggestionTab = ref<'starters' | 'followUps'>('starters');
|
||||
const contentWrapperRef = ref<HTMLElement | null>(null);
|
||||
const highlightedField = ref<AgentNotReadyReasonKey | null>(null);
|
||||
let highlightClearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -1922,6 +2104,7 @@ const navItems = computed(() => {
|
||||
{ key: 'basic', icon: 'info-circle', label: t('agent.editor.basicInfo') },
|
||||
{ key: 'prompts', icon: 'file-paste', label: t('agent.editor.promptsConfig') },
|
||||
{ key: 'model', icon: 'control-platform', label: t('agent.editor.modelConfig') },
|
||||
{ key: 'suggestions', icon: 'help-circle', label: t('agentEditor.questionSuggestions.navLabel') },
|
||||
];
|
||||
// 多轮对话(仅普通模式显示,Agent模式内部自动控制)
|
||||
if (!isAgentMode.value) {
|
||||
@@ -1958,7 +2141,7 @@ const navGroups = computed(() => {
|
||||
{
|
||||
key: 'basic',
|
||||
label: t('agentEditor.navGroups.basic'),
|
||||
items: pickItems(['basic', 'prompts', 'model', 'conversation']),
|
||||
items: pickItems(['basic', 'prompts', 'model', 'conversation', 'suggestions']),
|
||||
},
|
||||
{
|
||||
key: 'knowledge',
|
||||
@@ -2049,14 +2232,59 @@ const defaultFormData = {
|
||||
fallback_strategy: 'model' as 'fixed' | 'model',
|
||||
fallback_response: '',
|
||||
fallback_prompt: '',
|
||||
question_suggestions: {
|
||||
starters: {
|
||||
enabled: true,
|
||||
mode: 'hybrid' as 'curated' | 'knowledge' | 'hybrid',
|
||||
items: [] as string[],
|
||||
count: 6,
|
||||
},
|
||||
follow_ups: {
|
||||
enabled: false,
|
||||
mode: 'hybrid' as 'generated' | 'knowledge' | 'hybrid',
|
||||
count: 3,
|
||||
model_id: '',
|
||||
additional_instruction: '',
|
||||
categories: ['clarify', 'deepen', 'action'] as Array<'clarify' | 'deepen' | 'action'>,
|
||||
max_context_turns: 2,
|
||||
suppress_on_fallback: true,
|
||||
suppress_when_answer_asks_question: true,
|
||||
knowledge_fallback: true,
|
||||
allow_regenerate: false,
|
||||
},
|
||||
},
|
||||
// 已废弃字段(保留兼容)
|
||||
welcome_message: '',
|
||||
suggested_prompts: [] as string[],
|
||||
}
|
||||
};
|
||||
|
||||
const formData = ref(JSON.parse(JSON.stringify(defaultFormData)));
|
||||
|
||||
const starterSuggestionModeOptions = computed(() => [
|
||||
{ value: 'curated', label: t('agentEditor.questionSuggestions.modeCurated') },
|
||||
{ value: 'knowledge', label: t('agentEditor.questionSuggestions.modeKnowledge') },
|
||||
{ value: 'hybrid', label: t('agentEditor.questionSuggestions.modeHybrid') },
|
||||
]);
|
||||
const followUpSuggestionModeOptions = computed(() => [
|
||||
{ value: 'generated', label: t('agentEditor.questionSuggestions.modeGenerated') },
|
||||
{ value: 'knowledge', label: t('agentEditor.questionSuggestions.modeKnowledge') },
|
||||
{ value: 'hybrid', label: t('agentEditor.questionSuggestions.modeHybrid') },
|
||||
]);
|
||||
const followUpCategoryOptions = computed(() => [
|
||||
{ value: 'clarify', label: t('agentEditor.questionSuggestions.categoryClarify') },
|
||||
{ value: 'deepen', label: t('agentEditor.questionSuggestions.categoryDeepen') },
|
||||
{ value: 'action', label: t('agentEditor.questionSuggestions.categoryAction') },
|
||||
]);
|
||||
|
||||
const addStarterSuggestion = () => {
|
||||
const items = formData.value.config.question_suggestions.starters.items;
|
||||
if (items.length < 8) items.push('');
|
||||
};
|
||||
|
||||
const removeStarterSuggestion = (index: number) => {
|
||||
formData.value.config.question_suggestions.starters.items.splice(index, 1);
|
||||
};
|
||||
|
||||
const applyDefaultChatModelIfEmpty = () => {
|
||||
if (props.mode !== 'create' || !formData.value) return
|
||||
const chat =
|
||||
@@ -2570,8 +2798,20 @@ watch(() => props.visible, async (val) => {
|
||||
agentData.config.thinking = false;
|
||||
}
|
||||
|
||||
agentData.config.question_suggestions = {
|
||||
starters: {
|
||||
...defaultFormData.config.question_suggestions.starters,
|
||||
...(agentData.config.question_suggestions?.starters || {}),
|
||||
items: agentData.config.question_suggestions?.starters?.items || [],
|
||||
},
|
||||
follow_ups: {
|
||||
...defaultFormData.config.question_suggestions.follow_ups,
|
||||
...(agentData.config.question_suggestions?.follow_ups || {}),
|
||||
categories: agentData.config.question_suggestions?.follow_ups?.categories
|
||||
|| [...defaultFormData.config.question_suggestions.follow_ups.categories],
|
||||
},
|
||||
};
|
||||
// 确保数组字段存在
|
||||
if (!agentData.config.suggested_prompts) agentData.config.suggested_prompts = [];
|
||||
if (!agentData.config.knowledge_bases) agentData.config.knowledge_bases = [];
|
||||
if (!agentData.config.allowed_tools) agentData.config.allowed_tools = [];
|
||||
if (!agentData.config.mcp_services) agentData.config.mcp_services = [];
|
||||
@@ -3890,10 +4130,10 @@ const handleSave = async () => {
|
||||
// ReRank 模型按运行范围按需使用:知识库范围为 none,或未启用
|
||||
// knowledge_search 时不需要;其余情况由对话入口在使用前给出明确提示。
|
||||
|
||||
// 过滤空推荐问题
|
||||
if (formData.value.config.suggested_prompts) {
|
||||
formData.value.config.suggested_prompts = formData.value.config.suggested_prompts.filter((p: string) => p.trim() !== '');
|
||||
}
|
||||
formData.value.config.question_suggestions.starters.items =
|
||||
formData.value.config.question_suggestions.starters.items
|
||||
.map((p: string) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!formData.value.config.intent_prompts || Object.keys(formData.value.config.intent_prompts).length === 0) {
|
||||
delete formData.value.config.intent_prompts;
|
||||
@@ -4629,6 +4869,75 @@ const handleSave = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 开场 / 回答后推荐用顶部 tab 区分(参照模型管理),避免整块包围框
|
||||
.suggestion-tabs {
|
||||
margin-bottom: 4px;
|
||||
|
||||
:deep(.t-tabs__nav-item) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.t-tabs__operations) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 只用 tab 作导航,内容自行渲染在下方
|
||||
:deep(.t-tabs__content) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-advanced-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0 2px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--td-component-stroke);
|
||||
}
|
||||
|
||||
span {
|
||||
flex-shrink: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 计数徽标紧贴标签,避免在整宽行里被 space-between 甩开
|
||||
// 需与基础 `.setting-info .setting-info-header`(space-between)同等特异性才能覆盖
|
||||
.setting-info-header.setting-info-header--inline {
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.curated-items-count {
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.suggestion-checkboxes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ===== 工具配置:overview 面板 =====
|
||||
.tools-overview {
|
||||
display: flex;
|
||||
|
||||
@@ -84,6 +84,14 @@
|
||||
<botmsg :content="session.content" :session="session" :session-id="session_id"
|
||||
:user-query="getUserQuery(index)" @scroll-bottom="scrollToBottom"
|
||||
:isFirstEnter="isFirstEnter" :embeddedMode="embeddedMode"></botmsg>
|
||||
<FollowUpSuggestions v-if="!session.suggestionsDismissed"
|
||||
:suggestion-set="session.suggestionSet"
|
||||
:loading="session.suggestionLoading"
|
||||
:allow-regenerate="session.suggestionSet?.allow_regenerate"
|
||||
@select="(item) => handleFollowUpSelect(session, item)"
|
||||
@regenerate="loadFollowUpSuggestions(session, true, true)"
|
||||
@impression="(set) => recordSuggestionEvent(session, set, 'impression')"
|
||||
@dismiss="(set) => dismissSuggestions(session, set)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showGlobalTypingIndicator"
|
||||
@@ -134,6 +142,12 @@ import { useChatStreamHandler } from '@/composables/useChatStreamHandler';
|
||||
import { useStickyBottomOnResize } from '@/composables/useStickyBottomOnResize';
|
||||
import { clearCitationChunkCache } from '@/utils/citationChunkCache';
|
||||
import ChatReferencesDrawer from '@/components/ChatReferencesDrawer.vue';
|
||||
import FollowUpSuggestions from '@/components/chat/FollowUpSuggestions.vue';
|
||||
import {
|
||||
ensureMessageSuggestions,
|
||||
getMessageSuggestions,
|
||||
recordMessageSuggestionEvent,
|
||||
} from '@/api/message-suggestion';
|
||||
import { provideChatReferencesDrawer } from '@/composables/useChatReferencesDrawer';
|
||||
|
||||
const referencesDrawer = provideChatReferencesDrawer();
|
||||
@@ -252,6 +266,7 @@ const suggestedQuestions = ref([]);
|
||||
const suggestedQuestionsLoading = ref(false);
|
||||
let suggestedQuestionsFetchId = 0; // 用于取消过时的请求
|
||||
let suggestedDebounceTimer = null;
|
||||
let pendingSuggestionAttribution = null;
|
||||
|
||||
const cancelSuggestedQuestionsFetch = () => {
|
||||
suggestedQuestionsFetchId++;
|
||||
@@ -309,6 +324,54 @@ const handleSuggestedQuestionClick = (question) => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveAssistantMessageId = (message) => message?.id || message?.assistant_message_id;
|
||||
|
||||
const loadFollowUpSuggestions = async (message, ensure = false, regenerate = false) => {
|
||||
const messageId = resolveAssistantMessageId(message);
|
||||
const targetSessionId = session_id.value;
|
||||
if (!messageId || !targetSessionId || message.suggestionsDismissed) return;
|
||||
message.suggestionLoading = true;
|
||||
try {
|
||||
let response = ensure
|
||||
? await ensureMessageSuggestions(targetSessionId, messageId, regenerate)
|
||||
: await getMessageSuggestions(targetSessionId, messageId);
|
||||
let set = response?.data;
|
||||
for (let attempt = 0; set?.status === 'generating' && attempt < 120; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (session_id.value !== targetSessionId || message.suggestionsDismissed) return;
|
||||
response = await getMessageSuggestions(targetSessionId, messageId);
|
||||
set = response?.data;
|
||||
}
|
||||
message.suggestionSet = set?.status === 'ready' ? set : null;
|
||||
} catch (error) {
|
||||
if (ensure) console.warn('[FollowUpSuggestions] Failed to generate:', error);
|
||||
message.suggestionSet = null;
|
||||
} finally {
|
||||
message.suggestionLoading = false;
|
||||
nextTick(() => scrollToBottom());
|
||||
}
|
||||
};
|
||||
|
||||
const recordSuggestionEvent = (message, set, eventType, questionId = '') => {
|
||||
if (!set?.id) return;
|
||||
void recordMessageSuggestionEvent(session_id.value, set.id, eventType, questionId).catch(() => undefined);
|
||||
};
|
||||
|
||||
const handleFollowUpSelect = (message, item) => {
|
||||
recordSuggestionEvent(message, message.suggestionSet, 'click', item.id);
|
||||
pendingSuggestionAttribution = {
|
||||
suggestion_set_id: message.suggestionSet.id,
|
||||
question_id: item.id,
|
||||
};
|
||||
if (inputFieldRef.value?.triggerSend) inputFieldRef.value.triggerSend(item.text);
|
||||
else sendMsg(item.text);
|
||||
};
|
||||
|
||||
const dismissSuggestions = (message, set) => {
|
||||
message.suggestionsDismissed = true;
|
||||
recordSuggestionEvent(message, set, 'dismiss');
|
||||
};
|
||||
|
||||
// 防抖包装,切换知识库/文件时300ms内不重复请求
|
||||
const debouncedFetchSuggestions = () => {
|
||||
if (historyLoading.value || messagesList.length > 0) return;
|
||||
@@ -466,6 +529,11 @@ const {
|
||||
scrollContainer,
|
||||
debug: import.meta.env.DEV,
|
||||
onAfterMsgList: async () => {
|
||||
for (const message of messagesList) {
|
||||
if (message.role === 'assistant' && message.is_completed && message.suggestionSet === undefined) {
|
||||
void loadFollowUpSuggestions(message, false);
|
||||
}
|
||||
}
|
||||
const lastMessage = messagesList[messagesList.length - 1];
|
||||
if (lastMessage && !lastMessage.is_completed) {
|
||||
isReplying.value = true;
|
||||
@@ -507,6 +575,9 @@ const {
|
||||
attachStreamDebugToMessage(message);
|
||||
pendingStreamDebug.value = null;
|
||||
},
|
||||
onTurnComplete: (message) => {
|
||||
void loadFollowUpSuggestions(message, true);
|
||||
},
|
||||
});
|
||||
|
||||
const showGlobalTypingIndicator = computed(() =>
|
||||
@@ -668,6 +739,8 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = []
|
||||
const requestMcpServiceIds = agentEnabled ? mcpServiceIds : [];
|
||||
const requestSkillNames = agentEnabled ? skillNames : [];
|
||||
|
||||
const suggestionAttribution = pendingSuggestionAttribution;
|
||||
pendingSuggestionAttribution = null;
|
||||
await startStream({
|
||||
session_id: session_id.value,
|
||||
knowledge_base_ids: kbIds,
|
||||
@@ -684,6 +757,7 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = []
|
||||
images: imageAttachments.length > 0 ? imageAttachments : undefined,
|
||||
attachment_uploads: attachmentUploads.length > 0 ? attachmentUploads : undefined,
|
||||
query: value,
|
||||
suggestion_attribution: suggestionAttribution || undefined,
|
||||
method: 'POST',
|
||||
url: endpoint,
|
||||
});
|
||||
|
||||
@@ -69,6 +69,14 @@
|
||||
:embed-session-sig="sessionSig"
|
||||
:embed-visitor-id="visitorId"
|
||||
/>
|
||||
<FollowUpSuggestions v-if="!session.suggestionsDismissed"
|
||||
:suggestion-set="session.suggestionSet as any"
|
||||
:loading="Boolean(session.suggestionLoading)"
|
||||
:allow-regenerate="Boolean((session.suggestionSet as any)?.allow_regenerate)"
|
||||
@select="(item) => handleFollowUpSelect(session, item)"
|
||||
@regenerate="loadFollowUpSuggestions(session, true, true)"
|
||||
@impression="(set) => recordFollowUpEvent(set, 'impression')"
|
||||
@dismiss="(set) => dismissFollowUps(session, set)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,13 +115,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, toRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getEmbedSuggestedQuestions, onEmbedHostOpenWithQuery, type SuggestedQuestion } from '@/api/embed'
|
||||
import {
|
||||
ensureEmbedMessageSuggestions,
|
||||
getEmbedMessageSuggestions,
|
||||
getEmbedSuggestedQuestions,
|
||||
onEmbedHostOpenWithQuery,
|
||||
recordEmbedMessageSuggestionEvent,
|
||||
type SuggestedQuestion,
|
||||
} from '@/api/embed'
|
||||
import EmbedInputField from '@/components/EmbedInputField.vue'
|
||||
import EmbedBotMessage from '@/views/embed/EmbedBotMessage.vue'
|
||||
import EmbedUserMessage from '@/views/embed/EmbedUserMessage.vue'
|
||||
import ChatReferencesDrawer from '@/components/ChatReferencesDrawer.vue'
|
||||
import { provideChatReferencesDrawer } from '@/composables/useChatReferencesDrawer'
|
||||
import { useEmbedChatSession } from '@/composables/useEmbedChatSession'
|
||||
import FollowUpSuggestions from '@/components/chat/FollowUpSuggestions.vue'
|
||||
import type { MessageSuggestionItem, MessageSuggestionSet } from '@/api/message-suggestion'
|
||||
|
||||
provideChatReferencesDrawer()
|
||||
|
||||
@@ -151,6 +168,49 @@ const suggestedQuestions = ref<SuggestedQuestion[]>([])
|
||||
const suggestedLoading = ref(false)
|
||||
const hostContextRef = ref<Record<string, unknown>>(props.hostContext || {})
|
||||
|
||||
const loadFollowUpSuggestions = async (
|
||||
message: Record<string, unknown>,
|
||||
ensure = false,
|
||||
regenerate = false,
|
||||
) => {
|
||||
const messageId = String(message.id || message.assistant_message_id || '')
|
||||
const targetSessionId = props.sessionId
|
||||
if (!props.showSuggestedQuestions || !messageId || !targetSessionId || message.suggestionsDismissed) return
|
||||
message.suggestionLoading = true
|
||||
try {
|
||||
let response = ensure
|
||||
? await ensureEmbedMessageSuggestions(
|
||||
props.channelId, props.token, targetSessionId, messageId, props.sessionSig, props.visitorId, regenerate,
|
||||
)
|
||||
: await getEmbedMessageSuggestions(
|
||||
props.channelId, props.token, targetSessionId, messageId, props.sessionSig, props.visitorId,
|
||||
)
|
||||
let set = response?.data
|
||||
for (let attempt = 0; set?.status === 'generating' && attempt < 120; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
if (props.sessionId !== targetSessionId || message.suggestionsDismissed) return
|
||||
response = await getEmbedMessageSuggestions(
|
||||
props.channelId, props.token, targetSessionId, messageId, props.sessionSig, props.visitorId,
|
||||
)
|
||||
set = response?.data
|
||||
}
|
||||
message.suggestionSet = set?.status === 'ready' ? set : null
|
||||
} catch {
|
||||
message.suggestionSet = null
|
||||
} finally {
|
||||
message.suggestionLoading = false
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
const loadPersistedFollowUps = (messages: Record<string, unknown>[]) => {
|
||||
for (const message of messages) {
|
||||
if (message.role === 'assistant' && message.is_completed && message.suggestionSet === undefined) {
|
||||
void loadFollowUpSuggestions(message, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asUnknownArray(value: unknown): unknown[] | undefined {
|
||||
return Array.isArray(value) ? value : undefined
|
||||
}
|
||||
@@ -210,6 +270,7 @@ const {
|
||||
onClickScrollToBottom,
|
||||
sendMsg,
|
||||
handleStopGeneration,
|
||||
setSuggestionAttribution,
|
||||
} = useEmbedChatSession({
|
||||
sessionId: sessionIdRef,
|
||||
sessionSig: sessionSigRef,
|
||||
@@ -227,6 +288,8 @@ const {
|
||||
emit('session-title', title)
|
||||
}
|
||||
},
|
||||
onTurnComplete: (message) => { void loadFollowUpSuggestions(message, true) },
|
||||
onMessagesLoaded: loadPersistedFollowUps,
|
||||
})
|
||||
|
||||
const welcomeText = computed(() => props.welcomeMessage?.trim() || '')
|
||||
@@ -279,6 +342,38 @@ const handleSuggestedClick = (question: string) => {
|
||||
void sendMsg(text, { webSearchEnabled: webSearchEnabled.value })
|
||||
}
|
||||
|
||||
const recordFollowUpEvent = (
|
||||
set: MessageSuggestionSet,
|
||||
eventType: 'impression' | 'click' | 'dismiss',
|
||||
questionId = '',
|
||||
) => {
|
||||
if (!set?.id) return
|
||||
void recordEmbedMessageSuggestionEvent(
|
||||
props.channelId,
|
||||
props.token,
|
||||
props.sessionId,
|
||||
props.sessionSig,
|
||||
props.visitorId,
|
||||
set.id,
|
||||
eventType,
|
||||
questionId,
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
const handleFollowUpSelect = (message: Record<string, unknown>, item: MessageSuggestionItem) => {
|
||||
const set = message.suggestionSet as MessageSuggestionSet | undefined
|
||||
if (set) {
|
||||
recordFollowUpEvent(set, 'click', item.id)
|
||||
setSuggestionAttribution(set.id, item.id)
|
||||
}
|
||||
if (!isReplying.value) void sendMsg(item.text, { webSearchEnabled: webSearchEnabled.value })
|
||||
}
|
||||
|
||||
const dismissFollowUps = (message: Record<string, unknown>, set: MessageSuggestionSet) => {
|
||||
message.suggestionsDismissed = true
|
||||
recordFollowUpEvent(set, 'dismiss')
|
||||
}
|
||||
|
||||
let removeOpenQueryListener: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
</section>
|
||||
<div class="sr-only" role="status" aria-live="polite">{{ saveAnnouncement }}</div>
|
||||
</template>
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="passwordResetVisible"
|
||||
:header="t('system.globalSettings.passwordReset.dialogTitle')"
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type messageSuggestionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMessageSuggestionRepository(db *gorm.DB) interfaces.MessageSuggestionRepository {
|
||||
return &messageSuggestionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *messageSuggestionRepository) GetByCacheKey(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
assistantMessageID string,
|
||||
placement string,
|
||||
configHash string,
|
||||
locale string,
|
||||
) (*types.MessageSuggestionSet, error) {
|
||||
var set types.MessageSuggestionSet
|
||||
err := r.db.WithContext(ctx).
|
||||
Where(
|
||||
"tenant_id = ? AND assistant_message_id = ? AND placement = ? AND config_hash = ? AND locale = ?",
|
||||
tenantID, assistantMessageID, placement, configHash, locale,
|
||||
).
|
||||
First(&set).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &set, nil
|
||||
}
|
||||
|
||||
func (r *messageSuggestionRepository) GetByID(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
sessionID string,
|
||||
id string,
|
||||
) (*types.MessageSuggestionSet, error) {
|
||||
var set types.MessageSuggestionSet
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND tenant_id = ? AND session_id = ?", id, tenantID, sessionID).
|
||||
First(&set).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &set, nil
|
||||
}
|
||||
|
||||
func (r *messageSuggestionRepository) AcquireGeneration(
|
||||
ctx context.Context,
|
||||
candidate *types.MessageSuggestionSet,
|
||||
regenerate bool,
|
||||
) (*types.MessageSuggestionSet, bool, error) {
|
||||
now := time.Now()
|
||||
leaseUntil := now.Add(3 * time.Minute)
|
||||
candidate.Status = types.SuggestionStatusGenerating
|
||||
candidate.LeaseUntil = &leaseUntil
|
||||
candidate.Questions = types.SuggestionItems{}
|
||||
|
||||
result := r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{DoNothing: true}).
|
||||
Create(candidate)
|
||||
if result.Error != nil {
|
||||
return nil, false, result.Error
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
return candidate, true, nil
|
||||
}
|
||||
|
||||
existing, err := r.GetByCacheKey(
|
||||
ctx,
|
||||
candidate.TenantID,
|
||||
candidate.AssistantMessageID,
|
||||
candidate.Placement,
|
||||
candidate.ConfigHash,
|
||||
candidate.Locale,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if existing.Status == types.SuggestionStatusReady && !regenerate {
|
||||
return existing, false, nil
|
||||
}
|
||||
if existing.Status == types.SuggestionStatusSuppressed && !regenerate {
|
||||
return existing, false, nil
|
||||
}
|
||||
if existing.Status == types.SuggestionStatusGenerating && existing.LeaseUntil != nil && existing.LeaseUntil.After(now) {
|
||||
return existing, false, nil
|
||||
}
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&types.MessageSuggestionSet{}).
|
||||
Where("id = ?", existing.ID).
|
||||
Where("status <> ? OR lease_until IS NULL OR lease_until < ?", types.SuggestionStatusGenerating, now)
|
||||
if existing.Status == types.SuggestionStatusReady && regenerate {
|
||||
query = r.db.WithContext(ctx).Model(&types.MessageSuggestionSet{}).
|
||||
Where("id = ? AND status = ?", existing.ID, types.SuggestionStatusReady)
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": types.SuggestionStatusGenerating,
|
||||
"lease_until": leaseUntil,
|
||||
"suppression_reason": "",
|
||||
"questions": types.SuggestionItems{},
|
||||
"error_code": "",
|
||||
"generated_at": nil,
|
||||
"updated_at": now,
|
||||
}
|
||||
result = query.Updates(updates)
|
||||
if result.Error != nil {
|
||||
return nil, false, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
current, getErr := r.GetByCacheKey(
|
||||
ctx,
|
||||
candidate.TenantID,
|
||||
candidate.AssistantMessageID,
|
||||
candidate.Placement,
|
||||
candidate.ConfigHash,
|
||||
candidate.Locale,
|
||||
)
|
||||
return current, false, getErr
|
||||
}
|
||||
existing.Status = types.SuggestionStatusGenerating
|
||||
existing.LeaseUntil = &leaseUntil
|
||||
existing.Questions = types.SuggestionItems{}
|
||||
return existing, true, nil
|
||||
}
|
||||
|
||||
func (r *messageSuggestionRepository) Save(ctx context.Context, set *types.MessageSuggestionSet) error {
|
||||
if set == nil {
|
||||
return errors.New("message suggestion set is nil")
|
||||
}
|
||||
return r.db.WithContext(ctx).Save(set).Error
|
||||
}
|
||||
|
||||
func (r *messageSuggestionRepository) CreateEvent(
|
||||
ctx context.Context,
|
||||
event *types.MessageSuggestionEvent,
|
||||
) error {
|
||||
return r.db.WithContext(ctx).Create(event).Error
|
||||
}
|
||||
|
||||
func (r *messageSuggestionRepository) DeleteByMessageID(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
sessionID string,
|
||||
messageID string,
|
||||
) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("tenant_id = ? AND session_id = ? AND assistant_message_id = ?", tenantID, sessionID, messageID).
|
||||
Delete(&types.MessageSuggestionSet{}).Error
|
||||
}
|
||||
|
||||
func (r *messageSuggestionRepository) DeleteBySessionID(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
sessionID string,
|
||||
) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("tenant_id = ? AND session_id = ?", tenantID, sessionID).
|
||||
Delete(&types.MessageSuggestionSet{}).Error
|
||||
}
|
||||
@@ -34,8 +34,9 @@ func scopeCustomAgentsByModelID(db *gorm.DB, modelID string) *gorm.DB {
|
||||
return db.Where(
|
||||
"config->>'model_id' = ? OR config->>'rerank_model_id' = ? OR "+
|
||||
"config->>'vlm_model_id' = ? OR config->>'asr_model_id' = ? OR "+
|
||||
"config->>'query_understand_model_id' = ?",
|
||||
modelID, modelID, modelID, modelID, modelID,
|
||||
"config->>'query_understand_model_id' = ? OR "+
|
||||
"config->'question_suggestions'->'follow_ups'->>'model_id' = ?",
|
||||
modelID, modelID, modelID, modelID, modelID, modelID,
|
||||
)
|
||||
}
|
||||
return db.Where(
|
||||
@@ -43,7 +44,8 @@ func scopeCustomAgentsByModelID(db *gorm.DB, modelID string) *gorm.DB {
|
||||
"json_extract(config, '$.rerank_model_id') = ? OR "+
|
||||
"json_extract(config, '$.vlm_model_id') = ? OR "+
|
||||
"json_extract(config, '$.asr_model_id') = ? OR "+
|
||||
"json_extract(config, '$.query_understand_model_id') = ?",
|
||||
modelID, modelID, modelID, modelID, modelID,
|
||||
"json_extract(config, '$.query_understand_model_id') = ? OR "+
|
||||
"json_extract(config, '$.question_suggestions.follow_ups.model_id') = ?",
|
||||
modelID, modelID, modelID, modelID, modelID, modelID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,9 @@ func (s *customAgentService) CreateAgent(ctx context.Context, agent *types.Custo
|
||||
|
||||
// Set defaults
|
||||
agent.EnsureDefaults()
|
||||
if err := agent.Config.QuestionSuggestions.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Creating custom agent, ID: %s, tenant ID: %d, name: %s, agent_mode: %s",
|
||||
agent.ID, agent.TenantID, agent.Name, agent.Config.AgentMode)
|
||||
@@ -132,6 +135,7 @@ func (s *customAgentService) GetAgentByID(ctx context.Context, id string) (*type
|
||||
agent, err := s.repo.GetAgentByID(ctx, id, tenantID)
|
||||
if err == nil {
|
||||
// Found in database, return with customized config
|
||||
agent.EnsureDefaults()
|
||||
return agent, nil
|
||||
}
|
||||
// Not in database, return default built-in agent from registry (i18n-aware)
|
||||
@@ -152,6 +156,7 @@ func (s *customAgentService) GetAgentByID(ctx context.Context, id string) (*type
|
||||
return nil, err
|
||||
}
|
||||
|
||||
agent.EnsureDefaults()
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
@@ -168,6 +173,7 @@ func (s *customAgentService) GetAgentByIDAndTenant(ctx context.Context, id strin
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
agent.EnsureDefaults()
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
@@ -190,6 +196,7 @@ func (s *customAgentService) ListAgents(ctx context.Context) ([]*types.CustomAge
|
||||
// Track which built-in agents exist in database
|
||||
builtinInDB := make(map[string]bool)
|
||||
for _, agent := range allAgents {
|
||||
agent.EnsureDefaults()
|
||||
if types.IsBuiltinAgentID(agent.ID) {
|
||||
builtinInDB[agent.ID] = true
|
||||
}
|
||||
@@ -273,6 +280,9 @@ func (s *customAgentService) UpdateAgent(ctx context.Context, agent *types.Custo
|
||||
|
||||
// Ensure defaults
|
||||
existingAgent.EnsureDefaults()
|
||||
if err := existingAgent.Config.QuestionSuggestions.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Updating custom agent, ID: %s, name: %s", agent.ID, agent.Name)
|
||||
|
||||
@@ -306,6 +316,9 @@ func (s *customAgentService) updateBuiltinAgent(ctx context.Context, agent *type
|
||||
existingAgent.Config = agent.Config
|
||||
existingAgent.UpdatedAt = time.Now()
|
||||
existingAgent.EnsureDefaults()
|
||||
if err := existingAgent.Config.QuestionSuggestions.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Updating built-in agent config, ID: %s", agent.ID)
|
||||
|
||||
@@ -333,6 +346,9 @@ func (s *customAgentService) updateBuiltinAgent(ctx context.Context, agent *type
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
newAgent.EnsureDefaults()
|
||||
if err := newAgent.Config.QuestionSuggestions.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Creating built-in agent config record, ID: %s, tenant ID: %d", agent.ID, tenantID)
|
||||
|
||||
@@ -457,6 +473,29 @@ func (s *customAgentService) GetSuggestedQuestions(
|
||||
knowledgeIDs []string,
|
||||
tagIDs []string,
|
||||
limit int,
|
||||
) ([]types.SuggestedQuestion, error) {
|
||||
return s.getSuggestedQuestions(ctx, agentID, kbIDs, knowledgeIDs, tagIDs, limit, true)
|
||||
}
|
||||
|
||||
func (s *customAgentService) GetKnowledgeSuggestedQuestions(
|
||||
ctx context.Context,
|
||||
agentID string,
|
||||
kbIDs []string,
|
||||
knowledgeIDs []string,
|
||||
tagIDs []string,
|
||||
limit int,
|
||||
) ([]types.SuggestedQuestion, error) {
|
||||
return s.getSuggestedQuestions(ctx, agentID, kbIDs, knowledgeIDs, tagIDs, limit, false)
|
||||
}
|
||||
|
||||
func (s *customAgentService) getSuggestedQuestions(
|
||||
ctx context.Context,
|
||||
agentID string,
|
||||
kbIDs []string,
|
||||
knowledgeIDs []string,
|
||||
tagIDs []string,
|
||||
limit int,
|
||||
includeCurated bool,
|
||||
) ([]types.SuggestedQuestion, error) {
|
||||
if limit <= 0 {
|
||||
limit = 6
|
||||
@@ -483,16 +522,29 @@ func (s *customAgentService) GetSuggestedQuestions(
|
||||
|
||||
var result []types.SuggestedQuestion
|
||||
|
||||
// 1. Add agent config suggested_prompts first (highest priority)
|
||||
if len(agent.Config.SuggestedPrompts) > 0 {
|
||||
for _, prompt := range agent.Config.SuggestedPrompts {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
continue
|
||||
if includeCurated {
|
||||
suggestionConfig := agent.Config.QuestionSuggestions
|
||||
if suggestionConfig == nil || !suggestionConfig.Starters.Enabled {
|
||||
return []types.SuggestedQuestion{}, nil
|
||||
}
|
||||
if limit > suggestionConfig.Starters.Count {
|
||||
limit = suggestionConfig.Starters.Count
|
||||
}
|
||||
// Add curated agent prompts first (highest priority).
|
||||
if suggestionConfig.Starters.Mode == types.SuggestionModeCurated ||
|
||||
suggestionConfig.Starters.Mode == types.SuggestionModeHybrid {
|
||||
for _, prompt := range suggestionConfig.Starters.Items {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, types.SuggestedQuestion{
|
||||
Question: prompt,
|
||||
Source: "agent_config",
|
||||
})
|
||||
}
|
||||
result = append(result, types.SuggestedQuestion{
|
||||
Question: prompt,
|
||||
Source: "agent_config",
|
||||
})
|
||||
}
|
||||
if suggestionConfig.Starters.Mode == types.SuggestionModeCurated {
|
||||
return s.truncateQuestions(result, limit), nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,13 @@ var regThinkIndex = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
// It reads the chat history knowledge base configuration from the tenant's ChatHistoryConfig,
|
||||
// which is managed via the settings UI.
|
||||
type messageService struct {
|
||||
messageRepo interfaces.MessageRepository // Repository for message storage operations
|
||||
sessionRepo interfaces.SessionRepository // Repository for session validation
|
||||
tenantService interfaces.TenantService // Service for tenant operations (read ChatHistoryConfig)
|
||||
kbService interfaces.KnowledgeBaseService // Service for knowledge base operations (search chat history KB)
|
||||
knowService interfaces.KnowledgeService // Service for knowledge operations (index/delete passages)
|
||||
modelService interfaces.ModelService // Service for model operations (rerank model)
|
||||
messageRepo interfaces.MessageRepository // Repository for message storage operations
|
||||
sessionRepo interfaces.SessionRepository // Repository for session validation
|
||||
tenantService interfaces.TenantService // Service for tenant operations (read ChatHistoryConfig)
|
||||
kbService interfaces.KnowledgeBaseService // Service for knowledge base operations (search chat history KB)
|
||||
knowService interfaces.KnowledgeService // Service for knowledge operations (index/delete passages)
|
||||
modelService interfaces.ModelService // Service for model operations (rerank model)
|
||||
suggestionRepo interfaces.MessageSuggestionRepository
|
||||
}
|
||||
|
||||
// NewMessageService creates a new message service instance with the required repositories
|
||||
@@ -37,14 +38,16 @@ func NewMessageService(messageRepo interfaces.MessageRepository,
|
||||
kbService interfaces.KnowledgeBaseService,
|
||||
knowService interfaces.KnowledgeService,
|
||||
modelService interfaces.ModelService,
|
||||
suggestionRepo interfaces.MessageSuggestionRepository,
|
||||
) interfaces.MessageService {
|
||||
return &messageService{
|
||||
messageRepo: messageRepo,
|
||||
sessionRepo: sessionRepo,
|
||||
tenantService: tenantService,
|
||||
kbService: kbService,
|
||||
knowService: knowService,
|
||||
modelService: modelService,
|
||||
messageRepo: messageRepo,
|
||||
sessionRepo: sessionRepo,
|
||||
tenantService: tenantService,
|
||||
kbService: kbService,
|
||||
knowService: knowService,
|
||||
modelService: modelService,
|
||||
suggestionRepo: suggestionRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +292,11 @@ func (s *messageService) DeleteMessage(ctx context.Context, sessionID string, me
|
||||
})
|
||||
return err
|
||||
}
|
||||
if s.suggestionRepo != nil {
|
||||
if err := s.suggestionRepo.DeleteByMessageID(ctx, tenantID, sessionID, messageID); err != nil {
|
||||
logger.Warnf(ctx, "Failed to delete suggestions for message %s: %v", messageID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Async cleanup: delete the associated Knowledge entry from the chat history KB.
|
||||
// Use WithoutCancel so the goroutine survives after the HTTP request context is done.
|
||||
@@ -319,6 +327,11 @@ func (s *messageService) ClearSessionMessages(ctx context.Context, sessionID str
|
||||
logger.Errorf(ctx, "Failed to delete messages for session %s: %v", sessionID, err)
|
||||
return err
|
||||
}
|
||||
if s.suggestionRepo != nil {
|
||||
if err := s.suggestionRepo.DeleteBySessionID(ctx, tenantID, sessionID); err != nil {
|
||||
logger.Warnf(ctx, "Failed to delete suggestions for session %s: %v", sessionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "All messages cleared for session: %s", sessionID)
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var suggestionThinkBlock = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
var trailingCitationTags = regexp.MustCompile(`(?s)(?:\s*<(?:kb|web)>.*?</(?:kb|web)>)+\s*$`)
|
||||
|
||||
type messageSuggestionService struct {
|
||||
repo interfaces.MessageSuggestionRepository
|
||||
messageService interfaces.MessageService
|
||||
modelService interfaces.ModelService
|
||||
customAgentService interfaces.CustomAgentService
|
||||
}
|
||||
|
||||
func NewMessageSuggestionService(
|
||||
repo interfaces.MessageSuggestionRepository,
|
||||
messageService interfaces.MessageService,
|
||||
modelService interfaces.ModelService,
|
||||
customAgentService interfaces.CustomAgentService,
|
||||
) interfaces.MessageSuggestionService {
|
||||
return &messageSuggestionService{
|
||||
repo: repo,
|
||||
messageService: messageService,
|
||||
modelService: modelService,
|
||||
customAgentService: customAgentService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *messageSuggestionService) EnsureFollowUps(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
assistantMessageID string,
|
||||
regenerate bool,
|
||||
) (*types.MessageSuggestionSet, error) {
|
||||
message, err := s.messageService.GetMessage(ctx, sessionID, assistantMessageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if message.Role != "assistant" || !message.IsCompleted {
|
||||
return nil, errors.New("follow-up suggestions require a completed assistant message")
|
||||
}
|
||||
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
locale := message.ExecutionContext.Locale
|
||||
if locale == "" {
|
||||
locale, _ = types.LanguageFromContext(ctx)
|
||||
}
|
||||
if locale == "" {
|
||||
locale = types.DefaultLanguage()
|
||||
}
|
||||
configHash := message.ExecutionContext.AgentConfigHash
|
||||
if configHash == "" {
|
||||
configHash = "no-agent-config"
|
||||
}
|
||||
config := message.ExecutionContext.QuestionSuggestions
|
||||
if regenerate && (config == nil || !config.FollowUps.Enabled || !config.FollowUps.AllowRegenerate) {
|
||||
return nil, errors.New("suggestion regeneration is not allowed")
|
||||
}
|
||||
candidate := &types.MessageSuggestionSet{
|
||||
TenantID: tenantID,
|
||||
SessionID: sessionID,
|
||||
AssistantMessageID: assistantMessageID,
|
||||
AgentID: message.AgentID,
|
||||
AgentTenantID: message.AgentTenantID,
|
||||
Placement: types.SuggestionPlacementAfterAnswer,
|
||||
ConfigHash: configHash,
|
||||
Locale: locale,
|
||||
AllowRegenerate: config != nil && config.FollowUps.AllowRegenerate,
|
||||
}
|
||||
set, acquired, err := s.repo.AcquireGeneration(ctx, candidate, regenerate)
|
||||
if err != nil || !acquired {
|
||||
return set, err
|
||||
}
|
||||
|
||||
if config == nil || !config.FollowUps.Enabled {
|
||||
return s.suppress(ctx, set, "disabled")
|
||||
}
|
||||
if message.IsFallback && config.FollowUps.SuppressOnFallback {
|
||||
return s.suppress(ctx, set, "fallback_answer")
|
||||
}
|
||||
answer := strings.TrimSpace(suggestionThinkBlock.ReplaceAllString(message.Content, ""))
|
||||
if answer == "" {
|
||||
return s.suppress(ctx, set, "empty_answer")
|
||||
}
|
||||
if config.FollowUps.SuppressWhenAnswerAsksQuestion && answerEndsWithQuestion(answer) {
|
||||
return s.suppress(ctx, set, "answer_asks_question")
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
set.ModelID = config.FollowUps.ModelID
|
||||
if set.ModelID == "" {
|
||||
set.ModelID = message.ModelID
|
||||
}
|
||||
questions, usage, generateErr := s.generate(
|
||||
ctx,
|
||||
message,
|
||||
answer,
|
||||
config.FollowUps,
|
||||
)
|
||||
set.LatencyMs = time.Since(startedAt).Milliseconds()
|
||||
set.PromptTokens = usage.PromptTokens
|
||||
set.CompletionTokens = usage.CompletionTokens
|
||||
set.LeaseUntil = nil
|
||||
generatedAt := time.Now()
|
||||
set.GeneratedAt = &generatedAt
|
||||
|
||||
if generateErr != nil {
|
||||
set.Status = types.SuggestionStatusFailed
|
||||
set.ErrorCode = suggestionErrorCode(generateErr)
|
||||
if saveErr := s.repo.Save(ctx, set); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
logger.ErrorWithFields(ctx, generateErr, map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
"message_id": assistantMessageID,
|
||||
"set_id": set.ID,
|
||||
})
|
||||
return set, nil
|
||||
}
|
||||
if len(questions) == 0 {
|
||||
return s.suppress(ctx, set, "no_candidates")
|
||||
}
|
||||
set.Questions = questions
|
||||
set.Status = types.SuggestionStatusReady
|
||||
set.ErrorCode = ""
|
||||
if err := s.repo.Save(ctx, set); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if regenerate {
|
||||
_ = s.createEvent(ctx, set, "", types.SuggestionEventRegenerate)
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func (s *messageSuggestionService) GetFollowUps(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
assistantMessageID string,
|
||||
) (*types.MessageSuggestionSet, error) {
|
||||
message, err := s.messageService.GetMessage(ctx, sessionID, assistantMessageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
locale := message.ExecutionContext.Locale
|
||||
if locale == "" {
|
||||
locale, _ = types.LanguageFromContext(ctx)
|
||||
}
|
||||
if locale == "" {
|
||||
locale = types.DefaultLanguage()
|
||||
}
|
||||
configHash := message.ExecutionContext.AgentConfigHash
|
||||
if configHash == "" {
|
||||
configHash = "no-agent-config"
|
||||
}
|
||||
return s.repo.GetByCacheKey(
|
||||
ctx,
|
||||
tenantID,
|
||||
assistantMessageID,
|
||||
types.SuggestionPlacementAfterAnswer,
|
||||
configHash,
|
||||
locale,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *messageSuggestionService) RecordEvent(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
setID string,
|
||||
questionID string,
|
||||
eventType string,
|
||||
) error {
|
||||
if eventType != types.SuggestionEventImpression &&
|
||||
eventType != types.SuggestionEventClick &&
|
||||
eventType != types.SuggestionEventDismiss {
|
||||
return errors.New("invalid suggestion event type")
|
||||
}
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
set, err := s.repo.GetByID(ctx, tenantID, sessionID, setID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if questionID != "" && !containsSuggestionID(set.Questions, questionID) {
|
||||
return errors.New("question does not belong to suggestion set")
|
||||
}
|
||||
if eventType == types.SuggestionEventClick && questionID == "" {
|
||||
return errors.New("click event requires question_id")
|
||||
}
|
||||
return s.createEvent(ctx, set, questionID, eventType)
|
||||
}
|
||||
|
||||
func (s *messageSuggestionService) ValidateAttribution(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
query string,
|
||||
attribution *types.SuggestionAttribution,
|
||||
) error {
|
||||
if attribution == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(attribution.SuggestionSetID) == "" || strings.TrimSpace(attribution.QuestionID) == "" {
|
||||
return errors.New("invalid suggestion attribution")
|
||||
}
|
||||
set, err := s.repo.GetByID(
|
||||
ctx,
|
||||
types.MustTenantIDFromContext(ctx),
|
||||
sessionID,
|
||||
attribution.SuggestionSetID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if set.Status != types.SuggestionStatusReady {
|
||||
return errors.New("invalid suggestion attribution")
|
||||
}
|
||||
found := false
|
||||
for _, question := range set.Questions {
|
||||
if question.ID == attribution.QuestionID && strings.TrimSpace(question.Text) == strings.TrimSpace(query) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("invalid suggestion attribution")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *messageSuggestionService) generate(
|
||||
ctx context.Context,
|
||||
message *types.Message,
|
||||
answer string,
|
||||
config types.FollowUpSuggestionConfig,
|
||||
) (types.SuggestionItems, types.TokenUsage, error) {
|
||||
count := config.Count
|
||||
if count < 1 {
|
||||
count = 3
|
||||
}
|
||||
var generated types.SuggestionItems
|
||||
var usage types.TokenUsage
|
||||
var modelErr error
|
||||
if config.Mode == types.SuggestionModeGenerated || config.Mode == types.SuggestionModeHybrid {
|
||||
generated, usage, modelErr = s.generateWithModel(ctx, message, answer, config, count)
|
||||
}
|
||||
|
||||
needKnowledge := config.Mode == types.SuggestionModeKnowledge ||
|
||||
(config.Mode == types.SuggestionModeHybrid && len(generated) < count) ||
|
||||
(modelErr != nil && config.KnowledgeFallback)
|
||||
if needKnowledge {
|
||||
knowledge, err := s.generateFromKnowledge(ctx, message, count-len(generated))
|
||||
if err != nil && modelErr == nil {
|
||||
modelErr = err
|
||||
}
|
||||
generated = mergeSuggestionItems(generated, knowledge, count)
|
||||
}
|
||||
if len(generated) > 0 {
|
||||
return generated, usage, nil
|
||||
}
|
||||
if modelErr != nil {
|
||||
return nil, usage, modelErr
|
||||
}
|
||||
return generated, usage, nil
|
||||
}
|
||||
|
||||
func (s *messageSuggestionService) generateWithModel(
|
||||
ctx context.Context,
|
||||
message *types.Message,
|
||||
answer string,
|
||||
config types.FollowUpSuggestionConfig,
|
||||
count int,
|
||||
) (types.SuggestionItems, types.TokenUsage, error) {
|
||||
modelID := config.ModelID
|
||||
if modelID == "" {
|
||||
modelID = message.ModelID
|
||||
}
|
||||
if modelID == "" {
|
||||
return nil, types.TokenUsage{}, errors.New("suggestion model is not configured")
|
||||
}
|
||||
|
||||
modelCtx := ctx
|
||||
if message.AgentTenantID != 0 {
|
||||
modelCtx = context.WithValue(modelCtx, types.TenantIDContextKey, message.AgentTenantID)
|
||||
}
|
||||
chatModel, err := s.modelService.GetChatModel(modelCtx, modelID)
|
||||
if err != nil {
|
||||
return nil, types.TokenUsage{}, err
|
||||
}
|
||||
history, err := s.buildHistory(ctx, message.SessionID, config.MaxContextTurns)
|
||||
if err != nil {
|
||||
return nil, types.TokenUsage{}, err
|
||||
}
|
||||
categories := strings.Join(config.Categories, ", ")
|
||||
if categories == "" {
|
||||
categories = "clarify, deepen, action"
|
||||
}
|
||||
language := types.LanguageLocaleName(message.ExecutionContext.Locale)
|
||||
systemPrompt := fmt.Sprintf(
|
||||
"You generate exactly %d short follow-up questions after an assistant answer. "+
|
||||
"Return JSON only as {\"questions\":[{\"text\":\"...\",\"category\":\"...\"}]}. "+
|
||||
"Use %s. Allowed categories: %s. Questions must be answerable from the conversation, "+
|
||||
"must not repeat prior user questions, must not claim unavailable capabilities, and must not include numbering.",
|
||||
count, language, categories,
|
||||
)
|
||||
if instruction := strings.TrimSpace(config.AdditionalInstruction); instruction != "" {
|
||||
systemPrompt += " Additional agent instruction: " + instruction
|
||||
}
|
||||
userPrompt := "Conversation:\n" + history + "\n\nLatest assistant answer:\n" + truncateRunes(answer, 6000)
|
||||
thinking := false
|
||||
response, err := chatModel.Chat(modelCtx, []chat.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
}, &chat.ChatOptions{
|
||||
Temperature: 0.3,
|
||||
MaxCompletionTokens: 700,
|
||||
Thinking: &thinking,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, types.TokenUsage{}, err
|
||||
}
|
||||
items, err := parseGeneratedSuggestions(response.Content, config.Categories, count)
|
||||
return items, response.Usage, err
|
||||
}
|
||||
|
||||
func (s *messageSuggestionService) generateFromKnowledge(
|
||||
ctx context.Context,
|
||||
message *types.Message,
|
||||
count int,
|
||||
) (types.SuggestionItems, error) {
|
||||
if count <= 0 || message.AgentID == "" {
|
||||
return types.SuggestionItems{}, nil
|
||||
}
|
||||
knowledgeCtx := ctx
|
||||
if message.AgentTenantID != 0 {
|
||||
knowledgeCtx = context.WithValue(knowledgeCtx, types.TenantIDContextKey, message.AgentTenantID)
|
||||
}
|
||||
candidates, err := s.customAgentService.GetKnowledgeSuggestedQuestions(
|
||||
knowledgeCtx,
|
||||
message.AgentID,
|
||||
message.ExecutionContext.KnowledgeBaseIDs,
|
||||
message.ExecutionContext.KnowledgeIDs,
|
||||
message.ExecutionContext.TagIDs,
|
||||
count,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make(types.SuggestionItems, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
text := strings.TrimSpace(candidate.Question)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
item := types.SuggestionItem{
|
||||
ID: uuid.NewString(),
|
||||
Text: text,
|
||||
Source: candidate.Source,
|
||||
}
|
||||
if candidate.KnowledgeBaseID != "" {
|
||||
item.KnowledgeBaseIDs = []string{candidate.KnowledgeBaseID}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *messageSuggestionService) buildHistory(ctx context.Context, sessionID string, maxTurns int) (string, error) {
|
||||
if maxTurns < 1 {
|
||||
maxTurns = 2
|
||||
}
|
||||
messages, err := s.messageService.GetRecentMessagesBySession(ctx, sessionID, maxTurns*2+4)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
start := 0
|
||||
if len(messages) > maxTurns*2 {
|
||||
start = len(messages) - maxTurns*2
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, message := range messages[start:] {
|
||||
content := strings.TrimSpace(suggestionThinkBlock.ReplaceAllString(message.Content, ""))
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(message.Role)
|
||||
builder.WriteString(": ")
|
||||
builder.WriteString(truncateRunes(content, 3000))
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func (s *messageSuggestionService) suppress(
|
||||
ctx context.Context,
|
||||
set *types.MessageSuggestionSet,
|
||||
reason string,
|
||||
) (*types.MessageSuggestionSet, error) {
|
||||
set.Status = types.SuggestionStatusSuppressed
|
||||
set.SuppressionReason = reason
|
||||
set.Questions = types.SuggestionItems{}
|
||||
set.LeaseUntil = nil
|
||||
now := time.Now()
|
||||
set.GeneratedAt = &now
|
||||
if err := s.repo.Save(ctx, set); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func (s *messageSuggestionService) createEvent(
|
||||
ctx context.Context,
|
||||
set *types.MessageSuggestionSet,
|
||||
questionID string,
|
||||
eventType string,
|
||||
) error {
|
||||
actorID := types.SessionOwnerIDFromContext(ctx)
|
||||
if principal, ok := types.PrincipalFromContext(ctx); ok {
|
||||
actorID = principal.StorageID()
|
||||
}
|
||||
return s.repo.CreateEvent(ctx, &types.MessageSuggestionEvent{
|
||||
TenantID: set.TenantID,
|
||||
SessionID: set.SessionID,
|
||||
SuggestionSetID: set.ID,
|
||||
QuestionID: questionID,
|
||||
EventType: eventType,
|
||||
ActorID: actorID,
|
||||
})
|
||||
}
|
||||
|
||||
type generatedSuggestionEnvelope struct {
|
||||
Questions []struct {
|
||||
Text string `json:"text"`
|
||||
Category string `json:"category"`
|
||||
} `json:"questions"`
|
||||
}
|
||||
|
||||
func parseGeneratedSuggestions(content string, allowedCategories []string, limit int) (types.SuggestionItems, error) {
|
||||
content = strings.TrimSpace(suggestionThinkBlock.ReplaceAllString(content, ""))
|
||||
start := strings.Index(content, "{")
|
||||
end := strings.LastIndex(content, "}")
|
||||
if start < 0 || end < start {
|
||||
return nil, errors.New("model returned invalid suggestion JSON")
|
||||
}
|
||||
var envelope generatedSuggestionEnvelope
|
||||
if err := json.Unmarshal([]byte(content[start:end+1]), &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode suggestion JSON: %w", err)
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(allowedCategories))
|
||||
for _, category := range allowedCategories {
|
||||
allowed[category] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
items := make(types.SuggestionItems, 0, limit)
|
||||
for _, question := range envelope.Questions {
|
||||
text := strings.TrimSpace(question.Text)
|
||||
if text == "" || len([]rune(text)) > 200 {
|
||||
continue
|
||||
}
|
||||
key := normalizeSuggestionText(text)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
category := question.Category
|
||||
if len(allowed) > 0 {
|
||||
if _, ok := allowed[category]; !ok {
|
||||
category = ""
|
||||
}
|
||||
}
|
||||
items = append(items, types.SuggestionItem{
|
||||
ID: uuid.NewString(),
|
||||
Text: text,
|
||||
Category: category,
|
||||
Source: "model",
|
||||
})
|
||||
if len(items) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func mergeSuggestionItems(primary, fallback types.SuggestionItems, limit int) types.SuggestionItems {
|
||||
result := make(types.SuggestionItems, 0, limit)
|
||||
seen := make(map[string]struct{})
|
||||
for _, group := range []types.SuggestionItems{primary, fallback} {
|
||||
for _, item := range group {
|
||||
key := normalizeSuggestionText(item.Text)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, item)
|
||||
if len(result) == limit {
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeSuggestionText(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) || strings.ContainsRune("??!!,,.。::;;\"'", r) {
|
||||
return -1
|
||||
}
|
||||
return unicode.ToLower(r)
|
||||
}, strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func containsSuggestionID(items types.SuggestionItems, id string) bool {
|
||||
for _, item := range items {
|
||||
if item.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func answerEndsWithQuestion(answer string) bool {
|
||||
answer = strings.TrimSpace(trailingCitationTags.ReplaceAllString(answer, ""))
|
||||
return strings.HasSuffix(answer, "?") || strings.HasSuffix(answer, "?")
|
||||
}
|
||||
|
||||
func truncateRunes(value string, limit int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
func suggestionErrorCode(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "not_found"
|
||||
}
|
||||
value := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(value, "model"):
|
||||
return "model_error"
|
||||
case strings.Contains(value, "json"):
|
||||
return "invalid_model_output"
|
||||
default:
|
||||
return "generation_error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestParseGeneratedSuggestionsFiltersAndDeduplicates(t *testing.T) {
|
||||
content := "```json\n{\"questions\":[" +
|
||||
"{\"text\":\"如何继续实施?\",\"category\":\"action\"}," +
|
||||
"{\"text\":\"如何继续实施?\",\"category\":\"action\"}," +
|
||||
"{\"text\":\"有哪些风险?\",\"category\":\"unknown\"}" +
|
||||
"]}\n```"
|
||||
items, err := parseGeneratedSuggestions(content, []string{"clarify", "action"}, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("parseGeneratedSuggestions() error = %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("len(items) = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].Category != "action" {
|
||||
t.Fatalf("first category = %q, want action", items[0].Category)
|
||||
}
|
||||
if items[1].Category != "" {
|
||||
t.Fatalf("disallowed category = %q, want empty", items[1].Category)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.ID == "" || item.Source != "model" {
|
||||
t.Fatalf("item attribution fields are incomplete: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSuggestionItemsPreservesPriorityAndLimit(t *testing.T) {
|
||||
primary := types.SuggestionItems{{ID: "1", Text: "A?", Source: "model"}}
|
||||
fallback := types.SuggestionItems{
|
||||
{ID: "2", Text: "A?", Source: "faq"},
|
||||
{ID: "3", Text: "B?", Source: "faq"},
|
||||
{ID: "4", Text: "C?", Source: "faq"},
|
||||
}
|
||||
got := mergeSuggestionItems(primary, fallback, 2)
|
||||
if len(got) != 2 || got[0].ID != "1" || got[1].ID != "3" {
|
||||
t.Fatalf("mergeSuggestionItems() = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerEndsWithQuestion(t *testing.T) {
|
||||
if !answerEndsWithQuestion("请补充具体时间? ") {
|
||||
t.Fatal("Chinese question ending was not detected")
|
||||
}
|
||||
if answerEndsWithQuestion("结论已经给出。") {
|
||||
t.Fatal("statement was incorrectly detected as question")
|
||||
}
|
||||
if !answerEndsWithQuestion("需要我继续展开吗?\n<kb>1</kb>") {
|
||||
t.Fatal("question before a trailing citation was not detected")
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ type sessionService struct {
|
||||
webSearchProviderRepo interfaces.WebSearchProviderRepository // Repository for web search provider entities
|
||||
kbShareService interfaces.KBShareService // Service for KB sharing operations
|
||||
memoryService interfaces.MemoryService // Service for memory operations
|
||||
suggestionRepo interfaces.MessageSuggestionRepository
|
||||
}
|
||||
|
||||
// NewSessionService creates a new session service instance with all required dependencies
|
||||
@@ -63,6 +64,7 @@ func NewSessionService(cfg *config.Config,
|
||||
webSearchProviderRepo interfaces.WebSearchProviderRepository,
|
||||
kbShareService interfaces.KBShareService,
|
||||
memoryService interfaces.MemoryService,
|
||||
suggestionRepo interfaces.MessageSuggestionRepository,
|
||||
) interfaces.SessionService {
|
||||
return &sessionService{
|
||||
cfg: cfg,
|
||||
@@ -79,6 +81,7 @@ func NewSessionService(cfg *config.Config,
|
||||
webSearchProviderRepo: webSearchProviderRepo,
|
||||
kbShareService: kbShareService,
|
||||
memoryService: memoryService,
|
||||
suggestionRepo: suggestionRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +351,11 @@ func (s *sessionService) DeleteSession(ctx context.Context, id string) error {
|
||||
if rows == 0 {
|
||||
return apperrors.ErrSessionNotFound
|
||||
}
|
||||
if s.suggestionRepo != nil {
|
||||
if err := s.suggestionRepo.DeleteBySessionID(ctx, tenantID, id); err != nil {
|
||||
logger.Warnf(ctx, "Failed to delete suggestions for session %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -405,6 +413,13 @@ func (s *sessionService) BatchDeleteSessions(ctx context.Context, ids []string)
|
||||
})
|
||||
return err
|
||||
}
|
||||
if s.suggestionRepo != nil {
|
||||
for _, id := range visibleIDs {
|
||||
if err := s.suggestionRepo.DeleteBySessionID(ctx, tenantID, id); err != nil {
|
||||
logger.Warnf(ctx, "Failed to delete suggestions for session %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -447,6 +462,13 @@ func (s *sessionService) DeleteAllSessions(ctx context.Context) error {
|
||||
})
|
||||
return err
|
||||
}
|
||||
if s.suggestionRepo != nil && sessions != nil {
|
||||
for _, session := range sessions {
|
||||
if err := s.suggestionRepo.DeleteBySessionID(ctx, tenantID, session.ID); err != nil {
|
||||
logger.Warnf(ctx, "Failed to delete suggestions for session %s: %v", session.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "All sessions deleted for tenant %d", tenantID)
|
||||
return nil
|
||||
|
||||
@@ -150,6 +150,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
|
||||
must(container.Provide(repository.NewKnowledgeTagRepository))
|
||||
must(container.Provide(repository.NewSessionRepository))
|
||||
must(container.Provide(repository.NewMessageRepository))
|
||||
must(container.Provide(repository.NewMessageSuggestionRepository))
|
||||
must(container.Provide(repository.NewModelRepository))
|
||||
must(container.Provide(repository.NewUserRepository))
|
||||
must(container.Provide(repository.NewAuthTokenRepository))
|
||||
@@ -210,6 +211,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
|
||||
must(container.Provide(service.NewKnowledgePostProcessService, dig.Name("knowledgePostProcess")))
|
||||
|
||||
must(container.Provide(service.NewMessageService))
|
||||
must(container.Provide(service.NewMessageSuggestionService))
|
||||
must(container.Provide(service.NewMCPServiceService))
|
||||
must(container.Provide(service.NewMCPToolApprovalService))
|
||||
must(container.Provide(service.NewCustomAgentService))
|
||||
@@ -341,6 +343,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
|
||||
must(container.Provide(handler.NewTagHandler))
|
||||
must(container.Provide(session.NewHandler))
|
||||
must(container.Provide(handler.NewMessageHandler))
|
||||
must(container.Provide(handler.NewMessageSuggestionHandler))
|
||||
must(container.Provide(handler.NewModelHandler))
|
||||
must(container.Provide(handler.NewEvaluationHandler))
|
||||
must(container.Provide(handler.NewInitializationHandler))
|
||||
|
||||
@@ -93,6 +93,11 @@ func (h *CustomAgentHandler) CreateAgent(c *gin.Context) {
|
||||
Avatar: req.Avatar,
|
||||
Config: req.Config,
|
||||
}
|
||||
agent.EnsureDefaults()
|
||||
if err := agent.Config.QuestionSuggestions.Validate(); err != nil {
|
||||
c.Error(errors.NewBadRequestError(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Creating custom agent, name: %s, agent_mode: %s",
|
||||
secutils.SanitizeForLog(req.Name), req.Config.AgentMode)
|
||||
@@ -332,6 +337,11 @@ func (h *CustomAgentHandler) UpdateAgent(c *gin.Context) {
|
||||
Avatar: req.Avatar,
|
||||
Config: req.Config,
|
||||
}
|
||||
agent.EnsureDefaults()
|
||||
if err := agent.Config.QuestionSuggestions.Validate(); err != nil {
|
||||
c.Error(errors.NewBadRequestError(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Updating custom agent, ID: %s, name: %s",
|
||||
secutils.SanitizeForLog(id), secutils.SanitizeForLog(req.Name))
|
||||
|
||||
@@ -30,6 +30,7 @@ type EmbedChannelHandler struct {
|
||||
sessionService interfaces.SessionService
|
||||
sessionHandler *session.Handler
|
||||
messageHandler *MessageHandler
|
||||
suggestionHandler *MessageSuggestionHandler
|
||||
mcpOAuthHandler *MCPOAuthHandler
|
||||
mcpServiceHandler *MCPServiceHandler
|
||||
redis *redis.Client
|
||||
@@ -40,6 +41,7 @@ func NewEmbedChannelHandler(
|
||||
sessionService interfaces.SessionService,
|
||||
sessionHandler *session.Handler,
|
||||
messageHandler *MessageHandler,
|
||||
suggestionHandler *MessageSuggestionHandler,
|
||||
mcpOAuthHandler *MCPOAuthHandler,
|
||||
mcpServiceHandler *MCPServiceHandler,
|
||||
redisClient *redis.Client,
|
||||
@@ -49,6 +51,7 @@ func NewEmbedChannelHandler(
|
||||
sessionService: sessionService,
|
||||
sessionHandler: sessionHandler,
|
||||
messageHandler: messageHandler,
|
||||
suggestionHandler: suggestionHandler,
|
||||
mcpOAuthHandler: mcpOAuthHandler,
|
||||
mcpServiceHandler: mcpServiceHandler,
|
||||
redis: redisClient,
|
||||
@@ -462,6 +465,45 @@ func (h *EmbedChannelHandler) EmbedStopSession(c *gin.Context) {
|
||||
h.sessionHandler.StopSession(c)
|
||||
}
|
||||
|
||||
func (h *EmbedChannelHandler) EmbedEnsureMessageSuggestions(c *gin.Context) {
|
||||
if err := h.ensureEmbedSession(c); err != nil {
|
||||
return
|
||||
}
|
||||
ch, _ := middleware.EmbedChannelFromContext(c.Request.Context())
|
||||
if ch == nil || !ch.ShowSuggestedQuestions || h.suggestionHandler == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"status": "suppressed", "suppression_reason": "channel_disabled", "questions": []any{},
|
||||
}})
|
||||
return
|
||||
}
|
||||
h.suggestionHandler.Ensure(c)
|
||||
}
|
||||
|
||||
func (h *EmbedChannelHandler) EmbedGetMessageSuggestions(c *gin.Context) {
|
||||
if err := h.ensureEmbedSession(c); err != nil {
|
||||
return
|
||||
}
|
||||
ch, _ := middleware.EmbedChannelFromContext(c.Request.Context())
|
||||
if ch == nil || !ch.ShowSuggestedQuestions || h.suggestionHandler == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"status": "suppressed", "suppression_reason": "channel_disabled", "questions": []any{},
|
||||
}})
|
||||
return
|
||||
}
|
||||
h.suggestionHandler.Get(c)
|
||||
}
|
||||
|
||||
func (h *EmbedChannelHandler) EmbedRecordSuggestionEvent(c *gin.Context) {
|
||||
if err := h.ensureEmbedSession(c); err != nil {
|
||||
return
|
||||
}
|
||||
if h.suggestionHandler == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "suggestion service unavailable"})
|
||||
return
|
||||
}
|
||||
h.suggestionHandler.RecordEvent(c)
|
||||
}
|
||||
|
||||
func (h *EmbedChannelHandler) EmbedResolveMCPOAuth(c *gin.Context) {
|
||||
if err := h.ensureEmbedSession(c); err != nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MessageSuggestionHandler struct {
|
||||
service interfaces.MessageSuggestionService
|
||||
}
|
||||
|
||||
func NewMessageSuggestionHandler(service interfaces.MessageSuggestionService) *MessageSuggestionHandler {
|
||||
return &MessageSuggestionHandler{service: service}
|
||||
}
|
||||
|
||||
type EnsureMessageSuggestionsRequest struct {
|
||||
Regenerate bool `json:"regenerate"`
|
||||
}
|
||||
|
||||
type SuggestionEventRequest struct {
|
||||
SuggestionSetID string `json:"suggestion_set_id" binding:"required"`
|
||||
QuestionID string `json:"question_id"`
|
||||
EventType string `json:"event_type" binding:"required"`
|
||||
}
|
||||
|
||||
// Ensure godoc
|
||||
// @Summary 确保生成回答后推荐问题
|
||||
// @Description 对已完成的助手消息异步生成或重新生成推荐问题;相同配置快照会复用持久化结果
|
||||
// @Tags 会话
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param session_id path string true "会话 ID"
|
||||
// @Param message_id path string true "助手消息 ID"
|
||||
// @Param request body EnsureMessageSuggestionsRequest false "生成选项"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Success 202 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /sessions/{session_id}/messages/{message_id}/suggestions [post]
|
||||
func (h *MessageSuggestionHandler) Ensure(c *gin.Context) {
|
||||
var request EnsureMessageSuggestionsRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.Error(apperrors.NewBadRequestError("invalid request body"))
|
||||
return
|
||||
}
|
||||
}
|
||||
set, err := h.service.EnsureFollowUps(
|
||||
c.Request.Context(),
|
||||
secutils.SanitizeForLog(c.Param("session_id")),
|
||||
secutils.SanitizeForLog(c.Param("message_id")),
|
||||
request.Regenerate,
|
||||
)
|
||||
if err != nil {
|
||||
h.writeError(c, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if set != nil && set.Status == "generating" {
|
||||
status = http.StatusAccepted
|
||||
}
|
||||
c.JSON(status, gin.H{"success": true, "data": set})
|
||||
}
|
||||
|
||||
// Get godoc
|
||||
// @Summary 获取回答后推荐问题
|
||||
// @Tags 会话
|
||||
// @Produce json
|
||||
// @Param session_id path string true "会话 ID"
|
||||
// @Param message_id path string true "助手消息 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /sessions/{session_id}/messages/{message_id}/suggestions [get]
|
||||
func (h *MessageSuggestionHandler) Get(c *gin.Context) {
|
||||
set, err := h.service.GetFollowUps(
|
||||
c.Request.Context(),
|
||||
messageSuggestionSessionID(c),
|
||||
secutils.SanitizeForLog(c.Param("message_id")),
|
||||
)
|
||||
if err != nil {
|
||||
h.writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": set})
|
||||
}
|
||||
|
||||
func messageSuggestionSessionID(c *gin.Context) string {
|
||||
sessionID := c.Param("session_id")
|
||||
if sessionID == "" {
|
||||
sessionID = c.Param("id")
|
||||
}
|
||||
return secutils.SanitizeForLog(sessionID)
|
||||
}
|
||||
|
||||
// RecordEvent godoc
|
||||
// @Summary 上报推荐问题事件
|
||||
// @Description 记录曝光、点击或关闭事件
|
||||
// @Tags 会话
|
||||
// @Accept json
|
||||
// @Param session_id path string true "会话 ID"
|
||||
// @Param request body SuggestionEventRequest true "事件"
|
||||
// @Success 204
|
||||
// @Security Bearer
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /sessions/{session_id}/suggestion-events [post]
|
||||
func (h *MessageSuggestionHandler) RecordEvent(c *gin.Context) {
|
||||
var request SuggestionEventRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.Error(apperrors.NewBadRequestError("invalid request body"))
|
||||
return
|
||||
}
|
||||
err := h.service.RecordEvent(
|
||||
c.Request.Context(),
|
||||
secutils.SanitizeForLog(c.Param("session_id")),
|
||||
strings.TrimSpace(request.SuggestionSetID),
|
||||
strings.TrimSpace(request.QuestionID),
|
||||
strings.TrimSpace(request.EventType),
|
||||
)
|
||||
if err != nil {
|
||||
h.writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *MessageSuggestionHandler) writeError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c.Error(apperrors.NewNotFoundError("suggestions not found"))
|
||||
case strings.Contains(err.Error(), "completed assistant"):
|
||||
c.Error(apperrors.NewBadRequestError(err.Error()))
|
||||
case strings.Contains(err.Error(), "invalid suggestion event"),
|
||||
strings.Contains(err.Error(), "requires question_id"),
|
||||
strings.Contains(err.Error(), "does not belong"),
|
||||
strings.Contains(err.Error(), "not allowed"):
|
||||
c.Error(apperrors.NewBadRequestError(err.Error()))
|
||||
default:
|
||||
logger.Error(c.Request.Context(), "message suggestion operation failed", err)
|
||||
c.Error(apperrors.NewInternalServerError("message suggestion operation failed"))
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
|
||||
// Handler handles all HTTP requests related to conversation sessions
|
||||
type Handler struct {
|
||||
messageService interfaces.MessageService // Service for managing messages
|
||||
messageService interfaces.MessageService // Service for managing messages
|
||||
suggestionService interfaces.MessageSuggestionService
|
||||
sessionService interfaces.SessionService // Service for managing sessions
|
||||
streamManager interfaces.StreamManager // Manager for handling streaming responses
|
||||
config *config.Config // Application configuration
|
||||
@@ -35,6 +36,7 @@ type Handler struct {
|
||||
func NewHandler(
|
||||
sessionService interfaces.SessionService,
|
||||
messageService interfaces.MessageService,
|
||||
suggestionService interfaces.MessageSuggestionService,
|
||||
streamManager interfaces.StreamManager,
|
||||
config *config.Config,
|
||||
knowledgebaseService interfaces.KnowledgeBaseService,
|
||||
@@ -51,6 +53,7 @@ func NewHandler(
|
||||
return &Handler{
|
||||
sessionService: sessionService,
|
||||
messageService: messageService,
|
||||
suggestionService: suggestionService,
|
||||
streamManager: streamManager,
|
||||
config: config,
|
||||
knowledgebaseService: knowledgebaseService,
|
||||
|
||||
@@ -277,18 +277,19 @@ func createAgentQueryEvent(sessionID, assistantMessageID string) interfaces.Stre
|
||||
}
|
||||
|
||||
// createUserMessage creates a user message and returns the created message.
|
||||
func (h *Handler) createUserMessage(ctx context.Context, sessionID, query, requestID string, mentionedItems types.MentionedItems, images types.MessageImages, attachments types.MessageAttachments, channel string) (*types.Message, error) {
|
||||
func (h *Handler) createUserMessage(ctx context.Context, sessionID, query, requestID string, mentionedItems types.MentionedItems, images types.MessageImages, attachments types.MessageAttachments, channel string, attribution *types.SuggestionAttribution) (*types.Message, error) {
|
||||
return h.messageService.CreateMessage(ctx, &types.Message{
|
||||
SessionID: sessionID,
|
||||
Role: "user",
|
||||
Content: query,
|
||||
RequestID: requestID,
|
||||
CreatedAt: time.Now(),
|
||||
IsCompleted: true,
|
||||
MentionedItems: mentionedItems,
|
||||
Images: images,
|
||||
Attachments: attachments,
|
||||
Channel: channel,
|
||||
SessionID: sessionID,
|
||||
Role: "user",
|
||||
Content: query,
|
||||
RequestID: requestID,
|
||||
CreatedAt: time.Now(),
|
||||
IsCompleted: true,
|
||||
MentionedItems: mentionedItems,
|
||||
Images: images,
|
||||
Attachments: attachments,
|
||||
Channel: channel,
|
||||
ExecutionContext: types.MessageExecutionContext{SuggestionAttribution: attribution},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+149
-46
@@ -2,6 +2,7 @@ package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -20,30 +21,31 @@ import (
|
||||
|
||||
// qaRequestContext holds all the common data needed for QA requests
|
||||
type qaRequestContext struct {
|
||||
ctx context.Context
|
||||
c *gin.Context
|
||||
sessionID string
|
||||
requestID string
|
||||
receivedAt time.Time // Wall-clock time the handler started processing the request
|
||||
query string
|
||||
session *types.Session
|
||||
customAgent *types.CustomAgent
|
||||
assistantMessage *types.Message
|
||||
knowledgeBaseIDs []string
|
||||
knowledgeIDs []string
|
||||
tagScopes []types.TagScope
|
||||
tagIDs []string
|
||||
mcpServiceIDs []string
|
||||
skillNames []string
|
||||
summaryModelID string
|
||||
webSearchEnabled bool
|
||||
enableMemory bool // Whether memory feature is enabled
|
||||
mentionedItems types.MentionedItems
|
||||
effectiveTenantID uint64 // when using shared agent, tenant ID for model/KB/MCP resolution; 0 = use context tenant
|
||||
images []ImageAttachment // Uploaded images with analysis text
|
||||
userMessageID string // Created user message ID (populated after createUserMessage)
|
||||
channel string // Source channel: "web", "api", "im", etc.
|
||||
attachments types.MessageAttachments // Processed file attachments
|
||||
ctx context.Context
|
||||
c *gin.Context
|
||||
sessionID string
|
||||
requestID string
|
||||
receivedAt time.Time // Wall-clock time the handler started processing the request
|
||||
query string
|
||||
session *types.Session
|
||||
customAgent *types.CustomAgent
|
||||
assistantMessage *types.Message
|
||||
knowledgeBaseIDs []string
|
||||
knowledgeIDs []string
|
||||
tagScopes []types.TagScope
|
||||
tagIDs []string
|
||||
mcpServiceIDs []string
|
||||
skillNames []string
|
||||
summaryModelID string
|
||||
webSearchEnabled bool
|
||||
enableMemory bool // Whether memory feature is enabled
|
||||
mentionedItems types.MentionedItems
|
||||
effectiveTenantID uint64 // when using shared agent, tenant ID for model/KB/MCP resolution; 0 = use context tenant
|
||||
images []ImageAttachment // Uploaded images with analysis text
|
||||
userMessageID string // Created user message ID (populated after createUserMessage)
|
||||
channel string // Source channel: "web", "api", "im", etc.
|
||||
attachments types.MessageAttachments // Processed file attachments
|
||||
suggestionAttribution *types.SuggestionAttribution
|
||||
|
||||
// Snapshot of the request fields needed to persist the input-bar state
|
||||
// for session restoration. Kept verbatim from the request so we record
|
||||
@@ -102,6 +104,11 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
|
||||
logger.Error(ctx, "Query content is empty")
|
||||
return nil, nil, errors.NewBadRequestError("Query content cannot be empty")
|
||||
}
|
||||
if h.suggestionService != nil && request.SuggestionAttribution != nil {
|
||||
if err := h.suggestionService.ValidateAttribution(ctx, sessionID, request.Query, request.SuggestionAttribution); err != nil {
|
||||
return nil, nil, errors.NewBadRequestError("invalid suggestion attribution")
|
||||
}
|
||||
}
|
||||
|
||||
// SSRF protection: strip client-supplied URL/Caption fields from image attachments.
|
||||
// The URL field must only be populated server-side by saveImageAttachments; an
|
||||
@@ -259,6 +266,18 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
|
||||
tagIDs := dedupRequestStrings(append(request.TagIDs, mentionedIDsByType(request.MentionedItems, "tag")...))
|
||||
mcpServiceIDs := dedupRequestStrings(append(request.MCPServiceIDs, mentionedIDsByType(request.MentionedItems, "mcp")...))
|
||||
skillNames := dedupRequestStrings(append(request.SkillNames, mentionedIDsByType(request.MentionedItems, "skill")...))
|
||||
executionContext, agentID, agentTenantID, modelID := buildMessageExecutionContext(
|
||||
ctx,
|
||||
customAgent,
|
||||
effectiveTenantID,
|
||||
request.SummaryModelID,
|
||||
secutils.SanitizeForLogArray(kbIDs),
|
||||
secutils.SanitizeForLogArray(knowledgeIDs),
|
||||
secutils.SanitizeForLogArray(tagIDs),
|
||||
secutils.SanitizeForLogArray(mcpServiceIDs),
|
||||
secutils.SanitizeForLogArray(skillNames),
|
||||
request.WebSearchEnabled,
|
||||
)
|
||||
|
||||
// Build request context
|
||||
reqCtx := &qaRequestContext{
|
||||
@@ -271,33 +290,108 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
|
||||
session: session,
|
||||
customAgent: customAgent,
|
||||
assistantMessage: &types.Message{
|
||||
SessionID: sessionID,
|
||||
Role: "assistant",
|
||||
RequestID: c.GetString(types.RequestIDContextKey.String()),
|
||||
IsCompleted: false,
|
||||
Channel: request.Channel,
|
||||
SessionID: sessionID,
|
||||
Role: "assistant",
|
||||
RequestID: c.GetString(types.RequestIDContextKey.String()),
|
||||
IsCompleted: false,
|
||||
Channel: request.Channel,
|
||||
AgentID: agentID,
|
||||
AgentTenantID: agentTenantID,
|
||||
ModelID: modelID,
|
||||
ExecutionContext: executionContext,
|
||||
},
|
||||
knowledgeBaseIDs: secutils.SanitizeForLogArray(kbIDs),
|
||||
knowledgeIDs: secutils.SanitizeForLogArray(knowledgeIDs),
|
||||
tagScopes: tagScopes,
|
||||
tagIDs: secutils.SanitizeForLogArray(tagIDs),
|
||||
mcpServiceIDs: secutils.SanitizeForLogArray(mcpServiceIDs),
|
||||
skillNames: secutils.SanitizeForLogArray(skillNames),
|
||||
summaryModelID: secutils.SanitizeForLog(request.SummaryModelID),
|
||||
webSearchEnabled: request.WebSearchEnabled,
|
||||
enableMemory: enableMemory,
|
||||
mentionedItems: convertMentionedItems(request.MentionedItems),
|
||||
effectiveTenantID: effectiveTenantID,
|
||||
images: request.Images,
|
||||
channel: request.Channel,
|
||||
attachments: processedAttachments,
|
||||
reqAgentEnabled: request.AgentEnabled,
|
||||
reqAgentID: request.AgentID,
|
||||
knowledgeBaseIDs: secutils.SanitizeForLogArray(kbIDs),
|
||||
knowledgeIDs: secutils.SanitizeForLogArray(knowledgeIDs),
|
||||
tagScopes: tagScopes,
|
||||
tagIDs: secutils.SanitizeForLogArray(tagIDs),
|
||||
mcpServiceIDs: secutils.SanitizeForLogArray(mcpServiceIDs),
|
||||
skillNames: secutils.SanitizeForLogArray(skillNames),
|
||||
summaryModelID: secutils.SanitizeForLog(request.SummaryModelID),
|
||||
webSearchEnabled: request.WebSearchEnabled,
|
||||
enableMemory: enableMemory,
|
||||
mentionedItems: convertMentionedItems(request.MentionedItems),
|
||||
effectiveTenantID: effectiveTenantID,
|
||||
images: request.Images,
|
||||
channel: request.Channel,
|
||||
attachments: processedAttachments,
|
||||
suggestionAttribution: request.SuggestionAttribution,
|
||||
reqAgentEnabled: request.AgentEnabled,
|
||||
reqAgentID: request.AgentID,
|
||||
}
|
||||
|
||||
return reqCtx, &request, nil
|
||||
}
|
||||
|
||||
func buildMessageExecutionContext(
|
||||
ctx context.Context,
|
||||
agent *types.CustomAgent,
|
||||
effectiveTenantID uint64,
|
||||
modelOverride string,
|
||||
knowledgeBaseIDs []string,
|
||||
knowledgeIDs []string,
|
||||
tagIDs []string,
|
||||
mcpServiceIDs []string,
|
||||
skillNames []string,
|
||||
webSearchEnabled bool,
|
||||
) (types.MessageExecutionContext, string, uint64, string) {
|
||||
locale, ok := types.LanguageFromContext(ctx)
|
||||
if !ok {
|
||||
locale = types.DefaultLanguage()
|
||||
}
|
||||
|
||||
snapshot := types.MessageExecutionContext{
|
||||
KnowledgeBaseIDs: knowledgeBaseIDs,
|
||||
KnowledgeIDs: knowledgeIDs,
|
||||
TagIDs: tagIDs,
|
||||
MCPServiceIDs: mcpServiceIDs,
|
||||
SkillNames: skillNames,
|
||||
WebSearchEnabled: webSearchEnabled,
|
||||
Locale: locale,
|
||||
}
|
||||
if agent == nil {
|
||||
return snapshot, "", effectiveTenantID, modelOverride
|
||||
}
|
||||
|
||||
modelID := modelOverride
|
||||
if modelID == "" {
|
||||
modelID = agent.Config.ModelID
|
||||
}
|
||||
agentTenantID := effectiveTenantID
|
||||
if agentTenantID == 0 {
|
||||
agentTenantID = agent.TenantID
|
||||
}
|
||||
|
||||
// Marshal/unmarshal gives the snapshot independent backing slices, so a
|
||||
// later agent edit cannot mutate an in-flight message context.
|
||||
if agent.Config.QuestionSuggestions != nil {
|
||||
if encoded, err := json.Marshal(agent.Config.QuestionSuggestions); err == nil {
|
||||
var suggestions types.QuestionSuggestionConfig
|
||||
if json.Unmarshal(encoded, &suggestions) == nil {
|
||||
snapshot.QuestionSuggestions = &suggestions
|
||||
}
|
||||
}
|
||||
}
|
||||
hashInput := struct {
|
||||
QuestionSuggestions *types.QuestionSuggestionConfig `json:"question_suggestions,omitempty"`
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"`
|
||||
KnowledgeIDs []string `json:"knowledge_ids,omitempty"`
|
||||
TagIDs []string `json:"tag_ids,omitempty"`
|
||||
ModelID string `json:"model_id,omitempty"`
|
||||
}{
|
||||
QuestionSuggestions: snapshot.QuestionSuggestions,
|
||||
KnowledgeBaseIDs: knowledgeBaseIDs,
|
||||
KnowledgeIDs: knowledgeIDs,
|
||||
TagIDs: tagIDs,
|
||||
ModelID: modelID,
|
||||
}
|
||||
if encoded, err := json.Marshal(hashInput); err == nil {
|
||||
hash := sha256.Sum256(encoded)
|
||||
snapshot.AgentConfigHash = fmt.Sprintf("%x", hash[:])
|
||||
}
|
||||
|
||||
return snapshot, agent.ID, agentTenantID, modelID
|
||||
}
|
||||
|
||||
// resolveEnableMemory decides whether the memory pipeline runs for this
|
||||
// request. See the call-site comment in parseQARequest for the resolution
|
||||
// order. Lookup errors are logged but never propagate — a failure to read
|
||||
@@ -688,7 +782,7 @@ func (h *Handler) executeQA(reqCtx *qaRequestContext, mode qaMode, generateTitle
|
||||
}
|
||||
|
||||
// Create user message
|
||||
userMsg, err := h.createUserMessage(ctx, sessionID, reqCtx.query, reqCtx.requestID, reqCtx.mentionedItems, convertImageAttachments(reqCtx.images), reqCtx.attachments, reqCtx.channel)
|
||||
userMsg, err := h.createUserMessage(ctx, sessionID, reqCtx.query, reqCtx.requestID, reqCtx.mentionedItems, convertImageAttachments(reqCtx.images), reqCtx.attachments, reqCtx.channel, reqCtx.suggestionAttribution)
|
||||
if err != nil {
|
||||
reqCtx.c.Error(errors.NewInternalServerError(err.Error()))
|
||||
return
|
||||
@@ -964,4 +1058,13 @@ func (h *Handler) completeAssistantMessage(ctx context.Context, assistantMessage
|
||||
// Use WithoutCancel so the goroutine survives after the HTTP request context is done.
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
go h.messageService.IndexMessageToKB(bgCtx, userQuery, assistantMessage.Content, assistantMessage.ID, assistantMessage.SessionID)
|
||||
if userQuery != "" && h.suggestionService != nil {
|
||||
go func() {
|
||||
if _, err := h.suggestionService.EnsureFollowUps(
|
||||
bgCtx, assistantMessage.SessionID, assistantMessage.ID, false,
|
||||
); err != nil {
|
||||
logger.Warnf(bgCtx, "follow-up suggestion generation failed for message %s: %v", assistantMessage.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,10 +65,11 @@ type CreateKnowledgeQARequest struct {
|
||||
// user's personal memory setting doesn't leak into a widget
|
||||
// context; older clients that still send a literal bool also
|
||||
// land here (back-compat).
|
||||
EnableMemory *bool `json:"enable_memory,omitempty"`
|
||||
Images []ImageAttachment `json:"images"` // Attached images for multimodal chat
|
||||
AttachmentUploads []AttachmentUpload `json:"attachment_uploads,omitempty"` // Attached files (documents, audio, etc.)
|
||||
Channel string `json:"channel"` // Source channel: "web", "api", "im", etc.
|
||||
EnableMemory *bool `json:"enable_memory,omitempty"`
|
||||
Images []ImageAttachment `json:"images"` // Attached images for multimodal chat
|
||||
AttachmentUploads []AttachmentUpload `json:"attachment_uploads,omitempty"` // Attached files (documents, audio, etc.)
|
||||
Channel string `json:"channel"` // Source channel: "web", "api", "im", etc.
|
||||
SuggestionAttribution *types.SuggestionAttribution `json:"suggestion_attribution,omitempty"`
|
||||
}
|
||||
|
||||
// AttachmentUpload represents a file attachment upload from the client
|
||||
|
||||
@@ -60,6 +60,7 @@ type RouterParams struct {
|
||||
ChunkHandler *handler.ChunkHandler
|
||||
SessionHandler *session.Handler
|
||||
MessageHandler *handler.MessageHandler
|
||||
MessageSuggestionHandler *handler.MessageSuggestionHandler
|
||||
ModelHandler *handler.ModelHandler
|
||||
ModelCredentialsHandler *handler.ModelCredentialsHandler
|
||||
EvaluationHandler *handler.EvaluationHandler
|
||||
@@ -221,7 +222,7 @@ func NewRouter(params RouterParams) *gin.Engine {
|
||||
RegisterKnowledgeRoutes(v1, params.KnowledgeHandler, rbacGuards)
|
||||
RegisterFAQRoutes(v1, params.FAQHandler, rbacGuards)
|
||||
RegisterChunkRoutes(v1, params.ChunkHandler, rbacGuards)
|
||||
RegisterSessionRoutes(v1, params.SessionHandler, rbacGuards)
|
||||
RegisterSessionRoutes(v1, params.SessionHandler, params.MessageSuggestionHandler, rbacGuards)
|
||||
RegisterChatRoutes(v1, params.SessionHandler, rbacGuards)
|
||||
RegisterMessageRoutes(v1, params.MessageHandler, rbacGuards)
|
||||
RegisterModelRoutes(v1, params.ModelHandler, params.ModelCredentialsHandler, rbacGuards)
|
||||
@@ -505,7 +506,12 @@ func RegisterMessageRoutes(r *gin.RouterGroup, handler *handler.MessageHandler,
|
||||
// the message routes above. A future refactor can introduce
|
||||
// per-session ownership in the middleware layer the same way KB/agent
|
||||
// routes do today.
|
||||
func RegisterSessionRoutes(r *gin.RouterGroup, handler *session.Handler, g *rbacGuards) {
|
||||
func RegisterSessionRoutes(
|
||||
r *gin.RouterGroup,
|
||||
handler *session.Handler,
|
||||
suggestionHandler *handler.MessageSuggestionHandler,
|
||||
g *rbacGuards,
|
||||
) {
|
||||
// Sessions are per-user chat state, not knowledge-base content. The
|
||||
// chat capability lets a scoped key run the full conversation flow
|
||||
// (create/manage its own sessions) without full tenant access.
|
||||
@@ -528,6 +534,13 @@ func RegisterSessionRoutes(r *gin.RouterGroup, handler *session.Handler, g *rbac
|
||||
sessions.DELETE("/:id/pin", handler.UnpinSession)
|
||||
// 继续接收活跃流
|
||||
sessions.GET("/continue-stream/:session_id", handler.ContinueStream)
|
||||
if suggestionHandler != nil {
|
||||
// Gin requires wildcard names to be identical within the same HTTP-method
|
||||
// radix tree. Existing GET session routes use :id, so keep that name here.
|
||||
sessions.GET("/:id/messages/:message_id/suggestions", suggestionHandler.Get)
|
||||
sessions.POST("/:session_id/messages/:message_id/suggestions", suggestionHandler.Ensure)
|
||||
sessions.POST("/:session_id/suggestion-events", suggestionHandler.RecordEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1256,6 +1269,9 @@ func RegisterEmbedPublicRoutes(
|
||||
embed.POST("/agent-chat/:session_id", embedHandler.EmbedAgentChat)
|
||||
embed.GET("/messages/:session_id/load", embedHandler.EmbedLoadMessages)
|
||||
embed.POST("/sessions/:session_id/stop", embedHandler.EmbedStopSession)
|
||||
embed.GET("/sessions/:session_id/messages/:message_id/suggestions", embedHandler.EmbedGetMessageSuggestions)
|
||||
embed.POST("/sessions/:session_id/messages/:message_id/suggestions", embedHandler.EmbedEnsureMessageSuggestions)
|
||||
embed.POST("/sessions/:session_id/suggestion-events", embedHandler.EmbedRecordSuggestionEvent)
|
||||
embed.POST("/sessions/:session_id/events", embedHandler.EmbedRelayWebhookEvent)
|
||||
embed.POST("/sessions/:session_id/mcp-oauth-resolutions/:pending_id", embedHandler.EmbedResolveMCPOAuth)
|
||||
embed.POST("/sessions/:session_id/mcp-oauth-resolutions/:pending_id/cancel", embedHandler.EmbedCancelMCPOAuth)
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestConversationRoutesDeclareChatCapability(t *testing.T) {
|
||||
g := &rbacGuards{}
|
||||
v1 := gin.New().Group("/api/v1")
|
||||
|
||||
RegisterSessionRoutes(v1, &sessionhandler.Handler{}, g)
|
||||
RegisterSessionRoutes(v1, &sessionhandler.Handler{}, &handler.MessageSuggestionHandler{}, g)
|
||||
RegisterChatRoutes(v1, &sessionhandler.Handler{}, g)
|
||||
RegisterMessageRoutes(v1, &handler.MessageHandler{}, g)
|
||||
|
||||
@@ -25,6 +25,9 @@ func TestConversationRoutesDeclareChatCapability(t *testing.T) {
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/sessions"},
|
||||
{http.MethodGet, "/api/v1/sessions/:id/messages/:message_id/suggestions"},
|
||||
{http.MethodPost, "/api/v1/sessions/:session_id/messages/:message_id/suggestions"},
|
||||
{http.MethodPost, "/api/v1/sessions/:session_id/suggestion-events"},
|
||||
{http.MethodPost, "/api/v1/knowledge-chat/:session_id"},
|
||||
{http.MethodPost, "/api/v1/agent-chat/:session_id"},
|
||||
{http.MethodGet, "/api/v1/messages/:session_id/load"},
|
||||
|
||||
@@ -3,6 +3,8 @@ package types
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -245,9 +247,134 @@ type CustomAgentConfig struct {
|
||||
// under config/prompt_templates/intent_prompts.yaml.
|
||||
IntentPrompts map[string]string `yaml:"intent_prompts" json:"intent_prompts,omitempty"`
|
||||
|
||||
// ===== Suggested Prompts =====
|
||||
// 推荐问题列表,用于在前端对话面板展示快捷提问
|
||||
SuggestedPrompts []string `yaml:"suggested_prompts" json:"suggested_prompts,omitempty"`
|
||||
// ===== Conversation Question Suggestions =====
|
||||
// QuestionSuggestions owns both the static/knowledge-backed prompts shown
|
||||
// before the first user turn and the contextual follow-up questions shown
|
||||
// after a completed assistant answer.
|
||||
QuestionSuggestions *QuestionSuggestionConfig `yaml:"question_suggestions,omitempty" json:"question_suggestions,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
SuggestionModeCurated = "curated"
|
||||
SuggestionModeKnowledge = "knowledge"
|
||||
SuggestionModeGenerated = "generated"
|
||||
SuggestionModeHybrid = "hybrid"
|
||||
|
||||
SuggestionCategoryClarify = "clarify"
|
||||
SuggestionCategoryDeepen = "deepen"
|
||||
SuggestionCategoryAction = "action"
|
||||
)
|
||||
|
||||
// QuestionSuggestionConfig is the agent-owned configuration for question
|
||||
// suggestions. Channel settings may suppress rendering, but never override
|
||||
// this content/generation policy.
|
||||
type QuestionSuggestionConfig struct {
|
||||
Starters StarterSuggestionConfig `yaml:"starters" json:"starters"`
|
||||
FollowUps FollowUpSuggestionConfig `yaml:"follow_ups" json:"follow_ups"`
|
||||
}
|
||||
|
||||
// StarterSuggestionConfig controls prompts shown before the first user turn.
|
||||
type StarterSuggestionConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
Items []string `yaml:"items" json:"items"`
|
||||
Count int `yaml:"count" json:"count"`
|
||||
}
|
||||
|
||||
// FollowUpSuggestionConfig controls contextual questions generated after a
|
||||
// completed assistant answer.
|
||||
type FollowUpSuggestionConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
Count int `yaml:"count" json:"count"`
|
||||
ModelID string `yaml:"model_id,omitempty" json:"model_id,omitempty"`
|
||||
AdditionalInstruction string `yaml:"additional_instruction,omitempty" json:"additional_instruction,omitempty"`
|
||||
Categories []string `yaml:"categories,omitempty" json:"categories,omitempty"`
|
||||
MaxContextTurns int `yaml:"max_context_turns" json:"max_context_turns"`
|
||||
SuppressOnFallback bool `yaml:"suppress_on_fallback" json:"suppress_on_fallback"`
|
||||
SuppressWhenAnswerAsksQuestion bool `yaml:"suppress_when_answer_asks_question" json:"suppress_when_answer_asks_question"`
|
||||
KnowledgeFallback bool `yaml:"knowledge_fallback" json:"knowledge_fallback"`
|
||||
AllowRegenerate bool `yaml:"allow_regenerate" json:"allow_regenerate"`
|
||||
}
|
||||
|
||||
// EnsureDefaults normalizes suggestion configuration without overriding
|
||||
// explicit enable/disable choices.
|
||||
func (c *QuestionSuggestionConfig) EnsureDefaults() {
|
||||
if c.Starters.Mode == "" {
|
||||
c.Starters.Mode = SuggestionModeHybrid
|
||||
}
|
||||
if c.Starters.Count <= 0 {
|
||||
c.Starters.Count = 6
|
||||
}
|
||||
if c.Starters.Items == nil {
|
||||
c.Starters.Items = []string{}
|
||||
}
|
||||
if c.FollowUps.Mode == "" {
|
||||
c.FollowUps.Mode = SuggestionModeHybrid
|
||||
}
|
||||
if c.FollowUps.Count <= 0 {
|
||||
c.FollowUps.Count = 3
|
||||
}
|
||||
if c.FollowUps.MaxContextTurns <= 0 {
|
||||
c.FollowUps.MaxContextTurns = 2
|
||||
}
|
||||
if len(c.FollowUps.Categories) == 0 {
|
||||
c.FollowUps.Categories = []string{
|
||||
SuggestionCategoryClarify,
|
||||
SuggestionCategoryDeepen,
|
||||
SuggestionCategoryAction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rejects invalid agent-authored suggestion settings before they are
|
||||
// persisted or used to incur a model call.
|
||||
func (c *QuestionSuggestionConfig) Validate() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c.Starters.Count < 1 || c.Starters.Count > 8 {
|
||||
return fmt.Errorf("starter suggestion count must be between 1 and 8")
|
||||
}
|
||||
if c.FollowUps.Count < 1 || c.FollowUps.Count > 5 {
|
||||
return fmt.Errorf("follow-up suggestion count must be between 1 and 5")
|
||||
}
|
||||
if c.FollowUps.MaxContextTurns < 1 || c.FollowUps.MaxContextTurns > 5 {
|
||||
return fmt.Errorf("follow-up max_context_turns must be between 1 and 5")
|
||||
}
|
||||
if !oneOf(c.Starters.Mode, SuggestionModeCurated, SuggestionModeKnowledge, SuggestionModeHybrid) {
|
||||
return fmt.Errorf("invalid starter suggestion mode %q", c.Starters.Mode)
|
||||
}
|
||||
if !oneOf(c.FollowUps.Mode, SuggestionModeGenerated, SuggestionModeKnowledge, SuggestionModeHybrid) {
|
||||
return fmt.Errorf("invalid follow-up suggestion mode %q", c.FollowUps.Mode)
|
||||
}
|
||||
for i, item := range c.Starters.Items {
|
||||
trimmed := strings.TrimSpace(item)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("starter suggestion %d cannot be empty", i+1)
|
||||
}
|
||||
if len([]rune(trimmed)) > 200 {
|
||||
return fmt.Errorf("starter suggestion %d exceeds 200 characters", i+1)
|
||||
}
|
||||
}
|
||||
if len([]rune(strings.TrimSpace(c.FollowUps.AdditionalInstruction))) > 2000 {
|
||||
return fmt.Errorf("follow-up additional_instruction exceeds 2000 characters")
|
||||
}
|
||||
for _, category := range c.FollowUps.Categories {
|
||||
if !oneOf(category, SuggestionCategoryClarify, SuggestionCategoryDeepen, SuggestionCategoryAction) {
|
||||
return fmt.Errorf("invalid follow-up suggestion category %q", category)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func oneOf(value string, allowed ...string) bool {
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer interface for CustomAgentConfig
|
||||
@@ -282,6 +409,32 @@ func (a *CustomAgent) EnsureDefaults() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
if a.Config.QuestionSuggestions == nil {
|
||||
a.Config.QuestionSuggestions = &QuestionSuggestionConfig{
|
||||
Starters: StarterSuggestionConfig{
|
||||
Enabled: true,
|
||||
Mode: SuggestionModeHybrid,
|
||||
Items: []string{},
|
||||
Count: 6,
|
||||
},
|
||||
FollowUps: FollowUpSuggestionConfig{
|
||||
Enabled: false,
|
||||
Mode: SuggestionModeHybrid,
|
||||
Count: 3,
|
||||
MaxContextTurns: 2,
|
||||
SuppressOnFallback: true,
|
||||
SuppressWhenAnswerAsksQuestion: true,
|
||||
KnowledgeFallback: true,
|
||||
Categories: []string{
|
||||
SuggestionCategoryClarify,
|
||||
SuggestionCategoryDeepen,
|
||||
SuggestionCategoryAction,
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
a.Config.QuestionSuggestions.EnsureDefaults()
|
||||
}
|
||||
if a.Config.Temperature < 0 {
|
||||
a.Config.Temperature = 0.7
|
||||
}
|
||||
|
||||
@@ -79,6 +79,11 @@ type CustomAgentService interface {
|
||||
// - List of suggested questions
|
||||
// - Possible errors
|
||||
GetSuggestedQuestions(ctx context.Context, agentID string, kbIDs []string, knowledgeIDs []string, tagIDs []string, limit int) ([]types.SuggestedQuestion, error)
|
||||
|
||||
// GetKnowledgeSuggestedQuestions returns only knowledge-derived candidates.
|
||||
// It is independent of whether starter suggestions are enabled and is used
|
||||
// as a source/fallback for contextual follow-up generation.
|
||||
GetKnowledgeSuggestedQuestions(ctx context.Context, agentID string, kbIDs []string, knowledgeIDs []string, tagIDs []string, limit int) ([]types.SuggestedQuestion, error)
|
||||
}
|
||||
|
||||
// CustomAgentRepository defines the custom agent repository interface
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// MessageSuggestionRepository persists generated suggestions and their
|
||||
// product-analytics events. AcquireGeneration serializes duplicate requests
|
||||
// from reconnecting clients with an expiring lease.
|
||||
type MessageSuggestionRepository interface {
|
||||
GetByCacheKey(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
assistantMessageID string,
|
||||
placement string,
|
||||
configHash string,
|
||||
locale string,
|
||||
) (*types.MessageSuggestionSet, error)
|
||||
GetByID(ctx context.Context, tenantID uint64, sessionID string, id string) (*types.MessageSuggestionSet, error)
|
||||
AcquireGeneration(
|
||||
ctx context.Context,
|
||||
set *types.MessageSuggestionSet,
|
||||
regenerate bool,
|
||||
) (*types.MessageSuggestionSet, bool, error)
|
||||
Save(ctx context.Context, set *types.MessageSuggestionSet) error
|
||||
CreateEvent(ctx context.Context, event *types.MessageSuggestionEvent) error
|
||||
DeleteByMessageID(ctx context.Context, tenantID uint64, sessionID string, messageID string) error
|
||||
DeleteBySessionID(ctx context.Context, tenantID uint64, sessionID string) error
|
||||
}
|
||||
|
||||
type MessageSuggestionService interface {
|
||||
EnsureFollowUps(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
assistantMessageID string,
|
||||
regenerate bool,
|
||||
) (*types.MessageSuggestionSet, error)
|
||||
GetFollowUps(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
assistantMessageID string,
|
||||
) (*types.MessageSuggestionSet, error)
|
||||
RecordEvent(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
setID string,
|
||||
questionID string,
|
||||
eventType string,
|
||||
) error
|
||||
ValidateAttribution(ctx context.Context, sessionID string, query string, attribution *types.SuggestionAttribution) error
|
||||
}
|
||||
@@ -216,6 +216,18 @@ type Message struct {
|
||||
RenderedContent string `json:"-" gorm:"type:text;column:rendered_content;default:''"`
|
||||
// Channel indicates the source channel of this message (e.g., "web", "api", "im")
|
||||
Channel string `json:"channel,omitempty" gorm:"type:varchar(50);default:''"`
|
||||
// AgentID is the agent used for this individual assistant turn. Unlike the
|
||||
// session's last_request_state it remains stable when users switch agents.
|
||||
AgentID string `json:"agent_id,omitempty" gorm:"type:varchar(36);default:'';index"`
|
||||
// AgentTenantID is the effective/source tenant used to resolve a shared
|
||||
// agent's models and knowledge. It is intentionally not exposed in JSON.
|
||||
AgentTenantID uint64 `json:"-" gorm:"column:agent_tenant_id;default:0"`
|
||||
// ModelID is the requested/effective chat model binding captured for this
|
||||
// turn. It is useful for reproducibility and suggestion generation.
|
||||
ModelID string `json:"model_id,omitempty" gorm:"type:varchar(64);default:''"`
|
||||
// ExecutionContext stores the non-secret per-turn scope required to safely
|
||||
// generate contextual follow-up questions after the main stream completes.
|
||||
ExecutionContext MessageExecutionContext `json:"-" gorm:"type:jsonb;column:execution_context"`
|
||||
// KnowledgeID links this message to a Knowledge entry in the chat history knowledge base
|
||||
// Used for vector search indexing: when set, the message content has been indexed as a Knowledge passage
|
||||
KnowledgeID string `json:"knowledge_id,omitempty" gorm:"type:varchar(36);index"`
|
||||
@@ -227,6 +239,43 @@ type Message struct {
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
}
|
||||
|
||||
// MessageExecutionContext is a message-level snapshot of the non-secret
|
||||
// request state used by derived experiences such as follow-up suggestions.
|
||||
type MessageExecutionContext struct {
|
||||
AgentConfigHash string `json:"agent_config_hash,omitempty"`
|
||||
QuestionSuggestions *QuestionSuggestionConfig `json:"question_suggestions,omitempty"`
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"`
|
||||
KnowledgeIDs []string `json:"knowledge_ids,omitempty"`
|
||||
TagIDs []string `json:"tag_ids,omitempty"`
|
||||
MCPServiceIDs []string `json:"mcp_service_ids,omitempty"`
|
||||
SkillNames []string `json:"skill_names,omitempty"`
|
||||
WebSearchEnabled bool `json:"web_search_enabled"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
SuggestionAttribution *SuggestionAttribution `json:"suggestion_attribution,omitempty"`
|
||||
}
|
||||
|
||||
func (c MessageExecutionContext) Value() (driver.Value, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func (c *MessageExecutionContext) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*c = MessageExecutionContext{}
|
||||
return nil
|
||||
}
|
||||
var b []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
b = v
|
||||
case string:
|
||||
b = []byte(v)
|
||||
default:
|
||||
*c = MessageExecutionContext{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(b, c)
|
||||
}
|
||||
|
||||
// AgentSteps represents a collection of agent execution steps
|
||||
// Used for storing agent reasoning process in database
|
||||
type AgentSteps []AgentStep
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
SuggestionPlacementAfterAnswer = "after_answer"
|
||||
|
||||
SuggestionStatusGenerating = "generating"
|
||||
SuggestionStatusReady = "ready"
|
||||
SuggestionStatusSuppressed = "suppressed"
|
||||
SuggestionStatusFailed = "failed"
|
||||
|
||||
SuggestionEventImpression = "impression"
|
||||
SuggestionEventClick = "click"
|
||||
SuggestionEventDismiss = "dismiss"
|
||||
SuggestionEventRegenerate = "regenerate"
|
||||
)
|
||||
|
||||
// SuggestionAttribution is carried by the next user message after a click, so
|
||||
// analytics can distinguish a click from a question that was actually sent.
|
||||
type SuggestionAttribution struct {
|
||||
SuggestionSetID string `json:"suggestion_set_id"`
|
||||
QuestionID string `json:"question_id"`
|
||||
}
|
||||
|
||||
// SuggestionItem is a stable, attributable question rendered to an end user.
|
||||
type SuggestionItem struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Source string `json:"source"`
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"`
|
||||
}
|
||||
|
||||
type SuggestionItems []SuggestionItem
|
||||
|
||||
func (s SuggestionItems) Value() (driver.Value, error) {
|
||||
if s == nil {
|
||||
s = SuggestionItems{}
|
||||
}
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
func (s *SuggestionItems) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = SuggestionItems{}
|
||||
return nil
|
||||
}
|
||||
var b []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
b = v
|
||||
case string:
|
||||
b = []byte(v)
|
||||
default:
|
||||
*s = SuggestionItems{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(b, s)
|
||||
}
|
||||
|
||||
// MessageSuggestionSet is the durable generation/cache record for one
|
||||
// assistant message and one effective agent configuration.
|
||||
type MessageSuggestionSet struct {
|
||||
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
|
||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||
SessionID string `json:"session_id" gorm:"type:varchar(36);not null;index"`
|
||||
AssistantMessageID string `json:"assistant_message_id" gorm:"type:varchar(36);not null;index"`
|
||||
AgentID string `json:"agent_id" gorm:"type:varchar(36);not null;index"`
|
||||
AgentTenantID uint64 `json:"-" gorm:"not null;default:0"`
|
||||
Placement string `json:"placement" gorm:"type:varchar(32);not null"`
|
||||
ConfigHash string `json:"config_hash" gorm:"type:varchar(64);not null"`
|
||||
Locale string `json:"locale" gorm:"type:varchar(16);not null;default:''"`
|
||||
Status string `json:"status" gorm:"type:varchar(16);not null;index"`
|
||||
AllowRegenerate bool `json:"allow_regenerate" gorm:"not null;default:false"`
|
||||
SuppressionReason string `json:"suppression_reason,omitempty" gorm:"type:varchar(64);not null;default:''"`
|
||||
Questions SuggestionItems `json:"questions" gorm:"type:jsonb;not null"`
|
||||
ModelID string `json:"model_id,omitempty" gorm:"type:varchar(64);not null;default:''"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty" gorm:"not null;default:0"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty" gorm:"not null;default:0"`
|
||||
LatencyMs int64 `json:"latency_ms,omitempty" gorm:"not null;default:0"`
|
||||
ErrorCode string `json:"error_code,omitempty" gorm:"type:varchar(64);not null;default:''"`
|
||||
LeaseUntil *time.Time `json:"-"`
|
||||
GeneratedAt *time.Time `json:"generated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (MessageSuggestionSet) TableName() string { return "message_suggestion_sets" }
|
||||
|
||||
func (s *MessageSuggestionSet) BeforeCreate(_ *gorm.DB) error {
|
||||
if s.ID == "" {
|
||||
s.ID = uuid.NewString()
|
||||
}
|
||||
if s.Questions == nil {
|
||||
s.Questions = SuggestionItems{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MessageSuggestionEvent stores product analytics separately from the
|
||||
// security audit log. It references question IDs rather than copying text.
|
||||
type MessageSuggestionEvent struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||
SessionID string `json:"session_id" gorm:"type:varchar(36);not null;index"`
|
||||
SuggestionSetID string `json:"suggestion_set_id" gorm:"type:varchar(36);not null;index"`
|
||||
QuestionID string `json:"question_id,omitempty" gorm:"type:varchar(64);not null;default:''"`
|
||||
EventType string `json:"event_type" gorm:"type:varchar(32);not null;index"`
|
||||
ActorID string `json:"-" gorm:"type:varchar(512);not null;default:''"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"index"`
|
||||
}
|
||||
|
||||
func (MessageSuggestionEvent) TableName() string { return "message_suggestion_events" }
|
||||
@@ -0,0 +1,22 @@
|
||||
package types
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestQuestionSuggestionConfigValidate(t *testing.T) {
|
||||
config := &QuestionSuggestionConfig{
|
||||
Starters: StarterSuggestionConfig{Mode: SuggestionModeHybrid, Count: 6},
|
||||
FollowUps: FollowUpSuggestionConfig{
|
||||
Mode: SuggestionModeHybrid,
|
||||
Count: 3,
|
||||
MaxContextTurns: 2,
|
||||
Categories: []string{SuggestionCategoryClarify, SuggestionCategoryDeepen},
|
||||
},
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
t.Fatalf("valid config rejected: %v", err)
|
||||
}
|
||||
config.FollowUps.Count = 6
|
||||
if err := config.Validate(); err == nil {
|
||||
t.Fatal("out-of-range follow-up count was accepted")
|
||||
}
|
||||
}
|
||||
@@ -122,12 +122,62 @@ CREATE TABLE messages (
|
||||
knowledge_references JSON NOT NULL,
|
||||
agent_steps JSON DEFAULT NULL COMMENT 'Agent execution steps (reasoning process and tool calls)',
|
||||
is_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
agent_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
agent_tenant_id INTEGER NOT NULL DEFAULT 0,
|
||||
model_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
execution_context JSON NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE INDEX idx_messages_session_role ON messages(session_id, role);
|
||||
CREATE INDEX idx_messages_agent_id ON messages(agent_id);
|
||||
|
||||
CREATE TABLE message_suggestion_sets (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
session_id VARCHAR(36) NOT NULL,
|
||||
assistant_message_id VARCHAR(36) NOT NULL,
|
||||
agent_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
agent_tenant_id INTEGER NOT NULL DEFAULT 0,
|
||||
placement VARCHAR(32) NOT NULL,
|
||||
config_hash VARCHAR(64) NOT NULL,
|
||||
locale VARCHAR(16) NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL,
|
||||
allow_regenerate BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
suppression_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
questions JSON NOT NULL,
|
||||
model_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
latency_ms BIGINT NOT NULL DEFAULT 0,
|
||||
error_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
lease_until TIMESTAMP NULL DEFAULT NULL,
|
||||
generated_at TIMESTAMP NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY idx_message_suggestion_sets_cache_key
|
||||
(tenant_id, assistant_message_id, placement, config_hash, locale),
|
||||
KEY idx_message_suggestion_sets_session (tenant_id, session_id, created_at),
|
||||
KEY idx_message_suggestion_sets_status (status, lease_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE message_suggestion_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
session_id VARCHAR(36) NOT NULL,
|
||||
suggestion_set_id VARCHAR(36) NOT NULL,
|
||||
question_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
actor_id VARCHAR(512) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_message_suggestion_events_set (suggestion_set_id, created_at),
|
||||
KEY idx_message_suggestion_events_session (tenant_id, session_id, created_at),
|
||||
KEY idx_message_suggestion_events_type (event_type, created_at),
|
||||
CONSTRAINT fk_message_suggestion_events_set
|
||||
FOREIGN KEY (suggestion_set_id) REFERENCES message_suggestion_sets(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE chunks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
|
||||
@@ -166,6 +166,10 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
is_completed BOOLEAN NOT NULL DEFAULT 0,
|
||||
is_fallback BOOLEAN NOT NULL DEFAULT 0,
|
||||
channel VARCHAR(50) NOT NULL DEFAULT '',
|
||||
agent_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
agent_tenant_id INTEGER NOT NULL DEFAULT 0,
|
||||
model_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
execution_context TEXT NOT NULL DEFAULT '{}',
|
||||
agent_duration_ms INTEGER DEFAULT 0,
|
||||
knowledge_id VARCHAR(36),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -175,6 +179,56 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_knowledge_id ON messages(knowledge_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_agent_id ON messages(agent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_suggestion_sets (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
session_id VARCHAR(36) NOT NULL,
|
||||
assistant_message_id VARCHAR(36) NOT NULL,
|
||||
agent_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
agent_tenant_id INTEGER NOT NULL DEFAULT 0,
|
||||
placement VARCHAR(32) NOT NULL,
|
||||
config_hash VARCHAR(64) NOT NULL,
|
||||
locale VARCHAR(16) NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL,
|
||||
allow_regenerate BOOLEAN NOT NULL DEFAULT 0,
|
||||
suppression_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
questions TEXT NOT NULL DEFAULT '[]',
|
||||
model_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
lease_until DATETIME,
|
||||
generated_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_suggestion_sets_cache_key
|
||||
ON message_suggestion_sets(tenant_id, assistant_message_id, placement, config_hash, locale);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_suggestion_sets_session
|
||||
ON message_suggestion_sets(tenant_id, session_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_suggestion_sets_status
|
||||
ON message_suggestion_sets(status, lease_until);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_suggestion_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
session_id VARCHAR(36) NOT NULL,
|
||||
suggestion_set_id VARCHAR(36) NOT NULL,
|
||||
question_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
actor_id VARCHAR(512) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (suggestion_set_id) REFERENCES message_suggestion_sets(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_suggestion_events_set
|
||||
ON message_suggestion_events(suggestion_set_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_suggestion_events_session
|
||||
ON message_suggestion_events(tenant_id, session_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_suggestion_events_type
|
||||
ON message_suggestion_events(event_type, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
UPDATE custom_agents
|
||||
SET config = (config - 'question_suggestions') || jsonb_build_object(
|
||||
'suggested_prompts',
|
||||
COALESCE(config->'question_suggestions'->'starters'->'items', '[]'::jsonb)
|
||||
)
|
||||
WHERE config ? 'question_suggestions';
|
||||
|
||||
DROP TABLE IF EXISTS message_suggestion_events;
|
||||
DROP TABLE IF EXISTS message_suggestion_sets;
|
||||
|
||||
DROP INDEX IF EXISTS idx_messages_agent_id;
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS execution_context,
|
||||
DROP COLUMN IF EXISTS model_id,
|
||||
DROP COLUMN IF EXISTS agent_tenant_id,
|
||||
DROP COLUMN IF EXISTS agent_id;
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Migration: 000067_question_suggestions
|
||||
-- Description: Persist per-message execution context and attributable follow-up suggestions.
|
||||
|
||||
DO $$ BEGIN RAISE NOTICE '[Migration 000067] Adding message execution context...'; END $$;
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS agent_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS agent_tenant_id INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS model_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS execution_context JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_agent_id ON messages(agent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_suggestion_sets (
|
||||
id VARCHAR(36) PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
session_id VARCHAR(36) NOT NULL,
|
||||
assistant_message_id VARCHAR(36) NOT NULL,
|
||||
agent_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
agent_tenant_id INTEGER NOT NULL DEFAULT 0,
|
||||
placement VARCHAR(32) NOT NULL,
|
||||
config_hash VARCHAR(64) NOT NULL,
|
||||
locale VARCHAR(16) NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL,
|
||||
allow_regenerate BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
suppression_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
questions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
model_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
latency_ms BIGINT NOT NULL DEFAULT 0,
|
||||
error_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
lease_until TIMESTAMP WITH TIME ZONE,
|
||||
generated_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_suggestion_sets_cache_key
|
||||
ON message_suggestion_sets (
|
||||
tenant_id,
|
||||
assistant_message_id,
|
||||
placement,
|
||||
config_hash,
|
||||
locale
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_suggestion_sets_session
|
||||
ON message_suggestion_sets(tenant_id, session_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_suggestion_sets_status
|
||||
ON message_suggestion_sets(status, lease_until);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_suggestion_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
session_id VARCHAR(36) NOT NULL,
|
||||
suggestion_set_id VARCHAR(36) NOT NULL REFERENCES message_suggestion_sets(id) ON DELETE CASCADE,
|
||||
question_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
actor_id VARCHAR(512) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_suggestion_events_set
|
||||
ON message_suggestion_events(suggestion_set_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_suggestion_events_session
|
||||
ON message_suggestion_events(tenant_id, session_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_suggestion_events_type
|
||||
ON message_suggestion_events(event_type, created_at);
|
||||
|
||||
-- Promote the legacy starter-only field into the unified agent-owned policy.
|
||||
-- Follow-up generation remains off for existing agents until an owner opts in.
|
||||
UPDATE custom_agents
|
||||
SET config = (config - 'suggested_prompts') || jsonb_build_object(
|
||||
'question_suggestions',
|
||||
COALESCE(
|
||||
config->'question_suggestions',
|
||||
jsonb_build_object(
|
||||
'starters', jsonb_build_object(
|
||||
'enabled', true,
|
||||
'mode', 'hybrid',
|
||||
'items', COALESCE(config->'suggested_prompts', '[]'::jsonb),
|
||||
'count', 6
|
||||
),
|
||||
'follow_ups', jsonb_build_object(
|
||||
'enabled', false,
|
||||
'mode', 'hybrid',
|
||||
'count', 3,
|
||||
'categories', jsonb_build_array('clarify', 'deepen', 'action'),
|
||||
'max_context_turns', 2,
|
||||
'suppress_on_fallback', true,
|
||||
'suppress_when_answer_asks_question', true,
|
||||
'knowledge_fallback', true,
|
||||
'allow_regenerate', false
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
WHERE config IS NOT NULL;
|
||||
|
||||
DO $$ BEGIN RAISE NOTICE '[Migration 000067] Question suggestions ready'; END $$;
|
||||
Reference in New Issue
Block a user