@@ -119,24 +119,33 @@ export default {
});
},
+ // 封装输入验证逻辑
+ validateInput(input, message) {
+ if (!input.trim()) {
+ showDanger(message);
+ return false;
+ }
+ return true;
+ },
// 注册逻辑
register() {
- if (!this.form.username.trim()) {
- showDanger('用户名不能为空')
- return
+ // 验证用户名
+ if (!this.validateInput(this.form.username, '用户名不能为空')) {
+ return;
}
- if (!this.form.password.trim()) {
- showDanger('密码不能为空')
- return
+ // 验证密码
+ if (!this.validateInput(this.form.password, '密码不能为空')) {
+ return;
}
if (this.form.password !== this.form.confirmPassword) {
showDanger('两次输入的密码不一致')
return
}
- if (!this.form.captcha.trim()) {
- showDanger('验证码不能为空')
- return
+ // 验证验证码
+ if (!this.validateInput(this.form.captcha, '验证码不能为空')) {
+ return;
}
+
Api.user.register(this.form, ({data}) => {
console.log(data)
if (data.code === 0) {
@@ -159,6 +168,6 @@ export default {
}
-
\ No newline at end of file
+
diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml
index 12497dbb..8047bc38 100644
--- a/main/xiaozhi-server/config.yaml
+++ b/main/xiaozhi-server/config.yaml
@@ -94,10 +94,11 @@ Intent:
type: nointent
# plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用
# 系统默认已经记载“handle_exit_intent(退出识别)”、“play_music(音乐播放)”插件,请勿重复加载
- # 下面是加载查天气、角色切换的插件示例
+ # 下面是加载查天气、角色切换、加载查新闻的插件示例
functions:
- change_role
- get_weather
+ - get_news
# 插件的基础配置
plugins:
@@ -106,6 +107,14 @@ plugins:
# 想稳定一点就自行申请替换,每天有1000次免费调用
# 申请地址:https://console.qweather.com/#/apps/create-key/over
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
+ # 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
+ # 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
+ get_news:
+ default_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
+ category_urls:
+ society: "https://www.chinanews.com.cn/rss/society.xml"
+ world: "https://www.chinanews.com.cn/rss/world.xml"
+ finance: "https://www.chinanews.com.cn/rss/finance.xml"
Memory:
mem0ai:
diff --git a/main/xiaozhi-server/core/providers/tts/base.py b/main/xiaozhi-server/core/providers/tts/base.py
index d4a059f8..3146dc06 100644
--- a/main/xiaozhi-server/core/providers/tts/base.py
+++ b/main/xiaozhi-server/core/providers/tts/base.py
@@ -5,6 +5,7 @@ import numpy as np
import opuslib_next
from pydub import AudioSegment
from abc import ABC, abstractmethod
+from core.utils.tts import MarkdownCleaner
TAG = __name__
logger = setup_logging()
@@ -23,6 +24,7 @@ class TTSProviderBase(ABC):
tmp_file = self.generate_filename()
try:
max_repeat_time = 5
+ text = MarkdownCleaner.clean_markdown(text)
while not os.path.exists(tmp_file) and max_repeat_time > 0:
asyncio.run(self.text_to_speak(text, tmp_file))
if not os.path.exists(tmp_file):
@@ -47,7 +49,8 @@ class TTSProviderBase(ABC):
file_type = os.path.splitext(audio_file_path)[1]
if file_type:
file_type = file_type.lstrip('.')
- audio = AudioSegment.from_file(audio_file_path, format=file_type)
+ # 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
+ audio = AudioSegment.from_file(audio_file_path, format=file_type, parameters=["-nostdin"])
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
diff --git a/main/xiaozhi-server/core/utils/tts.py b/main/xiaozhi-server/core/utils/tts.py
index 7f71536e..eb383667 100644
--- a/main/xiaozhi-server/core/utils/tts.py
+++ b/main/xiaozhi-server/core/utils/tts.py
@@ -1,4 +1,5 @@
import os
+import re
import sys
from config.logger import setup_logging
import importlib
@@ -14,4 +15,98 @@ def create_instance(class_name, *args, **kwargs):
sys.modules[lib_name] = importlib.import_module(f'{lib_name}')
return sys.modules[lib_name].TTSProvider(*args, **kwargs)
- raise ValueError(f"不支持的TTS类型: {class_name},请检查该配置的type是否设置正确")
\ No newline at end of file
+ raise ValueError(f"不支持的TTS类型: {class_name},请检查该配置的type是否设置正确")
+
+
+class MarkdownCleaner:
+ """
+ 封装 Markdown 清理逻辑:直接用 MarkdownCleaner.clean_markdown(text) 即可
+ """
+ # 公式字符
+ NORMAL_FORMULA_CHARS = re.compile(r'[a-zA-Z\\^_{}\+\-\(\)\[\]=]')
+
+ @staticmethod
+ def _replace_inline_dollar(m: re.Match) -> str:
+ """
+ 只要捕获到完整的 "$...$":
+ - 如果内部有典型公式字符 => 去掉两侧 $
+ - 否则 (纯数字/货币等) => 保留 "$...$"
+ """
+ content = m.group(1)
+ if MarkdownCleaner.NORMAL_FORMULA_CHARS.search(content):
+ return content
+ else:
+ return m.group(0)
+
+ @staticmethod
+ def _replace_table_block(match: re.Match) -> str:
+ """
+ 当匹配到一个整段表格块时,回调该函数。
+ """
+ block_text = match.group('table_block')
+ lines = block_text.strip('\n').split('\n')
+
+ parsed_table = []
+ for line in lines:
+ line_stripped = line.strip()
+ if re.match(r'^\|\s*[-:]+\s*(\|\s*[-:]+\s*)+\|?$', line_stripped):
+ continue
+ columns = [col.strip() for col in line_stripped.split('|') if col.strip() != '']
+ if columns:
+ parsed_table.append(columns)
+
+ if not parsed_table:
+ return ""
+
+ headers = parsed_table[0]
+ data_rows = parsed_table[1:] if len(parsed_table) > 1 else []
+
+ lines_for_tts = []
+ if len(parsed_table) == 1:
+ # 只有一行
+ only_line_str = ", ".join(parsed_table[0])
+ lines_for_tts.append(f"单行表格:{only_line_str}")
+ else:
+ lines_for_tts.append(f"表头是:{', '.join(headers)}")
+ for i, row in enumerate(data_rows, start=1):
+ row_str_list = []
+ for col_index, cell_val in enumerate(row):
+ if col_index < len(headers):
+ row_str_list.append(f"{headers[col_index]} = {cell_val}")
+ else:
+ row_str_list.append(cell_val)
+ lines_for_tts.append(f"第 {i} 行:{', '.join(row_str_list)}")
+
+ return "\n".join(lines_for_tts) + "\n"
+
+ # 预编译所有正则表达式(按执行频率排序)
+ # 这里要把 replace_xxx 的静态方法放在最前定义,以便在列表里能正确引用它们。
+ REGEXES = [
+ (re.compile(r'```.*?```', re.DOTALL), ''), # 代码块
+ (re.compile(r'^#+\s*', re.MULTILINE), ''), # 标题
+ (re.compile(r'(\*\*|__)(.*?)\1'), r'\2'), # 粗体
+ (re.compile(r'(\*|_)(?=\S)(.*?)(?<=\S)\1'), r'\2'), # 斜体
+ (re.compile(r'!\[.*?\]\(.*?\)'), ''), # 图片
+ (re.compile(r'\[(.*?)\]\(.*?\)'), r'\1'), # 链接
+ (re.compile(r'^\s*>+\s*', re.MULTILINE), ''), # 引用
+ (
+ re.compile(r'(?P
(?:^[^\n]*\|[^\n]*\n)+)', re.MULTILINE),
+ _replace_table_block
+ ),
+ (re.compile(r'^\s*[*+-]\s*', re.MULTILINE), '- '), # 列表
+ (re.compile(r'\$\$.*?\$\$', re.DOTALL), ''), # 块级公式
+ (
+ re.compile(r'(? str:
+ """
+ 主入口方法:依序执行所有正则,移除或替换 Markdown 元素
+ """
+ for regex, replacement in MarkdownCleaner.REGEXES:
+ text = regex.sub(replacement, text)
+ return text.strip()
\ No newline at end of file
diff --git a/main/xiaozhi-server/plugins_func/functions/get_news.py b/main/xiaozhi-server/plugins_func/functions/get_news.py
new file mode 100644
index 00000000..43f8ea48
--- /dev/null
+++ b/main/xiaozhi-server/plugins_func/functions/get_news.py
@@ -0,0 +1,205 @@
+import random
+import requests
+import xml.etree.ElementTree as ET
+from bs4 import BeautifulSoup
+from config.logger import setup_logging
+from plugins_func.register import register_function, ToolType, ActionResponse, Action
+
+TAG = __name__
+logger = setup_logging()
+
+GET_NEWS_FUNCTION_DESC = {
+ "type": "function",
+ "function": {
+ "name": "get_news",
+ "description": (
+ "获取最新新闻,随机选择一条新闻进行播报。"
+ "用户可以指定新闻类型,如社会新闻、科技新闻、国际新闻等。"
+ "如果没有指定,默认播报社会新闻。"
+ "用户可以要求获取详细内容,此时会获取新闻的详细内容。"
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "新闻类别,例如社会、科技、国际。可选参数,如果不提供则使用默认类别"
+ },
+ "detail": {
+ "type": "boolean",
+ "description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容"
+ },
+ "lang": {
+ "type": "string",
+ "description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN"
+ }
+ },
+ "required": ["lang"]
+ }
+ }
+}
+
+
+def fetch_news_from_rss(rss_url):
+ """从RSS源获取新闻列表"""
+ try:
+ response = requests.get(rss_url)
+ response.raise_for_status()
+
+ # 解析XML
+ root = ET.fromstring(response.content)
+
+ # 查找所有item元素(新闻条目)
+ news_items = []
+ for item in root.findall('.//item'):
+ title = item.find('title').text if item.find('title') is not None else "无标题"
+ link = item.find('link').text if item.find('link') is not None else "#"
+ description = item.find('description').text if item.find('description') is not None else "无描述"
+ pubDate = item.find('pubDate').text if item.find('pubDate') is not None else "未知时间"
+
+ news_items.append({
+ 'title': title,
+ 'link': link,
+ 'description': description,
+ 'pubDate': pubDate
+ })
+
+ return news_items
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"获取RSS新闻失败: {e}")
+ return []
+
+
+def fetch_news_detail(url):
+ """获取新闻详情页内容并总结"""
+ try:
+ response = requests.get(url)
+ response.raise_for_status()
+
+ soup = BeautifulSoup(response.content, 'html.parser')
+
+ # 尝试提取正文内容 (这里的选择器需要根据实际网站结构调整)
+ content_div = soup.select_one('.content_desc, .content, article, .article-content')
+ if content_div:
+ paragraphs = content_div.find_all('p')
+ content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
+ return content
+ else:
+ # 如果找不到特定的内容区域,尝试获取所有段落
+ paragraphs = soup.find_all('p')
+ content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
+ return content[:2000] # 限制长度
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"获取新闻详情失败: {e}")
+ return "无法获取详细内容"
+
+
+def map_category(category_text):
+ """将用户输入的中文类别映射到配置文件中的类别键"""
+ if not category_text:
+ return None
+
+ # 类别映射字典,目前支持社会、国际、财经新闻,如需更多类型,参见配置文件
+ category_map = {
+ # 社会新闻
+ "社会": "society",
+ "社会新闻": "society",
+ # 国际新闻
+ "国际": "world",
+ "国际新闻": "world",
+ # 财经新闻
+ "财经": "finance",
+ "财经新闻": "finance",
+ "金融": "finance",
+ "经济": "finance"
+ }
+
+ # 转换为小写并去除空格
+ normalized_category = category_text.lower().strip()
+
+ # 返回映射结果,如果没有匹配项则返回原始输入
+ return category_map.get(normalized_category, category_text)
+
+
+@register_function('get_news', GET_NEWS_FUNCTION_DESC, ToolType.SYSTEM_CTL)
+def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_CN"):
+ """获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容"""
+ try:
+ # 如果detail为True,获取上一条新闻的详细内容
+ if detail:
+ if not hasattr(conn, 'last_news_link') or not conn.last_news_link or 'link' not in conn.last_news_link:
+ return ActionResponse(Action.REQLLM, "抱歉,没有找到最近查询的新闻,请先获取一条新闻。", None)
+
+ link = conn.last_news_link.get('link')
+ title = conn.last_news_link.get('title', '未知标题')
+
+ if link == '#':
+ return ActionResponse(Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None)
+
+ logger.bind(tag=TAG).debug(f"获取新闻详情: {title}, URL={link}")
+
+ # 获取新闻详情
+ detail_content = fetch_news_detail(link)
+
+ if not detail_content or detail_content == "无法获取详细内容":
+ return ActionResponse(Action.REQLLM, f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。", None)
+
+ # 构建详情报告
+ detail_report = (
+ f"根据下列数据,用{lang}回应用户的新闻详情查询请求:\n\n"
+ f"新闻标题: {title}\n"
+ f"详细内容: {detail_content}\n\n"
+ f"(请对上述新闻内容进行总结,提取关键信息,以自然、流畅的方式向用户播报,"
+ f"不要提及这是总结,就像是在讲述一个完整的新闻故事)"
+ )
+
+ return ActionResponse(Action.REQLLM, detail_report, None)
+
+ # 否则,获取新闻列表并随机选择一条
+ # 从配置中获取RSS URL
+ rss_config = conn.config["plugins"]["get_news"]
+ default_rss_url = rss_config.get("default_rss_url", "https://www.chinanews.com.cn/rss/society.xml")
+
+ # 将用户输入的类别映射到配置中的类别键
+ mapped_category = map_category(category)
+
+ # 如果提供了类别,尝试从配置中获取对应的URL
+ rss_url = default_rss_url
+ if mapped_category and mapped_category in rss_config.get("category_urls", {}):
+ rss_url = rss_config["category_urls"][mapped_category]
+
+ logger.bind(tag=TAG).info(f"获取新闻: 原始类别={category}, 映射类别={mapped_category}, URL={rss_url}")
+
+ # 获取新闻列表
+ news_items = fetch_news_from_rss(rss_url)
+
+ if not news_items:
+ return ActionResponse(Action.REQLLM, "抱歉,未能获取到新闻信息,请稍后再试。", None)
+
+ # 随机选择一条新闻
+ selected_news = random.choice(news_items)
+
+ # 保存当前新闻链接到连接对象,以便后续查询详情
+ if not hasattr(conn, 'last_news_link'):
+ conn.last_news_link = {}
+ conn.last_news_link = {
+ 'link': selected_news.get('link', '#'),
+ 'title': selected_news.get('title', '未知标题')
+ }
+
+ # 构建新闻报告
+ news_report = (
+ f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
+ f"新闻标题: {selected_news['title']}\n"
+ f"发布时间: {selected_news['pubDate']}\n"
+ f"新闻内容: {selected_news['description']}\n"
+ f"(请以自然、流畅的方式向用户播报这条新闻,可以适当总结内容,"
+ f"直接读出新闻即可,不需要额外多余的内容。"
+ f"如果用户询问更多详情,告知用户可以说'请详细介绍这条新闻'获取更多内容)"
+ )
+
+ return ActionResponse(Action.REQLLM, news_report, None)
+
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"获取新闻出错: {e}")
+ return ActionResponse(Action.REQLLM, "抱歉,获取新闻时发生错误,请稍后再试。", None)
\ No newline at end of file