Add knowledge retrieval configuration to Assistant model and related components
- Introduce new fields for knowledge retrieval configuration in AssistantConfig and Assistant models, including mode, top_n, and score_threshold. - Implement KnowledgeRetrievalConfig schema with validation for top_n. - Update backend services and routes to handle knowledge retrieval settings. - Enhance frontend components to support knowledge retrieval configuration, including a new dialog for advanced settings. - Add tests for knowledge retrieval configuration validation and description generation.
This commit is contained in:
@@ -164,6 +164,10 @@ class Assistant(Base):
|
|||||||
knowledge_base_id: Mapped[str | None] = mapped_column(
|
knowledge_base_id: Mapped[str | None] = mapped_column(
|
||||||
String(40), ForeignKey("knowledge_bases.id", ondelete="RESTRICT"), nullable=True
|
String(40), ForeignKey("knowledge_bases.id", ondelete="RESTRICT"), nullable=True
|
||||||
)
|
)
|
||||||
|
knowledge_retrieval_config: Mapped[dict] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=lambda: {"mode": "automatic", "top_n": 5, "score_threshold": 0.0},
|
||||||
|
)
|
||||||
|
|
||||||
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
||||||
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
|
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add assistant knowledge retrieval config
|
||||||
|
|
||||||
|
Revision ID: 20260712_0006
|
||||||
|
Revises: 20260712_0005
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "20260712_0006"
|
||||||
|
down_revision: str | Sequence[str] | None = "20260712_0005"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"assistants",
|
||||||
|
sa.Column(
|
||||||
|
"knowledge_retrieval_config",
|
||||||
|
sa.JSON(),
|
||||||
|
server_default=sa.text(
|
||||||
|
"'{\"mode\": \"automatic\", \"top_n\": 5, \"score_threshold\": 0.0}'"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("assistants", "knowledge_retrieval_config")
|
||||||
@@ -76,6 +76,15 @@ class AssistantConfig(BaseModel):
|
|||||||
# Prompt assistant reusable tools. Execution remains type-specific in the pipeline.
|
# Prompt assistant reusable tools. Execution remains type-specific in the pipeline.
|
||||||
tools: list[RuntimeTool] = Field(default_factory=list)
|
tools: list[RuntimeTool] = Field(default_factory=list)
|
||||||
knowledge_base_id: str | None = None
|
knowledge_base_id: str | None = None
|
||||||
|
knowledge_base_name: str = ""
|
||||||
|
knowledge_base_description: str = ""
|
||||||
|
knowledge_retrieval_config: dict = Field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"mode": "automatic",
|
||||||
|
"top_n": 5,
|
||||||
|
"score_threshold": 0.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# workflow 类型:节点图(nodes/edges)。非 workflow 为空,引擎据此决定是否启用。
|
# workflow 类型:节点图(nodes/edges)。非 workflow 为空,引擎据此决定是否启用。
|
||||||
graph: dict = {}
|
graph: dict = {}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
|||||||
vision_model_resource_id=assistant.vision_model_resource_id,
|
vision_model_resource_id=assistant.vision_model_resource_id,
|
||||||
model_resource_ids=await _resource_ids(session, assistant.id),
|
model_resource_ids=await _resource_ids(session, assistant.id),
|
||||||
knowledge_base_id=assistant.knowledge_base_id,
|
knowledge_base_id=assistant.knowledge_base_id,
|
||||||
|
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
|
||||||
tool_ids=await _tool_ids(session, assistant.id),
|
tool_ids=await _tool_ids(session, assistant.id),
|
||||||
prompt=assistant.prompt,
|
prompt=assistant.prompt,
|
||||||
api_url=assistant.api_url,
|
api_url=assistant.api_url,
|
||||||
@@ -217,6 +218,7 @@ async def duplicate_assistant(
|
|||||||
vision_enabled=source.vision_enabled,
|
vision_enabled=source.vision_enabled,
|
||||||
vision_model_resource_id=source.vision_model_resource_id,
|
vision_model_resource_id=source.vision_model_resource_id,
|
||||||
knowledge_base_id=source.knowledge_base_id,
|
knowledge_base_id=source.knowledge_base_id,
|
||||||
|
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
|
||||||
prompt=source.prompt,
|
prompt=source.prompt,
|
||||||
api_url=source.api_url,
|
api_url=source.api_url,
|
||||||
api_key=source.api_key,
|
api_key=source.api_key,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ RuntimeMode = Literal["pipeline", "realtime"]
|
|||||||
ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"]
|
ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"]
|
||||||
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
|
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
|
||||||
TurnEndStrategy = Literal["silence", "smart_turn"]
|
TurnEndStrategy = Literal["silence", "smart_turn"]
|
||||||
|
KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
|
||||||
ToolType = Literal["end_call", "http"]
|
ToolType = Literal["end_call", "http"]
|
||||||
ToolStatus = Literal["active", "archived", "draft"]
|
ToolStatus = Literal["active", "archived", "draft"]
|
||||||
ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"]
|
ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"]
|
||||||
@@ -62,6 +63,19 @@ class TurnConfig(CamelModel):
|
|||||||
turn_detection: TurnDetectionConfig = Field(default_factory=TurnDetectionConfig)
|
turn_detection: TurnDetectionConfig = Field(default_factory=TurnDetectionConfig)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeRetrievalConfig(CamelModel):
|
||||||
|
mode: KnowledgeRetrievalMode = "automatic"
|
||||||
|
top_n: int = Field(default=5, ge=-1)
|
||||||
|
score_threshold: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
@field_validator("top_n")
|
||||||
|
@classmethod
|
||||||
|
def validate_top_n(cls, value: int) -> int:
|
||||||
|
if value == 0:
|
||||||
|
raise ValueError("topN 必须为 -1 或大于 0")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
# 各 type 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据)
|
# 各 type 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据)
|
||||||
ALLOWED_FIELDS: dict[str, set[str]] = {
|
ALLOWED_FIELDS: dict[str, set[str]] = {
|
||||||
"prompt": {"prompt"},
|
"prompt": {"prompt"},
|
||||||
@@ -85,6 +99,9 @@ class AssistantUpsert(CamelModel):
|
|||||||
|
|
||||||
model_resource_ids: dict[ModelType, str] = Field(default_factory=dict)
|
model_resource_ids: dict[ModelType, str] = Field(default_factory=dict)
|
||||||
knowledge_base_id: str | None = None
|
knowledge_base_id: str | None = None
|
||||||
|
knowledge_retrieval_config: KnowledgeRetrievalConfig = Field(
|
||||||
|
default_factory=KnowledgeRetrievalConfig
|
||||||
|
)
|
||||||
tool_ids: list[str] = Field(default_factory=list)
|
tool_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
# 瘦类型专属(真列);按 type 取用,无关字段写入时清零
|
# 瘦类型专属(真列);按 type 取用,无关字段写入时清零
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from db.models import (
|
|||||||
Assistant,
|
Assistant,
|
||||||
AssistantModelBinding,
|
AssistantModelBinding,
|
||||||
AssistantToolBinding,
|
AssistantToolBinding,
|
||||||
|
KnowledgeBase,
|
||||||
ModelResource,
|
ModelResource,
|
||||||
Tool,
|
Tool,
|
||||||
)
|
)
|
||||||
@@ -126,6 +127,11 @@ async def resolve_runtime_config(
|
|||||||
if assistant.vision_model_resource_id
|
if assistant.vision_model_resource_id
|
||||||
else llm_resource
|
else llm_resource
|
||||||
)
|
)
|
||||||
|
knowledge_base = (
|
||||||
|
await session.get(KnowledgeBase, assistant.knowledge_base_id)
|
||||||
|
if assistant.knowledge_base_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return AssistantConfig(
|
return AssistantConfig(
|
||||||
name=assistant.name,
|
name=assistant.name,
|
||||||
@@ -138,6 +144,9 @@ async def resolve_runtime_config(
|
|||||||
turnConfig=assistant.turn_config or {},
|
turnConfig=assistant.turn_config or {},
|
||||||
tools=await _tools_for(session, assistant),
|
tools=await _tools_for(session, assistant),
|
||||||
knowledge_base_id=assistant.knowledge_base_id,
|
knowledge_base_id=assistant.knowledge_base_id,
|
||||||
|
knowledge_base_name=knowledge_base.name if knowledge_base else "",
|
||||||
|
knowledge_base_description=knowledge_base.description if knowledge_base else "",
|
||||||
|
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
|
||||||
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话
|
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话
|
||||||
graph=(assistant.graph or {}) if assistant.type == "workflow" else {},
|
graph=(assistant.graph or {}) if assistant.type == "workflow" else {},
|
||||||
# 外部托管类型连接信息(DB 存真 key,直接注入)
|
# 外部托管类型连接信息(DB 存真 key,直接注入)
|
||||||
|
|||||||
@@ -203,7 +203,13 @@ async def recover_interrupted_documents() -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def search(session: AsyncSession, kb_id: str, query: str, top_k: int | None = None) -> list[dict]:
|
async def search(
|
||||||
|
session: AsyncSession,
|
||||||
|
kb_id: str,
|
||||||
|
query: str,
|
||||||
|
top_k: int | None = None,
|
||||||
|
score_threshold: float = 0.0,
|
||||||
|
) -> list[dict]:
|
||||||
kb = await session.get(KnowledgeBase, kb_id)
|
kb = await session.get(KnowledgeBase, kb_id)
|
||||||
if not kb:
|
if not kb:
|
||||||
return []
|
return []
|
||||||
@@ -217,13 +223,20 @@ async def search(session: AsyncSession, kb_id: str, query: str, top_k: int | Non
|
|||||||
return []
|
return []
|
||||||
query_embedding = (await _embed(session, kb, [query]))[0]
|
query_embedding = (await _embed(session, kb, [query]))[0]
|
||||||
distance = KnowledgeChunk.embedding.cosine_distance(query_embedding)
|
distance = KnowledgeChunk.embedding.cosine_distance(query_embedding)
|
||||||
rows = (await session.execute(
|
statement = (
|
||||||
select(KnowledgeChunk, KnowledgeDocument.name, distance.label("distance"))
|
select(KnowledgeChunk, KnowledgeDocument.name, distance.label("distance"))
|
||||||
.join(KnowledgeDocument, KnowledgeDocument.id == KnowledgeChunk.document_id)
|
.join(KnowledgeDocument, KnowledgeDocument.id == KnowledgeChunk.document_id)
|
||||||
.where(KnowledgeChunk.knowledge_base_id == kb_id, KnowledgeDocument.status == "ready")
|
.where(
|
||||||
|
KnowledgeChunk.knowledge_base_id == kb_id,
|
||||||
|
KnowledgeDocument.status == "ready",
|
||||||
|
distance <= 1.0 - score_threshold,
|
||||||
|
)
|
||||||
.order_by(distance)
|
.order_by(distance)
|
||||||
.limit(top_k or settings.KNOWLEDGE_TOP_K)
|
)
|
||||||
)).all()
|
effective_top_k = settings.KNOWLEDGE_TOP_K if top_k is None else top_k
|
||||||
|
if effective_top_k != -1:
|
||||||
|
statement = statement.limit(effective_top_k)
|
||||||
|
rows = (await session.execute(statement)).all()
|
||||||
return [
|
return [
|
||||||
{"content": chunk.content, "document": name, "score": round(max(0.0, 1.0 - float(dist)), 4)}
|
{"content": chunk.content, "document": name, "score": round(max(0.0, 1.0 - float(dist)), 4)}
|
||||||
for chunk, name, dist in rows
|
for chunk, name, dist in rows
|
||||||
|
|||||||
@@ -83,13 +83,44 @@ VISION_ANALYSIS_SYSTEM_PROMPT = (
|
|||||||
"如果画面不足以判断,请明确说明不确定。"
|
"如果画面不足以判断,请明确说明不确定。"
|
||||||
)
|
)
|
||||||
KNOWLEDGE_TOOL_NAME = "search_knowledge_base"
|
KNOWLEDGE_TOOL_NAME = "search_knowledge_base"
|
||||||
KNOWLEDGE_SYSTEM_HINT = (
|
AUTOMATIC_KNOWLEDGE_SYSTEM_HINT = (
|
||||||
"你已连接内部知识库。系统会在每轮用户问题前自动提供相关资料;"
|
"你已连接内部知识库。系统会在每轮用户问题前自动提供相关资料;"
|
||||||
"回答资料事实时只根据检索内容,资料不足要明确说明。"
|
"回答资料事实时只根据检索内容,资料不足要明确说明。"
|
||||||
)
|
)
|
||||||
|
ON_DEMAND_KNOWLEDGE_SYSTEM_HINT = (
|
||||||
|
"你已连接内部知识库。当用户问题涉及可能存在于业务知识库中的事实时,"
|
||||||
|
"先调用 search_knowledge_base 检索;回答资料事实时只根据检索内容,"
|
||||||
|
"资料不足要明确说明。"
|
||||||
|
)
|
||||||
KNOWLEDGE_CONTEXT_MARKER = "<!-- knowledge-context -->"
|
KNOWLEDGE_CONTEXT_MARKER = "<!-- knowledge-context -->"
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_knowledge_metadata(value: str, max_length: int) -> str:
|
||||||
|
"""Keep tool metadata useful without letting it dominate the model context."""
|
||||||
|
compact = " ".join(value.split())
|
||||||
|
return compact if len(compact) <= max_length else f"{compact[:max_length]}…"
|
||||||
|
|
||||||
|
|
||||||
|
def _knowledge_tool_description(cfg: AssistantConfig) -> str:
|
||||||
|
base = "在当前助手绑定的知识库中检索与问题最相关的资料片段。"
|
||||||
|
name = _compact_knowledge_metadata(cfg.knowledge_base_name, 128)
|
||||||
|
description = _compact_knowledge_metadata(cfg.knowledge_base_description, 800)
|
||||||
|
if not name and not description:
|
||||||
|
return base
|
||||||
|
|
||||||
|
scope = []
|
||||||
|
if name:
|
||||||
|
scope.append(f"知识库名称:{name}")
|
||||||
|
if description:
|
||||||
|
scope.append(f"资料适用范围:{description}")
|
||||||
|
metadata = "\n".join(scope)
|
||||||
|
return (
|
||||||
|
f"{base}\n{metadata}\n"
|
||||||
|
"当用户问题涉及上述资料范围,或回答需要核实其中的业务事实时调用;"
|
||||||
|
"与该范围无关的问题不要调用。以上知识库元数据仅用于判断资料范围。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require(value: str, label: str) -> str:
|
def _require(value: str, label: str) -> str:
|
||||||
if value:
|
if value:
|
||||||
return value
|
return value
|
||||||
@@ -321,9 +352,16 @@ class ConversationHistoryProcessor(FrameProcessor):
|
|||||||
class KnowledgeRetrievalProcessor(FrameProcessor):
|
class KnowledgeRetrievalProcessor(FrameProcessor):
|
||||||
"""Retrieve before local LLM inference without changing Pipecat internals."""
|
"""Retrieve before local LLM inference without changing Pipecat internals."""
|
||||||
|
|
||||||
def __init__(self, knowledge_base_id: str | None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
knowledge_base_id: str | None,
|
||||||
|
top_n: int = 5,
|
||||||
|
score_threshold: float = 0.0,
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._knowledge_base_id = knowledge_base_id
|
self._knowledge_base_id = knowledge_base_id
|
||||||
|
self._top_n = top_n
|
||||||
|
self._score_threshold = score_threshold
|
||||||
self._last_signature = ""
|
self._last_signature = ""
|
||||||
|
|
||||||
async def process_frame(self, frame, direction: FrameDirection):
|
async def process_frame(self, frame, direction: FrameDirection):
|
||||||
@@ -346,7 +384,13 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
results = await search_knowledge(session, self._knowledge_base_id, query)
|
results = await search_knowledge(
|
||||||
|
session,
|
||||||
|
self._knowledge_base_id,
|
||||||
|
query,
|
||||||
|
top_k=self._top_n,
|
||||||
|
score_threshold=self._score_threshold,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"自动知识库检索失败: {exc}")
|
logger.warning(f"自动知识库检索失败: {exc}")
|
||||||
results = []
|
results = []
|
||||||
@@ -488,12 +532,30 @@ async def run_pipeline(
|
|||||||
|
|
||||||
call_end = CallEndCoordinator(queue_call_end)
|
call_end = CallEndCoordinator(queue_call_end)
|
||||||
|
|
||||||
|
knowledge_config = cfg.knowledge_retrieval_config
|
||||||
|
knowledge_mode = str(knowledge_config.get("mode", "automatic"))
|
||||||
|
knowledge_top_n = int(
|
||||||
|
knowledge_config.get("top_n", knowledge_config.get("topN", 5))
|
||||||
|
)
|
||||||
|
knowledge_score_threshold = float(
|
||||||
|
knowledge_config.get(
|
||||||
|
"score_threshold", knowledge_config.get("scoreThreshold", 0.0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
automatic_knowledge_id = (
|
||||||
|
cfg.knowledge_base_id if knowledge_mode == "automatic" else None
|
||||||
|
)
|
||||||
|
|
||||||
def with_vision_hint(text: str) -> str:
|
def with_vision_hint(text: str) -> str:
|
||||||
hints = []
|
hints = []
|
||||||
if vision_enabled:
|
if vision_enabled:
|
||||||
hints.append(VISION_SYSTEM_HINT)
|
hints.append(VISION_SYSTEM_HINT)
|
||||||
if cfg.knowledge_base_id:
|
if cfg.knowledge_base_id:
|
||||||
hints.append(KNOWLEDGE_SYSTEM_HINT)
|
hints.append(
|
||||||
|
AUTOMATIC_KNOWLEDGE_SYSTEM_HINT
|
||||||
|
if knowledge_mode == "automatic"
|
||||||
|
else ON_DEMAND_KNOWLEDGE_SYSTEM_HINT
|
||||||
|
)
|
||||||
return "\n\n".join(part for part in [text, *hints] if part)
|
return "\n\n".join(part for part in [text, *hints] if part)
|
||||||
|
|
||||||
context = LLMContext(
|
context = LLMContext(
|
||||||
@@ -518,7 +580,11 @@ async def run_pipeline(
|
|||||||
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
|
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
|
||||||
text_input = TextInputProcessor(should_ignore_input=lambda: call_end.ending)
|
text_input = TextInputProcessor(should_ignore_input=lambda: call_end.ending)
|
||||||
vision_capture = VisionCaptureProcessor()
|
vision_capture = VisionCaptureProcessor()
|
||||||
knowledge_retrieval = KnowledgeRetrievalProcessor(cfg.knowledge_base_id)
|
knowledge_retrieval = KnowledgeRetrievalProcessor(
|
||||||
|
automatic_knowledge_id,
|
||||||
|
top_n=knowledge_top_n,
|
||||||
|
score_threshold=knowledge_score_threshold,
|
||||||
|
)
|
||||||
vision_native_mode = vision_enabled and _vision_uses_main_llm(cfg)
|
vision_native_mode = vision_enabled and _vision_uses_main_llm(cfg)
|
||||||
vision_state: dict[str, str | None] = {"client_id": None}
|
vision_state: dict[str, str | None] = {"client_id": None}
|
||||||
vision_schema = FunctionSchema(
|
vision_schema = FunctionSchema(
|
||||||
@@ -537,7 +603,7 @@ async def run_pipeline(
|
|||||||
)
|
)
|
||||||
knowledge_schema = FunctionSchema(
|
knowledge_schema = FunctionSchema(
|
||||||
name=KNOWLEDGE_TOOL_NAME,
|
name=KNOWLEDGE_TOOL_NAME,
|
||||||
description="在当前助手绑定的知识库中检索与问题最相关的资料片段。",
|
description=_knowledge_tool_description(cfg),
|
||||||
properties={
|
properties={
|
||||||
"query": {"type": "string", "description": "用于检索的完整问题或关键词"}
|
"query": {"type": "string", "description": "用于检索的完整问题或关键词"}
|
||||||
},
|
},
|
||||||
@@ -551,7 +617,13 @@ async def run_pipeline(
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
results = await search_knowledge(session, cfg.knowledge_base_id, query)
|
results = await search_knowledge(
|
||||||
|
session,
|
||||||
|
cfg.knowledge_base_id,
|
||||||
|
query,
|
||||||
|
top_k=knowledge_top_n,
|
||||||
|
score_threshold=knowledge_score_threshold,
|
||||||
|
)
|
||||||
await params.result_callback({"status": "ok", "results": results})
|
await params.result_callback({"status": "ok", "results": results})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(f"知识库检索失败: {exc}")
|
logger.exception(f"知识库检索失败: {exc}")
|
||||||
@@ -618,14 +690,14 @@ async def run_pipeline(
|
|||||||
|
|
||||||
if vision_enabled:
|
if vision_enabled:
|
||||||
llm.register_function(VISION_TOOL_NAME, fetch_user_image)
|
llm.register_function(VISION_TOOL_NAME, fetch_user_image)
|
||||||
if cfg.knowledge_base_id:
|
if cfg.knowledge_base_id and knowledge_mode == "on_demand":
|
||||||
llm.register_function(KNOWLEDGE_TOOL_NAME, search_bound_knowledge)
|
llm.register_function(KNOWLEDGE_TOOL_NAME, search_bound_knowledge)
|
||||||
|
|
||||||
def set_visible_tools(schemas: list[FunctionSchema] | None = None) -> None:
|
def set_visible_tools(schemas: list[FunctionSchema] | None = None) -> None:
|
||||||
tools = list(schemas or [])
|
tools = list(schemas or [])
|
||||||
if vision_enabled:
|
if vision_enabled:
|
||||||
tools.append(vision_schema)
|
tools.append(vision_schema)
|
||||||
if cfg.knowledge_base_id:
|
if cfg.knowledge_base_id and knowledge_mode == "on_demand":
|
||||||
tools.append(knowledge_schema)
|
tools.append(knowledge_schema)
|
||||||
if tools:
|
if tools:
|
||||||
context.set_tools(ToolsSchema(standard_tools=tools))
|
context.set_tools(ToolsSchema(standard_tools=tools))
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from schemas import KnowledgeRetrievalConfig
|
||||||
from services.knowledge import extract_text, split_text
|
from services.knowledge import extract_text, split_text
|
||||||
|
|
||||||
|
|
||||||
@@ -19,5 +22,22 @@ class KnowledgeTextTest(unittest.TestCase):
|
|||||||
extract_text("archive.zip", b"data")
|
extract_text("archive.zip", b"data")
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeRetrievalConfigTest(unittest.TestCase):
|
||||||
|
def test_accepts_unlimited_top_n_with_threshold(self):
|
||||||
|
config = KnowledgeRetrievalConfig.model_validate(
|
||||||
|
{"mode": "on_demand", "topN": -1, "scoreThreshold": 0.65}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(config.top_n, -1)
|
||||||
|
self.assertEqual(
|
||||||
|
config.model_dump(by_alias=True),
|
||||||
|
{"mode": "on_demand", "topN": -1, "scoreThreshold": 0.65},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_zero_top_n(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
KnowledgeRetrievalConfig(top_n=0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
38
backend/tests/test_pipeline_knowledge.py
Normal file
38
backend/tests/test_pipeline_knowledge.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from models import AssistantConfig
|
||||||
|
from services.pipecat.pipeline import _knowledge_tool_description
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeToolDescriptionTest(unittest.TestCase):
|
||||||
|
def test_includes_bound_knowledge_scope(self):
|
||||||
|
description = _knowledge_tool_description(
|
||||||
|
AssistantConfig(
|
||||||
|
knowledge_base_name="产品服务知识库",
|
||||||
|
knowledge_base_description="产品价格、售后政策和退换货条件",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("知识库名称:产品服务知识库", description)
|
||||||
|
self.assertIn("资料适用范围:产品价格、售后政策和退换货条件", description)
|
||||||
|
self.assertIn("与该范围无关的问题不要调用", description)
|
||||||
|
|
||||||
|
def test_falls_back_when_metadata_is_empty(self):
|
||||||
|
description = _knowledge_tool_description(AssistantConfig())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
description,
|
||||||
|
"在当前助手绑定的知识库中检索与问题最相关的资料片段。",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_compacts_and_limits_description(self):
|
||||||
|
description = _knowledge_tool_description(
|
||||||
|
AssistantConfig(knowledge_base_description=("范围\n 内容 " * 200))
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn("\n ", description)
|
||||||
|
self.assertLess(len(description), 1000)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Wrench,
|
Wrench,
|
||||||
|
Settings2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ import {
|
|||||||
type AssistantType as ApiAssistantType,
|
type AssistantType as ApiAssistantType,
|
||||||
type AssistantUpsert,
|
type AssistantUpsert,
|
||||||
type KnowledgeBase,
|
type KnowledgeBase,
|
||||||
|
type KnowledgeRetrievalConfig,
|
||||||
type ModelResource,
|
type ModelResource,
|
||||||
type Tool,
|
type Tool,
|
||||||
type TurnConfig,
|
type TurnConfig,
|
||||||
@@ -138,6 +140,7 @@ type AssistantForm = {
|
|||||||
asr: string;
|
asr: string;
|
||||||
voice: string;
|
voice: string;
|
||||||
knowledgeBase: string;
|
knowledgeBase: string;
|
||||||
|
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
|
||||||
enableInterrupt: boolean;
|
enableInterrupt: boolean;
|
||||||
turnConfig: TurnConfig;
|
turnConfig: TurnConfig;
|
||||||
visionEnabled: boolean;
|
visionEnabled: boolean;
|
||||||
@@ -204,6 +207,14 @@ function defaultTurnConfig(): TurnConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
|
||||||
|
return {
|
||||||
|
mode: "automatic",
|
||||||
|
topN: 5,
|
||||||
|
scoreThreshold: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 后端 type(英文) ↔ 列表展示标签(中文)
|
// 后端 type(英文) ↔ 列表展示标签(中文)
|
||||||
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
||||||
prompt: "提示词",
|
prompt: "提示词",
|
||||||
@@ -250,6 +261,7 @@ function blankPromptForm(name: string): AssistantForm {
|
|||||||
asr: "",
|
asr: "",
|
||||||
voice: "",
|
voice: "",
|
||||||
knowledgeBase: "",
|
knowledgeBase: "",
|
||||||
|
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||||
enableInterrupt: true,
|
enableInterrupt: true,
|
||||||
turnConfig: defaultTurnConfig(),
|
turnConfig: defaultTurnConfig(),
|
||||||
visionEnabled: false,
|
visionEnabled: false,
|
||||||
@@ -530,6 +542,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
asr: a.modelResourceIds.ASR ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.modelResourceIds.TTS ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
knowledgeBase: a.knowledgeBaseId ?? "",
|
knowledgeBase: a.knowledgeBaseId ?? "",
|
||||||
|
knowledgeRetrievalConfig:
|
||||||
|
a.knowledgeRetrievalConfig ?? defaultKnowledgeRetrievalConfig(),
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
turnConfig: a.turnConfig,
|
turnConfig: a.turnConfig,
|
||||||
visionEnabled: a.visionEnabled,
|
visionEnabled: a.visionEnabled,
|
||||||
@@ -600,6 +614,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
visionModelResourceId: null,
|
visionModelResourceId: null,
|
||||||
modelResourceIds: {},
|
modelResourceIds: {},
|
||||||
knowledgeBaseId: null,
|
knowledgeBaseId: null,
|
||||||
|
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||||
toolIds: [],
|
toolIds: [],
|
||||||
prompt: "",
|
prompt: "",
|
||||||
apiUrl: "",
|
apiUrl: "",
|
||||||
@@ -665,6 +680,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}),
|
...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}),
|
||||||
},
|
},
|
||||||
knowledgeBaseId: form.runtimeMode === "pipeline" ? form.knowledgeBase || null : null,
|
knowledgeBaseId: form.runtimeMode === "pipeline" ? form.knowledgeBase || null : null,
|
||||||
|
knowledgeRetrievalConfig: form.knowledgeRetrievalConfig,
|
||||||
toolIds: form.toolIds,
|
toolIds: form.toolIds,
|
||||||
prompt: form.prompt,
|
prompt: form.prompt,
|
||||||
}),
|
}),
|
||||||
@@ -1906,6 +1922,18 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
title="知识库配置"
|
title="知识库配置"
|
||||||
description="选择助手回答时可检索的业务知识来源"
|
description="选择助手回答时可检索的业务知识来源"
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
知识库选择
|
||||||
|
</span>
|
||||||
|
<KnowledgeRetrievalConfigDialog
|
||||||
|
disabled={!form.knowledgeBase}
|
||||||
|
value={form.knowledgeRetrievalConfig}
|
||||||
|
onChange={(config) =>
|
||||||
|
updateForm("knowledgeRetrievalConfig", config)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<ResourceSelectField
|
<ResourceSelectField
|
||||||
value={form.knowledgeBase}
|
value={form.knowledgeBase}
|
||||||
onChange={(value) => updateForm("knowledgeBase", value)}
|
onChange={(value) => updateForm("knowledgeBase", value)}
|
||||||
@@ -3095,6 +3123,145 @@ function ResourceSelectField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function KnowledgeRetrievalConfigDialog({
|
||||||
|
disabled,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
disabled: boolean;
|
||||||
|
value: KnowledgeRetrievalConfig;
|
||||||
|
onChange: (config: KnowledgeRetrievalConfig) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function openDialog() {
|
||||||
|
setDraft(value);
|
||||||
|
setError(null);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
if (draft.topN === 0 || draft.topN < -1 || !Number.isInteger(draft.topN)) {
|
||||||
|
setError("Top N 必须为 -1 或大于 0 的整数");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft.scoreThreshold < 0 || draft.scoreThreshold > 1) {
|
||||||
|
setError("最低相关度必须在 0 到 1 之间");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(draft);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={openDialog}
|
||||||
|
aria-label="打开知识库高级配置"
|
||||||
|
title={
|
||||||
|
disabled
|
||||||
|
? "请先选择知识库"
|
||||||
|
: `${value.mode === "automatic" ? "自动检索" : "模型主动检索"} · Top N ${value.topN === -1 ? "不限" : value.topN} · 最低相关度 ${value.scoreThreshold}`
|
||||||
|
}
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>知识库高级配置</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
设置检索触发方式、返回数量和相关度过滤条件。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium text-foreground">检索方式</div>
|
||||||
|
<Select
|
||||||
|
value={draft.mode}
|
||||||
|
onValueChange={(mode: "automatic" | "on_demand") =>
|
||||||
|
setDraft({ ...draft, mode })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border-hairline-strong bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="automatic">自动检索</SelectItem>
|
||||||
|
<SelectItem value="on_demand">模型主动检索</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{draft.mode === "automatic"
|
||||||
|
? "每轮用户提问后自动检索,响应行为更稳定。"
|
||||||
|
: "由大模型判断是否调用知识库,依赖模型的工具调用能力。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
最多返回片段数
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="-1"
|
||||||
|
value={draft.topN}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft({ ...draft, topN: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||||
|
填写 -1 时保留所有达到阈值的结果。
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
最低相关度
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
value={draft.scoreThreshold}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
scoreThreshold: Number(event.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||||
|
仅保留相关度达到该值的片段,范围 0–1。
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={saveDraft}>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ToolPicker({
|
function ToolPicker({
|
||||||
tools,
|
tools,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
|
|||||||
@@ -976,16 +976,16 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||||
<DialogContent className="sm:max-w-xl">
|
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>知识库设置</DialogTitle>
|
<DialogTitle>知识库设置</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
修改用途说明与 Embedding 模型。名称可在页面标题处直接编辑。
|
修改用途说明与 Embedding 模型。名称可在页面标题处直接编辑。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-5 rounded-xl border border-hairline bg-surface-strong/20 p-4">
|
|
||||||
<label className="block space-y-2">
|
<div className="space-y-5">
|
||||||
<span className="text-sm font-medium">描述</span>
|
<FieldSection title="描述">
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="用途说明(可选)"
|
placeholder="用途说明(可选)"
|
||||||
value={description}
|
value={description}
|
||||||
@@ -993,9 +993,9 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
|||||||
rows={4}
|
rows={4}
|
||||||
className="field-sizing-fixed min-h-24 resize-y"
|
className="field-sizing-fixed min-h-24 resize-y"
|
||||||
/>
|
/>
|
||||||
</label>
|
</FieldSection>
|
||||||
<label className="block space-y-2">
|
|
||||||
<span className="text-sm font-medium">Embedding 模型</span>
|
<FieldSection title="Embedding 模型">
|
||||||
<Select value={embeddingId} onValueChange={setEmbeddingId}>
|
<Select value={embeddingId} onValueChange={setEmbeddingId}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="选择 Embedding 模型" />
|
<SelectValue placeholder="选择 Embedding 模型" />
|
||||||
@@ -1009,12 +1009,13 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{documents.length > 0 && (
|
{documents.length > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
已有文档时不能更换 Embedding 模型。
|
已有文档时不能更换 Embedding 模型。
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</label>
|
</FieldSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
|
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
|
||||||
取消
|
取消
|
||||||
@@ -1023,7 +1024,7 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
|||||||
disabled={busy || !embeddingId}
|
disabled={busy || !embeddingId}
|
||||||
onClick={() => void saveSettings()}
|
onClick={() => void saveSettings()}
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="animate-spin" size={15} /> : null}
|
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -1038,7 +1039,8 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
|||||||
直接验证当前知识库能否召回正确片段。
|
直接验证当前知识库能否召回正确片段。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
|
||||||
|
<FieldSection title="检索测试" scrollable>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="输入问题,例如:退款规则是什么?"
|
placeholder="输入问题,例如:退款规则是什么?"
|
||||||
@@ -1054,38 +1056,37 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
|||||||
onClick={() => void testSearch()}
|
onClick={() => void testSearch()}
|
||||||
>
|
>
|
||||||
{searching ? (
|
{searching ? (
|
||||||
<Loader2 className="animate-spin" size={15} />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Search size={15} />
|
<Search size={14} />
|
||||||
)}
|
)}
|
||||||
检索
|
检索
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{searchResults.length === 0 ? (
|
{searchResults.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
输入问题后点击检索查看结果。
|
输入问题后点击检索查看结果。
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[50vh] divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
|
searchResults.map((result, index) => (
|
||||||
{searchResults.map((result, index) => (
|
<div
|
||||||
<div
|
key={`${result.document}-${index}`}
|
||||||
key={`${result.document}-${index}`}
|
className="rounded-xl border border-hairline p-4"
|
||||||
className="space-y-2 px-4 py-3"
|
>
|
||||||
>
|
<div className="mb-2 flex justify-between gap-3 text-xs text-muted-foreground">
|
||||||
<div className="flex justify-between gap-3 text-xs text-muted-foreground">
|
<span className="truncate">来源:{result.document}</span>
|
||||||
<span className="truncate">来源:{result.document}</span>
|
<span className="shrink-0 tabular-nums">
|
||||||
<span className="shrink-0 tabular-nums">
|
相关度 {result.score}
|
||||||
相关度 {result.score}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
|
||||||
{result.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||||
</div>
|
{result.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</FieldSection>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setSearchOpen(false)}>
|
<Button variant="outline" onClick={() => setSearchOpen(false)}>
|
||||||
关闭
|
关闭
|
||||||
@@ -1222,3 +1223,30 @@ function EditableTitle({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 与模型资源弹窗相同的分区卡片 */
|
||||||
|
function FieldSection({
|
||||||
|
title,
|
||||||
|
scrollable,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
scrollable?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-hairline bg-surface-strong/20">
|
||||||
|
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"space-y-4 p-4",
|
||||||
|
scrollable ? "max-h-72 overflow-y-auto" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export type Assistant = {
|
|||||||
visionModelResourceId: string | null;
|
visionModelResourceId: string | null;
|
||||||
modelResourceIds: Partial<Record<ModelType, string>>;
|
modelResourceIds: Partial<Record<ModelType, string>>;
|
||||||
knowledgeBaseId: string | null;
|
knowledgeBaseId: string | null;
|
||||||
|
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
|
||||||
toolIds: string[];
|
toolIds: string[];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
@@ -189,6 +190,12 @@ export type Assistant = {
|
|||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type KnowledgeRetrievalConfig = {
|
||||||
|
mode: "automatic" | "on_demand";
|
||||||
|
topN: number;
|
||||||
|
scoreThreshold: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AssistantUpsert = Omit<Assistant, "id" | "updatedAt">;
|
export type AssistantUpsert = Omit<Assistant, "id" | "updatedAt">;
|
||||||
|
|
||||||
export const assistantsApi = {
|
export const assistantsApi = {
|
||||||
|
|||||||
Reference in New Issue
Block a user