This commit is contained in:
Windfarer
2026-03-30 11:13:44 +08:00
parent 72dfb9ce75
commit c1816fe6d6
54 changed files with 3412 additions and 807 deletions

View File

@@ -309,3 +309,26 @@ DOCREADER_TRANSPORT=grpc
# Weaviate 数据库名称(可选)
#WEAVIATE_COLLECTION=your_weaviate_db_name
# ----- OIDC Auth -----
# 如果需要启用OIDC登录设为true并填写后续字段
# OIDC_AUTH_ENABLE=false
# (Optional) 用于OIDC自动发现端点配置
# OIDC_AUTH_ISSUER_URL=http://127.0.0.1:5556/dex
# OIDC_AUTH_DISCOVERY_URL=http://127.0.0.1:5556/dex/.well-known/openid-configuration
# OIDC_AUTH_PROVIDER_DISPLAY_NAME=OIDC
# OIDC_AUTH_CLIENT_ID=client_id_for_oidc_client
# OIDC_AUTH_CLIENT_SECRET=secret_for_oidc_client
# (Optional) OIDC 端点配置, 如果上面的OIDC_AUTH_DISCOVERY_URL填过了下面的这些可以留空
# OIDC_AUTH_AUTHORIZATION_ENDPOINT=http://127.0.0.1:5556/dex/auth
# OIDC_AUTH_TOKEN_ENDPOINT=http://127.0.0.1:5556/dex/token
# OIDC_AUTH_USER_INFO_ENDPOINT=http://127.0.0.1:5556/dex/userinfo
# OIDC_AUTH_SCOPES="openid profile email"
# 用于OIDC用于信息中提取用户数据
# OIDC_USER_INFO_MAPPING_USER_NAME=name
# OIDC_USER_INFO_MAPPING_EMAIL=email

View File

@@ -2,6 +2,58 @@
All notable changes to this project will be documented in this file.
## [0.3.5] - 2026-03-27
### 🚀 New Features
- **NEW**: Telegram IM Integration — Telegram bot adapter with webhook and long-polling modes, streaming replies via editMessageText, file download via getFile API, and timing-safe secret token verification
- **NEW**: DingTalk IM Integration — DingTalk bot supporting webhook (HmacSHA256 signature verification) and Stream mode (via dingtalk-stream-sdk-go), with AI Card streaming via OpenAPI and AccessToken caching
- **NEW**: Mattermost IM Channel — Mattermost IM channel adapter support
- **NEW**: IM Slash Command System — pluggable command framework with five built-in commands: /help, /info, /search, /stop, /clear; wired into all IM channel message dispatch
- **NEW**: IM Distributed Coordination — Redis-based multi-instance coordination: per-user queue limits, global concurrency gate, message dedup, WebSocket leader election, /stop cancellation for queued and in-flight requests
- **NEW**: Suggested Questions — agent-specific suggested questions API based on knowledge bases, with frontend display in chat and create-chat views; image knowledge auto-enqueues question generation tasks
- **NEW**: VLM Auto-Describe MCP Tool Images — when MCP tools return image content, the agent automatically generates text descriptions via the configured VLM model, making image data accessible to text-only LLMs
- **NEW**: Novita AI Provider — new LLM provider with OpenAI-compatible API supporting chat, embedding, and VLLM model types
- **NEW**: Channel Tracking — channel field added to knowledge entries and messages to track source (web/api/im/browser_extension) with frontend labels and DB migrations
- **NEW**: Expose Built-in Parser Engine in Settings — built-in parser engine now visible and selectable in the settings UI
### ⚡ Improvements
- MCP tool names now derived from service.Name (stable across server reconnections) instead of UUID; added collision detection and unique (tenant_id, name) DB index
- Frontend formats MCP tool names from snake_case (e.g. mcp_my_server_search_docs) to human-readable form (My Server Search Docs)
- Enhanced intent classification and context templates: runtime metadata (current time, weekday) injected into context, critical instructions added to rewrite template for entity/keyword preservation
- Knowledge search: added SQL LIKE wildcard escaping, title-based filtering, URL and HTML file type support; FindByMetadataKey method added
- Chunk search returns total chunk counts per knowledge ID for improved agent context awareness
- MiniMax models upgraded from M2.1/M2.1-lightning to M2.7/M2.7-highspeed; Novita AI MiniMax reference updated to M2.7
- DingTalk AI Card streaming: create/deliver/update via OpenAPI; shared think-block rendering via im.TransformThinkBlocks applied to all IM reply paths (DingTalk, Telegram, Feishu)
- IM stream orphan reaper and edit throttling added for DingTalk and Telegram; Feishu stream reaper fixes memory leak
- WeCom group chat replies fixed via appchat API with user fallback; empty-stream fallback when no visible content is produced
- Improved LLM call log summarization: limits output to last few messages to reduce verbosity
- ParallelToolCalls option added to ChatOptions
### 🐛 Bug Fixes
- Fixed agent producing empty response when no knowledge base is configured: retry (max 2), nudge message, and fallback response added
- Fixed UTF-8 byte-based truncation in summary fallback causing PostgreSQL invalid byte sequence errors for Chinese/emoji content; changed to rune-based truncation
- Fixed marked.js usage errors; upgraded marked dependency to v17.0.5 for correct code block rendering
- Fixed vLLM streaming: reasoning content now parsed and propagated through streaming pipeline alongside standard response
- Fixed frontend page counter not resetting to 1 after knowledge file operations (tag, upload, move, edit, delete), causing pagination skips
- Fixed image markdown being stripped during message sanitization
- Fixed MCP tool naming to use service.Name instead of UUID, preventing tool call failures after server reconnection
- Fixed global default storage engine not respected when creating a new knowledge base (was hardcoded to "local")
- Fixed API key encryption loss when updating tenant settings via PUT /tenants/kv/{key}: AfterFind-decrypted plaintext no longer written back to DB
- Fixed empty passage filtering in rerank to prevent Aliyun and Baidu Qianfan 400 errors
- Fixed markdown table rows being passed raw to rerank; now converted to plain text (col1, col2) before reranking
- Fixed OpenRouter embedding provider missing support
- Fixed Milvus vector metric type now configurable via MILVUS_METRIC_TYPE environment variable
- Fixed temperature validation to accept zero as a valid value (was previously defaulting)
- Fixed pg_search update guarded with skip_embedding to prevent unnecessary re-embedding
- Fixed thinking block content being indexed into chat history knowledge base, degrading RAG retrieval quality
### 📚 Documentation
- Added Telegram and DingTalk IM platform setup guides (WebSocket/Webhook modes, streaming, architecture diagrams)
- Updated IM integration docs with Slack, slash commands, QA queue, rate limiting, and streaming output sections
### 🔒 Security Enhancements
- Enhanced SSRF protection in RemoteAPIChat: replaced default DialContext with SSRFSafeDialContext; added SSRF URL validation for BaseURL and endpoint in NewRemoteAPIChat and chat methods
## [0.3.4] - 2026-03-19
### 🚀 New Features
@@ -740,6 +792,7 @@ All notable changes to this project will be documented in this file.
- Docker Compose for quick startup and service orchestration.
- MCP server support for integrating with MCP-compatible clients.
[0.3.5]: https://github.com/Tencent/WeKnora/tree/v0.3.5
[0.3.4]: https://github.com/Tencent/WeKnora/tree/v0.3.4
[0.3.3]: https://github.com/Tencent/WeKnora/tree/v0.3.3
[0.3.2]: https://github.com/Tencent/WeKnora/tree/v0.3.2

View File

@@ -22,7 +22,7 @@
<img src="https://img.shields.io/badge/License-MIT-ffffff?labelColor=d4eaf7&color=2e6cc4" alt="License">
</a>
<a href="./CHANGELOG.md">
<img alt="Version" src="https://img.shields.io/badge/version-0.3.4-2e6cc4?labelColor=d4eaf7">
<img alt="Version" src="https://img.shields.io/badge/version-0.3.5-2e6cc4?labelColor=d4eaf7">
</a>
</p>
@@ -50,6 +50,17 @@ It adopts a modular architecture that combines multimodal preprocessing, semanti
## ✨ Latest Updates
**v0.3.5 Highlights:**
- **Telegram, DingTalk & Mattermost IM Integration**: Added Telegram bot (webhook/long-polling, streaming via editMessageText), DingTalk bot (webhook/Stream mode, AI Card streaming), and Mattermost adapter; IM channel coverage now includes WeCom, Feishu, Slack, Telegram, DingTalk, and Mattermost
- **IM Slash Commands & QA Queue**: Pluggable slash-command system (/help, /info, /search, /stop, /clear) with a bounded QA worker pool, per-user rate limiting, and Redis-based multi-instance coordination
- **Suggested Questions**: Agents surface context-aware suggested questions based on configured knowledge bases; image knowledge automatically enqueues question generation
- **VLM Auto-Describe MCP Tool Images**: When MCP tools return images, the agent generates text descriptions via the configured VLM model, enabling image content to be used by text-only LLMs
- **Novita AI Provider**: New LLM provider with OpenAI-compatible API supporting chat, embedding, and VLLM model types
- **MCP Tool Name Stability**: Tool names now based on service name (stable across reconnections) instead of UUID; unique name constraint added; frontend formats names into human-readable form
- **Channel Tracking**: Knowledge entries and messages record source channel (web/api/im/browser_extension) for traceability
- **Bug Fixes**: Fixed agent empty response when no knowledge base is configured, UTF-8 truncation in summaries for Chinese/emoji documents, API key encryption loss on tenant settings update, vLLM streaming reasoning content propagation, and rerank empty passage errors
**v0.3.4 Highlights:**
- **IM Bot Integration**: WeCom, Feishu, and Slack IM channel support with WebSocket/Webhook modes, streaming, and knowledge base integration
@@ -60,25 +71,21 @@ It adopts a modular architecture that combines multimodal preprocessing, semanti
- **AWS S3 Storage**: Integrated AWS S3 storage adapter with configuration UI and database migrations
- **AES-256-GCM Encryption**: API keys encrypted at rest with AES-256-GCM for enhanced security
- **Built-in MCP Service**: Built-in MCP service support for extending agent capabilities
- **Agent Streaming Panel**: Optimized AgentStreamDisplay with auto-scrolling, improved styling, and loading indicators
- **Hybrid Search Optimization**: Grouped targets and reused query embeddings for better retrieval performance
- **Final Answer Tool**: New final_answer tool with agent duration tracking for improved agent workflows
**v0.3.3 Highlights:**
- 🧩 **Parent-Child Chunking**: Hierarchical parent-child chunking strategy for enhanced context management and more accurate retrieval
- 📌 **Knowledge Base Pinning**: Pin frequently-used knowledge bases for quick access
- 🔄 **Fallback Response**: Fallback response handling with UI indicators when no relevant results are found
- 🖼️ **Image Icon Detection**: Automatic image icon detection and filtering in document processing
- 🧹 **Passage Cleaning for Rerank**: Passage cleaning for rerank model to improve relevance scoring accuracy
- 🐳 **Docker & Skill Management**: Enhanced Docker setup with entrypoint script and skill management
- 🗄️ **Storage Auto-Creation**: Storage engine connectivity check with auto-creation of buckets
- 🎨 **UI Consistency**: Standardized border styles, updated theme and component styles across the application
-**Chunk Size Tuning**: Updated chunk size configurations for knowledge base processing
<details>
<summary><b>Earlier Releases</b></summary>
**v0.3.3 Highlights:**
- **Parent-Child Chunking**: Hierarchical parent-child chunking strategy for enhanced context management and more accurate retrieval
- **Knowledge Base Pinning**: Pin frequently-used knowledge bases for quick access
- **Fallback Response**: Fallback response handling with UI indicators when no relevant results are found
- **Passage Cleaning for Rerank**: Passage cleaning for rerank model to improve relevance scoring accuracy
- **Storage Auto-Creation**: Storage engine connectivity check with auto-creation of buckets
- **Milvus Vector DB**: Added Milvus as a new vector database backend for knowledge retrieval
**v0.3.2 Highlights:**
- 🔍 **Knowledge Search**: New "Knowledge Search" entry point with semantic retrieval, supporting bringing search results directly into the conversation window
@@ -163,24 +170,28 @@ WeKnora employs a modern modular design to build a complete document understandi
## 🧩 Feature Matrix
| Module | Support | Description |
|---------|--------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Agent Mode | ✅ ReACT Agent Mode | Support for using built-in tools to retrieve knowledge bases, MCP tools, and web search, with cross-knowledge base retrieval and multiple iterations |
| Knowledge Base Types | ✅ FAQ / Document | Support for creating FAQ and document knowledge base types, with folder import, URL import, tag management, and online entry |
| Document Formats | ✅ PDF / Word / Txt / Markdown / Images (with OCR / Caption) | Support for structured and unstructured documents with text extraction from images |
| Model Management | ✅ Centralized configuration, built-in model sharing | Centralized model configuration with model selection in knowledge base settings, support for multi-tenant shared built-in models |
| Embedding Models | ✅ Local models, BGE / GTE APIs, etc. | Customizable embedding models, compatible with local deployment and cloud vector generation APIs |
| Vector DB Integration | ✅ PostgreSQL (pgvector), Elasticsearch | Support for mainstream vector index backends, flexible switching for different retrieval scenarios |
| Retrieval Strategies | ✅ BM25 / Dense Retrieval / GraphRAG | Support for sparse/dense recall and knowledge graph-enhanced retrieval with customizable retrieve-rerank-generate pipelines |
| LLM Integration | ✅ Support for Qwen, DeepSeek, etc., with thinking/non-thinking mode switching | Compatible with local models (e.g., via Ollama) or external API services with flexible inference configuration |
| Conversation Strategy | ✅ Agent models, normal mode models, retrieval thresholds, Prompt configuration | Support for configuring Agent models, normal mode models, retrieval thresholds, online Prompt configuration, precise control over multi-turn conversation behavior |
| Web Search | ✅ Extensible search engines, DuckDuckGo / Google | Support for extensible web search engines with built-in DuckDuckGo search engine |
| MCP Tools | ✅ uvx, npx launchers, Stdio/HTTP Streamable/SSE | Support for extending Agent capabilities through MCP, with built-in uvx and npx launchers, supporting three transport methods |
| QA Capabilities | ✅ Context-aware, multi-turn dialogue, prompt templates | Support for complex semantic modeling, instruction control and chain-of-thought Q&A with configurable prompts and context windows |
| E2E Testing | ✅ Retrieval+generation process visualization and metric evaluation | End-to-end testing tools for evaluating recall hit rates, answer coverage, BLEU/ROUGE and other metrics |
| Deployment Modes | ✅ Support for local deployment / Docker images | Meets private, offline deployment and flexible operation requirements, with fast development mode support |
| User Interfaces | ✅ Web UI + RESTful API | Interactive interface and standard API endpoints, with Agent mode/normal mode switching and tool call process display |
| Task Management | ✅ MQ async tasks, automatic database migration | MQ-based async task state maintenance, support for automatic database schema and data migration during version upgrades |
| Module | Support | Description |
|---------|---------|-------------|
| Agent Mode | ✅ ReACT Agent Mode | Built-in tools for knowledge base retrieval, MCP tool calls, and web search; cross-knowledge base retrieval with multi-step iteration |
| Knowledge Base Types | ✅ FAQ / Document | FAQ and document knowledge bases with folder import, URL import, tag management, online entry, and knowledge move |
| Document Formats | ✅ PDF / Word / Txt / Markdown / HTML / Images (OCR + Caption) | Structured and unstructured document parsing; image text extraction via OCR; image caption generation via VLM |
| IM Channel Integration | ✅ WeCom / Feishu / Slack / Telegram / DingTalk / Mattermost | WebSocket and Webhook modes; streaming replies; slash commands (/help, /info, /search, /stop, /clear); per-user rate limiting; Redis-based multi-instance coordination |
| Model Management | ✅ Centralized configuration, built-in model sharing | Centralized model config with per-knowledge-base model selection; multi-tenant shared built-in model support |
| Embedding Models | ✅ Local models (Ollama), BGE / GTE / OpenAI-compatible APIs | Customizable embedding models compatible with local deployment and cloud vector generation APIs |
| Vector DB Integration | ✅ PostgreSQL (pgvector) / Elasticsearch / Milvus / Weaviate / Qdrant | Five vector index backends with flexible switching to match retrieval scenario requirements |
| Object Storage | ✅ Local / MinIO / AWS S3 / Volcengine TOS | Pluggable storage adapters for file and image assets; bucket auto-creation on startup |
| Retrieval Strategies | ✅ BM25 / Dense Retrieval / GraphRAG | Sparse/dense recall and knowledge graph-enhanced retrieval; customizable retrieve-rerank-generate pipeline |
| LLM Integration | ✅ Qwen / DeepSeek / MiniMax / NVIDIA / Novita AI / OpenAI-compatible | Local models via Ollama or external API services; thinking/non-thinking mode switching; vLLM streaming reasoning content support |
| Conversation Strategy | ✅ Agent model, normal model, retrieval threshold, Prompt configuration | Online Prompt editing; retrieval threshold tuning; precise multi-turn conversation behavior control |
| Web Search | ✅ DuckDuckGo / Bing / Google (extensible) | Pluggable search engine providers; web search toggle per conversation |
| MCP Tools | ✅ uvx / npx launchers, Stdio / HTTP Streamable / SSE | Extend agent capabilities via MCP; stable tool naming with collision protection; VLM auto-description for tool-returned images |
| Suggested Questions | ✅ Knowledge-base-driven question suggestions | Agent surfaces context-aware suggested questions in chat interface; image knowledge auto-generates questions |
| QA Capabilities | ✅ Context-aware, multi-turn dialogue, prompt templates | Complex semantic modeling, instruction control, chain-of-thought Q&A with configurable prompts and context windows |
| Security | ✅ AES-256-GCM at-rest encryption, SSRF protection | API keys encrypted at rest; SSRF-safe HTTP client for remote API calls; sandbox execution for agent skills |
| E2E Testing | ✅ Retrieval + generation visualization and metric evaluation | End-to-end test tools for evaluating recall hit rates, answer coverage, BLEU/ROUGE metrics |
| Deployment Modes | ✅ Local / Docker / Kubernetes (Helm) | Private and offline deployment; fast development mode with hot-reload; Helm chart for Kubernetes |
| User Interfaces | ✅ Web UI + RESTful API | Interactive web interface and standard API; Agent/normal mode switching; tool call process display |
| Task Management | ✅ MQ async tasks, automatic database migration | MQ-based async task state; automatic schema and data migration on version upgrade |
## 🚀 Getting Started

View File

@@ -1,55 +1,42 @@
<p align="center">
<picture>
<img src="./docs/images/logo.png" alt="WeKnora Logo" height="120"/>
</picture>
</p>
<p align="center">
<picture>
<a href="https://trendshift.io/repositories/15289" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/15289" alt="Tencent%2FWeKnora | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</picture>
</p>
<p align="center">
<a href="https://weknora.weixin.qq.com" target="_blank">
<img alt="官方网站" src="https://img.shields.io/badge/官方网站-WeKnora-4e6b99">
</a>
<a href="https://chatbot.weixin.qq.com" target="_blank">
<img alt="微信对话开放平台" src="https://img.shields.io/badge/微信对话开放平台-5ac725">
</a>
<a href="https://github.com/Tencent/WeKnora/blob/main/LICENSE">
<img src="https://img.shields.io/badge/License-MIT-ffffff?labelColor=d4eaf7&color=2e6cc4" alt="License">
</a>
<a href="./CHANGELOG.md">
<img alt="版本" src="https://img.shields.io/badge/version-0.3.4-2e6cc4?labelColor=d4eaf7">
</a>
</p>
<p align="center">
| <a href="./README.md"><b>English</b></a> | <b>简体中文</b> | <a href="./README_JA.md"><b>日本語</b></a> |
</p>
<p align="center">
<h4 align="center">
| **[English](./README.md)** | **简体中文** | **[日本語](./README_JA.md)** |
[项目介绍](#-项目介绍) • [架构设计](#-架构设计) • [核心特性](#-核心特性) • [快速开始](#-快速开始) • [文档](#-文档) • [开发指南](#-开发指南)
</h4>
</p>
# 💡 WeKnora - 基于大模型的文档理解检索框架
## 📌 项目介绍
[**WeKnora维娜拉**](https://weknora.weixin.qq.com) 是一款基于大语言模型LLM的文档理解与语义检索框架专为结构复杂、内容异构的文档场景而打造。
**[WeKnora维娜拉](https://weknora.weixin.qq.com)** 是一款基于大语言模型LLM的文档理解与语义检索框架专为结构复杂、内容异构的文档场景而打造。
框架采用模块化架构,融合多模态预处理、语义向量索引、智能召回与大模型生成推理,构建起高效、可控的文档问答流程。核心检索流程基于 **RAGRetrieval-Augmented Generation** 机制,将上下文相关片段与语言模型结合,实现更高质量的语义回答。
**官网:** https://weknora.weixin.qq.com
**官网:** [https://weknora.weixin.qq.com](https://weknora.weixin.qq.com)
## ✨ 最新更新
**v0.3.5 版本亮点:**
- **Telegram、ding'ding & Mattermost IM集成**新增Telegram机器人webhook/长轮询流式editMessageText回复、钉钉机器人webhook/Stream模式AI卡片流式输出和Mattermost适配器IM频道现已覆盖企业微信、飞书、Slack、Telegram、钉钉、Mattermost共6个平台
- **IM斜杠命令与QA队列**:可插拔斜杠命令框架(/help、/info、/search、/stop、/clear配合有界QA工作池、用户级限流和基于Redis的多实例分布式协调
- **推荐问题**Agent基于关联知识库自动生成上下文相关的推荐问题在对话界面开场前展示图片知识自动触发问题生成任务
- **VLM自动描述MCP工具返回图片**当MCP工具返回图片时Agent通过配置的VLM模型自动生成文字描述使不支持图片输入的LLM也能理解图片内容
- **Novita AI提供商**新增Novita AI通过OpenAI兼容接口支持Chat、Embedding和VLLM模型类型
- **MCP工具名称稳定性**工具名称改为基于service.Name跨重连保持稳定新增唯一名称约束和碰撞防护前端将snake_case工具名格式化为可读形式
- **来源频道标记**知识条目和消息新增channel字段记录来源web/api/im/browser_extension便于追溯
- **重要修复**修复无知识库时Agent空响应、中文/emoji文档摘要UTF-8截断、租户设置更新时API密钥加密丢失、vLLM流式推理内容缺失、Rerank空段落过滤等问题
**v0.3.4 版本亮点:**
- **IM机器人集成**支持企业微信、飞书、Slack IM频道WebSocket/Webhook双模式流式回复与知识库集成
@@ -60,24 +47,19 @@
- **AWS S3存储**集成AWS S3存储适配器配置界面及数据库迁移
- **AES-256-GCM加密**API密钥静态加密采用AES-256-GCM增强安全性
- **内置MCP服务**支持内置MCP服务扩展Agent能力
- **Agent流式交互面板**优化AgentStreamDisplay组件自动滚动、样式增强与加载指示器
- **混合检索优化**:按目标分组并复用查询向量,提升检索性能
- **Final Answer工具**新增final_answer工具及Agent耗时跟踪优化Agent工作流
**更早版本**
**v0.3.3 版本亮点:**
- 🧩 **父子分块策略**:层级化的父子分块策略,增强上下文管理和检索精度
- 📌 **知识库置顶**:支持置顶常用知识库,快速访问
- 🔄 **兜底回复**无相关结果时的兜底回复处理及UI指示
- 🖼️ **图片图标检测**:文档处理中的图片图标自动检测与过滤
- 🧹 **Rerank段落清洗**Rerank模型段落清洗功能提升相关性评分准确度
- 🐳 **Docker与技能管理**增强Docker设置新增入口脚本和技能管理
- 🗄️ **存储桶自动创建**:存储引擎连通性检查增强,支持自动创建存储桶
- 🎨 **UI一致性优化**:统一边框样式、更新主题和组件样式,全面提升视觉一致性
-**分块尺寸调优**:更新知识库处理中的分块大小配置
<details>
<summary><b>更早版本</b></summary>
- **父子分块策略**:层级化的父子分块策略,增强上下文管理和检索精度
- **知识库置顶**:支持置顶常用知识库,快速访问
- **兜底回复**无相关结果时的兜底回复处理及UI指示
- **Rerank段落清洗**Rerank模型段落清洗功能提升相关性评分准确度
- **存储桶自动创建**:存储引擎连通性检查增强,支持自动创建存储桶
- **Milvus向量数据库**新增Milvus向量数据库后端用于知识检索
**v0.3.2 版本亮点:**
@@ -120,7 +102,7 @@
- 🎨 **全新UI**优化对话界面支持Agent模式/普通模式切换,展示工具调用过程,知识库管理界面全面升级
-**底层升级**引入MQ异步任务管理支持数据库自动迁移提供快速开发模式
</details>
## 🔒 安全声明
@@ -133,7 +115,7 @@
## 🏗️ 架构设计
![weknora-pipelone.png](./docs/images/architecture.png)
weknora-pipelone.png
WeKnora 采用现代化模块化设计,构建了一条完整的文档理解与检索流水线。系统主要包括文档解析、向量化处理、检索引擎和大模型推理等核心模块,每个组件均可灵活配置与扩展。
@@ -153,34 +135,42 @@ WeKnora 采用现代化模块化设计,构建了一条完整的文档理解与
## 📊 适用场景
| 应用场景 | 具体应用 | 核心价值 |
|---------|----------|----------|
| **企业知识管理** | 内部文档检索、规章制度问答、操作手册查询 | 提升知识查找效率,降低培训成本 |
| **科研文献分析** | 论文检索、研究报告分析、学术资料整理 | 加速文献调研,辅助研究决策 |
| **产品技术支持** | 产品手册问答、技术文档检索、故障排查 | 提升客户服务质量,减少技术支持负担 |
| **法律合规审查** | 合同条款检索、法规政策查询、案例分析 | 提高合规效率,降低法律风险 |
| **医疗知识辅助** | 医学文献检索、诊疗指南查询、例分析 | 辅助临床决策,提升诊疗质量 |
| 应用场景 | 具体应用 | 核心价值 |
| ---------- | -------------------- | ----------------- |
| **企业知识管理** | 内部文档检索、规章制度问答、操作手册查询 | 提升知识查找效率,降低培训成本 |
| **科研文献分析** | 论文检索、研究报告分析、学术资料整理 | 加速文献调研,辅助研究决策 |
| **产品技术支持** | 产品手册问答、技术文档检索、故障排查 | 提升客户服务质量,减少技术支持负担 |
| **法律合规审查** | 合同条款检索、法规政策查询、例分析 | 提高合规效率,降低法律风险 |
| **医疗知识辅助** | 医学文献检索、诊疗指南查询、病例分析 | 辅助临床决策,提升诊疗质量 |
## 🧩 功能模块能力
| 功能模块 | 支持情况 | 说明 |
|---------|-----------------------------------------------------|------|
| Agent模式 | ✅ ReACT Agent模式 | 支持使用内置工具检索知识库、MCP工具和网络搜索跨知识库检索多次迭代和反思 |
| 知识库类型 | ✅ FAQ / 文档 | 支持创建FAQ和文档两种类型知识库支持文件夹导入、URL导入、标签管理和在线录入 |
| 文档格式支持 | ✅ PDF / Word / Txt / Markdown / 图片(含 OCR / Caption | 支持多种结构化与非结构化文档内容解析,支持图文混排与图像文字提取 |
| 模型管理 | ✅ 集中配置、内置模型共享 | 模型集中配置,知识库设置页增加模型选择,支持多租户共享内置模型 |
| 嵌入模型支持 | ✅ 本地模型、BGE / GTE API 等 | 支持自定义 embedding 模型,兼容本地部署与云端向量生成接口 |
| 向量数据库接入 | ✅ PostgreSQLpgvector、Elasticsearch | 支持主流向量索引后端,可灵活切换与扩展,适配不同检索场景 |
| 检索机制 | ✅ BM25 / Dense Retrieve / GraphRAG | 支持稠密/稀疏召回、知识图谱增强检索等多种策略,可自由组合召回-重排-生成流程 |
| 大模型集成 | ✅ 支持 Qwen、DeepSeek 等,思考/非思考模式切换 | 可接入本地大模型(如 Ollama 启动)或调用外部 API 服务,支持推理模式灵活配置 |
| 对话策略 | ✅ Agent模型、普通模式模型、检索阈值、Prompt配置 | 支持配置Agent模型、普通模式所需的模型、检索阈值在线配置Prompt精确控制多轮对话行为 |
| 网络搜索 | ✅ 可扩展搜索引擎、DuckDuckGo / Google | 支持可扩展的网络搜索引擎内置DuckDuckGo搜索引擎 |
| MCP工具 | ✅ uvx、npx启动工具Stdio/HTTP Streamable/SSE | 支持通过MCP扩展Agent能力内置uvx、npx两种MCP启动工具支持三种传输方式 |
| 问答能力 | ✅ 上下文感知、多轮对话、提示词模板 | 支持复杂语义建模、指令控制与链式问答,可配置提示词与上下文窗口 |
| 端到端测试支持 | ✅ 检索+生成过程可视化与指标评估 | 提供一体化链路测试工具支持评估召回命中率、回答覆盖度、BLEU / ROUGE 等主流指标 |
| 部署模式 | ✅ 支持本地部署 / Docker 镜像 | 满足私有化、离线部署与灵活运维的需求,支持快速开发模式 |
| 用户界面 | ✅ Web UI + RESTful API | 提供交互式界面与标准 API 接口支持Agent模式/普通模式切换,展示工具调用过程 |
| 任务管理 | ✅ MQ异步任务、数据库自动迁移 | 引入MQ对异步任务进行状态维护支持版本升级时的数据库表结构和数据自动迁移 |
| 功能模块 | 支持情况 | 说明 |
| ------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| Agent模式 | ✅ ReACT Agent模式 | 内置工具检索知识库、调用MCP工具和网络搜索支持跨知识库检索与多轮迭代推理 |
| 知识库类型 | ✅ FAQ / 文档 | FAQ和文档两种类型支持文件夹导入、URL导入、标签管理、在线录入和知识迁移 |
| 文档格式支持 | ✅ PDF / Word / Txt / Markdown / HTML / 图片OCR + Caption | 结构化与非结构化文档解析图片OCR文字提取VLM图片描述生成 |
| IM频道集成 | ✅ 企业微信 / 飞书 / Slack / Telegram / 钉钉 / Mattermost | WebSocket和Webhook双模式流式回复斜杠命令/help、/info、/search、/stop、/clear用户级限流基于Redis的多实例分布式协调 |
| 模型管理 | ✅ 集中配置、内置模型共享 | 模型集中配置,知识库级别模型选择,支持多租户共享内置模型 |
| 嵌入模型支持 | ✅ 本地模型Ollama、BGE / GTE / OpenAI兼容接口 | 支持自定义embedding模型兼容本地部署与云端向量生成接口 |
| 向量数据库接入 | ✅ PostgreSQLpgvector/ Elasticsearch / Milvus / Weaviate / Qdrant | 五种向量索引后端,可灵活切换,适配不同检索场景 |
| 对象存储 | ✅ 本地 / MinIO / AWS S3 / 火山引擎TOS | 可插拔存储适配器;启动时自动创建存储桶 |
| 检索机制 | ✅ BM25 / Dense Retrieve / GraphRAG | 稠密/稀疏召回、知识图谱增强检索;可自由组合召回-重排-生成流程 |
| 大模型集成 | ✅ Qwen / DeepSeek / MiniMax / NVIDIA / Novita AI / OpenAI兼容 | 接入本地大模型Ollama或外部API服务思考/非思考模式切换vLLM流式推理内容支持 |
| 对话策略 | ✅ Agent模型、普通模式模型、检索阈值、Prompt配置 | 在线Prompt编辑检索阈值调节精确控制多轮对话行为 |
| 网络搜索 | ✅ DuckDuckGo / Bing / Google可扩展 | 可插拔搜索引擎;按对话开关网络搜索 |
| MCP工具 | ✅ uvx / npx启动工具Stdio / HTTP Streamable / SSE | 通过MCP扩展Agent能力工具名称稳定跨重连保持一致VLM自动描述工具返回图片 |
| 推荐问题 | ✅ 基于知识库的问题推荐 | Agent在对话前展示推荐问题图片知识自动触发问题生成 |
| 问答能力 | ✅ 上下文感知、多轮对话、提示词模板 | 复杂语义建模、指令控制与链式问答,可配置提示词与上下文窗口 |
| 安全机制 | ✅ AES-256-GCM静态加密、SSRF防护 | API密钥静态加密远程API调用的SSRF安全校验Agent技能沙盒执行 |
| 端到端测试支持 | ✅ 检索+生成过程可视化与指标评估 | 一体化链路测试支持评估召回命中率、回答覆盖度、BLEU/ROUGE等指标 |
| 部署模式 | ✅ 本地 / Docker / KubernetesHelm | 私有化和离线部署热重载快速开发模式Helm Chart支持Kubernetes部署 |
| 用户界面 | ✅ Web UI + RESTful API | 交互式界面与标准APIAgent/普通模式切换;工具调用过程可视化 |
| 任务管理 | ✅ MQ异步任务、数据库自动迁移 | MQ异步任务状态维护版本升级时自动执行数据库表结构和数据迁移 |
## 🚀 快速开始
@@ -188,9 +178,9 @@ WeKnora 采用现代化模块化设计,构建了一条完整的文档理解与
确保本地已安装以下工具:
* [Docker](https://www.docker.com/)
* [Docker Compose](https://docs.docker.com/compose/)
* [Git](https://git-scm.com/)
- [Docker](https://www.docker.com/)
- [Docker Compose](https://docs.docker.com/compose/)
- [Git](https://git-scm.com/)
### 📦 安装步骤
@@ -235,31 +225,37 @@ ollama serve > /dev/null 2>&1 &
#### ③.1 激活不同组合的功能
- 启动最小功能
```bash
docker compose up -d
```
- 启动全部功能
```bash
docker-compose --profile full up -d
```
- 需要 tracing 日志
```bash
docker-compose --profile jaeger up -d
```
- 需要 neo4j 知识图谱
```bash
docker-compose --profile neo4j up -d
```
- 需要 minio 文件存储服务
```bash
docker-compose --profile minio up -d
```
- 多选项组合
```bash
docker-compose --profile neo4j --profile minio up -d
```
@@ -276,9 +272,9 @@ make stop-all
启动成功后,可访问以下地址:
* Web UI`http://localhost`
* 后端 API`http://localhost:8080`
* 链路追踪Jaeger`http://localhost:16686`
- Web UI`http://localhost`
- 后端 API`http://localhost:8080`
- 链路追踪Jaeger`http://localhost:16686`
### 🔌 使用微信对话开放平台
@@ -301,6 +297,7 @@ git clone https://github.com/Tencent/WeKnora
> 推荐直接参考 [MCP配置说明](./mcp-server/MCP_CONFIG.md) 进行配置。
mcp客户端配置服务器
```json
{
"mcpServers": {
@@ -319,6 +316,7 @@ mcp客户端配置服务器
```
使用stdio命令直接运行
```
pip install weknora-mcp-server
python -m weknora-mcp-server
@@ -349,7 +347,7 @@ make clean-db
### ④ 访问Web UI
http://localhost
[http://localhost](http://localhost)
首次访问会自动跳转到注册登录页面,完成注册后,请创建一个新的知识库,并在该知识库的设置页面完成相关设置。
@@ -357,15 +355,12 @@ http://localhost
### Web UI 界面
<table>
<tr>
<td><b>知识库管理</b><br/><img src="./docs/images/knowledgebases.png" alt="知识库管理"></td>
<td><b>对话设置</b><br/><img src="./docs/images/settings.png" alt="对话设置"></td>
</tr>
<tr>
<td colspan="2"><b>Agent模式工具调用过程</b><br/><img src="./docs/images/agent-qa.png" alt="Agent模式工具调用过程"></td>
</tr>
</table>
| | |
| ----------------- | -------- |
| **知识库管理** | **对话设置** |
| **Agent模式工具调用过程** | |
**知识库管理:** 支持创建FAQ和文档两种类型知识库支持拖拽上传、文件夹导入、URL导入等多种方式自动识别文档结构并提取核心知识建立索引。支持标签管理和在线录入系统清晰展示处理进度和文档状态实现高效的知识库管理。
@@ -413,6 +408,7 @@ make dev-frontend # 启动前端(新终端)
```
**开发优势:**
- ✅ 前端修改自动热重载(无需重启)
- ✅ 后端修改快速重启5-10秒支持 Air 热重载)
- ✅ 无需重新构建 Docker 镜像
@@ -480,7 +476,7 @@ refactor: 重构文档解析模块
感谢以下优秀的贡献者们:
[![Contributors](https://contrib.rocks/image?repo=Tencent/WeKnora)](https://github.com/Tencent/WeKnora/graphs/contributors)
[Contributors](https://github.com/Tencent/WeKnora/graphs/contributors)
## 📄 许可证
@@ -489,10 +485,3 @@ refactor: 重构文档解析模块
## 📈 项目统计
<a href="https://www.star-history.com/#Tencent/WeKnora&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Tencent/WeKnora&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Tencent/WeKnora&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Tencent/WeKnora&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -22,7 +22,7 @@
<img src="https://img.shields.io/badge/License-MIT-ffffff?labelColor=d4eaf7&color=2e6cc4" alt="License">
</a>
<a href="./CHANGELOG.md">
<img alt="バージョン" src="https://img.shields.io/badge/version-0.3.4-2e6cc4?labelColor=d4eaf7">
<img alt="バージョン" src="https://img.shields.io/badge/version-0.3.5-2e6cc4?labelColor=d4eaf7">
</a>
</p>
@@ -50,6 +50,17 @@
## ✨ 最新アップデート
**v0.3.5 バージョンのハイライト:**
- **Telegram、DingTalk & Mattermost IM統合**Telegramボットwebhook/ロングポーリング、editMessageTextストリーミング、DingTalkボットwebhook/Streamモード、AIカードストリーミング、Mattermost アダプターを新規追加。IMチャネルはWeChat Work、Feishu、Slack、Telegram、DingTalk、Mattermost の6プラットフォームをカバー
- **IMスラッシュコマンドとQAキュー**:プラグイン式スラッシュコマンドフレームワーク(/help、/info、/search、/stop、/clear、有界QAワーカープール、ユーザー単位レート制限、RedisベースのマルチインスタンスDistributed Coordination
- **推奨質問**Agentが関連ナレッジベースに基づいてコンテキスト対応の推奨質問を自動生成し、チャットインターフェースに表示。画像ナレッジは質問生成タスクを自動キュー登録
- **VLMによるMCPツール画像自動説明**MCPツールが画像を返した場合、設定されたVLMモデルを使用してテキスト説明を自動生成し、テキストのみのLLMでも画像内容を利用可能に
- **Novita AIプロバイダー**OpenAI互換APIでchat、embedding、VLLMモデルタイプをサポートする新しいLLMプロバイダー
- **MCPツール名の安定性**ツール名をUUIDではなくservice.Nameから生成再接続後も安定。衝突防止制約を追加。フロントエンドでsnake_caseを人間が読みやすい形式に整形
- **チャネルトラッキング**ナレッジエントリとメッセージにchannelフィールド追加web/api/im/browser_extension
- **重要バグ修正**ナレッジベース未設定時のAgent空レスポンス、中国語/絵文字ドキュメントのUTF-8切り詰め、テナント設定更新時のAPIキー暗号化消失、vLLMストリーミング推論コンテンツ欠落、Rerankの空パッセージエラーを修正
**v0.3.4 バージョンのハイライト:**
- **IMボット統合**企業WeChat、Feishu、SlackのIMチャネルをサポート、WebSocket/Webhookモード、ストリーミング対応、ナレッジベース統合
@@ -60,25 +71,21 @@
- **AWS S3ストレージ**AWS S3ストレージアダプターを統合、設定UIとデータベースマイグレーション
- **AES-256-GCM暗号化**APIキーをAES-256-GCMで静的暗号化、セキュリティ強化
- **組み込みMCPサービス**組み込みMCPサービスサポートでAgent機能を拡張
- **Agentストリーミングパネル**AgentStreamDisplayコンポーネントの最適化、自動スクロール、スタイル改善、読み込みインジケーター
- **ハイブリッド検索最適化**:ターゲットのグループ化とクエリ埋め込みの再利用で検索性能を向上
- **Final Answerツール**新しいfinal_answerツールとAgentの所要時間追跡でワークフローを改善
**v0.3.3 バージョンのハイライト:**
- 🧩 **親子チャンキング**:階層型の親子チャンキング戦略により、コンテキスト管理と検索精度を強化
- 📌 **ナレッジベースのピン留め**:よく使うナレッジベースをピン留めして素早くアクセス
- 🔄 **フォールバックレスポンス**関連する結果がない場合のフォールバックレスポンス処理とUIインジケーター
- 🖼️ **画像アイコン検出**:ドキュメント処理における画像アイコンの自動検出とフィルタリング
- 🧹 **Rerankパッセージクリーニング**Rerankモデルのパッセージクリーニング機能で関連性スコアの精度を向上
- 🐳 **Docker・スキル管理**エントリポイントスクリプトとスキル管理によるDocker環境の強化
- 🗄️ **バケット自動作成**:ストレージエンジン接続チェックの強化、バケットの自動作成をサポート
- 🎨 **UI一貫性**:ボーダースタイルの統一、テーマとコンポーネントスタイルの更新で視覚的一貫性を向上
-**チャンクサイズ最適化**:ナレッジベース処理のチャンクサイズ設定を更新
<details>
<summary><b>過去のリリース</b></summary>
**v0.3.3 バージョンのハイライト:**
- **親子チャンキング**:階層型の親子チャンキング戦略により、コンテキスト管理と検索精度を強化
- **ナレッジベースのピン留め**:よく使うナレッジベースをピン留めして素早くアクセス
- **フォールバックレスポンス**関連する結果がない場合のフォールバックレスポンス処理とUIインジケーター
- **Rerankパッセージクリーニング**Rerankモデルのパッセージクリーニング機能で関連性スコアの精度を向上
- **バケット自動作成**:ストレージエンジン接続チェックの強化、バケットの自動作成をサポート
- **Milvusベクトルデータベース**ナレッジ検索用にMilvusベクトルデータベースバックエンドを追加
**v0.3.0 バージョンのハイライト:**
- 🏢 **共有スペース**共有スペース管理、メンバー招待、メンバー間でのナレッジベースとAgentの共有、テナント分離検索
@@ -148,24 +155,28 @@ WeKnoraは現代的なモジュラー設計を採用し、完全な文書理解
## 🧩 機能モジュール能力
| 機能モジュール | サポート状況 | 説明 |
|---------|-----------------------------------------------------|------|
| Agentモード | ✅ ReACT Agentモード | 組み込みツールでナレッジベースを検索、MCPツールとWeb検索を使用、クロスナレッジベース検索複数回の反復とリフレクションをサポート |
| ナレッジベースタイプ | ✅ FAQ / ドキュメント | FAQとドキュメントの2種類のナレッジベースの作成をサポート、フォルダーインポート、URLインポート、タグ管理、オンライン入力機能 |
| 文書フォーマットサポート | ✅ PDF / Word / Txt / Markdown / 画像OCR / Caption含む | 様々な構造化・非構造化文書コンテンツの解析をサポート、図文混在と画像文字抽出をサポート |
| モデル管理 | ✅ 集中設定、組み込みモデル共有 | モデルの集中設定、ナレッジベース設定ページにモデル選択を追加、マルチテナント間での組み込みモデル共有をサポート |
| 埋め込みモデルサポート | ✅ ローカルモデル、BGE / GTE API等 | カスタムembeddingモデルをサポート、ローカルデプロイとクラウドベクトル生成インターフェースに対応 |
| ベクトルデータベース接続 | ✅ PostgreSQLpgvector、Elasticsearch | 主流のベクトルインデックスバックエンドをサポート、柔軟な切り替えと拡張が可能、異なる検索シナリオに適応 |
| 検索メカニズム | ✅ BM25 / Dense Retrieve / GraphRAG | 密・疎検索、ナレッジグラフ強化検索など複数の戦略をサポート、検索-再ランキング-生成プロセスを自由に組み合わせ可能 |
| 大規模モデル統合 | ✅ Qwen、DeepSeek等をサポート、思考/非思考モード切り替え | ローカル大規模モデルOllama起動などに接続可能、または外部APIサービスを呼び出し、推論モードの柔軟な設定をサポート |
| 対話戦略 | ✅ Agentモデル、通常モードモデル、検索閾値、Prompt設定 | Agentモデル、通常モードに必要なモデル、検索閾値の設定をサポート、オンラインPrompt設定、マルチターン対話の動作を精密に制御 |
| Web検索 | ✅ 拡張可能な検索エンジン、DuckDuckGo / Google | 拡張可能なWeb検索エンジンをサポート、DuckDuckGo検索エンジンを組み込み |
| MCPツール | ✅ uvx、npx起動ツール、Stdio/HTTP Streamable/SSE | MCPを通じてAgent機能を拡張、uvx、npxの2種類のMCP起動ツールを組み込み、3種類の転送方式をサポート |
| Q&A能力 | ✅ コンテキスト認識、マルチターン対話、プロンプトテンプレート | 複雑な意味モデリング、指示制御、チェーンQ&Aをサポート、プロンプトとコンテキストウィンドウを設定可能 |
| エンドツーエンドテストサポート | ✅ 検索+生成プロセスの可視化と指標評価 | 一体化されたリンクテストツールを提供、リコール的中率、回答カバレッジ、BLEU / ROUGE等の主流指標の評価をサポート |
| デプロイメントモード | ✅ ローカルデプロイメント / Dockerイメージ | プライベート化、オフラインデプロイメント、柔軟な運用保守のニーズに対応、高速開発モードをサポート |
| ユーザーインターフェース | ✅ Web UI + RESTful API | インタラクティブインターフェースと標準APIインターフェースを提供、Agentモード/通常モードの切り替え、ツール呼び出しプロセスの表示をサポート |
| タスク管理 | ✅ MQ非同期タスク、データベース自動マイグレーション | MQによる非同期タスクの状態維持を導入、バージョンアップ時のデータベーステーブル構造とデータの自動マイグレーションをサポート |
| 機能モジュール | サポート状況 | 説明 |
|---------|------------|------|
| Agentモード | ✅ ReACT Agentモード | 組み込みツールでナレッジベースを検索、MCPツールとWeb検索を呼び出し、クロスナレッジベース検索複数回の反復推論をサポート |
| ナレッジベースタイプ | ✅ FAQ / ドキュメント | FAQとドキュメントの2種類のナレッジベース、フォルダーインポート、URLインポート、タグ管理、オンライン入力、ナレッジ移動をサポート |
| 文書フォーマットサポート | ✅ PDF / Word / Txt / Markdown / HTML / 画像OCR + Caption | 構造化・非構造化文書の解析、OCRによる画像文字抽出、VLMによる画像キャプション生成 |
| IMチャネル統合 | ✅ WeChat Work / Feishu / Slack / Telegram / DingTalk / Mattermost | WebSocket・Webhookモード、ストリーミング返信、スラッシュコマンド/help、/info、/search、/stop、/clear、ユーザー単位レート制限、RedisベースのマルチインスタンスDistributed Coordination |
| モデル管理 | ✅ 集中設定、組み込みモデル共有 | モデルの集中設定、ナレッジベース単位のモデル選択、マルチテナント間での組み込みモデル共有 |
| 埋め込みモデルサポート | ✅ ローカルモデルOllama、BGE / GTE / OpenAI互換API | カスタムembeddingモデル対応、ローカルデプロイとクラウドベクトル生成インターフェースに対応 |
| ベクトルデータベース接続 | ✅ PostgreSQLpgvector/ Elasticsearch / Milvus / Weaviate / Qdrant | 5種類のベクトルインデックスバックエンドを柔軟に切り替え可能 |
| オブジェクトストレージ | ✅ ローカル / MinIO / AWS S3 / 火山引擎TOS | プラグイン式ストレージアダプター、起動時にバケットを自動作成 |
| 検索メカニズム | ✅ BM25 / Dense Retrieve / GraphRAG | 密・疎検索、ナレッジグラフ強化検索、検索-再ランキング-生成を自由に組み合わせ |
| 大規模モデル統合 | ✅ Qwen / DeepSeek / MiniMax / NVIDIA / Novita AI / OpenAI互換 | ローカルモデルOllamaまたは外部APIサービスに接続、思考/非思考モード切り替え、vLLMストリーミング推論コンテンツ対応 |
| 対話戦略 | ✅ Agentモデル、通常モードモデル、検索閾値、Prompt設定 | オンラインPrompt編集、検索閾値チューニング、マルチターン対話の精密制御 |
| Web検索 | ✅ DuckDuckGo / Bing / Google拡張可能 | プラグイン式検索エンジン、対話ごとにWeb検索を切り替え可能 |
| MCPツール | ✅ uvx / npx起動ツール、Stdio / HTTP Streamable / SSE | MCPでAgent機能を拡張、安定したツール名管理衝突防止付き、ツール返却画像のVLM自動説明 |
| 推奨質問 | ✅ ナレッジベース連動の質問推奨 | Agentがチャット前に推奨質問を提示、画像ナレッジが質問生成を自動トリガー |
| Q&A能力 | ✅ コンテキスト認識、マルチターン対話、プロンプトテンプレート | 複雑な意味モデリング、指示制御、チェーンQ&A、プロンプトとコンテキストウィンドウを設定可能 |
| セキュリティ | ✅ AES-256-GCM静的暗号化、SSRF防護 | APIキーの静的暗号化、リモートAPI呼び出しのSSRFセーフ検証、Agentスキルのサンドボックス実行 |
| エンドツーエンドテストサポート | ✅ 検索+生成プロセスの可視化と指標評価 | 一体化テストツール、リコール的中率・回答カバレッジ・BLEU/ROUGE等の指標評価 |
| デプロイメントモード | ✅ ローカル / Docker / KubernetesHelm | プライベート化・オフラインデプロイ、ホットリロード高速開発モード、Kubernetes用Helm Chart |
| ユーザーインターフェース | ✅ Web UI + RESTful API | インタラクティブインターフェースと標準API、Agentモード/通常モード切り替え、ツール呼び出しプロセス表示 |
| タスク管理 | ✅ MQ非同期タスク、データベース自動マイグレーション | MQによる非同期タスク状態維持、バージョンアップ時のDB自動マイグレーション |
## 🚀 クイックスタート

View File

@@ -22,7 +22,7 @@
<img src="https://img.shields.io/badge/License-MIT-ffffff?labelColor=d4eaf7&color=2e6cc4" alt="License">
</a>
<a href="./CHANGELOG.md">
<img alt="버전" src="https://img.shields.io/badge/version-0.3.4-2e6cc4?labelColor=d4eaf7">
<img alt="버전" src="https://img.shields.io/badge/version-0.3.5-2e6cc4?labelColor=d4eaf7">
</a>
</p>
@@ -50,6 +50,17 @@
## ✨ 최신 업데이트
**v0.3.5 하이라이트:**
- **Telegram, DingTalk & Mattermost IM 통합**: Telegram 봇(webhook/롱폴링, editMessageText 스트리밍), DingTalk 봇(webhook/Stream 모드, AI 카드 스트리밍), Mattermost 어댑터를 신규 추가. IM 채널이 기업WeChat, Feishu, Slack, Telegram, DingTalk, Mattermost 6개 플랫폼으로 확대
- **IM 슬래시 커맨드 및 QA 큐**: 플러그인 방식 슬래시 커맨드 프레임워크(/help, /info, /search, /stop, /clear), 유계 QA 워커 풀, 사용자별 레이트 리밋, Redis 기반 멀티 인스턴스 분산 조정
- **추천 질문**: Agent가 연결된 지식베이스를 기반으로 컨텍스트 맞춤 추천 질문을 자동 생성해 채팅 화면에 표시; 이미지 지식은 질문 생성 작업을 자동 큐 등록
- **VLM을 통한 MCP 도구 이미지 자동 설명**: MCP 도구가 이미지를 반환하면 설정된 VLM 모델로 텍스트 설명을 자동 생성해 텍스트 전용 LLM에서도 이미지 내용 활용 가능
- **Novita AI 프로바이더**: OpenAI 호환 API로 chat, embedding, VLLM 모델 타입을 지원하는 신규 LLM 프로바이더
- **MCP 도구명 안정성**: UUID 대신 service.Name 기반 도구명(재연결 후에도 안정), 고유명 제약 및 충돌 방지 추가; 프론트엔드에서 snake_case를 사람이 읽기 쉬운 형태로 변환
- **채널 추적**: 지식 항목과 메시지에 channel 필드 추가(web/api/im/browser_extension)로 출처 추적 가능
- **주요 버그 수정**: 지식베이스 미설정 시 Agent 빈 응답, 한국어/이모지 문서 요약의 UTF-8 잘림, 테넌트 설정 업데이트 시 API 키 암호화 손실, vLLM 스트리밍 추론 콘텐츠 누락, Rerank 빈 패시지 오류 수정
**v0.3.4 하이라이트:**
- **IM 봇 통합**: 기업WeChat, Feishu, Slack IM 채널 지원, WebSocket/Webhook 모드, 스트리밍 및 지식베이스 통합
@@ -60,25 +71,21 @@
- **AWS S3 스토리지**: AWS S3 스토리지 어댑터 통합, 설정 UI 및 데이터베이스 마이그레이션
- **AES-256-GCM 암호화**: API 키를 AES-256-GCM으로 정적 암호화하여 보안 강화
- **내장 MCP 서비스**: 내장 MCP 서비스 지원으로 Agent 기능 확장
- **Agent 스트리밍 패널**: AgentStreamDisplay 컴포넌트 최적화, 자동 스크롤, 스타일 개선 및 로딩 인디케이터
- **하이브리드 검색 최적화**: 타겟 그룹화 및 쿼리 임베딩 재사용으로 검색 성능 향상
- **Final Answer 도구**: 새로운 final_answer 도구 및 Agent 소요 시간 추적으로 워크플로우 개선
**v0.3.3 하이라이트:**
- 🧩 **부모-자식 청킹**: 계층적 부모-자식 청킹 전략으로 컨텍스트 관리 및 검색 정확도 강화
- 📌 **지식베이스 고정**: 자주 사용하는 지식베이스를 고정하여 빠른 접근 지원
- 🔄 **폴백 응답**: 관련 결과가 없을 때 폴백 응답 처리 및 UI 표시기
- 🖼️ **이미지 아이콘 감지**: 문서 처리 시 이미지 아이콘 자동 감지 및 필터링
- 🧹 **Rerank 패시지 클리닝**: Rerank 모델의 패시지 클리닝 기능으로 관련성 점수 정확도 향상
- 🐳 **Docker 및 스킬 관리**: 엔트리포인트 스크립트와 스킬 관리로 Docker 설정 강화
- 🗄️ **버킷 자동 생성**: 스토리지 엔진 연결 확인 강화, 버킷 자동 생성 지원
- 🎨 **UI 일관성**: 테두리 스타일 통일, 테마 및 컴포넌트 스타일 업데이트로 시각적 일관성 향상
-**청크 크기 최적화**: 지식베이스 처리를 위한 청크 크기 구성 업데이트
<details>
<summary><b>이전 릴리스</b></summary>
**v0.3.3 하이라이트:**
- **부모-자식 청킹**: 계층적 부모-자식 청킹 전략으로 컨텍스트 관리 및 검색 정확도 강화
- **지식베이스 고정**: 자주 사용하는 지식베이스를 고정하여 빠른 접근 지원
- **폴백 응답**: 관련 결과가 없을 때 폴백 응답 처리 및 UI 표시기
- **Rerank 패시지 클리닝**: Rerank 모델의 패시지 클리닝 기능으로 관련성 점수 정확도 향상
- **버킷 자동 생성**: 스토리지 엔진 연결 확인 강화, 버킷 자동 생성 지원
- **Milvus 벡터 데이터베이스**: 지식 검색을 위한 Milvus 벡터 데이터베이스 백엔드 추가
**v0.3.0 하이라이트:**
- 🏢 **공유 공간**: 멤버 초대, 멤버 간 지식베이스/에이전트 공유, 테넌트 격리 검색을 지원하는 공유 공간
@@ -143,23 +150,27 @@ WeKnora는 완전한 문서 이해 및 검색 파이프라인을 구축하기
## 🧩 기능 매트릭스
| 모듈 | 지원 범위 | 설명 |
|---------|--------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Agent 모드 | ✅ ReACT Agent Mode | 내장 도구/지식베이스 검색, MCP 도구, 웹 검색 지원. 교차 지식베이스 검색 및 다중 반복 지원 |
| 지식베이스 타입 | ✅ FAQ / Document | FAQ/문서 지식베이스 생성 지원. 폴더 임포트, URL 임포트, 태그 관리, 온라인 입력 지원 |
| 문서 포맷 | ✅ PDF / Word / Txt / Markdown / Images (OCR / Caption 포함) | 구조화/비구조화 문서 처리 및 이미지 텍스트 추출 지원 |
| 모델 관리 | ✅ 중앙 설정, 내장 모델 공유 | 지식베이스 설정에서 모델 선택을 포함한 중앙 모델 관리 및 멀티테넌트 내장 모델 공유 지원 |
| 임베딩 모델 | ✅ 로컬 모델, BGE / GTE API 등 | 커스터마이징 가능한 임베딩 모델. 로컬 배포 및 클라우드 벡터 생성 API와 호환 |
| 벡터 DB 연동 | ✅ PostgreSQL (pgvector), Elasticsearch | 주요 벡터 인덱스 백엔드 지원, 검색 시나리오별 유연한 전환 |
| 검색 전략 | ✅ BM25 / Dense Retrieval / GraphRAG | 희소/밀집 검색 및 지식 그래프 강화 검색 지원. 검색-리랭크-생성 파이프라인 커스터마이징 가능 |
| LLM 연동 | ✅ Qwen, DeepSeek 등 지원, 사고/비사고 모드 전환 | 로컬 모델(예: Ollama) 또는 외부 API 서비스와 연동 가능한 유연한 추론 설정 |
| 대화 전략 | ✅ Agent 모델, 일반 모드 모델, 검색 임계값, 프롬프트 설정 | Agent/일반 모델, 검색 임계값, 온라인 프롬프트 설정 지원. 멀티턴 대화 동작 정밀 제어 |
| 웹 검색 | ✅ 확장 가능한 검색 엔진, DuckDuckGo / Google | 확장 가능한 웹 검색 엔진 지원, DuckDuckGo 기본 제공 |
| MCP 도구 | ✅ uvx, npx 런처, Stdio/HTTP Streamable/SSE | MCP를 통한 Agent 기능 확장. uvx/npx 런처 내장, 세 가지 전송 방식 지원 |
| QA 역량 | ✅ 문맥 인식, 멀티턴 대화, 프롬프트 템플릿 | 복잡한 시맨틱 모델링, 지시 제어, 체인형 Q&A 지원. 프롬프트/컨텍스트 윈도우 설정 가능 |
| E2E 테스트 | ✅ 검색+생성 과정 시각화 및 지표 평가 | 리콜 적중률, 답변 커버리지, BLEU/ROUGE 등 지표를 평가하는 종단간 테스트 도구 제공 |
| 배포 모드 | ✅ 로컬 배포 / Docker 이미지 | 프라이빗/오프라인 배포 및 유연한 운영 요구 충족. 고속 개발 모드 지원 |
| 사용자 인터페이스 | ✅ Web UI + RESTful API | 상호작용 UI와 표준 API 제공. Agent/일반 모드 전환 및 도구 호출 과정 표시 |
| 작업 관리 | ✅ MQ 비동기 작업, 자동 DB 마이그레이션 | MQ 기반 비동기 작업 상태 유지 및 버전 업그레이드 시 스키마/데이터 자동 마이그레이션 지원 |
|---------|---------|------|
| Agent 모드 | ✅ ReACT Agent Mode | 내장 도구지식베이스 검색, MCP 도구 웹 검색 호출; 교차 지식베이스 검색 및 다중 반복 추론 |
| 지식베이스 타입 | ✅ FAQ / Document | FAQ/문서 지식베이스, 폴더 임포트, URL 임포트, 태그 관리, 온라인 입력, 지식 이동 지원 |
| 문서 포맷 | ✅ PDF / Word / Txt / Markdown / HTML / 이미지 (OCR + Caption) | 구조화/비구조화 문서 파싱; OCR 이미지 텍스트 추출; VLM 이미지 캡션 생성 |
| IM 채널 통합 | ✅ WeChat Work / Feishu / Slack / Telegram / DingTalk / Mattermost | WebSocket·Webhook 모드, 스트리밍 답변, 슬래시 커맨드(/help, /info, /search, /stop, /clear), 사용자별 레이트 리밋, Redis 기반 멀티 인스턴스 분산 조정 |
| 모델 관리 | ✅ 중앙 설정, 내장 모델 공유 | 지식베이스별 모델 선택 포함 중앙 모델 관리; 멀티테넌트 내장 모델 공유 |
| 임베딩 모델 | ✅ 로컬 모델(Ollama), BGE / GTE / OpenAI 호환 API | 커스텀 임베딩 모델, 로컬 배포 및 클라우드 벡터 생성 API 호환 |
| 벡터 DB 연동 | ✅ PostgreSQL (pgvector) / Elasticsearch / Milvus / Weaviate / Qdrant | 5종 벡터 인덱스 백엔드, 검색 시나리오별 유연한 전환 |
| 오브젝트 스토리지 | ✅ 로컬 / MinIO / AWS S3 / Volcengine TOS | 플러그인 방식 스토리지 어댑터; 시작 시 버킷 자동 생성 |
| 검색 전략 | ✅ BM25 / Dense Retrieval / GraphRAG | 희소/밀집 검색, 지식 그래프 강화 검색; 검색-리랭크-생성 파이프라인 조합 가능 |
| LLM 연동 | ✅ Qwen / DeepSeek / MiniMax / NVIDIA / Novita AI / OpenAI 호환 | 로컬 모델(Ollama) 또는 외부 API, 사고/비사고 모드 전환, vLLM 스트리밍 추론 콘텐츠 지원 |
| 대화 전략 | ✅ Agent 모델, 일반 모드 모델, 검색 임계값, 프롬프트 설정 | 온라인 프롬프트 편집, 검색 임계값 조정, 멀티턴 대화 동작 정밀 제어 |
| 웹 검색 | ✅ DuckDuckGo / Bing / Google (확장 가능) | 플러그인 방식 검색 엔진; 대화별 웹 검색 켜기/끄기 |
| MCP 도구 | ✅ uvx / npx 런처, Stdio / HTTP Streamable / SSE | MCP로 Agent 기능 확장; 안정적인 도구명 관리(충돌 방지); 도구 반환 이미지 VLM 자동 설명 |
| 추천 질문 | ✅ 지식베이스 기반 질문 추천 | Agent가 채팅 전 추천 질문 표시; 이미지 지식이 질문 생성 자동 트리거 |
| QA 역량 | ✅ 문맥 인식, 멀티턴 대화, 프롬프트 템플릿 | 복잡한 시맨틱 모델링, 지시 제어, 체인형 Q&A; 프롬프트/컨텍스트 윈도우 설정 |
| 보안 | ✅ AES-256-GCM 정적 암호화, SSRF 방어 | API 키 정적 암호화; 원격 API 호출 SSRF 안전 검증; Agent 스킬 샌드박스 실행 |
| E2E 테스트 | ✅ 검색+생성 과정 시각화 및 지표 평가 | 리콜 적중률, 답변 커버리지, BLEU/ROUGE 지표 평가 종단간 테스트 도구 |
| 배포 모드 | ✅ 로컬 / Docker / Kubernetes (Helm) | 프라이빗/오프라인 배포; 핫 리로드 고속 개발 모드; Kubernetes용 Helm Chart |
| 사용자 인터페이스 | ✅ Web UI + RESTful API | 상호작용 UI와 표준 API; Agent/일반 모드 전환; 도구 호출 과정 표시 |
| 작업 관리 | ✅ MQ 비동기 작업, 자동 DB 마이그레이션 | MQ 기반 비동기 작업 상태 유지; 버전 업그레이드 시 스키마/데이터 자동 마이그레이션 |
## 🚀 시작하기

View File

@@ -1 +1 @@
0.3.4
0.3.5

View File

@@ -25,14 +25,17 @@ templates:
### Workflow
1. **Analyze:** Understand the user's request.
2. **Plan:** If the task is complex, use todo_write to create a plan.
3. **Execute:** Use available tools to gather information or perform actions.
3. **Execute:** Use available tools (including any connected MCP tools) to gather information or perform actions.
After receiving tool results, analyze them and incorporate the findings into your answer.
4. **Synthesize:** Call the final_answer tool with your comprehensive answer. You MUST always end by calling final_answer.
### Tool Guidelines
* **MCP Tools:** If external MCP tools are available, use them to fulfill the user's request. Analyze and incorporate their results into your final answer.
* **web_search / web_fetch:** Use these if enabled to find information from the internet.
* **todo_write:** Use for managing multi-step tasks.
* **thinking:** Use to plan and reflect.
* **final_answer:** MANDATORY as your final action. Always submit your complete answer through this tool. NEVER end your turn without calling it.
If you cannot fully answer, explain what you tried and why. If the question is outside your capabilities, say so politely.
### User-Friendly Communication
In ALL outputs visible to users (including your thinking/reasoning), you MUST:

View File

@@ -186,6 +186,19 @@ services:
- jaeger
- full
dex:
image: dexidp/dex:latest
container_name: WeKnora-dex-dev
ports:
- "5556:5556"
volumes:
- ./misc/dex-config.yaml:/etc/dex/config.yaml
command: ["dex", "serve", "/etc/dex/config.yaml"]
profiles:
- dex
- full
networks:
WeKnora-network-dev:
driver: bridge

View File

@@ -340,6 +340,18 @@ services:
profiles:
- weaviate
dex:
image: dexidp/dex:latest
container_name: dex
ports:
- "5556:5556"
volumes:
- ./misc/dex-config.yaml:/etc/dex/config.yaml
command: ["dex", "serve", "/etc/dex/config.yaml"]
profiles:
- dex
- full
networks:
WeKnora-network:
driver: bridge

View File

@@ -1,6 +1,6 @@
# IM 集成开发文档
WeKnora 的 IM 集成模块将企业即时通讯平台企业微信、飞书、Slack、Telegram、钉钉接入 WeKnora 知识问答管道,支持在 IM 中直接向 AI 提问并获得实时流式回答。
WeKnora 的 IM 集成模块将企业即时通讯平台企业微信、飞书、Slack、Telegram、钉钉、Mattermost)接入 WeKnora 知识问答管道,支持在 IM 中直接向 AI 提问并获得实时流式回答。
IM 渠道绑定到 Agent一个 Agent 可接入多个 IM 渠道,所有配置通过前端 Agent 编辑器管理,存储在数据库中。
@@ -12,6 +12,7 @@ IM 渠道绑定到 Agent一个 Agent 可接入多个 IM 渠道,所有配置
- [Slack 接入](#slack-接入)
- [Telegram 接入](#telegram-接入)
- [钉钉接入](#钉钉接入)
- [Mattermost 接入](#mattermost-接入)
- [前端管理](#前端管理)
- [架构总览](#架构总览)
- [数据模型](#数据模型)
@@ -25,6 +26,7 @@ IM 渠道绑定到 Agent一个 Agent 可接入多个 IM 渠道,所有配置
- [Slack](#slack)
- [Telegram](#telegram)
- [钉钉 (DingTalk)](#钉钉-dingtalk)
- [Mattermost](#mattermost)
- [斜杠指令系统](#斜杠指令系统)
- [QA 队列与限流](#qa-队列与限流)
- [流式输出机制](#流式输出机制)
@@ -466,6 +468,49 @@ curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
2. **消息接收地址**:粘贴从 WeKnora 复制的回调地址
3. 钉钉会通过 HmacSHA256 签名验证回调请求
---
### Mattermost 接入
Mattermost 为**自建部署**,当前仅支持 **Webhook** 模式:**出站 WebhookOutgoing Webhook** 将用户消息 POST 到 WeKnoraBot 通过 **REST API v4** 发回频道/线程回复。与 Slack Events API 类似,回调需快速返回 `200`,实际回答由 Bot Token 异步调用接口完成。
> 需要公网或内网可达的回调 URLMattermost 服务器能访问 WeKnora 的 `/api/v1/im/callback/{channel_id}`)。若 WeKnora 在内网,请在 Mattermost 中配置 [Trusted Internal Connections](https://docs.mattermost.com/configure/environment-configuration-settings.html) 或将回调地址加入允许列表。
#### 第一步:创建 Bot 账户并获取 Token
1. 在 Mattermost 中创建专用 Bot 账户(或使用个人账户的 **Personal Access Token**,不推荐生产环境)。
2. 为 Bot 授予在目标频道/团队发消息、读取文件等所需权限(若需将用户上传的文件写入知识库,需能下载附件)。
3.**Profile → Security → Personal Access Tokens**(或 Bot 设置)生成 Token复制保存为 **Bot Token**
#### 第二步:创建 Outgoing Webhook
1. 打开 **产品菜单 → 集成 → 出站 Webhook**(若不可见,需系统管理员在 **系统控制台 → 集成** 中启用出站 Webhook
2. 点击 **Add Outgoing Webhook**,填写标题与描述。
3. **Content Type** 建议选择 **application/json**(也支持 `application/x-www-form-urlencoded`WeKnora 两种均可解析)。
4. 选择触发频道,或配置**触发词**(与频道组合规则以 Mattermost 说明为准)。
5. **Callback URLs** 先留空或填占位;保存后在 WeKnora 创建渠道后会得到正式地址。
6. 保存后复制页面上的 **Token**(出站 Webhook 密钥),即 **Outgoing Webhook Token**
#### 第三步:在 WeKnora 中添加 IM 渠道
1. 进入 Agent 编辑器 → **IM 集成****添加渠道**
2. 填写配置:
- **平台**选择「Mattermost」
- **接入模式**:固定为 **Webhook**(选择 Mattermost 后界面会禁用 WebSocket
- **输出模式**:流式输出 / 完整输出(流式通过编辑帖子实现)
- **Site URL**Mattermost 站点根地址,如 `https://mattermost.example.com`(无尾部 `/`
- **Bot Token**:上一步生成的 Token
- **Outgoing Webhook Token**:出站 Webhook 页面复制的 Token用于校验回调且参与 `bot_identity` 去重)
- **Bot User ID**可选Bot 在 Mattermost 中的用户 ID填写后可忽略 Bot 自身触发的回调,避免自回复循环
3. 保存后,复制渠道卡片上的 **回调地址**,回到 Mattermost 出站 Webhook 配置,将 **Callback URLs** 设为该地址并保存。
#### 第四步:验证
在绑定频道发送触发出站 Webhook 的消息,应收到 WeKnora 的回复;回复默认出现在**触发帖所在线程**(使用 Mattermost 的 `root_id` 与触发 `post_id` 对齐)。
**参考文档:** [Outgoing webhooks](https://developers.mattermost.com/integrate/webhooks/outgoing/)
---
## 前端管理
@@ -475,7 +520,7 @@ IM 渠道在 Agent 编辑器的 **IM 集成** 标签页中管理(仅编辑模
### 渠道列表
每个渠道以卡片形式展示,包含:
- **平台标识**:企业微信(绿色)/ 飞书(蓝色)/ Slack紫色/ Telegram蓝色/ 钉钉(蓝色)
- **平台标识**:企业微信(绿色)/ 飞书(蓝色)/ Slack紫色/ Telegram蓝色/ 钉钉(蓝色)/ Mattermost蓝色
- **渠道名称**:用户自定义
- **接入模式**WebSocket / Webhook
- **输出模式**:流式输出 / 完整输出
@@ -511,6 +556,17 @@ IM 渠道在 Agent 编辑器的 **IM 集成** 标签页中管理(仅编辑模
│ │ │ │ │ │ │
│ ─────┼─────────────┼─────────────┼──────────────┼─────────────┼──────── │
│ └─────────────┼─────────────┼──────────────┘─────────────┘ │
│ │ │
│ ┌────┴────────────┐ │
│ │ Mattermost │ Webhook-only │
│ └────────┬────────┘ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Mattermost │ │
│ │ Adapter │ │
│ └────────┬────────┘ │
│ │ │
│ ──────────────────┴────────────────────────────────────────────────────│
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ im.Service │ 服务编排层 │
@@ -559,7 +615,7 @@ CREATE TABLE im_channels (
id VARCHAR(36) PRIMARY KEY,
tenant_id BIGINT NOT NULL,
agent_id VARCHAR(36) NOT NULL, -- 绑定的 Agent ID
platform VARCHAR(20) NOT NULL, -- 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk'
platform VARCHAR(20) NOT NULL, -- 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost'
name VARCHAR(255) NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT true,
mode VARCHAR(20) NOT NULL DEFAULT 'websocket', -- 'webhook' | 'websocket'
@@ -587,6 +643,9 @@ CREATE TABLE im_channels (
| Telegram | Webhook | `bot_token`, `secret_token`(可选) |
| 钉钉 | WebSocket | `client_id`, `client_secret`, `card_template_id`(可选) |
| 钉钉 | Webhook | `client_id`, `client_secret`, `card_template_id`(可选) |
| Mattermost | Webhook唯一支持 | `site_url`, `bot_token`, `outgoing_token`(必填);`bot_user_id`(可选,过滤机器人自身消息) |
`mattermost` 渠道的 `mode` 在数据库中固定为 `webhook`(创建时若未指定,服务端与模型钩子会默认 `webhook`)。`bot_identity` 形如 `mattermost:wh:{outgoing_token}`,用于防止同一出站 Webhook 重复绑定多个渠道。
### im_channel_sessions 表
@@ -626,7 +685,7 @@ CREATE TABLE im_channels (
### IMChannel — IM 渠道
每个 IM 渠道代表一个 IM 平台机器人与 WeKnora Agent 的绑定关系。一个 Agent 可以绑定多个渠道(如同时接入企业微信、飞书Slack同一平台也可以创建多个渠道如不同的企业微信机器人
每个 IM 渠道代表一个 IM 平台机器人与 WeKnora Agent 的绑定关系。一个 Agent 可以绑定多个渠道(如同时接入企业微信、飞书Slack 与 Mattermost),同一平台也可以创建多个渠道(如不同的企业微信机器人)。
渠道有一个计算字段 `BotIdentity`,由平台类型、模式和核心凭证推导,用于防止同一机器人被重复创建。
@@ -638,7 +697,7 @@ CREATE TABLE im_channels (
```go
type IncomingMessage struct {
Platform Platform // "wecom" | "feishu" | "slack" | "telegram" | "dingtalk"
Platform Platform // "wecom" | "feishu" | "slack" | "telegram" | "dingtalk" | "mattermost"
MessageType MessageType // "text" | "file" | "image"
UserID string // 平台用户标识
UserName string // 显示名 (可选)
@@ -1127,6 +1186,59 @@ EndStream:
---
### Mattermost
仅支持 **Webhook** 模式Mattermost **Outgoing Webhook** 将请求体 POST 到 WeKnora 统一回调地址;适配器实现 `Adapter``StreamSender``FileDownloader`(标准库 HTTP 调用 REST API无第三方 SDK
#### Webhook 模式Outgoing Webhook
```
Mattermost 服务器 ──HTTP POST──▶ /api/v1/im/callback/{channel_id}
校验 body 中 token = outgoing_token
解析 JSON 或 x-www-form-urlencoded
通过 REST API v4 回复Bearer Bot Token
```
- **安全校验:** 请求体中的 `token` 必须与渠道凭证中的 `outgoing_token` 一致(创建渠道时工厂会校验 `outgoing_token` 非空)。
- **载荷格式:** 支持 `application/json``application/x-www-form-urlencoded`(与 [官方文档](https://developers.mattermost.com/integrate/webhooks/outgoing/) 一致)。
- **机器人过滤:** 若配置了 `bot_user_id`,且 `user_id` 与之相同,则返回 `nil` 消息,避免自激。
- **线程回复:** `IncomingMessage.Extra` 中保存 `thread_root_id`(有 `root_id` 时用其值,否则用触发帖 `post_id``SendReply` / `CreatePost` 时设置 `root_id`,使回复出现在同一线程。
- **消息去重:** `MessageID` 使用触发帖的 `post_id`
- **文件消息:** 若载荷含 `file_ids`,取首个文件 ID 作为 `FileKey``DownloadFile``GET /api/v4/files/{id}/info``GET /api/v4/files/{id}` 下载内容。
#### 流式回复
与 Slack 类似,通过**编辑同一条帖子**展示累积全文:
```
StartStream:
1. POST /api/v4/posts → 创建占位帖(如「正在思考...」),得到 post id
SendStreamChunk:
2. PUT /api/v4/posts/{post_id}/patch → Patch message 字段(累积全文)
EndStream:
3. 最后一次 Patch与 SendStreamChunk 相同逻辑
```
流式刷新间隔由 Service 侧 `streamFlushInterval`300ms批量合并以降低编辑频率、减轻 API 压力。
#### URL 验证
Mattermost 出站 Webhook 无 Slack/Feishu 类 challenge 流程,`HandleURLVerification` 恒为 `false`
#### 源码文件
| 文件 | 职责 |
|------|------|
| `internal/im/mattermost/adapter.go` | 出站 Webhook 解析、Token 校验、发帖/补丁流式、文件下载 |
| `internal/im/mattermost/client.go` | REST v4`CreatePost``PatchPostMessage`、文件 info/下载 |
| `internal/im/mattermost/form_parse.go` | 表单编码 body 与 `file_ids` 辅助解析 |
---
## 斜杠指令系统
IM 渠道支持斜杠指令Slash Commands用户在聊天中输入 `/指令名` 即可触发,无需经过 QA 管道,且不受限流约束。
@@ -1300,6 +1412,7 @@ QA 管道 ──chunk──chunk──chunk──▶ EventBus
| 企业微信 Webhook | PicUrl 直接下载 或 MediaId 临时素材 API |
| 企业微信 WebSocket | 加密附件 URL + per-message AES 密钥解密 |
| Telegram | getFile API 获取文件路径 + HTTPS 下载(支持文档和图片) |
| Mattermost | `GET /api/v4/files/{file_id}/info` + `GET /api/v4/files/{file_id}`Bearer Bot Token |
---

View File

@@ -0,0 +1,642 @@
# WeKnora OIDC 认证调用流程
本文档说明 WeKnora 当前 OIDC 登录能力的实际调用过程,覆盖:
- 前端如何判断是否展示 OIDC 登录入口
- 用户点击 OIDC 登录后的前后端调用链路
- 后端如何与 OIDC Provider 交互
- 登录成功后前端如何接收结果并落盘本地登录态
- 关键配置项与本地联调方式
本文内容基于当前项目实现,相关代码主要位于:
- 后端路由:`internal/router/router.go`
- 认证处理:`internal/handler/auth.go`
- 认证服务:`internal/application/service/user.go`
- 配置定义:`internal/config/config.go`
- 前端登录页:`frontend/src/views/auth/Login.vue`
- 前端全局回调处理:`frontend/src/App.vue`
- 前端认证 API`frontend/src/api/auth/index.ts`
- 本地 Dex 示例:`misc/dex-config.yaml`
---
## 1. 整体设计说明
本项目的 OIDC 登录采用的是 **后端发起授权参数生成、后端接收回调并完成 code 换 token、前端通过 URL hash 接收最终登录结果** 的模式。
和常见的纯前端 OIDC SDK 不同WeKnora 的特点是:
1. **前端只负责发起跳转**,不直接和 OIDC Provider 交换 token。
2. **后端负责用授权码 `code` 向 OIDC Provider 换取 token**
3. 后端拿到 OIDC 用户信息后,会:
- 查找本地用户;
- 若用户不存在,则自动创建本地账号和默认租户;
- 最终签发 WeKnora 自己的本地 JWT`token` / `refresh_token`)。
4. 后端不会直接把登录结果放在 query string 中,而是:
- 将结果 JSON 序列化后再做 base64url 编码;
-`#oidc_result=...` 的形式重定向回前端;
- 前端在 `App.vue` 中统一解析 hash完成登录态持久化。
因此,**OIDC Provider 的 token 只用于后端换取用户身份,本项目真正的业务访问凭证仍然是 WeKnora 自己签发的 JWT**。
---
## 2. 相关接口
当前 OIDC 相关接口均注册在 `internal/router/router.go` 中:
- `GET /api/v1/auth/oidc/config`
- 获取 OIDC 是否启用,以及 Provider 展示名称
- `GET /api/v1/auth/oidc/url`
- 生成第三方登录跳转地址
- `GET /api/v1/auth/oidc/callback`
- OIDC Provider 回调地址
其中,前端常规登录仍然使用:
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/refresh`
- `POST /api/v1/auth/logout`
- `GET /api/v1/auth/me`
---
## 3. 调用流程图
### 3.1 总体时序图
```mermaid
sequenceDiagram
autonumber
participant U as 用户浏览器
participant FE as 前端(Login/App)
participant BE as WeKnora 后端
participant OP as OIDC Provider
FE->>BE: GET /api/v1/auth/oidc/config
BE-->>FE: { enabled, provider_display_name }
U->>FE: 点击“OIDC 登录”
FE->>BE: GET /api/v1/auth/oidc/url?redirect_uri=...
BE-->>FE: { success, authorization_url, state }
FE->>OP: 浏览器跳转到 authorization_url
OP-->>BE: GET /api/v1/auth/oidc/callback?code=...&state=...
BE->>OP: POST token endpoint (code 换 token)
OP-->>BE: access_token / id_token
BE->>OP: GET userinfo endpoint可选
OP-->>BE: 用户信息 claims
BE->>BE: 查找/自动创建本地用户
BE->>BE: 签发本地 token、refresh_token
BE-->>FE: 302 到 /#oidc_result=...
FE->>FE: App.vue 解析 hash
FE->>FE: 写入 authStore/token/tenant
FE-->>U: 跳转 /platform/knowledge-bases
```
### 3.2 后端回调处理分支图
```mermaid
flowchart TD
A[OIDC Provider 回调到 /api/v1/auth/oidc/callback] --> B{query 中是否有 error}
B -- 是 --> C[拼接 #oidc_error 和 error_description]
C --> D[302 重定向到前端登录页]
B -- 否 --> E[解析 state]
E --> F{state 是否合法且包含 redirect_uri}
F -- 否 --> G[302 到前端并带 #oidc_error=invalid_state]
F -- 是 --> H{是否有 code}
H -- 否 --> I[302 到前端并带 #oidc_error=missing_code]
H -- 是 --> J[调用 LoginWithOIDC传入 code 和 redirect_uri]
J --> K[向 OIDC token endpoint 换 token]
K --> L[解析 id_token / 调用 userinfo]
L --> M{本地用户是否存在}
M -- 否 --> N[自动注册新用户并创建默认租户]
M -- 是 --> O[使用现有用户]
N --> P[签发 WeKnora JWT]
O --> P
P --> Q[编码为 oidc_result]
Q --> R[302 重定向到前端首页 hash]
```
---
## 4. 前端调用流程
## 4.1 登录页初始化:决定是否展示 OIDC 登录按钮
登录页组件位于 `frontend/src/views/auth/Login.vue`
页面加载时会执行:
```ts
loadOIDCConfig()
```
该函数调用:
```ts
getOIDCConfig() -> GET /api/v1/auth/oidc/config
```
后端 `GetOIDCConfig` 会读取 `configInfo.OIDCAuth`
- `enabled`: 是否启用 OIDC
- `provider_display_name`: 登录按钮上展示的供应商名称
前端据此决定:
- 是否显示 OIDC 登录按钮;
- 按钮文案是否显示为“使用 XXX 登录”。
---
## 4.2 用户点击 OIDC 登录按钮
用户点击按钮后,`Login.vue` 中会执行 `handleOIDCLogin()`
核心逻辑:
1. 前端先构造后端回调地址:
```ts
const getBackendOIDCRedirectURI = () => `${window.location.origin}/api/v1/auth/oidc/callback`
```
其中:
- `redirect_uri`:提供给 OIDC Provider 的回调地址,必须是后端地址。
登录成功后,后端固定回跳前端首页 `/`,并通过 hash 传递 OIDC 结果。
2. 前端调用:
```ts
GET /api/v1/auth/oidc/url?redirect_uri=...
```
3. 后端返回 `authorization_url` 后,前端直接执行:
```ts
window.location.href = authorizationURL
```
浏览器随后离开 WeKnora 页面,跳转到 OIDC Provider 的授权页。
---
## 5. 后端生成授权地址
对应处理器:`AuthHandler.GetOIDCAuthorizationURL`
对应服务:`userService.GetOIDCAuthorizationURL`
### 5.1 参数校验
后端要求以下参数必须存在:
- `redirect_uri`
否则直接返回校验错误。
### 5.2 读取 OIDC 配置
`getOIDCConfig()` 会执行以下逻辑:
1. 检查 `OIDCAuth.Enable` 是否为 `true`
2. 设置默认值:
- `ProviderDisplayName` 默认是 `OIDC`
- `Scopes` 默认是 `openid profile email`
- `UserInfoMapping.Username` 默认是 `name`
- `UserInfoMapping.Email` 默认是 `email`
3. 若未显式配置授权/令牌端点,则通过 `discovery_url` 拉取 OIDC Discovery 文档;
4. 自动补齐:
- `authorization_endpoint`
- `token_endpoint`
- `userinfo_endpoint`
### 5.3 生成 state
后端不会把 state 只当成随机串,而是编码了一个 JSON 结构:
```json
{
"nonce": "随机字符串",
"redirect_uri": "后端回调地址"
}
```
然后再做 base64url 编码,作为 `state` 传给 Provider。
这样在 OIDC Provider 回调时,后端就可以从 `state` 里还原:
- 本次使用的后端 `redirect_uri`
### 5.4 拼接授权地址
后端最终拼接的参数包含:
- `response_type=code`
- `client_id`
- `redirect_uri`
- `scope`
- `state`
然后返回给前端:
```json
{
"success": true,
"provider_display_name": "Dex",
"authorization_url": "...",
"state": "..."
}
```
---
## 6. OIDC Provider 回调到后端
OIDC Provider 完成认证后,会回调:
```text
GET /api/v1/auth/oidc/callback
```
处理器为 `AuthHandler.OIDCRedirectCallback`
### 6.1 Provider 返回错误时
如果 query 中带有:
- `error`
- `error_description`
后端不会返回 JSON而是直接 302 到前端首页 `/`,并带上 hash
```text
#oidc_error=...&oidc_error_description=...
```
### 6.2 解析 state
后端会把 `state` 做 base64url 解码并解析为结构体。
如果出现以下情况,将判定失败:
- `state` 无法解码
- JSON 结构非法
- `state.redirect_uri` 为空
失败时会重定向到前端首页:
```text
#oidc_error=invalid_state
```
### 6.3 校验 code
如果没有收到 `code`,则重定向:
```text
#oidc_error=missing_code
```
### 6.4 正式执行 OIDC 登录
`state``code` 都合法,则调用:
```go
LoginWithOIDC(ctx, code, decodedState.RedirectURI)
```
注意这里传入的是 **state 中保存的 redirect_uri**,而不是重新拼接的地址,这样保证了 code 交换时使用的 `redirect_uri` 和授权时完全一致。
---
## 7. 后端用 code 换 token 并解析用户身份
核心逻辑位于 `internal/application/service/user.go`
## 7.1 换取 OIDC token
`exchangeOIDCCode()` 会向 OIDC Provider 的 `token_endpoint` 发起:
```text
POST application/x-www-form-urlencoded
```
表单参数包括:
- `grant_type=authorization_code`
- `code`
- `redirect_uri`
- `client_id`
- `client_secret`
期望返回字段:
- `access_token`
- `id_token`
- `token_type`
如果 `access_token``id_token` 都缺失,则认为失败。
## 7.2 解析用户信息
`resolveOIDCUserInfo()` 的处理顺序是:
1. 如果有 `id_token`,先本地解码 JWT payload提取 claims
2. 如果配置了 `userinfo_endpoint` 且有 `access_token`,再调用 userinfo 接口;
3. 将两部分 claims 合并userinfo 的字段可覆盖前面已取到的字段;
4. 根据配置的 `user_info_mapping` 提取:
- 用户名字段
- 邮箱字段
默认映射:
- `username -> name`
- `email -> email`
另外还有回退逻辑:
1. 若 username 为空,尝试 `preferred_username`
2. 再尝试 `name`
3. 再尝试从邮箱前缀生成用户名
如果最终没有拿到邮箱,则直接报错,因为本地用户是按 email 关联的。
---
## 8. 本地用户关联与自动开通
### 8.1 通过邮箱查找用户
后端使用 OIDC 返回的邮箱执行:
```go
userRepo.GetUserByEmail(ctx, userInfo.Email)
```
### 8.2 用户不存在时自动注册
若本地不存在该邮箱用户,则调用 `provisionOIDCUser()` 自动创建账号。
自动创建逻辑包括:
1. 根据 OIDC 用户名/邮箱生成本地用户名;
2. 若用户名冲突,则自动追加 `-1``-2` 等后缀;
3. 生成随机密码;
4. 调用现有 `Register()` 流程创建用户;
5. `Register()` 内部还会自动创建默认租户。
因此,**首次使用 OIDC 登录的用户,不需要提前在 WeKnora 中手工建号**。
### 8.3 用户禁用处理
如果找到的本地用户 `IsActive=false`,则登录失败,回调给前端的错误信息为:
```text
Account is disabled
```
---
## 9. 生成 WeKnora 本地登录态
OIDC 登录成功后,后端不会直接把 OIDC token 交给前端使用,而是继续执行:
```go
GenerateTokens(ctx, user)
```
生成两类 JWT
- `token`:访问令牌,默认 24 小时
- `refresh_token`:刷新令牌,默认 7 天
并写入本地 `auth_tokens` 存储(通过 `tokenRepo.CreateToken`)。
最终后端还会查询当前用户所属租户,并返回:
- `user`
- `tenant`
- `token`
- `refresh_token`
- `is_new_user`
这一步意味着:
> OIDC 只负责“确认你是谁”WeKnora 自己负责“签发系统内可用的业务令牌”。
---
## 10. 后端如何把结果传回前端
`OIDCRedirectCallback` 在拿到 `OIDCCallbackResponse` 后,会执行:
1. 将响应对象 JSON 序列化;
2. 用 base64url 编码;
3. 302 重定向到:
```text
/#oidc_result=ENCODED_PAYLOAD
```
失败时则返回:
```text
/#oidc_error=...&oidc_error_description=...
```
这里使用 hash 的好处是:
- 不会把结果作为 query 参数再次发送给服务端;
- 前端可以在浏览器本地直接读取并清理;
- 避免刷新时重复向后端暴露这些参数。
---
## 11. 前端如何消费 OIDC 回调结果
前端不是在 `Login.vue` 中处理回调,而是在 `frontend/src/App.vue` 中统一处理。
这样即使后端把用户重定向到 `/`,应用根组件也能接住这次 OIDC 登录结果。
## 11.1 App.vue 解析 hash
应用挂载时执行:
```ts
handleGlobalOIDCCallback()
```
它会读取:
```ts
window.location.hash
```
并解析以下字段:
- `oidc_error`
- `oidc_error_description`
- `oidc_result`
## 11.2 错误分支
如果存在 `oidc_error`
1. 调用 `clearOIDCCallbackState('/login')` 清理 URL
2. 跳转到 `/login`
3. 弹出错误消息。
## 11.3 成功分支
如果存在 `oidc_result`
1. base64url 解码并反序列化;
2. 如果 `response.success=true`
- 写入 `authStore.setUser(...)`
- 写入 `authStore.setToken(...)`
- 写入 `authStore.setRefreshToken(...)`
- 写入 `authStore.setTenant(...)`
3. 最终跳转到:
```text
/platform/knowledge-bases
```
这与普通账号密码登录成功后的持久化逻辑保持一致。
---
## 12. 关键配置项
OIDC 配置定义位于 `internal/config/config.go`,环境变量示例见 `.env.example`
### 12.1 主要配置项
| 配置项 | 说明 |
|---|---|
| `OIDC_AUTH_ENABLE` | 是否启用 OIDC 登录 |
| `OIDC_AUTH_ISSUER_URL` | Issuer 地址,可用于自动拼 discovery URL |
| `OIDC_AUTH_DISCOVERY_URL` | OIDC Discovery 地址 |
| `OIDC_AUTH_PROVIDER_DISPLAY_NAME` | 前端按钮显示名称 |
| `OIDC_AUTH_CLIENT_ID` | OIDC Client ID |
| `OIDC_AUTH_CLIENT_SECRET` | OIDC Client Secret |
| `OIDC_AUTH_AUTHORIZATION_ENDPOINT` | 授权端点,可选 |
| `OIDC_AUTH_TOKEN_ENDPOINT` | Token 端点,可选 |
| `OIDC_AUTH_USER_INFO_ENDPOINT` | UserInfo 端点,可选 |
| `OIDC_AUTH_SCOPES` | Scope 列表,默认 `openid profile email` |
| `OIDC_USER_INFO_MAPPING_USER_NAME` | claims 中映射到用户名的字段名 |
| `OIDC_USER_INFO_MAPPING_EMAIL` | claims 中映射到邮箱的字段名 |
### 12.2 启用时的最小要求
`OIDC_AUTH_ENABLE=true` 时,后端校验要求:
1. 必须有 `client_id`
2. 必须有 `client_secret`
3. 必须满足以下二选一:
- 配置 `discovery_url`
- 或同时配置 `authorization_endpoint + token_endpoint`
---
## 13. 本地联调示例Dex
[Dex](https://dexidp.io/) 是一个简单易用的OIDC Provider您可以通过它对接多种第三方认证系统如OAuth2.0GoogleGitHubLDAP等。除了Dex之外您也可以选择[KeyCloak](https://www.keycloak.org/)等其他符合OpenID Connect协议的Provider进行接入。
项目中已提供 Dex 示例配置:`misc/dex-config.yaml`
其中静态客户端配置示例:
```yaml
staticClients:
- id: weknora
redirectURIs:
- 'http://127.0.0.1:5173/api/v1/auth/oidc/callback'
- 'http://127.0.0.1/api/v1/auth/oidc/callback'
name: 'WeKnora'
# secret: <YOUR_SECRET_HERE>
```
这说明本地调试时,需要确保 **Provider 注册的 redirect URI 与前端实际传给后端的 `redirect_uri` 完全一致**
前端当前实现中使用的是:
```ts
${window.location.origin}/api/v1/auth/oidc/callback
```
所以:
- 若前端从 `http://127.0.0.1:5173` 访问,则 redirect URI 为
`http://127.0.0.1:5173/api/v1/auth/oidc/callback`
- 若通过 Nginx 统一入口访问,则可能是
`http://127.0.0.1/api/v1/auth/oidc/callback`
Provider 必须提前把这些地址加入白名单。
---
## 14. 调用链路总结
可以把当前 OIDC 登录理解为以下 4 个阶段:
### 阶段一:前端发现能力
前端调用 `/auth/oidc/config`,决定是否展示第三方登录入口。
### 阶段二:浏览器跳转授权
前端调用 `/auth/oidc/url` 获取授权地址,然后跳转到 OIDC Provider。
### 阶段三:后端完成身份兑换
Provider 回调后端 `/auth/oidc/callback`,后端用 `code` 换 token、拉取用户信息、关联或创建本地用户并签发 WeKnora JWT。
### 阶段四:前端接收最终结果
后端 302 回前端,并通过 `#oidc_result` 传递登录结果;前端在 `App.vue` 中统一解析,写入本地登录态并进入业务页面。
---
## 15. 注意事项
1. **`redirect_uri` 必须严格匹配** Provider 客户端配置。
2. **前端首页不是 OIDC Provider 回调地址**
- Provider 回调的是后端 `/api/v1/auth/oidc/callback`
- 后端再固定重定向到前端首页 `/`
3. **邮箱是本地账号关联主键**
- 若 Provider 没返回 email将无法完成登录。
4. **首次 OIDC 登录会自动创建用户和默认租户**
5. **真正用于访问 WeKnora API 的仍是本地 JWT**,不是 OIDC access token。
6. 当前实现对 `state` 做了编码封装,但 **没有服务端持久化 state/nonce 校验**;它主要用于传递上下文和基本防错,而不是完整的防重放机制。
---
## 16. 相关源码定位
- 前端是否展示 OIDC 登录按钮:
- `frontend/src/views/auth/Login.vue`
- 前端获取授权地址:
- `frontend/src/api/auth/index.ts`
- `frontend/src/views/auth/Login.vue`
- 前端解析回调 hash
- `frontend/src/App.vue`
- OIDC 接口路由注册:
- `internal/router/router.go`
- OIDC HTTP 处理:
- `internal/handler/auth.go`
- OIDC 业务逻辑:
- `internal/application/service/user.go`
- OIDC 配置结构与环境变量覆盖:
- `internal/config/config.go`
- Dex 本地示例:
- `misc/dex-config.yaml`

View File

@@ -142,6 +142,24 @@ environment:
- 仅支持 **只读查询**,包括 `SELECT`, `SHOW`, `DESCRIBE`, `EXPLAIN`, `PRAGMA` 等语句。
- 禁止执行任何修改数据的操作,如 `INSERT`, `UPDATE`, `DELETE`, `CREATE`, `DROP` 等。
## 7. 页面里刚保存的配置几秒后又消失了?
这类问题通常不是配置真的被系统清掉了,而是浏览器代理、缓存或插件干扰导致前端读到了异常响应,页面随后又被旧状态覆盖。
建议按下面顺序排查:
1. 先关闭浏览器代理、抓包工具、自动改写请求的插件,再重新打开页面。
2. 确认浏览器没有把 `localhost` 或当前访问域名走代理;如果配置了 PAC请将 `localhost``127.0.0.1` 和实际部署域名加入直连名单。
3. 强制刷新页面,或直接使用无痕窗口重新登录后再保存一次配置。
4. 打开浏览器开发者工具的 `Network` 面板,确认保存配置相关请求返回的是最新内容,且没有被代理改写、缓存命中或重定向到其他环境。
5. 如果是调试模式部署,可尝试重启 `app` 服务后再验证一次:
```bash
docker compose restart app
```
如果重启后短时间恢复正常,但再次访问又出现相同现象,仍应优先检查浏览器代理、缓存和多环境串连问题,而不是直接判断为后端配置丢失。
## P.S.
如果以上方式未解决问题请在issue中描述您的问题并提供必要的日志信息辅助我们进行问题排查

View File

@@ -1,12 +1,12 @@
{
"name": "knowledage-base",
"version": "0.1.3",
"version": "0.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "knowledage-base",
"version": "0.1.3",
"version": "0.3.4",
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@types/dompurify": "^3.0.5",
@@ -16,7 +16,7 @@
"docx-preview": "^0.3.7",
"dompurify": "^3.2.6",
"highlight.js": "^11.11.1",
"marked": "^5.1.2",
"marked": "^17.0.5",
"mermaid": "^11.4.1",
"pagefind": "^1.1.1",
"papaparse": "^5.5.3",
@@ -33,7 +33,6 @@
},
"devDependencies": {
"@tsconfig/node22": "^22.0.1",
"@types/marked": "^5.0.2",
"@types/node": "^22.14.0",
"@vitejs/plugin-vue": "6.0.0",
"@vitejs/plugin-vue-jsx": "5.0.1",
@@ -1806,13 +1805,6 @@
"@types/lodash": "*"
}
},
"node_modules/@types/marked": {
"version": "5.0.2",
"resolved": "https://mirrors.tencent.com/npm/@types/marked/-/marked-5.0.2.tgz",
"integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.16.3",
"resolved": "https://mirrors.tencent.com/npm/@types/node/-/node-22.16.3.tgz",
@@ -3190,10 +3182,9 @@
}
},
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://mirrors.tencent.com/npm/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"version": "3.3.3",
"resolved": "https://mirrors.tencent.com/npm/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -3956,15 +3947,14 @@
}
},
"node_modules/marked": {
"version": "5.1.2",
"resolved": "https://mirrors.tencent.com/npm/marked/-/marked-5.1.2.tgz",
"integrity": "sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==",
"license": "MIT",
"version": "17.0.5",
"resolved": "https://mirrors.tencent.com/npm/marked/-/marked-17.0.5.tgz",
"integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 16"
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
@@ -4260,9 +4250,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://mirrors.tencent.com/npm/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://mirrors.tencent.com/npm/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { MessagePlugin } from 'tdesign-vue-next'
import ManualKnowledgeEditor from '@/components/manual-knowledge-editor.vue'
import { useAuthStore } from '@/stores/auth'
// TDesign locale configs
import enUSConfig from 'tdesign-vue-next/esm/locale/en_US'
@@ -10,6 +13,8 @@ import koKRConfig from 'tdesign-vue-next/esm/locale/ko_KR'
import ruRUConfig from 'tdesign-vue-next/esm/locale/ru_RU'
const { locale } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
const tdLocaleMap: Record<string, object> = {
'en-US': enUSConfig,
@@ -19,6 +24,97 @@ const tdLocaleMap: Record<string, object> = {
}
const tdGlobalConfig = computed(() => tdLocaleMap[locale.value] || enUSConfig)
const decodeOIDCResult = (encoded: string) => {
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
const binary = window.atob(padded)
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes))
}
const clearOIDCCallbackState = (path = '/') => {
window.history.replaceState({}, document.title, path)
}
const persistOIDCLoginResponse = async (response: any) => {
if (response.user && response.tenant && response.token) {
authStore.setUser({
id: response.user.id || '',
username: response.user.username || '',
email: response.user.email || '',
avatar: response.user.avatar,
tenant_id: String(response.tenant.id) || '',
can_access_all_tenants: response.user.can_access_all_tenants || false,
created_at: response.user.created_at || new Date().toISOString(),
updated_at: response.user.updated_at || new Date().toISOString()
})
authStore.setToken(response.token)
if (response.refresh_token) {
authStore.setRefreshToken(response.refresh_token)
}
authStore.setTenant({
id: String(response.tenant.id) || '',
name: response.tenant.name || '',
api_key: response.tenant.api_key || '',
owner_id: response.user.id || '',
created_at: response.tenant.created_at || new Date().toISOString(),
updated_at: response.tenant.updated_at || new Date().toISOString()
})
}
await nextTick()
router.replace('/platform/knowledge-bases')
}
const handleGlobalOIDCCallback = async () => {
const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : ''
if (!hash) return
const params = new URLSearchParams(hash)
const oidcError = params.get('oidc_error')
const oidcErrorDescription = params.get('oidc_error_description')
const oidcResult = params.get('oidc_result')
if (!oidcError && !oidcResult) return
if (oidcError) {
clearOIDCCallbackState('/login')
await router.replace('/login')
MessagePlugin.error(oidcErrorDescription || 'OIDC login failed')
return
}
try {
if (!oidcResult) {
clearOIDCCallbackState('/login')
await router.replace('/login')
MessagePlugin.error('OIDC login failed')
return
}
const response = decodeOIDCResult(oidcResult)
if (response.success) {
clearOIDCCallbackState('/')
MessagePlugin.success('Login successful')
await persistOIDCLoginResponse(response)
return
}
clearOIDCCallbackState('/login')
await router.replace('/login')
MessagePlugin.error(response.message || 'OIDC login failed')
} catch (error: any) {
console.error('Global OIDC callback handling failed:', error)
clearOIDCCallbackState('/login')
await router.replace('/login')
MessagePlugin.error(error.message || 'OIDC login failed')
}
}
onMounted(() => {
handleGlobalOIDCCallback()
})
</script>
<template>
<t-config-provider :globalConfig="tdGlobalConfig">

View File

@@ -182,7 +182,7 @@ export interface IMChannel {
id: string;
tenant_id?: number;
agent_id: string;
platform: 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk';
platform: 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost';
name: string;
enabled: boolean;
mode: 'webhook' | 'websocket';

View File

@@ -39,6 +39,20 @@ export interface LoginResponse {
refresh_token?: string
}
export interface OIDCAuthURLResponse {
success: boolean
authorization_url?: string
state?: string
message?: string
}
export interface OIDCConfigResponse {
success: boolean
enabled: boolean
provider_display_name?: string
message?: string
}
// 用户注册接口
export interface RegisterRequest {
username: string
@@ -130,6 +144,37 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
}
}
/**
* 获取 OIDC 登录跳转地址
*/
export async function getOIDCAuthorizationURL(redirectURI: string): Promise<OIDCAuthURLResponse> {
try {
const response = await get(`/api/v1/auth/oidc/url?redirect_uri=${encodeURIComponent(redirectURI)}`)
return response as unknown as OIDCAuthURLResponse
} catch (error: any) {
return {
success: false,
message: error.message || t('error.auth.loginFailed')
}
}
}
/**
* 获取 OIDC 登录配置
*/
export async function getOIDCConfig(): Promise<OIDCConfigResponse> {
try {
const response = await get('/api/v1/auth/oidc/config')
return response as unknown as OIDCConfigResponse
} catch (error: any) {
return {
success: false,
enabled: false,
message: error.message || t('error.auth.loginFailed')
}
}
}
/**
* 用户注册
*/

View File

@@ -91,6 +91,7 @@
<t-radio-button value="slack">{{ $t('agentEditor.im.slack') }}</t-radio-button>
<t-radio-button value="telegram">{{ $t('agentEditor.im.telegram') }}</t-radio-button>
<t-radio-button value="dingtalk">{{ $t('agentEditor.im.dingtalk') }}</t-radio-button>
<t-radio-button value="mattermost">{{ $t('agentEditor.im.mattermost') }}</t-radio-button>
</t-radio-group>
</div>
@@ -104,10 +105,11 @@
<div class="form-item">
<label class="form-label">{{ $t('agentEditor.im.mode') }}</label>
<t-radio-group v-model="formData.mode">
<t-radio-button value="websocket">WebSocket</t-radio-button>
<t-radio-button value="websocket" :disabled="formData.platform === 'mattermost'">WebSocket</t-radio-button>
<t-radio-button value="webhook">Webhook</t-radio-button>
</t-radio-group>
<p class="form-hint">{{ $t('agentEditor.im.modeHint') }}</p>
<p v-if="formData.platform === 'mattermost'" class="form-hint">{{ $t('agentEditor.im.mattermostModeHint') }}</p>
<p v-else class="form-hint">{{ $t('agentEditor.im.modeHint') }}</p>
</div>
<!-- Output mode -->
@@ -283,13 +285,50 @@
<p class="form-hint">{{ $t('agentEditor.im.dingtalkCardTemplateIdHint') }}</p>
</div>
</template>
<!-- Mattermost credentials -->
<template v-if="formData.platform === 'mattermost'">
<div class="platform-link-hint">
<t-icon name="jump" class="hint-link-icon" />
<a href="https://developers.mattermost.com/integrate/webhooks/outgoing/" target="_blank" rel="noopener noreferrer" class="hint-link">
{{ $t('agentEditor.im.mattermostConsole') }}
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
</div>
<div class="form-item">
<label class="form-label">Site URL</label>
<t-input v-model="formData.credentials.site_url" placeholder="https://mattermost.example.com" />
</div>
<div class="form-item">
<label class="form-label">Bot Token</label>
<t-input v-model="formData.credentials.bot_token" type="password" placeholder="Bot Token" />
</div>
<div class="form-item">
<label class="form-label">Outgoing Webhook Token</label>
<t-input v-model="formData.credentials.outgoing_token" type="password" placeholder="Token from Outgoing Webhook" />
</div>
<div class="form-item">
<label class="form-label">Bot User ID</label>
<t-input v-model="formData.credentials.bot_user_id" placeholder="Optional — filter bot self-messages" />
</div>
<div class="form-item mattermost-post-main-row">
<div class="mattermost-post-main-label">
<label class="form-label">{{ $t('agentEditor.im.mattermostPostToMain') }}</label>
<t-switch
:value="!!formData.credentials.post_to_main"
@change="(v: boolean) => { formData.credentials.post_to_main = v }"
/>
</div>
<p class="form-hint">{{ $t('agentEditor.im.mattermostPostToMainHint') }}</p>
</div>
</template>
</div>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { MessagePlugin } from 'tdesign-vue-next';
import { listIMChannels, createIMChannel, updateIMChannel, deleteIMChannel, toggleIMChannel } from '@/api/agent';
@@ -313,7 +352,7 @@ const knowledgeBases = ref<{ id: string; name: string }[]>([]);
const defaultCredentials = (): Record<string, any> => ({});
const formData = ref({
platform: 'wecom' as 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk',
platform: 'wecom' as 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost',
name: '',
mode: 'websocket' as 'webhook' | 'websocket',
output_mode: 'stream' as 'stream' | 'full',
@@ -326,6 +365,18 @@ function platformLabel(platform: string): string {
return t(key);
}
watch(
() => formData.value.platform,
(p) => {
if (p === 'mattermost') {
formData.value.mode = 'webhook';
if (typeof formData.value.credentials.post_to_main !== 'boolean') {
formData.value.credentials.post_to_main = false;
}
}
},
);
async function loadChannels() {
loading.value = true;
try {
@@ -343,16 +394,29 @@ async function loadChannels() {
}
function getCallbackUrl(channel: IMChannel): string {
const base = window.location.origin;
const base = import.meta.env.VITE_IS_DOCKER ? window.location.origin : 'http://127.0.0.1:8080';
return `${base}/api/v1/im/callback/${channel.id}`;
}
async function copyUrl(channel: IMChannel) {
const text = getCallbackUrl(channel);
try {
await navigator.clipboard.writeText(getCallbackUrl(channel));
await navigator.clipboard.writeText(text);
MessagePlugin.success(t('common.copySuccess'));
} catch {
MessagePlugin.error(t('common.copyFailed'));
const el = document.createElement('textarea');
el.value = text;
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0';
document.body.appendChild(el);
el.focus();
el.select();
const ok = document.execCommand('copy');
document.body.removeChild(el);
if (ok) {
MessagePlugin.success(t('common.copySuccess'));
} else {
MessagePlugin.error(t('common.copyFailed'));
}
}
}
@@ -572,6 +636,11 @@ onMounted(() => {
background: rgba(23, 126, 251, 0.08);
color: #177efb;
}
&.mattermost {
background: rgba(25, 42, 77, 0.08);
color: #192a4d;
}
}
.channel-name {
@@ -661,6 +730,20 @@ onMounted(() => {
}
}
.mattermost-post-main-row {
.mattermost-post-main-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.form-label {
margin-bottom: 0;
flex: 1;
}
}
}
.form-divider {
height: 1px;
background: var(--td-component-stroke);

View File

@@ -172,7 +172,7 @@ const checkImage = (url) => {
img.src = url;
});
};
renderer.image = function (href: string, title: string | null, text: string) {
renderer.image = function ({href, title, text}) {
if (!isValidImageURL(href)) {
return `<p>${t('error.invalidImageLink')}</p>`;
}
@@ -185,7 +185,7 @@ renderer.image = function (href: string, title: string | null, text: string) {
};
// 自定义代码块渲染器,只显示语言标签
renderer.code = function (text: string, lang?: string) {
renderer.code = function ({text, lang}) {
// Mermaid 图表处理
if (lang === 'mermaid') {
// 生成唯一ID

View File

@@ -197,7 +197,7 @@ async function renderMarkdown(blob: Blob) {
gfm: true,
});
const renderer = new marked.Renderer();
renderer.code = function (text: string, lang?: string) {
renderer.code = function ({text, lang}) {
let highlighted = '';
if (lang && hljs.getLanguage(lang)) {
try { highlighted = hljs.highlight(text, { language: lang }).value; }

View File

@@ -1027,6 +1027,12 @@ export default {
auth: {
login: 'Login',
logout: 'Logout',
oidcLogin: 'Sign in with OIDC',
oidcLoginWithProvider: 'Sign in with {provider}',
redirectingToOIDC: 'Redirecting to identity provider...',
orContinueWith: 'Or continue with',
oidcLoginFailed: 'OIDC login failed',
oidcStateMismatch: 'OIDC state verification failed, please try again',
username: 'Username',
email: 'Email',
password: 'Password',
@@ -3208,12 +3214,13 @@ export default {
},
im: {
title: 'IM Integration',
description: 'Connect agent to instant messaging platforms like WeCom, Feishu, Slack, Telegram and DingTalk',
description: 'Connect agent to instant messaging platforms like WeCom, Feishu, Slack, Telegram, DingTalk, and Mattermost',
wecom: 'WeCom',
feishu: 'Feishu',
slack: 'Slack',
telegram: 'Telegram',
dingtalk: 'DingTalk',
mattermost: 'Mattermost',
addChannel: 'Add Channel',
editChannel: 'Edit Channel',
deleteConfirm: 'Are you sure you want to delete this channel? This action cannot be undone.',
@@ -3235,6 +3242,11 @@ export default {
dingtalkConsole: 'DingTalk Open Platform',
dingtalkCardTemplateId: 'Card Template ID (optional)',
dingtalkCardTemplateIdHint: 'Create an AI Card template at open-dev.dingtalk.com/fe/card to enable streaming output with typewriter effect',
mattermostConsole: 'Mattermost integrations',
mattermostModeHint: 'Mattermost only supports Webhook mode (outgoing webhook + bot token).',
mattermostPostToMain: 'Post replies in channel timeline',
mattermostPostToMainHint:
'When on, bot replies are new top-level posts in the channel. When off (default), they stay in the thread and the main view only shows “N replies”.',
modeHint: 'WebSocket is recommended for easier setup',
consoleTip: 'to get credentials',
fileKnowledgeBase: 'File Storage Knowledge Base',

View File

@@ -3237,12 +3237,13 @@ export default {
},
im: {
title: "IM 통합",
description: "에이전트를 WeCom, Feishu, Slack, Telegram, DingTalk 등 인스턴트 메시징 플랫폼에 연결",
description: "에이전트를 WeCom, Feishu, Slack, Telegram, DingTalk, Mattermost 등 인스턴트 메시징 플랫폼에 연결",
wecom: "WeCom",
feishu: "Feishu",
slack: "Slack",
telegram: "Telegram",
dingtalk: "DingTalk",
mattermost: "Mattermost",
addChannel: "채널 추가",
editChannel: "채널 편집",
deleteConfirm: "이 채널을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
@@ -3264,6 +3265,11 @@ export default {
dingtalkConsole: "DingTalk 개발 플랫폼",
dingtalkCardTemplateId: "카드 템플릿 ID (선택 사항)",
dingtalkCardTemplateIdHint: "open-dev.dingtalk.com/fe/card에서 AI 카드 템플릿을 만들면 타자기 효과 스트리밍 출력이 활성화됩니다",
mattermostConsole: "Mattermost 통합",
mattermostModeHint: "Mattermost는 Webhook(아웃고잉 웹훅 + 봇 토큰)만 지원합니다.",
mattermostPostToMain: "답변을 채널 메인 타임라인에 표시",
mattermostPostToMainHint:
"켜면 봇 답변이 채널의 새 최상위 게시물로 올라갑니다. 끄면(기본) 스레드 답변이며 메인 화면에는 「N개 답변」만 보입니다.",
modeHint: "WebSocket 방식이 설정이 더 간편하여 권장됩니다",
consoleTip: "자격 증명 정보를 가져오세요",
fileKnowledgeBase: "파일 저장 지식 베이스",

View File

@@ -989,6 +989,12 @@ export default {
auth: {
login: 'Вход',
logout: 'Выход',
oidcLogin: 'Войти через OIDC',
oidcLoginWithProvider: 'Войти через {provider}',
redirectingToOIDC: 'Перенаправление к поставщику удостоверений...',
orContinueWith: 'Или продолжить с помощью',
oidcLoginFailed: 'Ошибка входа через OIDC',
oidcStateMismatch: 'Не удалось проверить состояние OIDC, попробуйте снова',
username: 'Имя пользователя',
email: 'Почта Email',
password: 'Пароль',
@@ -2870,12 +2876,13 @@ export default {
},
im: {
title: 'Интеграция IM',
description: 'Подключите агента к платформам мгновенных сообщений, таким как WeCom, Feishu, Slack, Telegram и DingTalk',
description: 'Подключите агента к платформам мгновенных сообщений, таким как WeCom, Feishu, Slack, Telegram, DingTalk и Mattermost',
wecom: 'WeCom',
feishu: 'Feishu',
slack: 'Slack',
telegram: 'Telegram',
dingtalk: 'DingTalk',
mattermost: 'Mattermost',
addChannel: 'Добавить канал',
editChannel: 'Редактировать канал',
deleteConfirm: 'Вы уверены, что хотите удалить этот канал? Это действие не может быть отменено.',
@@ -2897,6 +2904,11 @@ export default {
dingtalkConsole: 'Платформа DingTalk',
dingtalkCardTemplateId: 'ID шаблона карточки (необязательно)',
dingtalkCardTemplateIdHint: 'Создайте шаблон AI-карточки на open-dev.dingtalk.com/fe/card для потоковой передачи с эффектом печатной машинки',
mattermostConsole: 'Интеграции Mattermost',
mattermostModeHint: 'Mattermost поддерживает только режим Webhook (исходящий вебхук + токен бота).',
mattermostPostToMain: 'Ответы в основной ленте канала',
mattermostPostToMainHint:
'Включено — ответы бота как новые сообщения в канале. Выключено (по умолчанию) — ответы в ветке, в основном окне только «N ответов».',
modeHint: 'Рекомендуется WebSocket — проще настроить',
consoleTip: 'для получения учётных данных',
fileKnowledgeBase: 'База знаний для файлов',

View File

@@ -887,6 +887,12 @@ export default {
loginSuccess: "登录成功!",
loginFailed: "登录失败",
loggingIn: "登录中...",
oidcLogin: "使用 OIDC 登录",
oidcLoginWithProvider: "使用 {provider} 登录",
redirectingToOIDC: "正在跳转到身份提供商...",
orContinueWith: "或使用以下方式继续",
oidcLoginFailed: "OIDC 登录失败",
oidcStateMismatch: "OIDC 状态校验失败,请重试",
register: "注册",
registering: "注册中...",
createAccount: "创建账户",
@@ -3183,12 +3189,13 @@ export default {
},
im: {
title: "IM 集成",
description: "将智能体接入即时通讯平台支持企业微信、飞书、Slack、Telegram钉钉",
description: "将智能体接入即时通讯平台支持企业微信、飞书、Slack、Telegram钉钉和 Mattermost",
wecom: "企业微信",
feishu: "飞书",
slack: "Slack",
telegram: "Telegram",
dingtalk: "钉钉",
mattermost: "Mattermost",
addChannel: "添加渠道",
editChannel: "编辑渠道",
deleteConfirm: "确定删除该渠道?删除后无法恢复。",
@@ -3210,6 +3217,11 @@ export default {
dingtalkConsole: "钉钉开放平台",
dingtalkCardTemplateId: "卡片模板 ID可选",
dingtalkCardTemplateIdHint: "在 open-dev.dingtalk.com/fe/card 创建 AI 卡片模板,启用后支持打字机效果的流式输出",
mattermostConsole: "Mattermost 集成说明",
mattermostModeHint: "Mattermost 仅支持 Webhook出站 Webhook + Bot Token。",
mattermostPostToMain: "回复显示在频道主时间线",
mattermostPostToMainHint:
"开启后 Bot 回复作为频道内新帖子关闭默认则作为原消息的线程回复主窗口仅显示「N 条回复」。",
modeHint: "推荐使用 WebSocket 方式接入,配置更简单",
consoleTip: "前往获取凭证信息",
fileKnowledgeBase: "文件保存知识库",

View File

@@ -1,4 +1,5 @@
import mermaid from 'mermaid';
import type {Tokens} from 'marked';
let mermaidInitialized = false;
@@ -40,7 +41,7 @@ export const ensureMermaidInitialized = () => {
export const createMermaidCodeRenderer = (idPrefix: string) => {
let mermaidCount = 0;
return (text: string, lang?: string) => {
return ({text, lang}: Tokens.Code) => {
if (lang === 'mermaid') {
const id = `${idPrefix}-${++mermaidCount}`;
return `<div class="mermaid" id="${id}">${text}</div>`;

File diff suppressed because it is too large Load Diff

View File

@@ -587,10 +587,7 @@ const props = defineProps<{
}>();
// Configure marked for security
marked.use({
mangle: false,
headerIds: false
});
marked.use({});
// Event stream
const eventStream = computed(() => props.session?.agentEventStream || []);

View File

@@ -85,8 +85,6 @@ import {
} from '@/utils/mermaidShared';
marked.use({
mangle: false,
headerIds: false,
breaks: true, // 全局启用单个换行支持
});
@@ -136,7 +134,7 @@ const closePreImg = () => {
// 创建自定义渲染器实例
const customRenderer = new marked.Renderer();
// 覆盖图片渲染方法
customRenderer.image = function(href, title, text) {
customRenderer.image = function({href, title, text}){
if (!isValidImageURL(href)) {
return `<p>${t('error.invalidImageLink')}</p>`;
}

View File

@@ -41,12 +41,12 @@ export default defineConfig({
// 代理配置,用于开发环境
proxy: {
'/api': {
target: 'http://localhost:8080',
target: 'http://127.0.0.1:8080',
changeOrigin: true,
secure: false,
},
'/files': {
target: 'http://localhost:8080',
target: 'http://127.0.0.1:8080',
changeOrigin: true,
secure: false,
}

View File

@@ -5,7 +5,7 @@ description: |
with document parsing, vector search, and LLM integration.
type: application
version: 0.1.0
appVersion: "v0.3.4"
appVersion: "v0.3.5"
kubeVersion: ">=1.25.0-0"
home: https://github.com/Tencent/WeKnora
icon: https://raw.githubusercontent.com/Tencent/WeKnora/main/docs/images/logo.png

View File

@@ -27,6 +27,13 @@ const (
// maxLLMRetries is the maximum number of retries for transient LLM errors.
maxLLMRetries = 2
// maxEmptyResponseRetries is the maximum number of retries when the LLM
// returns an empty content with a natural stop (no tool calls). This guards
// against the agent completing with an empty answer when the LLM fails to
// produce content (e.g., thinking-only loops without KB).
// Trade-off: each retry costs ~2s of LLM latency; 2 retries = max 4s extra.
maxEmptyResponseRetries = 2
)
// transientErrorMarkers are substrings that indicate a transient (retryable) error.

View File

@@ -270,6 +270,7 @@ func (e *AgentEngine) executeLoop(
common.PipelineInfo(ctx, "Agent", "loop_start", map[string]interface{}{
"max_iterations": e.config.MaxIterations,
})
emptyRetries := 0
for state.CurrentRound < e.config.MaxIterations {
// Check for context cancellation (request timeout, user cancel, etc.)
select {
@@ -336,6 +337,28 @@ func (e *AgentEngine) executeLoop(
// 2. Analyze: Check for stop conditions (natural stop or final_answer tool)
verdict := e.analyzeResponse(ctx, response, step, state.CurrentRound, sessionID, roundStart)
if verdict.isDone {
// Guard against empty content: when the LLM stops naturally with no
// content and no tool calls (e.g., thinking-only loop without KB),
// retry with a nudge message instead of accepting an empty answer.
if verdict.emptyContent {
emptyRetries++
if emptyRetries <= maxEmptyResponseRetries {
logger.Warnf(ctx, "[Agent][Round-%d] Empty content with stop - retrying (%d/%d)",
state.CurrentRound+1, emptyRetries, maxEmptyResponseRetries)
messages = append(messages, chat.Message{
Role: "user",
Content: "Please provide your answer by calling the final_answer tool.",
})
continue
}
// Retries exhausted — use fallback message rather than empty answer
logger.Warnf(ctx, "[Agent][Round-%d] Empty content after %d retries - using fallback",
state.CurrentRound+1, maxEmptyResponseRetries)
state.FinalAnswer = "I'm sorry, I was unable to generate a response. Please try again."
state.IsComplete = true
state.RoundSteps = append(state.RoundSteps, verdict.step)
break
}
state.FinalAnswer = verdict.finalAnswer
state.IsComplete = true
state.RoundSteps = append(state.RoundSteps, verdict.step)

View File

@@ -0,0 +1,221 @@
package agent
import (
"context"
"fmt"
"sync"
"testing"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// Mock: chat.Chat
// ---------------------------------------------------------------------------
type mockResponse struct {
chunks []types.StreamResponse
}
type mockChat struct {
mu sync.Mutex
responses []mockResponse
callCount int
}
func (m *mockChat) ChatStream(_ context.Context, _ []chat.Message, _ *chat.ChatOptions) (<-chan types.StreamResponse, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.callCount >= len(m.responses) {
return nil, fmt.Errorf("unexpected ChatStream call #%d (only %d responses prepared)", m.callCount, len(m.responses))
}
resp := m.responses[m.callCount]
m.callCount++
ch := make(chan types.StreamResponse, len(resp.chunks))
for _, chunk := range resp.chunks {
ch <- chunk
}
close(ch)
return ch, nil
}
func (m *mockChat) Chat(_ context.Context, _ []chat.Message, _ *chat.ChatOptions) (*types.ChatResponse, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockChat) GetModelName() string { return "mock-model" }
func (m *mockChat) GetModelID() string { return "mock-id" }
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
type testEngineOption func(*types.AgentConfig)
func withMaxIterations(n int) testEngineOption {
return func(cfg *types.AgentConfig) {
cfg.MaxIterations = n
}
}
func newTestEngine(t *testing.T, chatModel chat.Chat, opts ...testEngineOption) *AgentEngine {
t.Helper()
cfg := &types.AgentConfig{
MaxIterations: 10,
Temperature: 0.7,
}
for _, opt := range opts {
opt(cfg)
}
engine := NewAgentEngine(
cfg,
chatModel,
nil,
event.NewEventBus(),
nil,
nil,
nil,
"test-session",
"",
)
require.NotNil(t, engine, "NewAgentEngine returned nil (agenttoken.NewEstimator failed?)")
return engine
}
func emptyMessages() []chat.Message {
return []chat.Message{
{Role: "system", Content: "You are a test agent."},
{Role: "user", Content: "test query"},
}
}
func emptyTools() []chat.Tool {
return nil
}
// ---------------------------------------------------------------------------
// TC1: Empty content + stop → should NOT complete with empty FinalAnswer
// ---------------------------------------------------------------------------
func TestExecuteLoop_EmptyContentWithStop_ShouldNotCompleteWithEmpty(t *testing.T) {
// Simulate: LLM returns empty content with no tool calls (natural stop).
// The stream closes with no content chunks → streamLLMToEventBus returns fullContent="".
// streamThinkingToEventBus wraps it as ChatResponse{Content:"", FinishReason:"stop"}.
// analyzeResponse() returns verdict{isDone:true, finalAnswer:""} → BUG: empty answer.
//
// Prepare 3 responses for initial attempt + 2 retries (after fix).
mock := &mockChat{
responses: []mockResponse{
{chunks: []types.StreamResponse{{Done: true}}},
{chunks: []types.StreamResponse{{Done: true}}},
{chunks: []types.StreamResponse{{Done: true}}},
},
}
engine := newTestEngine(t, mock)
state := &types.AgentState{}
ctx := context.Background()
_, err := engine.executeLoop(ctx, state, "test query", emptyMessages(), emptyTools(), "sess-1", "msg-1")
assert.NoError(t, err)
assert.True(t, state.IsComplete)
assert.NotEmpty(t, state.FinalAnswer,
"BUG: FinalAnswer is empty when LLM returns empty content with stop. "+
"analyzeResponse() should not allow empty content to be accepted as final answer.")
}
// ---------------------------------------------------------------------------
// TC2: Non-empty content + stop → normal completion (regression guard)
// ---------------------------------------------------------------------------
func TestExecuteLoop_NonEmptyContentWithStop_ShouldComplete(t *testing.T) {
mock := &mockChat{
responses: []mockResponse{
{chunks: []types.StreamResponse{
{Content: "Here is my answer", Done: true},
}},
},
}
engine := newTestEngine(t, mock)
state := &types.AgentState{}
ctx := context.Background()
_, err := engine.executeLoop(ctx, state, "test query", emptyMessages(), emptyTools(), "sess-1", "msg-1")
assert.NoError(t, err)
assert.True(t, state.IsComplete)
assert.Equal(t, "Here is my answer", state.FinalAnswer)
}
// ---------------------------------------------------------------------------
// TC4: Empty → retry with nudge → non-empty → success
// ---------------------------------------------------------------------------
func TestExecuteLoop_EmptyThenNonEmpty_ShouldRetryAndComplete(t *testing.T) {
mock := &mockChat{
responses: []mockResponse{
// Round 1: empty content → triggers retry + nudge
{chunks: []types.StreamResponse{{Done: true}}},
// Round 2: after nudge, LLM produces answer
{chunks: []types.StreamResponse{
{Content: "Here is the answer.", Done: true},
}},
},
}
engine := newTestEngine(t, mock)
state := &types.AgentState{}
ctx := context.Background()
_, err := engine.executeLoop(ctx, state, "test query", emptyMessages(), emptyTools(), "sess-1", "msg-1")
assert.NoError(t, err)
assert.True(t, state.IsComplete)
assert.Equal(t, "Here is the answer.", state.FinalAnswer)
}
// ---------------------------------------------------------------------------
// TC5: FinishReason propagation through streamThinkingToEventBus
// ---------------------------------------------------------------------------
func TestStreamThinkingToEventBus_PropagatesFinishReason(t *testing.T) {
tests := []struct {
name string
finishReason string
wantReason string
}{
{"stop", "stop", "stop"},
{"tool_calls", "tool_calls", "tool_calls"},
{"length", "length", "length"},
{"empty_fallback", "", "stop"}, // empty FinishReason → fallback to "stop"
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockChat{
responses: []mockResponse{
{chunks: []types.StreamResponse{
{Content: "test content", Done: true, FinishReason: tt.finishReason},
}},
},
}
engine := newTestEngine(t, mock)
ctx := context.Background()
msgs := []chat.Message{{Role: "user", Content: "test"}}
tools := []chat.Tool{}
resp, err := engine.streamThinkingToEventBus(ctx, msgs, tools, 0, "sess-1")
assert.NoError(t, err)
assert.Equal(t, tt.wantReason, resp.FinishReason)
})
}
}

View File

@@ -51,9 +51,10 @@ func (e *AgentEngine) manageContextWindow(ctx context.Context, messages []chat.M
// responseVerdict captures the result of analyzing an LLM response to determine
// whether the agent loop should stop and what the final answer is (if any).
type responseVerdict struct {
isDone bool
finalAnswer string
step types.AgentStep
isDone bool
finalAnswer string
emptyContent bool // LLM returned stop with no tool calls and empty content
step types.AgentStep
}
// analyzeResponse inspects the LLM response for stop conditions:
@@ -102,9 +103,10 @@ func (e *AgentEngine) analyzeResponse(
})
return responseVerdict{
isDone: true,
finalAnswer: response.Content,
step: step,
isDone: true,
finalAnswer: response.Content,
emptyContent: response.Content == "",
step: step,
}
}

View File

@@ -15,9 +15,10 @@ import (
// streamLLMResult holds accumulated output from a streaming LLM call.
type streamLLMResult struct {
Content string
ToolCalls []types.LLMToolCall
Usage *types.TokenUsage
Content string
ToolCalls []types.LLMToolCall
Usage *types.TokenUsage
FinishReason string // actual finish_reason from LLM (captured from last stream chunk)
}
// streamLLMToEventBus streams LLM response through EventBus (generic method)
@@ -66,6 +67,10 @@ func (e *AgentEngine) streamLLMToEventBus(
result.Usage = chunk.Usage
}
if chunk.FinishReason != "" {
result.FinishReason = chunk.FinishReason
}
if emitFunc != nil {
emitFunc(&chunk, result.Content)
}
@@ -205,10 +210,18 @@ func (e *AgentEngine) streamThinkingToEventBus(
fullContent := agenttools.StripThinkBlocks(llmResult.Content)
// Use actual finish_reason from LLM stream instead of hardcoding "stop".
// Fallback to "stop" when the stream did not report a finish_reason
// (e.g., certain Ollama models or providers that omit the field).
finishReason := llmResult.FinishReason
if finishReason == "" {
finishReason = "stop"
}
resp := &types.ChatResponse{
Content: fullContent,
ToolCalls: llmResult.ToolCalls,
FinishReason: "stop",
FinishReason: finishReason,
}
if llmResult.Usage != nil {
resp.Usage = *llmResult.Usage

View File

@@ -12,6 +12,15 @@ import (
var ErrKnowledgeNotFound = errors.New("knowledge not found")
// escapeLikeKeyword escapes SQL LIKE wildcards (%, _) in a keyword
// so they are treated as literal characters.
func escapeLikeKeyword(keyword string) string {
keyword = strings.ReplaceAll(keyword, `\`, `\\`)
keyword = strings.ReplaceAll(keyword, "%", `\%`)
keyword = strings.ReplaceAll(keyword, "_", `\_`)
return keyword
}
// omitFieldsOnUpdate defines fields to omit when updating knowledge
var omitFieldsOnUpdate = []string{"DeletedAt"}
@@ -90,7 +99,8 @@ func (r *knowledgeRepository) ListPagedKnowledgeByKnowledgeBaseID(
query = query.Where("tag_id = ?", tagID)
}
if keyword != "" {
query = query.Where("(file_name LIKE ? OR title LIKE ?)", "%"+keyword+"%", "%"+keyword+"%")
escaped := escapeLikeKeyword(keyword)
query = query.Where("(file_name LIKE ? OR title LIKE ?)", "%"+escaped+"%", "%"+escaped+"%")
}
if fileType != "" {
if fileType == "manual" {
@@ -108,13 +118,14 @@ func (r *knowledgeRepository) ListPagedKnowledgeByKnowledgeBaseID(
}
// Then query paginated data
dataQuery := r.db.WithContext(ctx).
dataQuery := r.db.Debug().WithContext(ctx).
Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID)
if tagID != "" {
dataQuery = dataQuery.Where("tag_id = ?", tagID)
}
if keyword != "" {
dataQuery = dataQuery.Where("(file_name LIKE ? OR title LIKE ?)", "%"+keyword+"%", "%"+keyword+"%")
escaped := escapeLikeKeyword(keyword)
dataQuery = dataQuery.Where("(file_name LIKE ? OR title LIKE ?)", "%"+escaped+"%", "%"+escaped+"%")
}
if fileType != "" {
if fileType == "manual" {
@@ -313,6 +324,29 @@ func (r *knowledgeRepository) CountKnowledgeByStatus(
// If keyword is empty, returns recent files
// Only returns documents from document-type knowledge bases (excludes FAQ)
// Returns (results, hasMore, error)
// FindByMetadataKey finds a knowledge item by a key-value pair in the metadata JSON column.
// Uses Postgres jsonb operator: metadata->>'key' = 'value'.
func (r *knowledgeRepository) FindByMetadataKey(
ctx context.Context,
tenantID uint64,
kbID string,
key string,
value string,
) (*types.Knowledge, error) {
var knowledge types.Knowledge
err := r.db.WithContext(ctx).
Where("tenant_id = ? AND knowledge_base_id = ? AND deleted_at IS NULL", tenantID, kbID).
Where("metadata->>? = ?", key, value).
First(&knowledge).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &knowledge, nil
}
func (r *knowledgeRepository) SearchKnowledge(
ctx context.Context,
tenantID uint64,
@@ -337,7 +371,8 @@ func (r *knowledgeRepository) SearchKnowledge(
// If keyword is provided, filter by file_name or title
if keyword != "" {
query = query.Where("(knowledges.file_name LIKE ? OR knowledges.title LIKE ?)", "%"+keyword+"%", "%"+keyword+"%")
escaped := escapeLikeKeyword(keyword)
query = query.Where("(knowledges.file_name LIKE ? OR knowledges.title LIKE ?)", "%"+escaped+"%", "%"+escaped+"%")
}
// If fileTypes is provided, filter by file extension or type
@@ -455,7 +490,8 @@ func (r *knowledgeRepository) SearchKnowledgeInScopes(
Where("knowledges.deleted_at IS NULL")
if keyword != "" {
query = query.Where("(knowledges.file_name LIKE ? OR knowledges.title LIKE ?)", "%"+keyword+"%", "%"+keyword+"%")
escaped := escapeLikeKeyword(keyword)
query = query.Where("(knowledges.file_name LIKE ? OR knowledges.title LIKE ?)", "%"+escaped+"%", "%"+escaped+"%")
}
if len(fileTypes) > 0 {

View File

@@ -4,8 +4,12 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
@@ -15,12 +19,19 @@ import (
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
type oidcAuthorizationState struct {
Nonce string `json:"nonce"`
RedirectURI string `json:"redirect_uri,omitempty"`
}
var (
jwtSecretOnce sync.Once
jwtSecret string
@@ -49,10 +60,12 @@ type userService struct {
userRepo interfaces.UserRepository
tokenRepo interfaces.AuthTokenRepository
tenantService interfaces.TenantService
config *config.Config
}
// NewUserService creates a new user service instance
func NewUserService(
configInfo *config.Config,
userRepo interfaces.UserRepository,
tokenRepo interfaces.AuthTokenRepository,
tenantService interfaces.TenantService,
@@ -61,6 +74,7 @@ func NewUserService(
userRepo: userRepo,
tokenRepo: tokenRepo,
tenantService: tenantService,
config: configInfo,
}
}
@@ -198,6 +212,116 @@ func (s *userService) Login(ctx context.Context, req *types.LoginRequest) (*type
}, nil
}
// GetOIDCAuthorizationURL builds the OIDC authorization URL.
func (s *userService) GetOIDCAuthorizationURL(ctx context.Context, redirectURI string) (*types.OIDCAuthURLResponse, error) {
cfg, err := s.getOIDCConfig(ctx)
if err != nil {
return nil, err
}
if strings.TrimSpace(redirectURI) == "" {
return nil, errors.New("redirect_uri is required")
}
nonce, err := generateRandomString(24)
if err != nil {
return nil, fmt.Errorf("failed to generate state: %w", err)
}
state, err := encodeOIDCAuthorizationState(&oidcAuthorizationState{
Nonce: nonce,
RedirectURI: strings.TrimSpace(redirectURI),
})
if err != nil {
return nil, fmt.Errorf("failed to encode OIDC state: %w", err)
}
query := url.Values{}
query.Set("response_type", "code")
query.Set("client_id", cfg.ClientID)
query.Set("redirect_uri", redirectURI)
query.Set("scope", strings.Join(cfg.Scopes, " "))
query.Set("state", state)
authURL := cfg.AuthorizationEndpoint
if strings.Contains(authURL, "?") {
authURL += "&" + query.Encode()
} else {
authURL += "?" + query.Encode()
}
return &types.OIDCAuthURLResponse{
Success: true,
ProviderDisplayName: cfg.ProviderDisplayName,
AuthorizationURL: authURL,
State: state,
}, nil
}
// LoginWithOIDC exchanges code for tokens, loads user info, provisions user if needed, and returns local login tokens.
func (s *userService) LoginWithOIDC(ctx context.Context, code, redirectURI string) (*types.OIDCCallbackResponse, error) {
if strings.TrimSpace(code) == "" {
return nil, errors.New("code is required")
}
if strings.TrimSpace(redirectURI) == "" {
return nil, errors.New("redirect_uri is required")
}
cfg, err := s.getOIDCConfig(ctx)
if err != nil {
return nil, err
}
tokenResp, err := s.exchangeOIDCCode(ctx, cfg, code, redirectURI)
if err != nil {
return nil, err
}
userInfo, err := s.resolveOIDCUserInfo(ctx, cfg, tokenResp)
if err != nil {
return nil, err
}
if strings.TrimSpace(userInfo.Email) == "" {
return nil, errors.New("OIDC provider did not return email")
}
user, err := s.userRepo.GetUserByEmail(ctx, userInfo.Email)
if err != nil && !isUserLookupNotFound(err) {
return nil, fmt.Errorf("failed to query user by email: %w", err)
}
isNewUser := false
if isUserLookupNotFound(err) || user == nil {
user, err = s.provisionOIDCUser(ctx, userInfo)
if err != nil {
return nil, err
}
isNewUser = true
}
if !user.IsActive {
return &types.OIDCCallbackResponse{Success: false, Message: "Account is disabled"}, nil
}
accessToken, refreshToken, err := s.GenerateTokens(ctx, user)
if err != nil {
return nil, fmt.Errorf("failed to generate local tokens: %w", err)
}
tenant, err := s.tenantService.GetTenantByID(ctx, user.TenantID)
if err != nil {
logger.Warnf(ctx, "Failed to get tenant info after OIDC login: %v", err)
}
return &types.OIDCCallbackResponse{
Success: true,
Message: "Login successful",
User: user,
Tenant: tenant,
Token: accessToken,
RefreshToken: refreshToken,
IsNewUser: isNewUser,
}, nil
}
// GetUserByID gets a user by ID
func (s *userService) GetUserByID(ctx context.Context, id string) (*types.User, error) {
return s.userRepo.GetUserByID(ctx, id)
@@ -439,3 +563,296 @@ func (s *userService) SearchUsers(ctx context.Context, query string, limit int)
}
return s.userRepo.SearchUsers(ctx, query, limit)
}
type oidcDiscoveryDocument struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
}
type oidcTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
}
func (s *userService) getOIDCConfig(ctx context.Context) (*config.OIDCAuthConfig, error) {
if s.config == nil || s.config.OIDCAuth == nil || !s.config.OIDCAuth.Enable {
return nil, errors.New("OIDC login is disabled")
}
cfg := *s.config.OIDCAuth
if cfg.UserInfoMapping == nil {
cfg.UserInfoMapping = &config.OIDCUserInfoMapping{Username: "name", Email: "email"}
}
if err := s.populateOIDCEndpoints(ctx, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func (s *userService) populateOIDCEndpoints(ctx context.Context, cfg *config.OIDCAuthConfig) error {
if strings.TrimSpace(cfg.AuthorizationEndpoint) != "" && strings.TrimSpace(cfg.TokenEndpoint) != "" {
return nil
}
if strings.TrimSpace(cfg.DiscoveryURL) == "" {
return errors.New("OIDC discovery_url or explicit endpoints are required")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.DiscoveryURL, nil)
if err != nil {
return fmt.Errorf("failed to create OIDC discovery request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to load OIDC discovery document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("OIDC discovery request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var doc oidcDiscoveryDocument
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return fmt.Errorf("failed to decode OIDC discovery document: %w", err)
}
if cfg.AuthorizationEndpoint == "" {
cfg.AuthorizationEndpoint = doc.AuthorizationEndpoint
}
if cfg.TokenEndpoint == "" {
cfg.TokenEndpoint = doc.TokenEndpoint
}
if cfg.UserInfoEndpoint == "" {
cfg.UserInfoEndpoint = doc.UserInfoEndpoint
}
if cfg.AuthorizationEndpoint == "" || cfg.TokenEndpoint == "" {
return errors.New("OIDC discovery document missing required endpoints")
}
return nil
}
func (s *userService) exchangeOIDCCode(ctx context.Context, cfg *config.OIDCAuthConfig, code, redirectURI string) (*oidcTokenResponse, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
form.Set("client_id", cfg.ClientID)
form.Set("client_secret", cfg.ClientSecret)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create OIDC token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to exchange OIDC code: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("OIDC token exchange failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var tokenResp oidcTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("failed to decode OIDC token response: %w", err)
}
if strings.TrimSpace(tokenResp.AccessToken) == "" && strings.TrimSpace(tokenResp.IDToken) == "" {
return nil, errors.New("OIDC token response missing access_token and id_token")
}
return &tokenResp, nil
}
func (s *userService) resolveOIDCUserInfo(ctx context.Context, cfg *config.OIDCAuthConfig, tokenResp *oidcTokenResponse) (*types.OIDCUserInfo, error) {
claims := map[string]interface{}{}
if strings.TrimSpace(tokenResp.IDToken) != "" {
idTokenClaims, err := decodeJWTClaims(tokenResp.IDToken)
if err != nil {
logger.Warnf(ctx, "Failed to decode OIDC id_token claims: %v", err)
} else {
for k, v := range idTokenClaims {
claims[k] = v
}
}
}
if strings.TrimSpace(cfg.UserInfoEndpoint) != "" && strings.TrimSpace(tokenResp.AccessToken) != "" {
userInfoClaims, err := s.fetchOIDCUserInfo(ctx, cfg.UserInfoEndpoint, tokenResp.AccessToken)
if err != nil {
logger.Warnf(ctx, "Failed to fetch OIDC userinfo, fallback to id_token claims: %v", err)
} else {
for k, v := range userInfoClaims {
claims[k] = v
}
}
}
info := &types.OIDCUserInfo{Claims: claims}
if sub, _ := claims["sub"].(string); sub != "" {
info.Subject = sub
}
info.Username = extractClaimAsString(claims, cfg.UserInfoMapping.Username)
info.Email = extractClaimAsString(claims, cfg.UserInfoMapping.Email)
if info.Username == "" {
info.Username = extractClaimAsString(claims, "preferred_username")
}
if info.Username == "" {
info.Username = extractClaimAsString(claims, "name")
}
if info.Username == "" && info.Email != "" {
info.Username = strings.Split(info.Email, "@")[0]
}
return info, nil
}
func (s *userService) fetchOIDCUserInfo(ctx context.Context, endpoint, accessToken string) (map[string]interface{}, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("userinfo request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var claims map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&claims); err != nil {
return nil, err
}
return claims, nil
}
func (s *userService) provisionOIDCUser(ctx context.Context, info *types.OIDCUserInfo) (*types.User, error) {
username := s.generateOIDCUsername(ctx, info)
randomPassword, err := generateRandomString(32)
if err != nil {
return nil, fmt.Errorf("failed to generate password for OIDC user: %w", err)
}
user, err := s.Register(ctx, &types.RegisterRequest{
Username: username,
Email: info.Email,
Password: randomPassword,
})
if err != nil {
return nil, fmt.Errorf("failed to auto-provision OIDC user: %w", err)
}
return user, nil
}
func (s *userService) generateOIDCUsername(ctx context.Context, info *types.OIDCUserInfo) string {
base := sanitizeUsernameCandidate(info.Username)
if base == "" {
base = sanitizeUsernameCandidate(strings.Split(info.Email, "@")[0])
}
if base == "" {
base = "oidc-user"
}
candidate := base
for i := 0; i < 20; i++ {
existing, err := s.userRepo.GetUserByUsername(ctx, candidate)
if isUserLookupNotFound(err) || (err == nil && existing == nil) {
return candidate
}
if err != nil && !isUserLookupNotFound(err) {
logger.Warnf(ctx, "Failed to check existing OIDC username %q: %v", candidate, err)
}
candidate = fmt.Sprintf("%s-%d", base, i+1)
}
return fmt.Sprintf("%s-%d", base, time.Now().Unix())
}
func generateRandomString(length int) (string, error) {
buffer := make([]byte, length)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func encodeOIDCAuthorizationState(state *oidcAuthorizationState) (string, error) {
payload, err := json.Marshal(state)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
func decodeJWTClaims(token string) (map[string]interface{}, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil, errors.New("invalid JWT format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, err
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, err
}
return claims, nil
}
func extractClaimAsString(claims map[string]interface{}, key string) string {
key = strings.TrimSpace(key)
if key == "" {
return ""
}
value, ok := claims[key]
if !ok || value == nil {
return ""
}
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func sanitizeUsernameCandidate(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
return ""
}
var b strings.Builder
lastDash := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '.' {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
result := strings.Trim(b.String(), "-._")
if len(result) > 50 {
result = strings.Trim(result[:50], "-._")
}
return result
}
func isUserLookupNotFound(err error) bool {
if err == nil {
return false
}
return errors.Is(err, apprepo.ErrUserNotFound) || strings.Contains(strings.ToLower(err.Error()), "user not found")
}

View File

@@ -20,6 +20,7 @@ type Config struct {
Server *ServerConfig `yaml:"server" json:"server"`
KnowledgeBase *KnowledgeBaseConfig `yaml:"knowledge_base" json:"knowledge_base"`
Tenant *TenantConfig `yaml:"tenant" json:"tenant"`
OIDCAuth *OIDCAuthConfig `yaml:"oidc_auth" json:"oidc_auth"`
Models []ModelConfig `yaml:"models" json:"models"`
VectorDatabase *VectorDatabaseConfig `yaml:"vector_database" json:"vector_database"`
DocReader *DocReaderConfig `yaml:"docreader" json:"docreader"`
@@ -85,13 +86,13 @@ type ConversationConfig struct {
Summary *SummaryConfig `yaml:"summary" json:"summary"`
// Prompt template ID fields — resolved to text by backfillConversationDefaults
FallbackPromptID string `yaml:"fallback_prompt_id" json:"fallback_prompt_id"`
RewritePromptID string `yaml:"rewrite_prompt_id" json:"rewrite_prompt_id"`
GenerateSessionTitlePromptID string `yaml:"generate_session_title_prompt_id" json:"generate_session_title_prompt_id"`
GenerateSummaryPromptID string `yaml:"generate_summary_prompt_id" json:"generate_summary_prompt_id"`
ExtractEntitiesPromptID string `yaml:"extract_entities_prompt_id" json:"extract_entities_prompt_id"`
ExtractRelationshipsPromptID string `yaml:"extract_relationships_prompt_id" json:"extract_relationships_prompt_id"`
GenerateQuestionsPromptID string `yaml:"generate_questions_prompt_id" json:"generate_questions_prompt_id"`
FallbackPromptID string `yaml:"fallback_prompt_id" json:"fallback_prompt_id"`
RewritePromptID string `yaml:"rewrite_prompt_id" json:"rewrite_prompt_id"`
GenerateSessionTitlePromptID string `yaml:"generate_session_title_prompt_id" json:"generate_session_title_prompt_id"`
GenerateSummaryPromptID string `yaml:"generate_summary_prompt_id" json:"generate_summary_prompt_id"`
ExtractEntitiesPromptID string `yaml:"extract_entities_prompt_id" json:"extract_entities_prompt_id"`
ExtractRelationshipsPromptID string `yaml:"extract_relationships_prompt_id" json:"extract_relationships_prompt_id"`
GenerateQuestionsPromptID string `yaml:"generate_questions_prompt_id" json:"generate_questions_prompt_id"`
// Resolved prompt text fields (populated by backfill, not from YAML)
FallbackPrompt string `yaml:"-" json:"fallback_prompt"`
@@ -162,6 +163,25 @@ type TenantConfig struct {
EnableCrossTenantAccess bool `yaml:"enable_cross_tenant_access" json:"enable_cross_tenant_access"`
}
type OIDCUserInfoMapping struct {
Username string `yaml:"username" json:"username"`
Email string `yaml:"email" json:"email"`
}
type OIDCAuthConfig struct {
Enable bool `yaml:"enable" json:"enable"`
IssuerURL string `yaml:"issuer_url" json:"issuer_url"`
DiscoveryURL string `yaml:"discovery_url" json:"discovery_url"`
ProviderDisplayName string `yaml:"provider_display_name" json:"provider_display_name"`
ClientID string `yaml:"client_id" json:"client_id"`
ClientSecret string `yaml:"client_secret" json:"-"`
AuthorizationEndpoint string `yaml:"authorization_endpoint" json:"authorization_endpoint"`
TokenEndpoint string `yaml:"token_endpoint" json:"token_endpoint"`
UserInfoEndpoint string `yaml:"user_info_endpoint" json:"user_info_endpoint"`
Scopes []string `yaml:"scopes" json:"scopes"`
UserInfoMapping *OIDCUserInfoMapping `yaml:"user_info_mapping" json:"user_info_mapping"`
}
// PromptTemplateI18n holds localized name and description for a prompt template.
type PromptTemplateI18n struct {
Name string `yaml:"name" json:"name"`
@@ -175,16 +195,16 @@ type PromptTemplateI18n struct {
// - user: 用户侧 Prompt仅在需要 system+user 配对的模板中使用,如 rewrite、keywords_extraction
// - i18n: 多语言 name/description键为 locale如 "zh-CN"、"en-US"、"ko-KR"),后端根据请求语言替换 Name/Description 再返回
type PromptTemplate struct {
ID string `yaml:"id" json:"id"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
User string `yaml:"user" json:"user,omitempty"`
HasKnowledgeBase bool `yaml:"has_knowledge_base" json:"has_knowledge_base,omitempty"`
HasWebSearch bool `yaml:"has_web_search" json:"has_web_search,omitempty"`
Default bool `yaml:"default" json:"default,omitempty"`
Mode string `yaml:"mode" json:"mode,omitempty"`
I18n map[string]PromptTemplateI18n `yaml:"i18n" json:"-"`
ID string `yaml:"id" json:"id"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
User string `yaml:"user" json:"user,omitempty"`
HasKnowledgeBase bool `yaml:"has_knowledge_base" json:"has_knowledge_base,omitempty"`
HasWebSearch bool `yaml:"has_web_search" json:"has_web_search,omitempty"`
Default bool `yaml:"default" json:"default,omitempty"`
Mode string `yaml:"mode" json:"mode,omitempty"`
I18n map[string]PromptTemplateI18n `yaml:"i18n" json:"-"`
}
// PromptTemplatesConfig 提示词模板配置
@@ -386,6 +406,8 @@ func LoadConfig() (*Config, error) {
}
// Validate configuration values
applyOIDCEnvOverrides(&cfg)
if err := ValidateConfig(&cfg); err != nil {
return nil, err
}
@@ -398,6 +420,19 @@ func LoadConfig() (*Config, error) {
func ValidateConfig(cfg *Config) error {
var errs []string
if cfg.OIDCAuth != nil && cfg.OIDCAuth.Enable {
if strings.TrimSpace(cfg.OIDCAuth.ClientID) == "" {
errs = append(errs, "oidc_auth.client_id is required when OIDC is enabled")
}
if strings.TrimSpace(cfg.OIDCAuth.ClientSecret) == "" {
errs = append(errs, "oidc_auth.client_secret is required when OIDC is enabled")
}
if strings.TrimSpace(cfg.OIDCAuth.DiscoveryURL) == "" &&
(strings.TrimSpace(cfg.OIDCAuth.AuthorizationEndpoint) == "" || strings.TrimSpace(cfg.OIDCAuth.TokenEndpoint) == "") {
errs = append(errs, "oidc_auth.discovery_url or both oidc_auth.authorization_endpoint and oidc_auth.token_endpoint are required when OIDC is enabled")
}
}
if cfg.Conversation != nil {
if cfg.Conversation.EmbeddingTopK < 0 {
errs = append(errs, "conversation.embedding_top_k must be >= 0")
@@ -437,6 +472,68 @@ func ValidateConfig(cfg *Config) error {
return nil
}
func applyOIDCEnvOverrides(cfg *Config) {
if cfg.OIDCAuth == nil {
cfg.OIDCAuth = &OIDCAuthConfig{}
}
if cfg.OIDCAuth.UserInfoMapping == nil {
cfg.OIDCAuth.UserInfoMapping = &OIDCUserInfoMapping{}
}
if value := strings.TrimSpace(os.Getenv("OIDC_AUTH_ENABLE")); value != "" {
cfg.OIDCAuth.Enable = strings.EqualFold(value, "true")
}
if value := strings.TrimSpace(os.Getenv("OIDC_AUTH_ISSUER_URL")); value != "" {
cfg.OIDCAuth.IssuerURL = value
}
if value := strings.TrimSpace(os.Getenv("OIDC_AUTH_DISCOVERY_URL")); value != "" {
cfg.OIDCAuth.DiscoveryURL = value
}
if value := strings.TrimSpace(os.Getenv("OIDC_AUTH_PROVIDER_DISPLAY_NAME")); value != "" {
cfg.OIDCAuth.ProviderDisplayName = value
}
if value := strings.TrimSpace(os.Getenv("OIDC_AUTH_CLIENT_ID")); value != "" {
cfg.OIDCAuth.ClientID = value
}
if value := strings.TrimSpace(os.Getenv("OIDC_AUTH_CLIENT_SECRET")); value != "" {
cfg.OIDCAuth.ClientSecret = value
}
if value := strings.TrimSpace(os.Getenv("OIDC_AUTH_AUTHORIZATION_ENDPOINT")); value != "" {
cfg.OIDCAuth.AuthorizationEndpoint = value
}
if value := strings.TrimSpace(os.Getenv("OIDC_AUTH_TOKEN_ENDPOINT")); value != "" {
cfg.OIDCAuth.TokenEndpoint = value
}
if value := strings.TrimSpace(os.Getenv("OIDC_AUTH_USER_INFO_ENDPOINT")); value != "" {
cfg.OIDCAuth.UserInfoEndpoint = value
}
if value := strings.TrimSpace(os.Getenv("OIDC_AUTH_SCOPES")); value != "" {
cfg.OIDCAuth.Scopes = strings.Fields(strings.ReplaceAll(value, ",", " "))
}
if value := strings.TrimSpace(os.Getenv("OIDC_USER_INFO_MAPPING_USER_NAME")); value != "" {
cfg.OIDCAuth.UserInfoMapping.Username = value
}
if value := strings.TrimSpace(os.Getenv("OIDC_USER_INFO_MAPPING_EMAIL")); value != "" {
cfg.OIDCAuth.UserInfoMapping.Email = value
}
if cfg.OIDCAuth.ProviderDisplayName == "" {
cfg.OIDCAuth.ProviderDisplayName = "OIDC"
}
if len(cfg.OIDCAuth.Scopes) == 0 {
cfg.OIDCAuth.Scopes = []string{"openid", "profile", "email"}
}
if cfg.OIDCAuth.UserInfoMapping.Username == "" {
cfg.OIDCAuth.UserInfoMapping.Username = "name"
}
if cfg.OIDCAuth.UserInfoMapping.Email == "" {
cfg.OIDCAuth.UserInfoMapping.Email = "email"
}
if cfg.OIDCAuth.DiscoveryURL == "" && cfg.OIDCAuth.IssuerURL != "" {
cfg.OIDCAuth.DiscoveryURL = strings.TrimRight(cfg.OIDCAuth.IssuerURL, "/") + "/.well-known/openid-configuration"
}
}
// backfillConversationDefaults resolves prompt template ID references
// into actual prompt text content. Only xxx_id fields are used;
// no fallback to default templates.
@@ -592,7 +689,7 @@ func loadPromptTemplates(configDir string) (*PromptTemplatesConfig, error) {
"agent_system_prompt.yaml": &config.AgentSystemPrompt,
"graph_extraction.yaml": &config.GraphExtraction,
"generate_questions.yaml": &config.GenerateQuestions,
"intent_prompts.yaml": &config.IntentPrompts,
"intent_prompts.yaml": &config.IntentPrompts,
}
// 加载每个模板文件

View File

@@ -56,6 +56,7 @@ import (
imPkg "github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/im/dingtalk"
"github.com/Tencent/WeKnora/internal/im/feishu"
"github.com/Tencent/WeKnora/internal/im/mattermost"
"github.com/Tencent/WeKnora/internal/im/slack"
"github.com/Tencent/WeKnora/internal/im/telegram"
"github.com/Tencent/WeKnora/internal/im/wecom"
@@ -1171,6 +1172,40 @@ func registerIMAdapterFactories(imService *imPkg.Service) {
}
})
// Register Mattermost adapter factory (outgoing webhook + REST API).
imService.RegisterAdapterFactory("mattermost", func(factoryCtx context.Context, channel *imPkg.IMChannel, msgHandler func(context.Context, *imPkg.IncomingMessage) error) (imPkg.Adapter, context.CancelFunc, error) {
creds, err := parseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse mattermost credentials: %w", err)
}
mode := channel.Mode
if mode == "" {
mode = "webhook"
}
if mode != "webhook" {
return nil, nil, fmt.Errorf("unsupported mattermost mode: %s (only webhook is supported)", mode)
}
siteURL := getString(creds, "site_url")
botToken := getString(creds, "bot_token")
outgoingToken := getString(creds, "outgoing_token")
botUserID := getString(creds, "bot_user_id")
if outgoingToken == "" {
return nil, nil, fmt.Errorf("mattermost outgoing_token is required")
}
client, err := mattermost.NewClient(siteURL, botToken)
if err != nil {
return nil, nil, err
}
postReplyToMain := credentialBool(creds, "post_to_main")
adapter := mattermost.NewAdapter(client, outgoingToken, botUserID, postReplyToMain)
return adapter, func() {}, nil
})
// Load and start all enabled channels from database
if err := imService.LoadAndStartChannels(); err != nil {
logger.Warnf(ctx, "[IM] Failed to load channels from database: %v", err)
@@ -1198,3 +1233,24 @@ func getString(creds map[string]interface{}, key string) string {
}
return ""
}
// credentialBool reads a boolean from JSON credentials (bool, string "true"/"1", or non-zero number).
func credentialBool(creds map[string]interface{}, key string) bool {
v, ok := creds[key]
if !ok {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
s := strings.TrimSpace(strings.ToLower(x))
return s == "true" || s == "1" || s == "yes"
case float64:
return x != 0
case int:
return x != 0
default:
return false
}
}

View File

@@ -1,6 +1,8 @@
package handler
import (
"encoding/base64"
"encoding/json"
"net/http"
"os"
"strings"
@@ -157,6 +159,161 @@ func (h *AuthHandler) Login(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// GetOIDCAuthorizationURL godoc
// @Summary 获取OIDC授权地址
// @Description 根据后端OIDC配置生成第三方登录跳转地址
// @Tags 认证
// @Accept json
// @Produce json
// @Param redirect_uri query string true "OIDC回调地址"
// @Success 200 {object} types.OIDCAuthURLResponse
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 403 {object} errors.AppError "OIDC未启用"
// @Router /auth/oidc/url [get]
func (h *AuthHandler) GetOIDCAuthorizationURL(c *gin.Context) {
ctx := c.Request.Context()
redirectURI := strings.TrimSpace(c.Query("redirect_uri"))
if redirectURI == "" {
appErr := errors.NewValidationError("redirect_uri is required")
c.Error(appErr)
return
}
resp, err := h.userService.GetOIDCAuthorizationURL(ctx, redirectURI)
if err != nil {
logger.Errorf(ctx, "Failed to generate OIDC authorization URL: %v", err)
appErr := errors.NewForbiddenError("OIDC authorization unavailable").WithDetails(err.Error())
c.Error(appErr)
return
}
c.JSON(http.StatusOK, resp)
}
// GetOIDCConfig godoc
// @Summary 获取OIDC登录配置
// @Description 返回OIDC是否启用以及provider展示名称供前端决定是否展示OIDC登录入口
// @Tags 认证
// @Accept json
// @Produce json
// @Success 200 {object} types.OIDCConfigResponse
// @Router /auth/oidc/config [get]
func (h *AuthHandler) GetOIDCConfig(c *gin.Context) {
providerDisplayName := ""
enabled := false
if h.configInfo != nil && h.configInfo.OIDCAuth != nil {
enabled = h.configInfo.OIDCAuth.Enable
providerDisplayName = strings.TrimSpace(h.configInfo.OIDCAuth.ProviderDisplayName)
}
c.JSON(http.StatusOK, &types.OIDCConfigResponse{
Success: true,
Enabled: enabled,
ProviderDisplayName: providerDisplayName,
})
}
// OIDCRedirectCallback godoc
// @Summary OIDC登录重定向回调
// @Description 接收OIDC provider回调并由后端完成code交换随后重定向回前端登录页
// @Tags 认证
// @Accept json
// @Produce json
// @Param code query string false "OIDC授权码"
// @Param state query string false "OIDC状态"
// @Param error query string false "OIDC错误码"
// @Success 302
// @Router /auth/oidc/callback [get]
func (h *AuthHandler) OIDCRedirectCallback(c *gin.Context) {
ctx := c.Request.Context()
frontendRedirectURI := "/"
if providerError := strings.TrimSpace(c.Query("error")); providerError != "" {
redirectURL := frontendRedirectURI + "#oidc_error=" + urlQueryEscape(providerError)
if description := strings.TrimSpace(c.Query("error_description")); description != "" {
redirectURL += "&oidc_error_description=" + urlQueryEscape(description)
}
c.Redirect(http.StatusFound, redirectURL)
return
}
state := strings.TrimSpace(c.Query("state"))
decodedState, err := decodeOIDCState(state)
if err != nil {
logger.Errorf(ctx, "Failed to decode OIDC state: %v", err)
c.Redirect(http.StatusFound, frontendRedirectURI+"#oidc_error="+urlQueryEscape("invalid_state"))
return
}
code := strings.TrimSpace(c.Query("code"))
if code == "" {
c.Redirect(http.StatusFound, frontendRedirectURI+"#oidc_error="+urlQueryEscape("missing_code"))
return
}
resp, err := h.userService.LoginWithOIDC(ctx, code, strings.TrimSpace(decodedState.RedirectURI))
if err != nil {
logger.Errorf(ctx, "Failed to complete OIDC login via redirect callback: %v", err)
c.Redirect(http.StatusFound, frontendRedirectURI+"#oidc_error="+urlQueryEscape("login_failed")+"&oidc_error_description="+urlQueryEscape(err.Error()))
return
}
if !resp.Success {
c.Redirect(http.StatusFound, frontendRedirectURI+"#oidc_error="+urlQueryEscape("login_failed")+"&oidc_error_description="+urlQueryEscape(resp.Message))
return
}
payload, err := encodeOIDCCallbackPayload(resp)
if err != nil {
logger.Errorf(ctx, "Failed to encode OIDC callback payload: %v", err)
c.Redirect(http.StatusFound, frontendRedirectURI+"#oidc_error="+urlQueryEscape("payload_encode_failed"))
return
}
c.Redirect(http.StatusFound, frontendRedirectURI+"#oidc_result="+urlQueryEscape(payload))
}
func encodeOIDCCallbackPayload(resp *types.OIDCCallbackResponse) (string, error) {
payload, err := json.Marshal(resp)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
type oidcStatePayload struct {
Nonce string `json:"nonce"`
RedirectURI string `json:"redirect_uri,omitempty"`
}
func decodeOIDCState(raw string) (*oidcStatePayload, error) {
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil {
return nil, err
}
var payload oidcStatePayload
if err := json.Unmarshal(decoded, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.RedirectURI) == "" {
return nil, errors.NewValidationError("state.redirect_uri is required")
}
return &payload, nil
}
func urlQueryEscape(value string) string {
replacer := strings.NewReplacer(
"%", "%25",
" ", "%20",
"#", "%23",
"&", "%26",
"+", "%2B",
"=", "%3D",
"?", "%3F",
)
return replacer.Replace(value)
}
// Logout godoc
// @Summary 用户登出
// @Description 撤销当前访问令牌并登出

View File

@@ -12,7 +12,9 @@ import (
)
// validIMPlatforms is the set of supported IM platforms.
var validIMPlatforms = map[string]bool{"wecom": true, "feishu": true, "slack": true, "telegram": true, "dingtalk": true}
var validIMPlatforms = map[string]bool{
"wecom": true, "feishu": true, "slack": true, "telegram": true, "dingtalk": true, "mattermost": true,
}
// IMHandler handles IM platform callback requests and channel CRUD.
type IMHandler struct {
@@ -57,7 +59,7 @@ func (h *IMHandler) CreateIMChannel(c *gin.Context) {
}
if !validIMPlatforms[req.Platform] {
c.JSON(http.StatusBadRequest, gin.H{"error": "platform must be 'wecom', 'feishu', 'slack', 'telegram' or 'dingtalk'"})
c.JSON(http.StatusBadRequest, gin.H{"error": "platform must be 'wecom', 'feishu', 'slack', 'telegram', 'dingtalk' or 'mattermost'"})
return
}
@@ -76,7 +78,11 @@ func (h *IMHandler) CreateIMChannel(c *gin.Context) {
channel.Enabled = *req.Enabled
}
if channel.Mode == "" {
channel.Mode = "websocket"
if channel.Platform == "mattermost" {
channel.Mode = "webhook"
} else {
channel.Mode = "websocket"
}
}
if channel.OutputMode == "" {
channel.OutputMode = "stream"
@@ -265,6 +271,8 @@ func (h *IMHandler) IMCallback(c *gin.Context) {
return
}
logger.Infof(ctx, "[IM] Callback received platform=%s path_channel_id=%s", channel.Platform, channelID)
// Handle URL verification
if adapter.HandleURLVerification(c) {
return
@@ -287,6 +295,11 @@ func (h *IMHandler) IMCallback(c *gin.Context) {
// If nil, it's a non-message event - just acknowledge
if msg == nil {
if channel.Platform == "mattermost" {
logger.Infof(ctx, "[IM] Mattermost callback ignored (no message): path_channel_id=%s — check: (1) trigger word must be the *first word* of the post; (2) if channel+trigger are both set, post must be in that channel; (3) bot_user_id must not match the sender", channelID)
} else {
logger.Infof(ctx, "[IM] Callback parsed no message to process platform=%s path_channel_id=%s", channel.Platform, channelID)
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
}

View File

@@ -11,11 +11,12 @@ import (
type Platform string
const (
PlatformWeCom Platform = "wecom"
PlatformFeishu Platform = "feishu"
PlatformSlack Platform = "slack"
PlatformTelegram Platform = "telegram"
PlatformDingtalk Platform = "dingtalk"
PlatformWeCom Platform = "wecom"
PlatformFeishu Platform = "feishu"
PlatformSlack Platform = "slack"
PlatformTelegram Platform = "telegram"
PlatformDingtalk Platform = "dingtalk"
PlatformMattermost Platform = "mattermost"
)
// MessageType identifies the kind of IM message.

View File

@@ -0,0 +1,341 @@
package mattermost
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// Compile-time checks.
var (
_ im.Adapter = (*Adapter)(nil)
_ im.StreamSender = (*Adapter)(nil)
_ im.FileDownloader = (*Adapter)(nil)
)
const (
extraKeyThreadRoot = "thread_root_id"
extraKeyChannelID = "channel_id"
)
// Adapter implements im integration for Mattermost (outgoing webhook inbound + REST outbound).
type Adapter struct {
client *Client
outgoingToken string
botUserID string
// postReplyToMain: when true, bot replies are new top-level channel posts (visible in main timeline).
// When false (default), replies use root_id tied to the trigger post so they appear in Mattermost threads.
postReplyToMain bool
}
// NewAdapter creates a Mattermost adapter.
func NewAdapter(client *Client, outgoingToken, botUserID string, postReplyToMain bool) *Adapter {
return &Adapter{
client: client,
outgoingToken: strings.TrimSpace(outgoingToken),
botUserID: strings.TrimSpace(botUserID),
postReplyToMain: postReplyToMain,
}
}
// outgoingPayload matches Mattermost outgoing webhook parameters (JSON or form).
type outgoingPayload struct {
Token string `json:"token"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
ChannelID string `json:"channel_id"`
PostID string `json:"post_id"`
Text string `json:"text"`
RootID string `json:"root_id"`
FileIDsRaw json.RawMessage `json:"file_ids"`
}
func (a *Adapter) Platform() im.Platform {
return im.PlatformMattermost
}
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
return false
}
func (a *Adapter) VerifyCallback(c *gin.Context) error {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
payload, err := parseOutgoingBody(c.Request.Header.Get("Content-Type"), bodyBytes)
if err != nil {
return fmt.Errorf("parse outgoing payload: %w", err)
}
if a.outgoingToken != "" && payload.Token != a.outgoingToken {
return fmt.Errorf("invalid outgoing webhook token")
}
return nil
}
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
payload, err := parseOutgoingBody(c.Request.Header.Get("Content-Type"), bodyBytes)
if err != nil {
return nil, err
}
if a.botUserID != "" && payload.UserID == a.botUserID {
logger.Infof(c.Request.Context(), "[Mattermost] Skip callback: user_id matches bot_user_id (avoid self-reply loop)")
return nil, nil
}
if strings.TrimSpace(payload.Text) == "" && len(parseFileIDs(payload.FileIDsRaw)) == 0 {
logger.Infof(c.Request.Context(), "[Mattermost] Skip callback: empty text and no file_ids")
return nil, nil
}
var threadRoot string
if a.postReplyToMain {
threadRoot = ""
} else {
threadRoot = payload.RootID
if threadRoot == "" {
threadRoot = payload.PostID
}
}
msg := &im.IncomingMessage{
Platform: im.PlatformMattermost,
UserID: payload.UserID,
UserName: payload.UserName,
ChatID: payload.ChannelID,
ChatType: im.ChatTypeGroup,
Content: strings.TrimSpace(payload.Text),
MessageID: payload.PostID,
Extra: map[string]string{
extraKeyThreadRoot: threadRoot,
extraKeyChannelID: payload.ChannelID,
},
}
fileIDs := parseFileIDs(payload.FileIDsRaw)
if len(fileIDs) > 0 {
msg.MessageType = im.MessageTypeFile
msg.FileKey = fileIDs[0]
if len(fileIDs) > 1 {
msg.Extra["file_ids"] = strings.Join(fileIDs, ",")
}
} else {
msg.MessageType = im.MessageTypeText
}
return msg, nil
}
func parseOutgoingBody(contentType string, body []byte) (*outgoingPayload, error) {
ct := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
switch {
case ct == "application/json" || strings.HasSuffix(ct, "+json"):
var p outgoingPayload
if err := json.Unmarshal(body, &p); err != nil {
return nil, err
}
return &p, nil
case ct == "application/x-www-form-urlencoded" || ct == "":
// Parse as form (Mattermost default for some configs).
values, err := parseFormBody(body)
if err != nil {
return nil, err
}
p := &outgoingPayload{
Token: values.Get("token"),
UserID: values.Get("user_id"),
UserName: values.Get("user_name"),
ChannelID: values.Get("channel_id"),
PostID: values.Get("post_id"),
Text: values.Get("text"),
RootID: values.Get("root_id"),
}
if f := values.Get("file_ids"); f != "" {
p.FileIDsRaw = json.RawMessage(jsonArrayFromCSV(f))
}
return p, nil
default:
// Try JSON fallback for unknown types.
var p outgoingPayload
if err := json.Unmarshal(body, &p); err == nil && (p.Token != "" || p.ChannelID != "") {
return &p, nil
}
return nil, fmt.Errorf("unsupported content-type: %s", contentType)
}
}
func parseFileIDs(raw json.RawMessage) []string {
if len(raw) == 0 {
return nil
}
var arr []string
if err := json.Unmarshal(raw, &arr); err == nil {
return arr
}
var s string
if err := json.Unmarshal(raw, &s); err == nil && s != "" {
return splitFileIDs(s)
}
return nil
}
func splitFileIDs(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
channelID := incoming.ChatID
if channelID == "" {
channelID = incoming.Extra[extraKeyChannelID]
}
if channelID == "" {
return fmt.Errorf("missing channel_id")
}
threadRoot := ""
if incoming.Extra != nil {
threadRoot = incoming.Extra[extraKeyThreadRoot]
}
_, err := a.client.CreatePost(ctx, channelID, threadRoot, reply.Content)
return err
}
type mmStreamState struct {
mu sync.Mutex
content strings.Builder
postID string
channel string
}
var (
mmStreamsMu sync.Mutex
mmStreams = map[string]*mmStreamState{}
)
func (a *Adapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
channelID := incoming.ChatID
if channelID == "" {
channelID = incoming.Extra[extraKeyChannelID]
}
threadRoot := ""
if incoming.Extra != nil {
threadRoot = incoming.Extra[extraKeyThreadRoot]
}
postID, err := a.client.CreatePost(ctx, channelID, threadRoot, "正在思考...")
if err != nil {
return "", err
}
streamID := channelID + ":" + postID
mmStreamsMu.Lock()
mmStreams[streamID] = &mmStreamState{postID: postID, channel: channelID}
mmStreamsMu.Unlock()
logger.Infof(ctx, "[Mattermost] Streaming started: stream_id=%s", streamID)
return streamID, nil
}
func (a *Adapter) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
if content == "" {
return nil
}
mmStreamsMu.Lock()
state, ok := mmStreams[streamID]
mmStreamsMu.Unlock()
if !ok {
return fmt.Errorf("unknown stream ID: %s", streamID)
}
state.mu.Lock()
state.content.WriteString(content)
full := state.content.String()
postID := state.postID
state.mu.Unlock()
if err := a.client.PatchPostMessage(ctx, postID, full); err != nil {
logger.Warnf(ctx, "[Mattermost] Patch post failed: %v", err)
}
return nil
}
func (a *Adapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
mmStreamsMu.Lock()
state, ok := mmStreams[streamID]
delete(mmStreams, streamID)
mmStreamsMu.Unlock()
if !ok {
return nil
}
state.mu.Lock()
full := state.content.String()
postID := state.postID
state.mu.Unlock()
if err := a.client.PatchPostMessage(ctx, postID, full); err != nil {
logger.Warnf(ctx, "[Mattermost] EndStream patch failed: %v", err)
}
logger.Infof(ctx, "[Mattermost] Streaming ended: post_id=%s", postID)
return nil
}
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
if msg.FileKey == "" {
return nil, "", fmt.Errorf("file_key is required")
}
info, err := a.client.GetFileInfo(ctx, msg.FileKey)
if err != nil {
return nil, "", fmt.Errorf("file info: %w", err)
}
name := info.Name
if name == "" {
name = msg.FileName
}
if name == "" {
name = msg.FileKey
}
rc, err := a.client.GetFileReader(ctx, msg.FileKey)
if err != nil {
return nil, "", err
}
return rc, name, nil
}

View File

@@ -0,0 +1,182 @@
package mattermost
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client calls Mattermost REST API v4.
type Client struct {
baseURL string
httpClient *http.Client
token string
}
// NewClient builds an API client. siteURL is the Mattermost server root (e.g. https://mm.example.com).
func NewClient(siteURL, botToken string) (*Client, error) {
siteURL = strings.TrimSpace(siteURL)
siteURL = strings.TrimRight(siteURL, "/")
if siteURL == "" {
return nil, fmt.Errorf("site_url is required")
}
if strings.TrimSpace(botToken) == "" {
return nil, fmt.Errorf("bot_token is required")
}
return &Client{
baseURL: siteURL + "/api/v4",
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
token: strings.TrimSpace(botToken),
}, nil
}
func (c *Client) authHeader(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
}
// CreatePost creates a channel post. rootID is the thread root post id (empty for new top-level post).
func (c *Client) CreatePost(ctx context.Context, channelID, rootID, message string) (postID string, err error) {
body := map[string]string{
"channel_id": channelID,
"message": message,
}
if rootID != "" {
body["root_id"] = rootID
}
payload, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/posts", bytes.NewReader(payload))
if err != nil {
return "", err
}
c.authHeader(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if resp.StatusCode == http.StatusForbidden {
return "", fmt.Errorf("mattermost create post: 403 forbidden — add the bot user to this Mattermost channel (Channel menu → Members → Add); body=%s", truncateForErr(respBody))
}
return "", fmt.Errorf("mattermost create post: status=%d body=%s", resp.StatusCode, truncateForErr(respBody))
}
var created struct {
ID string `json:"id"`
}
if err := json.Unmarshal(respBody, &created); err != nil {
return "", fmt.Errorf("decode create post: %w", err)
}
if created.ID == "" {
return "", fmt.Errorf("mattermost create post: empty id")
}
return created.ID, nil
}
// PatchPostMessage updates a post's message field.
func (c *Client) PatchPostMessage(ctx context.Context, postID, message string) error {
payload, err := json.Marshal(map[string]string{"message": message})
if err != nil {
return err
}
url := fmt.Sprintf("%s/posts/%s/patch", c.baseURL, postID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload))
if err != nil {
return err
}
c.authHeader(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("mattermost patch post: status=%d body=%s", resp.StatusCode, truncateForErr(respBody))
}
return nil
}
// FileInfo holds metadata from GET /files/{id}/info.
type FileInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
}
// GetFileInfo fetches file metadata.
func (c *Client) GetFileInfo(ctx context.Context, fileID string) (*FileInfo, error) {
url := fmt.Sprintf("%s/files/%s/info", c.baseURL, fileID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
c.authHeader(req)
req.Header.Del("Content-Type")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("mattermost file info: status=%d body=%s", resp.StatusCode, truncateForErr(respBody))
}
var info FileInfo
if err := json.Unmarshal(respBody, &info); err != nil {
return nil, fmt.Errorf("decode file info: %w", err)
}
return &info, nil
}
// GetFileReader opens a download stream for file content.
func (c *Client) GetFileReader(ctx context.Context, fileID string) (io.ReadCloser, error) {
url := fmt.Sprintf("%s/files/%s", c.baseURL, fileID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
c.authHeader(req)
req.Header.Del("Content-Type")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("mattermost get file: status=%d body=%s", resp.StatusCode, truncateForErr(body))
}
return resp.Body, nil
}
func truncateForErr(b []byte) string {
const max = 512
s := string(b)
if len(s) > max {
return s[:max] + "..."
}
return s
}

View File

@@ -0,0 +1,27 @@
package mattermost
import (
"encoding/json"
"fmt"
"net/url"
)
func parseFormBody(body []byte) (url.Values, error) {
values, err := url.ParseQuery(string(body))
if err != nil {
return nil, fmt.Errorf("parse form: %w", err)
}
return values, nil
}
func jsonArrayFromCSV(csv string) []byte {
parts := splitFileIDs(csv)
if len(parts) == 0 {
return []byte("[]")
}
b, err := json.Marshal(parts)
if err != nil {
return []byte("[]")
}
return b
}

View File

@@ -39,7 +39,11 @@ func (ch *IMChannel) BeforeCreate(tx *gorm.DB) error {
ch.ID = uuid.New().String()
}
if ch.Mode == "" {
ch.Mode = "websocket"
if ch.Platform == "mattermost" {
ch.Mode = "webhook"
} else {
ch.Mode = "websocket"
}
}
if ch.OutputMode == "" {
ch.OutputMode = "stream"
@@ -104,6 +108,10 @@ func (ch *IMChannel) computeBotIdentity() string {
if clientID := str("client_id"); clientID != "" {
return "dingtalk:" + clientID
}
case "mattermost":
if tok := str("outgoing_token"); tok != "" {
return "mattermost:wh:" + tok
}
}
return ""
}

View File

@@ -18,10 +18,13 @@ import (
// 无需认证的API列表
var noAuthAPI = map[string][]string{
"/health": {"GET"},
"/api/v1/auth/register": {"POST"},
"/api/v1/auth/login": {"POST"},
"/api/v1/auth/refresh": {"POST"},
"/health": {"GET"},
"/api/v1/auth/register": {"POST"},
"/api/v1/auth/login": {"POST"},
"/api/v1/auth/oidc/config": {"GET"},
"/api/v1/auth/oidc/url": {"GET"},
"/api/v1/auth/oidc/callback": {"GET"},
"/api/v1/auth/refresh": {"POST"},
}
// 检查请求是否在无需认证的API列表中

View File

@@ -510,6 +510,7 @@ func (c *RemoteAPIChat) processStream(ctx context.Context, stream *openai.ChatCo
Done: true,
ToolCalls: state.buildOrderedToolCalls(),
Usage: state.usage,
FinishReason: state.lastFinishReason,
}
} else {
streamChan <- types.StreamResponse{
@@ -633,6 +634,7 @@ type streamState struct {
hasThinking bool
fieldExtractors map[int]*jsonFieldExtractor // per tool-call-index extractors for streaming field extraction
usage *types.TokenUsage // captured from the final stream chunk when include_usage is enabled
lastFinishReason string // last observed finish_reason for EOF handler fallback
}
func newStreamState() *streamState {
@@ -666,6 +668,11 @@ func (c *RemoteAPIChat) processStreamDelta(ctx context.Context, choice *openai.C
delta := choice.Delta
isDone := string(choice.FinishReason) != ""
// Track finish_reason for EOF handler fallback
if isDone {
state.lastFinishReason = string(choice.FinishReason)
}
// 处理 tool calls
if len(delta.ToolCalls) > 0 {
c.processToolCallsDelta(ctx, delta.ToolCalls, state, streamChan)
@@ -698,6 +705,7 @@ func (c *RemoteAPIChat) processStreamDelta(ctx context.Context, choice *openai.C
Content: delta.Content,
Done: isDone,
ToolCalls: state.buildOrderedToolCalls(),
FinishReason: string(choice.FinishReason),
}
}
@@ -707,6 +715,7 @@ func (c *RemoteAPIChat) processStreamDelta(ctx context.Context, choice *openai.C
Content: "",
Done: true,
ToolCalls: state.buildOrderedToolCalls(),
FinishReason: string(choice.FinishReason),
}
}
@@ -720,6 +729,17 @@ func (c *RemoteAPIChat) processStreamDelta(ctx context.Context, choice *openai.C
}
state.hasThinking = false
}
// Catch-all: isDone but none of the above branches sent a response with
// FinishReason (empty content, no tool calls, no thinking). This prevents
// the finish_reason from being lost in the streaming pipeline.
if isDone && delta.Content == "" && len(state.toolCallMap) == 0 && !state.hasThinking {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Done: true,
FinishReason: string(choice.FinishReason),
}
}
}
// processToolCallsDelta 处理 tool calls 的增量更新

View File

@@ -393,6 +393,9 @@ func RegisterEvaluationRoutes(r *gin.RouterGroup, handler *handler.EvaluationHan
func RegisterAuthRoutes(r *gin.RouterGroup, handler *handler.AuthHandler) {
r.POST("/auth/register", handler.Register)
r.POST("/auth/login", handler.Login)
r.GET("/auth/oidc/config", handler.GetOIDCConfig)
r.GET("/auth/oidc/url", handler.GetOIDCAuthorizationURL)
r.GET("/auth/oidc/callback", handler.OIDCRedirectCallback)
r.POST("/auth/refresh", handler.RefreshToken)
r.GET("/auth/validate", handler.ValidateToken)
r.POST("/auth/logout", handler.Logout)

View File

@@ -71,6 +71,7 @@ type StreamResponse struct {
ToolCalls []LLMToolCall `json:"tool_calls,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
Usage *TokenUsage `json:"usage,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
}
// References references

View File

@@ -12,6 +12,10 @@ type UserService interface {
Register(ctx context.Context, req *types.RegisterRequest) (*types.User, error)
// Login authenticates a user and returns tokens
Login(ctx context.Context, req *types.LoginRequest) (*types.LoginResponse, error)
// GetOIDCAuthorizationURL builds the third-party OIDC authorization URL
GetOIDCAuthorizationURL(ctx context.Context, redirectURI string) (*types.OIDCAuthURLResponse, error)
// LoginWithOIDC exchanges the callback code, auto-provisions users if needed, and completes login
LoginWithOIDC(ctx context.Context, code, redirectURI string) (*types.OIDCCallbackResponse, error)
// GetUserByID gets a user by ID
GetUserByID(ctx context.Context, id string) (*types.User, error)
// GetUserByEmail gets a user by email

View File

@@ -64,6 +64,36 @@ type LoginRequest struct {
Password string `json:"password" binding:"required,min=6"`
}
type OIDCAuthURLResponse struct {
Success bool `json:"success"`
ProviderDisplayName string `json:"provider_display_name,omitempty"`
AuthorizationURL string `json:"authorization_url,omitempty"`
State string `json:"state,omitempty"`
}
type OIDCConfigResponse struct {
Success bool `json:"success"`
Enabled bool `json:"enabled"`
ProviderDisplayName string `json:"provider_display_name,omitempty"`
}
type OIDCCallbackResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *User `json:"user,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
IsNewUser bool `json:"is_new_user,omitempty"`
}
type OIDCUserInfo struct {
Subject string `json:"subject,omitempty"`
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
Claims map[string]interface{} `json:"claims,omitempty"`
}
// RegisterRequest represents a registration request
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=2,max=50"`

20
misc/dex-config.yaml Normal file
View File

@@ -0,0 +1,20 @@
# config.yamlreference https://github.com/dexidp/dex/blob/master/examples/config-dev.yaml
issuer: http://127.0.0.1:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
staticClients:
- id: weknora
redirectURIs:
- 'http://127.0.0.1:5173/api/v1/auth/oidc/callback'
- 'http://127.0.0.1/api/v1/auth/oidc/callback'
name: 'WeKnora'
# add a "secret" field here
enablePasswordDB: true
oauth2:
responseTypes:
- code
- id_token
- token
skipApprovalScreen: true

View File

@@ -69,12 +69,14 @@ show_help() {
echo " --qdrant 启动 Qdrant 向量数据库"
echo " --neo4j 启动 Neo4j 图数据库"
echo " --jaeger 启动 Jaeger 链路追踪"
echo " --dex 启动 DexOIDC 身份认证)"
echo " --full 启动所有可选服务"
echo ""
echo "示例:"
echo " $0 start # 启动基础服务"
echo " $0 start --qdrant # 启动基础服务 + Qdrant"
echo " $0 start --qdrant --jaeger # 启动基础服务 + Qdrant + Jaeger"
echo " $0 start --dex # 启动基础服务 + Dex"
echo " $0 start --full # 启动所有服务"
echo " $0 app # 在另一个终端启动后端"
echo " $0 frontend # 在另一个终端启动前端"
@@ -140,9 +142,13 @@ start_services() {
PROFILES="$PROFILES --profile jaeger"
ENABLED_SERVICES="$ENABLED_SERVICES jaeger"
;;
--dex)
PROFILES="$PROFILES --profile dex"
ENABLED_SERVICES="$ENABLED_SERVICES dex"
;;
--full)
PROFILES="--profile full"
ENABLED_SERVICES="minio qdrant neo4j jaeger"
ENABLED_SERVICES="minio qdrant neo4j jaeger dex"
break
;;
*)
@@ -176,6 +182,9 @@ start_services() {
if [[ "$ENABLED_SERVICES" == *"jaeger"* ]]; then
echo " - Jaeger: localhost:16686"
fi
if [[ "$ENABLED_SERVICES" == *"dex"* ]]; then
echo " - Dex: localhost:5556"
fi
echo ""
log_info "接下来的步骤:"
@@ -350,4 +359,3 @@ case "$CMD" in
esac
exit 0