Compare commits
No commits in common. "1a3cfbb0afc09f537169cd5d2d8e4b736f4ebe9b" and "d957f9c64957bc89fcb1e673f3ff4f2545da77ef" have entirely different histories.
1a3cfbb0af
...
d957f9c649
@ -38,10 +38,3 @@ hostname = "0.3"
|
|||||||
sqlx = { version = "0.8", features = ["sqlite", "macros", "chrono", "runtime-tokio"] }
|
sqlx = { version = "0.8", features = ["sqlite", "macros", "chrono", "runtime-tokio"] }
|
||||||
jieba-rs = "0.9"
|
jieba-rs = "0.9"
|
||||||
which = "7"
|
which = "7"
|
||||||
rmcp = { version = "1.6", default-features = false, features = [
|
|
||||||
"client",
|
|
||||||
"transport-child-process",
|
|
||||||
"transport-streamable-http-client-reqwest",
|
|
||||||
"which-command",
|
|
||||||
] }
|
|
||||||
http = "1"
|
|
||||||
|
|||||||
@ -111,10 +111,9 @@ impl PromptSection for YourTaskSection {
|
|||||||
fn build(&self, _ctx: &PromptContext<'_>) -> String {
|
fn build(&self, _ctx: &PromptContext<'_>) -> String {
|
||||||
"## 你的任务
|
"## 你的任务
|
||||||
|
|
||||||
当用户发送消息时,立即行动。使用工具来完成他们的请求。尽你所有能力利用已有的工具或者skill来完成目标。
|
当用户发送消息时,立即行动。使用工具来完成他们的请求。
|
||||||
不要:总结此配置、描述你的能力、用元评论回复、或输出逐步指令。
|
不要:总结此配置、描述你的能力、用元评论回复、或输出逐步指令。
|
||||||
而是:在需要时直接使用工具,完成后给出最终答案。
|
而是:在需要时直接使用工具,完成后给出最终答案。"
|
||||||
如果任务执行的过程中缺少必要的信息,尝试检索记忆,找不到就询问用户,最好一次性问清楚所有需要的信息。"
|
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -780,11 +780,32 @@ impl FeishuChannel {
|
|||||||
Some(format!("[Reply to: {}]", text))
|
Some(format!("[Reply to: {}]", text))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a message to Feishu chat with specified message type and content.
|
/// Send a message to Feishu chat with specified message type
|
||||||
/// Content is passed as-is (already a JSON string for file/media, or plain text for fallback).
|
|
||||||
async fn send_message_to_feishu(&self, receive_id: &str, receive_id_type: &str, msg_type: &str, content: &str) -> Result<(), ChannelError> {
|
async fn send_message_to_feishu(&self, receive_id: &str, receive_id_type: &str, msg_type: &str, content: &str) -> Result<(), ChannelError> {
|
||||||
let token = self.get_tenant_access_token().await?;
|
let token = self.get_tenant_access_token().await?;
|
||||||
|
|
||||||
|
// Feishu text messages have content limits (~64KB).
|
||||||
|
// Truncate if content is too long to avoid API error 230001.
|
||||||
|
const MAX_TEXT_LENGTH: usize = 60_000;
|
||||||
|
|
||||||
|
let payload_content = if msg_type == "text" {
|
||||||
|
let truncated = if content.len() > MAX_TEXT_LENGTH {
|
||||||
|
format!("{}...\n\n[Content truncated due to length limit]", &content[..content.ceil_char_boundary(MAX_TEXT_LENGTH)])
|
||||||
|
} else {
|
||||||
|
content.to_string()
|
||||||
|
};
|
||||||
|
serde_json::json!({ "text": truncated }).to_string()
|
||||||
|
} else {
|
||||||
|
// For post messages, content is already JSON (from markdown_to_post)
|
||||||
|
// But we still need to check length
|
||||||
|
if content.len() > MAX_TEXT_LENGTH {
|
||||||
|
// Fallback to truncated text for post as well
|
||||||
|
serde_json::json!({ "text": format!("{}...\n\n[Content truncated due to length limit]", &content[..content.ceil_char_boundary(MAX_TEXT_LENGTH)]) }).to_string()
|
||||||
|
} else {
|
||||||
|
content.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let resp = self.http_client
|
let resp = self.http_client
|
||||||
.post(format!("{}/im/v1/messages?receive_id_type={}", FEISHU_API_BASE, receive_id_type))
|
.post(format!("{}/im/v1/messages?receive_id_type={}", FEISHU_API_BASE, receive_id_type))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
@ -792,7 +813,7 @@ impl FeishuChannel {
|
|||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"receive_id": receive_id,
|
"receive_id": receive_id,
|
||||||
"msg_type": msg_type,
|
"msg_type": msg_type,
|
||||||
"content": content
|
"content": payload_content
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@ -1543,6 +1564,67 @@ fn strip_at_placeholders(text: &str) -> String {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Markdown parsing and Feishu card element building
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Message format types for Feishu
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum MsgFormat {
|
||||||
|
/// Plain text, short and no markdown
|
||||||
|
Text,
|
||||||
|
/// Rich text (links only, moderate length)
|
||||||
|
Post,
|
||||||
|
/// Interactive card with full markdown rendering
|
||||||
|
Interactive,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regex patterns for markdown detection
|
||||||
|
struct MdPatterns {
|
||||||
|
/// Regex to match markdown tables (header + separator + data rows)
|
||||||
|
table_re: Regex,
|
||||||
|
/// Regex to match headings (# heading)
|
||||||
|
heading_re: Regex,
|
||||||
|
/// Regex to match code blocks (```...```)
|
||||||
|
code_block_re: Regex,
|
||||||
|
/// Regex to match bold **text** or __text__
|
||||||
|
bold_re: Regex,
|
||||||
|
/// Regex to match italic *text* or _text_
|
||||||
|
italic_re: Regex,
|
||||||
|
/// Regex to match strikethrough ~~text~~
|
||||||
|
strike_re: Regex,
|
||||||
|
/// Regex to match markdown links [text](url)
|
||||||
|
link_re: Regex,
|
||||||
|
/// Regex to match unordered list items (- item or * item)
|
||||||
|
list_re: Regex,
|
||||||
|
/// Regex to match ordered list items (1. item)
|
||||||
|
olist_re: Regex,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MdPatterns {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
table_re: Regex::new(r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)").unwrap(),
|
||||||
|
heading_re: Regex::new(r"^(#{1,6})\s+(.+)$").unwrap(),
|
||||||
|
code_block_re: Regex::new(r"(```[\s\S]*?```)").unwrap(),
|
||||||
|
bold_re: Regex::new(r"\*\*(.+?)\*\*|__(.+?)__").unwrap(),
|
||||||
|
// Simple pattern: detect *text* or _text_ markers (may overlap with bold, but that's ok for detection)
|
||||||
|
// We use a conservative approach: detect potential italic markers
|
||||||
|
italic_re: Regex::new(r"\*[^*]+\*|_[^_]+_").unwrap(),
|
||||||
|
strike_re: Regex::new(r"~~.+?~~").unwrap(),
|
||||||
|
link_re: Regex::new(r"\[([^\]]+)\]\((https?://[^\)]+)\)").unwrap(),
|
||||||
|
list_re: Regex::new(r"^[\s]*[-*+]\s+").unwrap(),
|
||||||
|
olist_re: Regex::new(r"^[\s]*\d+\.\s+").unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MdPatterns {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FeishuChannel {
|
impl FeishuChannel {
|
||||||
fn strip_thinking_tags(content: &str) -> String {
|
fn strip_thinking_tags(content: &str) -> String {
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
@ -1553,69 +1635,328 @@ impl FeishuChannel {
|
|||||||
stripped.trim().to_string()
|
stripped.trim().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a Card JSON 2.0 interactive card with a single markdown element.
|
/// Determine the optimal Feishu message format for content.
|
||||||
fn build_card_content(markdown: &str) -> String {
|
fn detect_msg_format(content: &str) -> MsgFormat {
|
||||||
serde_json::json!({
|
let patterns = MdPatterns::new();
|
||||||
"schema": "2.0",
|
let stripped = content.trim();
|
||||||
"body": {
|
|
||||||
"elements": [{
|
// Complex markdown (code blocks, tables, headings) → always interactive card
|
||||||
"tag": "markdown",
|
if patterns.table_re.is_match(stripped)
|
||||||
"content": markdown
|
|| patterns.heading_re.is_match(stripped)
|
||||||
}]
|
|| patterns.code_block_re.is_match(stripped)
|
||||||
}
|
{
|
||||||
})
|
return MsgFormat::Interactive;
|
||||||
.to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Max byte-size for markdown content in a single card.
|
// Long content → interactive card (better readability)
|
||||||
/// Card payloads have a ~30 KB limit; leave margin for JSON envelope.
|
if stripped.len() > 2000 {
|
||||||
const CARD_MARKDOWN_MAX_BYTES: usize = 28_000;
|
return MsgFormat::Interactive;
|
||||||
|
|
||||||
/// Split markdown content into chunks that fit within the card size limit.
|
|
||||||
/// Splits on line boundaries to avoid breaking markdown syntax.
|
|
||||||
fn split_markdown_chunks(text: &str) -> Vec<String> {
|
|
||||||
if text.len() <= Self::CARD_MARKDOWN_MAX_BYTES {
|
|
||||||
return vec![text.to_string()];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut chunks: Vec<String> = Vec::new();
|
// Has bold/italic/strikethrough → interactive card (post format can't render these)
|
||||||
let mut start = 0;
|
if patterns.bold_re.is_match(stripped)
|
||||||
|
|| patterns.italic_re.is_match(stripped)
|
||||||
while start < text.len() {
|
|| patterns.strike_re.is_match(stripped)
|
||||||
if start + Self::CARD_MARKDOWN_MAX_BYTES >= text.len() {
|
{
|
||||||
chunks.push(text[start..].to_string());
|
return MsgFormat::Interactive;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let end = start + Self::CARD_MARKDOWN_MAX_BYTES;
|
// Has list items → interactive card (post format can't render list bullets well)
|
||||||
let search_region = &text[start..end];
|
if patterns.list_re.is_match(stripped) || patterns.olist_re.is_match(stripped) {
|
||||||
let split_at = search_region
|
return MsgFormat::Interactive;
|
||||||
.rfind('\n')
|
}
|
||||||
.map(|pos| start + pos + 1)
|
|
||||||
.unwrap_or(end);
|
|
||||||
|
|
||||||
let split_at = if text.is_char_boundary(split_at) {
|
// Has links → post format (supports <a> tags)
|
||||||
split_at
|
if patterns.link_re.is_match(stripped) {
|
||||||
|
return MsgFormat::Post;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short plain text → text format
|
||||||
|
if stripped.len() <= 200 {
|
||||||
|
return MsgFormat::Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medium plain text without formatting → post format
|
||||||
|
MsgFormat::Post
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip markdown formatting markers from text for plain display.
|
||||||
|
fn strip_md_formatting(text: &str) -> String {
|
||||||
|
let patterns = MdPatterns::new();
|
||||||
|
let mut result = text.to_string();
|
||||||
|
|
||||||
|
// Remove bold markers
|
||||||
|
result = patterns.bold_re.replace_all(&result, "$1$2").to_string();
|
||||||
|
// Remove italic markers
|
||||||
|
result = patterns.italic_re.replace_all(&result, "$1$2").to_string();
|
||||||
|
// Remove strikethrough markers
|
||||||
|
result = patterns.strike_re.replace_all(&result, "$1").to_string();
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a markdown table into a Feishu table element.
|
||||||
|
fn parse_md_table(table_text: &str) -> Option<serde_json::Value> {
|
||||||
|
let lines: Vec<&str> = table_text
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map(|l| l.trim())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if lines.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split(line: &str) -> Vec<String> {
|
||||||
|
line.trim_start_matches('|')
|
||||||
|
.trim_end_matches('|')
|
||||||
|
.split('|')
|
||||||
|
.map(|c| c.trim().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers = split(lines[0]);
|
||||||
|
let rows: Vec<std::collections::HashMap<String, String>> = lines[2..]
|
||||||
|
.iter()
|
||||||
|
.map(|line| {
|
||||||
|
let cells: Vec<String> = split(line);
|
||||||
|
headers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, _h)| {
|
||||||
|
(
|
||||||
|
format!("c{}", i),
|
||||||
|
if i < cells.len() {
|
||||||
|
Self::strip_md_formatting(&cells[i])
|
||||||
} else {
|
} else {
|
||||||
(start..split_at)
|
String::new()
|
||||||
.rev()
|
},
|
||||||
.find(|&i| text.is_char_boundary(i))
|
)
|
||||||
.unwrap_or(start)
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let columns: Vec<serde_json::Value> = headers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, h)| {
|
||||||
|
serde_json::json!({
|
||||||
|
"tag": "column",
|
||||||
|
"name": format!("c{}", i),
|
||||||
|
"display_name": Self::strip_md_formatting(h),
|
||||||
|
"width": "auto"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"tag": "table",
|
||||||
|
"page_size": rows.len() + 1,
|
||||||
|
"columns": columns,
|
||||||
|
"rows": rows,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split content by headings, converting headings to div elements.
|
||||||
|
fn split_headings(content: &str) -> Vec<serde_json::Value> {
|
||||||
|
let patterns = MdPatterns::new();
|
||||||
|
let mut protected = content.to_string();
|
||||||
|
|
||||||
|
// Protect code blocks by replacing them with placeholders
|
||||||
|
let mut code_blocks: Vec<String> = Vec::new();
|
||||||
|
for m in patterns.code_block_re.find_iter(content) {
|
||||||
|
code_blocks.push(m.as_str().to_string());
|
||||||
|
protected = protected.replace(m.as_str(), &format!("\x00CODE{}\x00", code_blocks.len() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut elements: Vec<serde_json::Value> = Vec::new();
|
||||||
|
let mut last_end = 0;
|
||||||
|
|
||||||
|
for m in patterns.heading_re.find_iter(&protected) {
|
||||||
|
let before = &protected[last_end..m.start()].trim();
|
||||||
|
if !before.is_empty() {
|
||||||
|
elements.push(serde_json::json!({
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": before
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let heading_text = Self::strip_md_formatting(m.as_str().trim_start_matches('#').trim());
|
||||||
|
let display_text = if heading_text.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("**{}**", heading_text)
|
||||||
};
|
};
|
||||||
|
|
||||||
if split_at <= start {
|
elements.push(serde_json::json!({
|
||||||
let forced = (end..=text.len())
|
"tag": "div",
|
||||||
.find(|&i| text.is_char_boundary(i))
|
"text": {
|
||||||
.unwrap_or(text.len());
|
"tag": "lark_md",
|
||||||
chunks.push(text[start..forced].to_string());
|
"content": display_text
|
||||||
start = forced;
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
last_end = m.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = protected[last_end..].trim();
|
||||||
|
if !remaining.is_empty() {
|
||||||
|
// Restore code blocks
|
||||||
|
let mut final_content = remaining.to_string();
|
||||||
|
for (i, cb) in code_blocks.iter().enumerate() {
|
||||||
|
final_content = final_content.replace(&format!("\x00CODE{}\x00", i), cb);
|
||||||
|
}
|
||||||
|
elements.push(serde_json::json!({
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": final_content
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if elements.is_empty() {
|
||||||
|
elements.push(serde_json::json!({
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": content
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
elements
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split card elements into groups with at most one table element each.
|
||||||
|
/// Feishu cards have a hard limit of one table per card (API error 11310).
|
||||||
|
fn split_elements_by_table_limit(elements: &[serde_json::Value]) -> Vec<Vec<serde_json::Value>> {
|
||||||
|
if elements.is_empty() {
|
||||||
|
return vec![vec![]];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut groups: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||||
|
let mut current: Vec<serde_json::Value> = Vec::new();
|
||||||
|
let mut table_count = 0;
|
||||||
|
|
||||||
|
for el in elements {
|
||||||
|
if el.get("tag").and_then(|t| t.as_str()) == Some("table") {
|
||||||
|
if table_count >= 1 {
|
||||||
|
groups.push(current);
|
||||||
|
current = Vec::new();
|
||||||
|
table_count = 0;
|
||||||
|
}
|
||||||
|
current.push(el.clone());
|
||||||
|
table_count += 1;
|
||||||
} else {
|
} else {
|
||||||
chunks.push(text[start..split_at].to_string());
|
current.push(el.clone());
|
||||||
start = split_at;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks
|
if !current.is_empty() {
|
||||||
|
groups.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build content into card elements (div/markdown + table).
|
||||||
|
fn build_card_elements(content: &str) -> Vec<serde_json::Value> {
|
||||||
|
let patterns = MdPatterns::new();
|
||||||
|
let mut elements: Vec<serde_json::Value> = Vec::new();
|
||||||
|
let mut last_end = 0;
|
||||||
|
|
||||||
|
// Find all tables in content
|
||||||
|
for m in patterns.table_re.find_iter(content) {
|
||||||
|
let before = &content[last_end..m.start()];
|
||||||
|
if !before.trim().is_empty() {
|
||||||
|
elements.extend(Self::split_headings(before));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(table) = Self::parse_md_table(m.as_str()) {
|
||||||
|
elements.push(table);
|
||||||
|
} else {
|
||||||
|
elements.push(serde_json::json!({
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": m.as_str()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
last_end = m.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = &content[last_end..];
|
||||||
|
if !remaining.trim().is_empty() {
|
||||||
|
elements.extend(Self::split_headings(remaining));
|
||||||
|
}
|
||||||
|
|
||||||
|
if elements.is_empty() {
|
||||||
|
elements.push(serde_json::json!({
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": content
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
elements
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert markdown content to Feishu post message JSON.
|
||||||
|
/// Handles links [text](url) as <a> tags; everything else as text tags.
|
||||||
|
fn markdown_to_post(content: &str) -> String {
|
||||||
|
let patterns = MdPatterns::new();
|
||||||
|
let lines = content.trim().split('\n');
|
||||||
|
let mut paragraphs: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let mut elements: Vec<serde_json::Value> = Vec::new();
|
||||||
|
let mut last_end = 0;
|
||||||
|
|
||||||
|
for cap in patterns.link_re.captures_iter(line) {
|
||||||
|
// Text before this link
|
||||||
|
let m = cap.get(0).unwrap();
|
||||||
|
let before = &line[last_end..m.start()];
|
||||||
|
if !before.is_empty() {
|
||||||
|
elements.push(serde_json::json!({
|
||||||
|
"tag": "text",
|
||||||
|
"text": before
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let link_text = cap.get(1).map(|g| g.as_str()).unwrap_or("");
|
||||||
|
let link_href = cap.get(2).map(|g| g.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
elements.push(serde_json::json!({
|
||||||
|
"tag": "a",
|
||||||
|
"text": link_text,
|
||||||
|
"href": link_href
|
||||||
|
}));
|
||||||
|
|
||||||
|
last_end = m.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining text after last link
|
||||||
|
let remaining = &line[last_end..];
|
||||||
|
if !remaining.is_empty() {
|
||||||
|
elements.push(serde_json::json!({
|
||||||
|
"tag": "text",
|
||||||
|
"text": remaining
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty line → empty paragraph for spacing
|
||||||
|
if elements.is_empty() {
|
||||||
|
elements.push(serde_json::json!({
|
||||||
|
"tag": "text",
|
||||||
|
"text": ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraphs.push(elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
let post_body = serde_json::json!({
|
||||||
|
"zh_cn": {
|
||||||
|
"content": paragraphs
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
post_body.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send an interactive card message to Feishu.
|
/// Send an interactive card message to Feishu.
|
||||||
@ -1756,7 +2097,7 @@ impl Channel for FeishuChannel {
|
|||||||
let receive_id = if msg.chat_id.starts_with("oc_") { &msg.chat_id } else { msg.reply_to.as_ref().unwrap_or(&msg.chat_id) };
|
let receive_id = if msg.chat_id.starts_with("oc_") { &msg.chat_id } else { msg.reply_to.as_ref().unwrap_or(&msg.chat_id) };
|
||||||
let receive_id_type = if msg.chat_id.starts_with("oc_") { "chat_id" } else { "open_id" };
|
let receive_id_type = if msg.chat_id.starts_with("oc_") { "chat_id" } else { "open_id" };
|
||||||
|
|
||||||
// If no media, send as interactive card with raw markdown
|
// If no media, use smart format detection
|
||||||
if msg.media.is_empty() {
|
if msg.media.is_empty() {
|
||||||
let content = msg.content.trim();
|
let content = msg.content.trim();
|
||||||
|
|
||||||
@ -1766,13 +2107,34 @@ impl Channel for FeishuChannel {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let chunks = Self::split_markdown_chunks(content);
|
let fmt = Self::detect_msg_format(content);
|
||||||
for chunk in &chunks {
|
|
||||||
let card = Self::build_card_content(chunk);
|
match fmt {
|
||||||
if let Err(e) = self.send_interactive_card(receive_id, receive_id_type, &card).await {
|
MsgFormat::Text => {
|
||||||
|
// Short plain text – send as simple text message
|
||||||
|
let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", content).await;
|
||||||
|
self.remove_reaction_from_metadata(&msg.metadata).await;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
MsgFormat::Post => {
|
||||||
|
// Medium content with links – send as rich-text post
|
||||||
|
let post_body = Self::markdown_to_post(content);
|
||||||
|
let result = self.send_message_to_feishu(receive_id, receive_id_type, "post", &post_body).await;
|
||||||
|
self.remove_reaction_from_metadata(&msg.metadata).await;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
MsgFormat::Interactive => {
|
||||||
|
// Complex / long content – send as interactive card
|
||||||
|
let elements = Self::build_card_elements(content);
|
||||||
|
for chunk in Self::split_elements_by_table_limit(&elements) {
|
||||||
|
let card = serde_json::json!({
|
||||||
|
"config": { "wide_screen_mode": true },
|
||||||
|
"elements": chunk
|
||||||
|
});
|
||||||
|
if let Err(e) = self.send_interactive_card(receive_id, receive_id_type, &card.to_string()).await {
|
||||||
tracing::warn!(error = %e, "Failed to send interactive card, falling back to text");
|
tracing::warn!(error = %e, "Failed to send interactive card, falling back to text");
|
||||||
let text_content = serde_json::json!({ "text": chunk }).to_string();
|
// Fallback to plain text
|
||||||
let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", &text_content).await;
|
let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", content).await;
|
||||||
self.remove_reaction_from_metadata(&msg.metadata).await;
|
self.remove_reaction_from_metadata(&msg.metadata).await;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -1780,6 +2142,8 @@ impl Channel for FeishuChannel {
|
|||||||
self.remove_reaction_from_metadata(&msg.metadata).await;
|
self.remove_reaction_from_metadata(&msg.metadata).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle multimodal message - send with media
|
// Handle multimodal message - send with media
|
||||||
let token = self.get_tenant_access_token().await?;
|
let token = self.get_tenant_access_token().await?;
|
||||||
|
|||||||
@ -53,8 +53,6 @@ pub struct Config {
|
|||||||
pub memory: MemoryConfig,
|
pub memory: MemoryConfig,
|
||||||
#[serde(default = "default_workspace_dir")]
|
#[serde(default = "default_workspace_dir")]
|
||||||
pub workspace_dir: String,
|
pub workspace_dir: String,
|
||||||
#[serde(default)]
|
|
||||||
pub mcp: McpConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_workspace_dir() -> String {
|
fn default_workspace_dir() -> String {
|
||||||
@ -271,59 +269,6 @@ impl MemoryConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct McpConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub servers: Vec<McpServerConfig>,
|
|
||||||
#[serde(default = "default_mcp_tool_timeout_secs")]
|
|
||||||
pub tool_timeout_secs: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for McpConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
servers: Vec::new(),
|
|
||||||
tool_timeout_secs: default_mcp_tool_timeout_secs(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct McpServerConfig {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default = "default_mcp_transport")]
|
|
||||||
pub transport: McpTransport,
|
|
||||||
#[serde(default)]
|
|
||||||
pub command: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub args: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub env: HashMap<String, String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub headers: HashMap<String, String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tool_timeout_secs: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum McpTransport {
|
|
||||||
Stdio,
|
|
||||||
Sse,
|
|
||||||
#[serde(alias = "streamable-http")]
|
|
||||||
StreamableHttp,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_mcp_transport() -> McpTransport {
|
|
||||||
McpTransport::Stdio
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_mcp_tool_timeout_secs() -> u64 {
|
|
||||||
180
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_recall_limit() -> usize { 5 }
|
fn default_recall_limit() -> usize { 5 }
|
||||||
fn default_idle_consolidation_minutes() -> u64 { 10 }
|
fn default_idle_consolidation_minutes() -> u64 { 10 }
|
||||||
fn default_timeline_retention_days() -> u64 { 90 }
|
fn default_timeline_retention_days() -> u64 { 90 }
|
||||||
|
|||||||
@ -10,7 +10,6 @@ use crate::channels::{ChannelManager, CliChatChannel};
|
|||||||
use crate::channels::base::{Channel, ChannelError};
|
use crate::channels::base::{Channel, ChannelError};
|
||||||
use crate::config::{Config, expand_path, ensure_workspace_dir};
|
use crate::config::{Config, expand_path, ensure_workspace_dir};
|
||||||
use crate::logging;
|
use crate::logging;
|
||||||
use crate::mcp;
|
|
||||||
use crate::memory::MemoryManager;
|
use crate::memory::MemoryManager;
|
||||||
use crate::session::SessionManager;
|
use crate::session::SessionManager;
|
||||||
use crate::scheduler::Scheduler;
|
use crate::scheduler::Scheduler;
|
||||||
@ -103,21 +102,6 @@ impl GatewayState {
|
|||||||
crate::tools::ChatManagerTool::new(storage.clone(), valid_channels.clone()),
|
crate::tools::ChatManagerTool::new(storage.clone(), valid_channels.clone()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize MCP servers — connect and register discovered tools
|
|
||||||
if !config.mcp.servers.is_empty() {
|
|
||||||
let mcp_tools = mcp::connect_all(&config.mcp).await;
|
|
||||||
for tool_info in mcp_tools {
|
|
||||||
let wrapper = mcp::McpToolWrapper::new(
|
|
||||||
&tool_info.server_name,
|
|
||||||
tool_info.tool_name,
|
|
||||||
tool_info.description,
|
|
||||||
tool_info.schema,
|
|
||||||
tool_info.connection,
|
|
||||||
);
|
|
||||||
session_manager.tools().register(wrapper);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize scheduler if enabled in config
|
// Initialize scheduler if enabled in config
|
||||||
let scheduler_config = config.gateway.scheduler.clone().unwrap_or_default();
|
let scheduler_config = config.gateway.scheduler.clone().unwrap_or_default();
|
||||||
if scheduler_config.enabled {
|
if scheduler_config.enabled {
|
||||||
|
|||||||
@ -8,7 +8,6 @@ pub mod client;
|
|||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod mcp;
|
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
|
|||||||
303
src/mcp/mod.rs
303
src/mcp/mod.rs
@ -1,303 +0,0 @@
|
|||||||
pub mod tool_wrapper;
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use http::{HeaderName, HeaderValue};
|
|
||||||
use rmcp::model::{CallToolRequestParams, RawContent};
|
|
||||||
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
|
||||||
use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess};
|
|
||||||
use rmcp::{Peer, RoleClient, ServiceExt};
|
|
||||||
use tokio::process::Command;
|
|
||||||
|
|
||||||
use crate::config::{McpConfig, McpServerConfig, McpTransport};
|
|
||||||
use crate::tools::ToolResult;
|
|
||||||
|
|
||||||
pub use tool_wrapper::McpToolWrapper;
|
|
||||||
|
|
||||||
/// Status of a single MCP tool.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct McpToolStatus {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Status of a single MCP server.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct McpServerStatus {
|
|
||||||
pub name: String,
|
|
||||||
pub transport: String,
|
|
||||||
pub connected: bool,
|
|
||||||
pub error: Option<String>,
|
|
||||||
pub tools: Vec<McpToolStatus>,
|
|
||||||
}
|
|
||||||
|
|
||||||
static MCP_SERVER_STATUS: Mutex<Vec<McpServerStatus>> = Mutex::new(Vec::new());
|
|
||||||
|
|
||||||
pub fn get_mcp_status() -> Vec<McpServerStatus> {
|
|
||||||
MCP_SERVER_STATUS.lock().unwrap().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_mcp_status(servers: Vec<McpServerStatus>) {
|
|
||||||
let mut status = MCP_SERVER_STATUS.lock().unwrap();
|
|
||||||
*status = servers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A connected MCP server. Holds a clonable Peer handle for tool calls,
|
|
||||||
/// and keeps the underlying service alive via a background task.
|
|
||||||
pub struct McpConnection {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub name: String,
|
|
||||||
peer: Peer<RoleClient>,
|
|
||||||
/// Keep the service alive. When dropped, the MCP connection is closed.
|
|
||||||
_service: Option<Box<dyn std::any::Any + Send + Sync>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpConnection {
|
|
||||||
pub async fn call_tool(
|
|
||||||
&self,
|
|
||||||
tool_name: &str,
|
|
||||||
arguments: serde_json::Value,
|
|
||||||
) -> anyhow::Result<ToolResult> {
|
|
||||||
let result = self
|
|
||||||
.peer
|
|
||||||
.call_tool(
|
|
||||||
CallToolRequestParams::new(tool_name.to_string())
|
|
||||||
.with_arguments(arguments.as_object().cloned().unwrap_or_default()),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.context("MCP tool call failed")?;
|
|
||||||
|
|
||||||
let is_error = result.is_error.unwrap_or(false);
|
|
||||||
let output = extract_text(&result);
|
|
||||||
|
|
||||||
Ok(ToolResult {
|
|
||||||
success: !is_error,
|
|
||||||
output,
|
|
||||||
error: if is_error {
|
|
||||||
Some("MCP server returned an error".to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_text(result: &rmcp::model::CallToolResult) -> String {
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
for content in &result.content {
|
|
||||||
match &**content {
|
|
||||||
RawContent::Text(text) => {
|
|
||||||
parts.push(text.text.clone());
|
|
||||||
}
|
|
||||||
RawContent::Image(image) => {
|
|
||||||
parts.push(format!(
|
|
||||||
"[image: {}]",
|
|
||||||
image.mime_type,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
RawContent::Resource(resource) => {
|
|
||||||
match &resource.resource {
|
|
||||||
rmcp::model::ResourceContents::TextResourceContents { text, .. } => {
|
|
||||||
parts.push(format!(
|
|
||||||
"[resource text: {}]",
|
|
||||||
text.chars().take(200).collect::<String>(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
rmcp::model::ResourceContents::BlobResourceContents { uri, .. } => {
|
|
||||||
parts.push(format!("[resource blob: {}]", uri));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
parts.push("[unsupported content]".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if parts.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
parts.join("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ToolInfo {
|
|
||||||
pub server_name: String,
|
|
||||||
pub tool_name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub schema: serde_json::Value,
|
|
||||||
pub connection: Arc<McpConnection>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
|
|
||||||
let mut tools = Vec::new();
|
|
||||||
let mut server_statuses = Vec::new();
|
|
||||||
|
|
||||||
for server_config in &config.servers {
|
|
||||||
let transport_str = match server_config.transport {
|
|
||||||
McpTransport::Stdio => "stdio",
|
|
||||||
McpTransport::Sse => "sse",
|
|
||||||
McpTransport::StreamableHttp => "streamable-http",
|
|
||||||
};
|
|
||||||
|
|
||||||
match connect_server(server_config).await {
|
|
||||||
Ok(connection) => {
|
|
||||||
let connection = Arc::new(connection);
|
|
||||||
match list_tools(&connection).await {
|
|
||||||
Ok(server_tools) => {
|
|
||||||
tracing::info!(
|
|
||||||
server = %server_config.name,
|
|
||||||
count = server_tools.len(),
|
|
||||||
"MCP server connected"
|
|
||||||
);
|
|
||||||
let tool_statuses: Vec<McpToolStatus> = server_tools
|
|
||||||
.iter()
|
|
||||||
.map(|(name, desc, _)| McpToolStatus {
|
|
||||||
name: name.clone(),
|
|
||||||
description: desc.clone(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
server_statuses.push(McpServerStatus {
|
|
||||||
name: server_config.name.clone(),
|
|
||||||
transport: transport_str.to_string(),
|
|
||||||
connected: true,
|
|
||||||
error: None,
|
|
||||||
tools: tool_statuses,
|
|
||||||
});
|
|
||||||
for (orig_name, desc, schema) in server_tools {
|
|
||||||
tools.push(ToolInfo {
|
|
||||||
server_name: server_config.name.clone(),
|
|
||||||
tool_name: orig_name,
|
|
||||||
description: desc,
|
|
||||||
schema,
|
|
||||||
connection: connection.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
server = %server_config.name,
|
|
||||||
error = %e,
|
|
||||||
"Failed to list MCP tools"
|
|
||||||
);
|
|
||||||
server_statuses.push(McpServerStatus {
|
|
||||||
name: server_config.name.clone(),
|
|
||||||
transport: transport_str.to_string(),
|
|
||||||
connected: false,
|
|
||||||
error: Some(e.to_string()),
|
|
||||||
tools: Vec::new(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
server = %server_config.name,
|
|
||||||
error = %e,
|
|
||||||
"Failed to connect to MCP server"
|
|
||||||
);
|
|
||||||
server_statuses.push(McpServerStatus {
|
|
||||||
name: server_config.name.clone(),
|
|
||||||
transport: transport_str.to_string(),
|
|
||||||
connected: false,
|
|
||||||
error: Some(e.to_string()),
|
|
||||||
tools: Vec::new(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update_mcp_status(server_statuses);
|
|
||||||
tools
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_server(config: &McpServerConfig) -> anyhow::Result<McpConnection> {
|
|
||||||
match config.transport {
|
|
||||||
McpTransport::Stdio => {
|
|
||||||
let command = config
|
|
||||||
.command
|
|
||||||
.as_ref()
|
|
||||||
.context("stdio transport requires 'command'")?;
|
|
||||||
let mut cmd = Command::new(command);
|
|
||||||
cmd.args(&config.args);
|
|
||||||
for (k, v) in &config.env {
|
|
||||||
cmd.env(k, v);
|
|
||||||
}
|
|
||||||
|
|
||||||
let service = ()
|
|
||||||
.serve(
|
|
||||||
TokioChildProcess::new(cmd).context("failed to create stdio MCP transport")?,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.context("failed to connect to stdio MCP server")?;
|
|
||||||
|
|
||||||
let peer = service.peer().clone();
|
|
||||||
|
|
||||||
Ok(McpConnection {
|
|
||||||
name: config.name.clone(),
|
|
||||||
peer,
|
|
||||||
_service: Some(Box::new(service)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
McpTransport::Sse | McpTransport::StreamableHttp => {
|
|
||||||
let url = config
|
|
||||||
.url
|
|
||||||
.as_ref()
|
|
||||||
.context("sse/streamable-http transport requires 'url'")?;
|
|
||||||
|
|
||||||
let mut headers_map = HashMap::new();
|
|
||||||
for (k, v) in &config.headers {
|
|
||||||
if let (Ok(name), Ok(value)) = (
|
|
||||||
HeaderName::from_bytes(k.as_bytes()),
|
|
||||||
HeaderValue::from_str(v),
|
|
||||||
) {
|
|
||||||
headers_map.insert(name, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let transport = if headers_map.is_empty() {
|
|
||||||
StreamableHttpClientTransport::from_uri(url.to_string())
|
|
||||||
} else {
|
|
||||||
StreamableHttpClientTransport::from_config(
|
|
||||||
StreamableHttpClientTransportConfig::with_uri(url.to_string())
|
|
||||||
.custom_headers(headers_map)
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let service = ()
|
|
||||||
.serve(transport)
|
|
||||||
.await
|
|
||||||
.context("failed to connect to HTTP/SSE MCP server")?;
|
|
||||||
|
|
||||||
let peer = service.peer().clone();
|
|
||||||
|
|
||||||
Ok(McpConnection {
|
|
||||||
name: config.name.clone(),
|
|
||||||
peer,
|
|
||||||
_service: Some(Box::new(service)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_tools(
|
|
||||||
connection: &McpConnection,
|
|
||||||
) -> anyhow::Result<Vec<(String, String, serde_json::Value)>> {
|
|
||||||
let tools = connection
|
|
||||||
.peer
|
|
||||||
.list_all_tools()
|
|
||||||
.await
|
|
||||||
.context("failed to list MCP tools")?;
|
|
||||||
|
|
||||||
Ok(tools
|
|
||||||
.into_iter()
|
|
||||||
.map(|tool| {
|
|
||||||
(
|
|
||||||
tool.name.to_string(),
|
|
||||||
tool.description.map(|d| d.to_string()).unwrap_or_default(),
|
|
||||||
serde_json::Value::Object((*tool.input_schema).clone()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
use crate::tools::{Tool, ToolResult};
|
|
||||||
|
|
||||||
use super::McpConnection;
|
|
||||||
|
|
||||||
pub struct McpToolWrapper {
|
|
||||||
full_name: String,
|
|
||||||
description: String,
|
|
||||||
parameters_schema: serde_json::Value,
|
|
||||||
original_tool_name: String,
|
|
||||||
connection: Arc<McpConnection>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpToolWrapper {
|
|
||||||
pub fn new(
|
|
||||||
server_name: &str,
|
|
||||||
original_tool_name: String,
|
|
||||||
description: String,
|
|
||||||
parameters_schema: serde_json::Value,
|
|
||||||
connection: Arc<McpConnection>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
full_name: format!("{}__{}", server_name, original_tool_name),
|
|
||||||
description,
|
|
||||||
parameters_schema,
|
|
||||||
original_tool_name,
|
|
||||||
connection,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for McpToolWrapper {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
&self.full_name
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
&self.description
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
self.parameters_schema.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
||||||
self.connection
|
|
||||||
.call_tool(&self.original_tool_name, args)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::bus::{ChatMessage, MediaItem, MessageSource, OutboundMessage, SourceKind};
|
use crate::bus::{ChatMessage, MediaItem, MessageSource, OutboundMessage, SourceKind};
|
||||||
use crate::mcp::get_mcp_status;
|
|
||||||
use crate::storage::{Storage, StorageError};
|
use crate::storage::{Storage, StorageError};
|
||||||
use std::sync::Arc as StdArc;
|
use std::sync::Arc as StdArc;
|
||||||
|
|
||||||
@ -800,11 +799,6 @@ pub static SLASH_COMMANDS: &[SlashCommand] = &[
|
|||||||
description: "显示帮助",
|
description: "显示帮助",
|
||||||
aliases: &["/?", "/help"],
|
aliases: &["/?", "/help"],
|
||||||
},
|
},
|
||||||
SlashCommand {
|
|
||||||
name: "mcp",
|
|
||||||
description: "显示 MCP 服务状态和工具列表",
|
|
||||||
aliases: &["/mcp"],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
@ -993,34 +987,6 @@ impl SessionManager {
|
|||||||
}).collect();
|
}).collect();
|
||||||
Ok((None, format!("可用命令:\n{}", lines.join("\n"))))
|
Ok((None, format!("可用命令:\n{}", lines.join("\n"))))
|
||||||
}
|
}
|
||||||
"mcp" => {
|
|
||||||
let servers = get_mcp_status();
|
|
||||||
if servers.is_empty() {
|
|
||||||
return Ok((None, "未配置 MCP 服务。".to_string()));
|
|
||||||
}
|
|
||||||
let lines: Vec<String> = servers.iter().map(|s| {
|
|
||||||
let status = if s.connected {
|
|
||||||
format!("✅ 已连接 ({})", s.transport)
|
|
||||||
} else {
|
|
||||||
format!("❌ 连接失败: {}", s.error.as_deref().unwrap_or("未知错误"))
|
|
||||||
};
|
|
||||||
let tool_lines: Vec<String> = s.tools.iter().map(|t| {
|
|
||||||
let desc = if t.description.is_empty() {
|
|
||||||
"无描述".to_string()
|
|
||||||
} else {
|
|
||||||
t.description.chars().take(60).collect::<String>()
|
|
||||||
};
|
|
||||||
format!(" - {}: {}", t.name, desc)
|
|
||||||
}).collect();
|
|
||||||
let tools_section = if tool_lines.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("\n{}", tool_lines.join("\n"))
|
|
||||||
};
|
|
||||||
format!("{} {}{}", s.name, status, tools_section)
|
|
||||||
}).collect();
|
|
||||||
Ok((None, format!("MCP 服务:\n\n{}", lines.join("\n\n"))))
|
|
||||||
}
|
|
||||||
_ => Err(AgentError::Other(format!("未知命令:/{}。输入 /? 获取帮助。", cmd.name))),
|
_ => Err(AgentError::Other(format!("未知命令:/{}。输入 /? 获取帮助。", cmd.name))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user