From 84671c5e6b96c9be8e67e4914fb1090b8baea398 Mon Sep 17 00:00:00 2001 From: ninemine <1371605831@qq.com> Date: Wed, 19 Nov 2025 11:52:24 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E4=B8=AA=E5=8F=AF=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 2 + .gitignore | 188 ++++++ .gitmodules | 4 + PWF | 1 + Plugins/WPSAPI.py | 1491 +++++++++++++++++++++++++++++++++++++++++++ Plugins/__init__.py | 0 app.py | 3 + requirements.txt | 10 + 8 files changed, 1699 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gitmodules create mode 160000 PWF create mode 100644 Plugins/WPSAPI.py create mode 100644 Plugins/__init__.py create mode 100644 app.py create mode 100644 requirements.txt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e7828f --- /dev/null +++ b/.gitignore @@ -0,0 +1,188 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore +# IDE +.vscode/ + +# Database +Assets/db.db +liubai_web.pid +Assets/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..8ba6f4c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "PWF"] + path = PWF + url = http://www.liubai.site:3000/ninemine/PWF.git + branch = main diff --git a/PWF b/PWF new file mode 160000 index 0000000..89de330 --- /dev/null +++ b/PWF @@ -0,0 +1 @@ +Subproject commit 89de330e2d102d59c6732b017fee7ec8d04bc186 diff --git a/Plugins/WPSAPI.py b/Plugins/WPSAPI.py new file mode 100644 index 0000000..5c3dd50 --- /dev/null +++ b/Plugins/WPSAPI.py @@ -0,0 +1,1491 @@ +from PWF.Convention.Runtime.Config import * +from PWF.CoreModules.plugin_interface import PluginInterface +from PWF.CoreModules.flags import * +from PWF.Convention.Runtime.Architecture import Architecture +from PWF.Convention.Runtime.GlobalConfig import ProjectConfig +from PWF.Convention.Runtime.Web import ToolURL +from PWF.Convention.Runtime.String import LimitStringLength +from fastapi.responses import HTMLResponse +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Optional, Sequence, TypedDict, override, Union +import httpx +import re +from datetime import datetime, timedelta +from llama_index.core.tools import FunctionTool +from llama_index.llms.ollama import Ollama +from llama_index.core.agent.workflow import AgentWorkflow +from llama_index.core.agent.workflow.react_agent import ReActAgent +from bs4 import BeautifulSoup + +logger: ProjectConfig = Architecture.Get(ProjectConfig) +MAIN_WEBHOOK_URL = logger.FindItem("main_webhook_url", "") +logger.SaveProperties() + +def get_target_web_page_url(year:str, month:str, day:str) -> str: + return fr"http://mrxwlb.com/{year}/{month}/{day}/{year}年{month}月{day}日新闻联播文字版/" + +ollama_url = "http://www.liubai.site:11434" + +class NewsAIAgent: + """新闻AI智能体 - 基于LlamaIndex和Ollama的工具调用智能体""" + + def __init__(self, ollama_url: str = "http://www.liubai.site:11434"): + self.ollama_url = ollama_url + self.client: Optional[httpx.AsyncClient] = None + self.llm = Ollama(model="qwen2.5:7b", base_url=ollama_url, request_timeout=600.0) + self.workflow: Optional[AgentWorkflow] = None + self._initialize_agent() + + def _initialize_agent(self): + """初始化智能体和工具""" + # 创建工具函数 + tools = [ + FunctionTool.from_defaults( + fn=self._get_current_date, + name="get_current_date", + description="获取当前日期,返回格式为'年-月-日',例如'2025-11-19'" + ), + FunctionTool.from_defaults( + fn=self._get_yesterday_date, + name="get_yesterday_date", + description="获取昨天的日期,返回格式为'年-月-日',例如'2025-11-18'" + ), + FunctionTool.from_defaults( + fn=self._get_news_content, + name="get_news_content", + description="获取指定日期的新闻联播文字内容。参数date格式为'年-月-日',例如'2025-11-19'。返回该日期的新闻内容文本。" + ), + FunctionTool.from_defaults( + fn=self._parse_date_from_text, + name="parse_date_from_text", + description="从文本中解析日期。支持格式:'2025年11月19日'、'2025-11-19'、'2025/11/19'、'今天'、'昨天'等。返回格式为'年-月-日'" + ), + ] + + # 系统提示词 - 更清晰明确的指令 + system_prompt = """你是一个新闻分析助手,专门回答关于新闻联播的问题。 + +【重要】你必须按照以下步骤使用工具: + +步骤1: 确定日期 +- 如果用户问"今天"或"今日",调用 get_current_date +- 如果用户问"昨天"或"昨日",调用 get_yesterday_date +- 如果用户提到具体日期(如"2025年11月17日"),调用 parse_date_from_text + +步骤2: 获取新闻内容 +- 拿到日期后,必须调用 get_news_content(date="YYYY-MM-DD") 获取新闻 +- 注意:date参数格式必须是 "年-月-日",例如 "2025-11-19" + +步骤3: 回答问题 +- 仔细阅读获取到的新闻内容 +- 基于新闻内容准确回答用户的问题 +- 如果新闻中没有相关信息,明确告知用户 + +【禁止】直接回答问题而不调用工具!你必须先获取新闻内容才能回答。""" + + # 创建ReActAgent + agent = ReActAgent( + llm=self.llm, + tools=tools, + verbose=True, + system_prompt=system_prompt, + ) + + # 创建AgentWorkflow + self.workflow = AgentWorkflow( + agents=[agent], + timeout=600.0, + ) + + async def _get_client(self) -> httpx.AsyncClient: + """获取HTTP客户端(懒加载)""" + if self.client is None: + self.client = httpx.AsyncClient(timeout=120.0) + return self.client + + def _get_current_date(self) -> str: + """获取当前日期 + + Returns: + 当前日期字符串,格式:年-月-日 + """ + now = datetime.now() + return f"{now.year}-{str(now.month).zfill(2)}-{str(now.day).zfill(2)}" + + def _get_yesterday_date(self) -> str: + """获取昨天日期 + + Returns: + 昨天日期字符串,格式:年-月-日 + """ + yesterday = datetime.now() - timedelta(days=1) + return f"{yesterday.year}-{str(yesterday.month).zfill(2)}-{str(yesterday.day).zfill(2)}" + + def _parse_date_from_text(self, text: str) -> str: + """从文本中解析日期 + + Args: + text: 包含日期信息的文本 + + Returns: + 日期字符串,格式:年-月-日 + """ + # 尝试匹配日期格式 + date_patterns = [ + r'(\d{4})年(\d{1,2})月(\d{1,2})日', + r'(\d{4})-(\d{1,2})-(\d{1,2})', + r'(\d{4})/(\d{1,2})/(\d{1,2})', + ] + + for pattern in date_patterns: + match = re.search(pattern, text) + if match: + year, month, day = match.groups() + return f"{year}-{month.zfill(2)}-{day.zfill(2)}" + + # 检查相对日期 + if "今天" in text or "今日" in text: + return self._get_current_date() + elif "昨天" in text or "昨日" in text: + return self._get_yesterday_date() + + # 默认返回今天 + return self._get_current_date() + + def _get_news_content(self, date: str) -> str: + """获取指定日期的新闻内容(带缓存) + + Args: + date: 日期字符串,格式:年-月-日,例如'2025-11-19' + + Returns: + 新闻文字内容 + """ + try: + # 解析日期 + parts = date.split('-') + if len(parts) != 3: + return f"日期格式错误,请使用'年-月-日'格式,例如'2025-11-19'" + + year, month, day = parts + # 去掉前导零(某些网站URL格式要求) + month = str(int(month)) + day = str(int(day)) + + # 检查缓存 + cache_key = f"news_cache/{year}/{month}/{day}" + cached_file = ProjectConfig().GetFile(cache_key, False) + + if cached_file.Exists(): + cached_content = cached_file.LoadAsText() + logger.Log("Info", f"从缓存加载新闻: {date}") + return cached_content + + # 如果没有缓存,则抓取网页 + logger.Log("Info", f"从网页抓取新闻: {date}") + url = get_target_web_page_url(year, month, day) + + # 使用同步方式获取(因为工具函数需要同步) + import requests + + # 直接使用中文URL,让requests自动处理编码 + url = f"http://mrxwlb.com/{year}/{month}/{day}/{year}年{month}月{day}日新闻联播文字版/" + + # 添加浏览器请求头,模拟真实浏览器访问 + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + } + + logger.Log("Info", f"请求URL: {url}") + response = requests.get(url, headers=headers, timeout=30, allow_redirects=True) + response.raise_for_status() + + # 使用BeautifulSoup解析HTML + soup = BeautifulSoup(response.text, 'html.parser') + + # 移除script和style标签 + for script in soup(["script", "style"]): + script.decompose() + + # 获取文本内容 + content = soup.get_text() + # 清理空白字符 + lines = (line.strip() for line in content.splitlines()) + chunks = (phrase.strip() for line in lines for phrase in line.split(" ")) + content = ' '.join(chunk for chunk in chunks if chunk) + + if not content or len(content) < 100: + return f"未能获取到{year}年{month}月{day}日的新闻内容" + + # 保存到缓存 + ProjectConfig().GetFile(cache_key, True).SaveAsText(content) + logger.Log("Info", f"新闻内容已缓存: {date}") + + return content + + except Exception as e: + logger.Log("Error", f"获取新闻失败: {e}") + return f"获取新闻时出错: {str(e)}" + + async def answer_question(self, query: str) -> str: + """根据问题回答新闻内容 + + Args: + query: 用户问题 + + Returns: + AI生成的回答 + """ + try: + logger.Log("Info", f"="*50) + logger.Log("Info", f"用户提问: {query}") + logger.Log("Info", f"使用模型: {self.llm.model}") + logger.Log("Info", f"="*50) + + # 使用workflow运行agent(注意参数是user_msg) + result = await self.workflow.run(user_msg=query) + + logger.Log("Info", f"Agent执行完成,结果类型: {type(result)}") + + # 提取回答内容 + if hasattr(result, 'response'): + answer = str(result.response) + logger.Log("Info", f"从result.response提取答案") + elif hasattr(result, 'message'): + answer = str(result.message) + logger.Log("Info", f"从result.message提取答案") + else: + answer = str(result) + logger.Log("Info", f"直接转换result为字符串") + + logger.Log("Info", f"最终答案长度: {len(answer)} 字符") + + return answer + + except Exception as e: + logger.Log("Error", f"回答问题失败: {e}") + import traceback + error_trace = traceback.format_exc() + logger.Log("Error", f"详细错误:\n{error_trace}") + return f"处理问题时出错: {str(e)}" + + async def close(self): + """关闭客户端""" + if self.client: + await self.client.aclose() + self.client = None + +class GuideEntry(TypedDict, total=False): + """单条图鉴信息。""" + + title: str + identifier: str + description: str + category: str + metadata: Dict[str, str] + icon: str + badge: str + links: Sequence[Dict[str, str]] + tags: Sequence[str] + details: Sequence[Union[str, Dict[str, Any]]] + group: str + + +@dataclass(frozen=True) +class GuideSection: + """图鉴章节。""" + + title: str + entries: Sequence[GuideEntry] = field(default_factory=tuple) + description: str = "" + layout: str = "grid" + section_id: str | None = None + + +@dataclass(frozen=True) +class GuidePage: + """完整图鉴页面。""" + + title: str + sections: Sequence[GuideSection] = field(default_factory=tuple) + subtitle: str = "" + metadata: Dict[str, str] = field(default_factory=dict) + related_links: Dict[str, Sequence[Dict[str, str]]] = field(default_factory=dict) + + +def render_markdown_page(page: GuidePage) -> str: + """保留 Markdown 渲染(备用)。""" + + def _render_section(section: GuideSection) -> str: + lines: List[str] = [f"## {section.title}"] + if section.description: + lines.append(section.description) + if not section.entries: + lines.append("> 暂无内容。") + return "\n".join(lines) + for entry in section.entries: + title = entry.get("title", "未命名") + identifier = entry.get("identifier") + desc = entry.get("description", "") + category = entry.get("category") + metadata = entry.get("metadata", {}) + bullet = f"- **{title}**" + if identifier: + bullet += f"|`{identifier}`" + if category: + bullet += f"|{category}" + lines.append(bullet) + if desc: + lines.append(f" - {desc}") + for meta_key, meta_value in metadata.items(): + lines.append(f" - {meta_key}:{meta_value}") + return "\n".join(lines) + + lines: List[str] = [f"# {page.title}"] + if page.subtitle: + lines.append(page.subtitle) + if page.metadata: + lines.append("") + for key, value in page.metadata.items(): + lines.append(f"- {key}:{value}") + for section in page.sections: + lines.append("") + lines.append(_render_section(section)) + return "\n".join(lines) + + +def render_html_page(page: GuidePage) -> str: + """渲染 Apple Store 风格的 HTML 页面。""" + + def escape(text: Optional[str]) -> str: + if not text: + return "" + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + def render_metadata(metadata: Dict[str, str]) -> str: + if not metadata: + return "" + cards = [] + for key, value in metadata.items(): + cards.append( + f""" +
+
{escape(key)}
+
{escape(value)}
+
+ """ + ) + return f'
{"".join(cards)}
' + + def render_links(links: Optional[Sequence[Dict[str, str]]]) -> str: + if not links: + return "" + items = [] + for link in links: + href = escape(link.get("href", "#")) + label = escape(link.get("label", "前往")) + items.append(f'{label}') + return "".join(items) + + def render_tags(tags: Optional[Sequence[str]]) -> str: + if not tags: + return "" + chips = "".join(f'{escape(tag)}' for tag in tags) + return f'
{chips}
' + + def render_details(details: Optional[Sequence[Union[str, Dict[str, Any]]]]) -> str: + if not details: + return "" + blocks: List[str] = [] + for detail in details: + if isinstance(detail, str): + blocks.append(f'

{escape(detail)}

') + elif isinstance(detail, dict): + kind = detail.get("type") + if kind == "list": + items = "".join( + f'
  • {escape(str(item))}
  • ' for item in detail.get("items", []) + ) + blocks.append(f'') + elif kind == "steps": + items = "".join( + f'
  • {idx+1}{escape(str(item))}
  • ' + for idx, item in enumerate(detail.get("items", [])) + ) + blocks.append(f'
      {items}
    ') + elif kind == "table": + rows = [] + for row in detail.get("rows", []): + cols = "".join(f"{escape(str(col))}" for col in row) + rows.append(f"{cols}") + head = "" + headers = detail.get("headers") + if headers: + head = "".join(f"{escape(str(col))}" for col in headers) + head = f"{head}" + blocks.append( + f'{head}{"".join(rows)}
    ' + ) + if not blocks: + return "" + return f'
    {"".join(blocks)}
    ' + + def render_entry(entry: GuideEntry) -> str: + icon = escape(entry.get("icon")) + badge = escape(entry.get("badge")) + title = escape(entry.get("title")) + identifier = escape(entry.get("identifier")) + description = escape(entry.get("description")) + category = escape(entry.get("category")) + metadata_items = [] + for meta_key, meta_value in entry.get("metadata", {}).items(): + metadata_items.append( + f'
  • {escape(meta_key)}{escape(str(meta_value))}
  • ' + ) + metadata_html = "" + if metadata_items: + metadata_html = f'' + identifier_html = f'{identifier}' if identifier else "" + category_html = f'{category}' if category else "" + badge_html = f'{badge}' if badge else "" + icon_html = f'
    {icon}
    ' if icon else "" + links_html = render_links(entry.get("links")) + tags_html = render_tags(entry.get("tags")) + details_html = render_details(entry.get("details")) + group = escape(entry.get("group")) + group_attr = f' data-group="{group}"' if group else "" + return f""" +
    + {icon_html} +
    +
    +

    {title}{badge_html}

    + +
    +

    {description}

    + {metadata_html} + {tags_html} + {details_html} + {links_html} +
    +
    + """ + + def render_section(section: GuideSection) -> str: + layout_class = "entries-grid" if section.layout == "grid" else "entries-list" + section_attr = f' id="{escape(section.section_id)}"' if section.section_id else "" + cards = "".join(render_entry(entry) for entry in section.entries) + description_html = ( + f'

    {escape(section.description)}

    ' + if section.description + else "" + ) + if not cards: + cards = '
    暂无内容
    ' + return f""" +
    +
    +

    {escape(section.title)}

    + {description_html} +
    +
    + {cards} +
    +
    + """ + + def render_related(related: Dict[str, Sequence[Dict[str, str]]]) -> str: + if not related: + return "" + blocks: List[str] = [] + for label, links in related.items(): + if not links: + continue + items = "".join( + f'{escape(link.get("label", ""))}' + for link in links + ) + blocks.append( + f""" + + """ + ) + if not blocks: + return "" + return f'' + + sections_html = "".join(render_section(section) for section in page.sections) + metadata_html = render_metadata(page.metadata) + related_html = render_related(page.related_links) + subtitle_html = f'

    {escape(page.subtitle)}

    ' if page.subtitle else "" + + return f""" + + + + + + {escape(page.title)} + + + +
    +

    {escape(page.title)}

    + {subtitle_html} +
    +
    + {metadata_html} + {related_html} + {sections_html} +
    + + + """ + + +class MessageSender: + """消息发送器""" + + def __init__(self, webhook_url: str): + """初始化消息发送器 + + Args: + webhook_url: Webhook URL + """ + self.webhook_url = webhook_url + self.client: Optional[httpx.AsyncClient] = None + + async def _get_client(self) -> httpx.AsyncClient: + """获取HTTP客户端(懒加载)""" + if self.client is None: + self.client = httpx.AsyncClient(timeout=10.0) + return self.client + + async def send_message(self, message: Dict[str, Any]) -> bool: + """发送消息到WPS + + Args: + message: 消息字典 + + Returns: + 是否发送成功 + """ + try: + client = await self._get_client() + response = await client.post(self.webhook_url, json=message) + + if response.status_code == 200: + logger.Log("Info", f"消息发送成功: {message.get('msgtype')}") + return True + else: + logger.Log("Error", f"消息发送失败: status={response.status_code}, body={response.text}") + return False + + except Exception as e: + logger.Log("Error", f"发送消息异常: {e}") + return False + + async def send_text(self, content: str, at_user_id: Optional[int] = None) -> bool: + """发送文本消息 + + Args: + content: 文本内容 + at_user_id: @用户ID(可选) + + Returns: + 是否发送成功 + """ + # 如果需要@人 + if at_user_id: + content = f' {content}' + + message = { + "msgtype": "text", + "text": { + "content": content + } + } + return await self.send_message(message) + + async def send_markdown(self, text: str) -> bool: + """发送Markdown消息 + + Args: + text: Markdown文本 + + Returns: + 是否发送成功 + """ + message = { + "msgtype": "markdown", + "markdown": { + "text": text + } + } + return await self.send_message(message) + + async def send_link(self, title: str, text: str, + message_url: str = "", btn_title: str = "查看详情") -> bool: + """发送链接消息 + + Args: + title: 标题 + text: 文本内容 + message_url: 跳转URL + btn_title: 按钮文字 + + Returns: + 是否发送成功 + """ + message = { + "msgtype": "link", + "link": { + "title": title, + "text": text, + "messageUrl": message_url, + "btnTitle": btn_title + } + } + return await self.send_message(message) + + async def close(self): + """关闭HTTP客户端""" + if self.client: + await self.client.aclose() + self.client = None + + +class BasicWPSInterface(PluginInterface): + user_id_to_username: Optional[Callable[[int], str]] = None + + @override + def is_enable_plugin(self) -> bool: + return False + + def get_webhook_url(self, message: str, chat_id: int, user_id: int) -> str: + ''' + 根据消息和用户ID获取Webhook URL, 返回空字符串表示不需要回复消息 + Args: + message: 消息内容 + chat_id: 聊天ID + user_id: 用户ID + Returns: + Webhook URL + ''' + return "" + + def get_webhook_request(self, data:Any|None) -> None: + pass + + def get_message_sender(self, webhook_url: str) -> MessageSender: + return MessageSender(webhook_url) + + def get_message_sender_type(self) -> Literal["text", "markdown", "link"]: + return "markdown" + + def get_message_sender_function(self, webhook_url: str, type: Literal["text", "markdown", "link"]) -> Coroutine[Any, Any, bool]: + if type == "text": + return self.get_message_sender(webhook_url).send_text + elif type == "markdown": + return self.get_message_sender(webhook_url).send_markdown + elif type == "link": + return self.get_message_sender(webhook_url).send_link + else: + raise ValueError(f"Invalid message sender type: {type}") + + def parse_message_after_at(self, message: str) -> str: + ''' + 已过时 + ''' + return message + + async def send_markdown_message(self, message: str, chat_id: int, user_id: int) -> str|None: + webhook_url = self.get_webhook_url(message, chat_id, user_id) + if get_internal_debug(): + logger.Log("Info", f"Webhook URL: {webhook_url}, Message: {LimitStringLength(message)}, Chat ID: {chat_id}, User ID: {user_id}") + if webhook_url == "" or webhook_url == None: + return None + + username: str = self.user_id_to_username(user_id) if self.user_id_to_username else "" + send_message = f"""## {username} +--- +{message} +""" + + result = await self.get_message_sender_function(webhook_url, self.get_message_sender_type())(send_message) + if get_internal_verbose(): + logger.Log("Info", f"Webhook URL: {webhook_url}, Message: {LimitStringLength(message)}, Result: {result}") + return None + + @override + async def callback(self, message: str, chat_id: int, user_id: int) -> str|None: + message = self.parse_message_after_at(message) + if message == "": + return None + return await self.send_markdown_message(message, chat_id, user_id) + + +class WPSAPI(BasicWPSInterface): + """核心 WPS 插件基类,提供图鉴模板设施。""" + + guide_section_labels: Dict[str, str] = { + "commands": "指令一览", + "items": "物品与资源", + "recipes": "配方与合成", + "guides": "系统指引", + } + + def get_guide_title(self) -> str: + return self.__class__.__name__ + + def get_guide_subtitle(self) -> str: + return "" + + def get_guide_metadata(self) -> Dict[str, str]: + return {} + + def collect_command_entries(self) -> Sequence[GuideEntry]: + return () + + def collect_item_entries(self) -> Sequence[GuideEntry]: + return () + + def collect_recipe_entries(self) -> Sequence[GuideEntry]: + return () + + def collect_guide_entries(self) -> Sequence[GuideEntry]: + return () + + def collect_additional_sections(self) -> Sequence[GuideSection]: + return () + + def collect_guide_sections(self) -> Sequence[GuideSection]: + sections: List[GuideSection] = [] + + command_entries = tuple(self.collect_command_entries()) + if command_entries: + sections.append( + GuideSection( + title=self.guide_section_labels["commands"], + entries=command_entries, + layout="list", + ) + ) + + item_entries = tuple(self.collect_item_entries()) + if item_entries: + sections.append( + GuideSection( + title=self.guide_section_labels["items"], + entries=item_entries, + ) + ) + + recipe_entries = tuple(self.collect_recipe_entries()) + if recipe_entries: + sections.append( + GuideSection( + title=self.guide_section_labels["recipes"], + entries=recipe_entries, + ) + ) + + guide_entries = tuple(self.collect_guide_entries()) + if guide_entries: + sections.append( + GuideSection( + title=self.guide_section_labels["guides"], + entries=guide_entries, + layout="list", + ) + ) + + additional_sections = tuple(self.collect_additional_sections()) + if additional_sections: + sections.extend(additional_sections) + + return tuple(sections) + + def build_guide_page(self) -> GuidePage: + metadata: Dict[str, str] = {} + for key, value in self.get_guide_metadata().items(): + metadata[key] = str(value) + + related = self.get_related_links() + + return GuidePage( + title=self.get_guide_title(), + subtitle=self.get_guide_subtitle(), + sections=self.collect_guide_sections(), + metadata=metadata, + related_links=related, + ) + + def render_guide_page(self, page: GuidePage) -> str: + return render_html_page(page) + + def render_guide(self) -> str: + return self.render_guide_page(self.build_guide_page()) + + def get_guide_response(self, content: str) -> HTMLResponse: + return HTMLResponse(content) + + @override + def generate_router_illustrated_guide(self): + async def handler() -> HTMLResponse: + return self.get_guide_response(self.render_guide()) + + return handler + + def get_related_links(self) -> Dict[str, Sequence[Dict[str, str]]]: + links: Dict[str, Sequence[Dict[str, str]]] = {} + + parents = [] + for base in self.__class__.__mro__[1:]: + if not issubclass(base, WPSAPI): + continue + if base.__module__.startswith("Plugins."): + parents.append(base) + if base is WPSAPI: + break + if parents: + parents_links = [self._build_class_link(cls) for cls in reversed(parents)] + links["父类链"] = tuple(filter(None, parents_links)) + + child_links = [self._build_class_link(child) for child in self._iter_subclasses(self.__class__)] + child_links = [link for link in child_links if link] + if child_links: + links["子类"] = tuple(child_links) + + return links + + def _build_class_link(self, cls: type) -> Optional[Dict[str, str]]: + if not hasattr(cls, "__module__") or not cls.__module__.startswith("Plugins."): + return None + path = f"/api/{cls.__name__}" + return { + "label": cls.__name__, + "href": path, + } + + def _iter_subclasses(self, cls: type) -> List[type]: + collected: List[type] = [] + for subclass in cls.__subclasses__(): + if not issubclass(subclass, WPSAPI): + continue + if not subclass.__module__.startswith("Plugins."): + continue + collected.append(subclass) + collected.extend(self._iter_subclasses(subclass)) + return collected + + def get_guide_subtitle(self) -> str: + return "核心 Webhook 转发插件" + + def get_guide_metadata(self) -> Dict[str, str]: + return { + "Webhook 状态": "已配置" if MAIN_WEBHOOK_URL else "未配置", + } + + def collect_command_entries(self) -> Sequence[GuideEntry]: + return ( + { + "title": "say", + "identifier": "say", + "description": "将后续消息内容以 Markdown 形式发送到主 Webhook。", + "metadata": {"别名": "说"}, + "icon": "🗣️", + "badge": "核心", + }, + ) + + def collect_guide_entries(self) -> Sequence[GuideEntry]: + return ( + { + "title": "Webhook 绑定", + "description": ( + "在项目配置中设置 `main_webhook_url` 后插件自动启用," + "所有注册的命令将调用 `send_markdown_message` 发送富文本。" + ), + "icon": "🔗", + }, + { + "title": "消息格式", + "description": ( + "默认使用 Markdown 模式发送,支持 `聊天ID` 与 `用户ID` 的 @ 提醒。" + ), + "icon": "📝", + }, + ) + + @override + def is_enable_plugin(self) -> bool: + if MAIN_WEBHOOK_URL == "": + logger.Log("Error", f"{ConsoleFrontColor.RED}WPSAPI未配置主Webhook URL{ConsoleFrontColor.RESET}") + return MAIN_WEBHOOK_URL != "" + + def get_main_webhook_url(self) -> str: + return MAIN_WEBHOOK_URL + + @override + def get_webhook_url(self, message: str, chat_id: int, user_id: int) -> str: + webhook_url = ProjectConfig().GetFile(f"webhook_url/{chat_id}",True).LoadAsText() + if webhook_url == "": + webhook_url = self.get_main_webhook_url() + return webhook_url + + @override + def get_webhook_request(self, data:Any|None) -> None: + return None + + @override + def wake_up(self) -> None: + logger.Log("Info", f"{ConsoleFrontColor.GREEN}WPSAPI核心插件已加载{ConsoleFrontColor.RESET}") + self.register_plugin("say") + self.register_plugin("说") + +class WPSAPIHelp(WPSAPI): + @override + def dependencies(self) -> List[Type]: + return [WPSAPI] + + @override + def is_enable_plugin(self) -> bool: + return True + + @override + async def callback(self, message: str, chat_id: int, user_id: int) -> str|None: + mapper: Dict[str, List[str]] = {} + for key in PluginInterface.plugin_instances.keys(): + plugin = PluginInterface.plugin_instances.get(key) + if plugin: + if plugin.__class__.__name__ not in mapper: + mapper[plugin.__class__.__name__] = [] + mapper[plugin.__class__.__name__].append(key) + desc = "" + for plugin_name, keys in mapper.items(): + desc += f"- {plugin_name}: {", ".join(keys)}\n" + return await self.send_markdown_message(f"""# 指令入口 +{desc} +""", chat_id, user_id) + + @override + def wake_up(self) -> None: + logger.Log("Info", f"{ConsoleFrontColor.GREEN}WPSAPIHelp 插件已加载{ConsoleFrontColor.RESET}") + self.register_plugin("help") + self.register_plugin("帮助") + +class WPSAPIWebhook(WPSAPI): + @override + def dependencies(self) -> List[Type]: + return [WPSAPI] + + @override + def is_enable_plugin(self) -> bool: + return True + + @override + def wake_up(self) -> None: + logger.Log("Info", f"{ConsoleFrontColor.GREEN}WPSAPIWebhook 插件已加载{ConsoleFrontColor.RESET}") + self.register_plugin("chat_url_register") + self.register_plugin("会话注册") + + @override + async def callback(self, message: str, chat_id: int, user_id: int) -> str|None: + ProjectConfig().GetFile(f"webhook_url/{chat_id}",True).SaveAsText(message) + return await self.send_markdown_message(f"会话注册成功", chat_id, user_id) + +class NewsAIPlugin(WPSAPI): + """新闻AI智能问答插件""" + + def __init__(self): + super().__init__() + self.ai_agent = NewsAIAgent(ollama_url) + + @override + def dependencies(self) -> List[Type]: + return [WPSAPI] + + @override + def is_enable_plugin(self) -> bool: + return True + + def get_guide_title(self) -> str: + return "新闻AI智能问答" + + def get_guide_subtitle(self) -> str: + return "基于AI的新闻联播智能问答系统" + + def get_guide_metadata(self) -> Dict[str, str]: + return { + "AI模型": "qwen3:0.6b", + "数据源": "每日新闻联播", + "功能": "智能问答", + } + + def collect_command_entries(self) -> Sequence[GuideEntry]: + return ( + { + "title": "问新闻", + "identifier": "ask_news", + "description": "询问新闻内容,支持自动识别日期或查询今天/昨天的新闻。", + "metadata": {"别名": "新闻问答, 问"}, + "icon": "🤖", + "badge": "AI", + "details": [ + { + "type": "list", + "items": [ + "支持日期格式:2024年11月19日、2024-11-19、2024/11/19", + "支持相对日期:今天、昨天、今日、昨日", + "自动使用AI分析新闻内容并回答问题", + "示例:问 今天有什么重要新闻?", + "示例:问 2024年11月19日 经济相关的新闻有哪些?", + ] + } + ] + }, + { + "title": "新闻摘要", + "identifier": "news_summary", + "description": "获取指定日期新闻的AI摘要。", + "metadata": {"别名": "摘要"}, + "icon": "📝", + "badge": "AI", + }, + ) + + def collect_guide_entries(self) -> Sequence[GuideEntry]: + return ( + { + "title": "智能问答", + "description": ( + "使用AI理解用户问题,从新闻内容中提取相关信息进行回答。" + "支持自然语言提问,可以询问特定主题、人物、事件等。" + ), + "icon": "💬", + }, + { + "title": "日期识别", + "description": ( + "自动从问题中识别日期,支持多种格式。" + "如果未指定日期,默认查询当天新闻。" + ), + "icon": "📅", + }, + { + "title": "内容抓取", + "description": ( + "自动从新闻网站抓取指定日期的新闻联播文字版内容。" + ), + "icon": "🌐", + }, + ) + + @override + def wake_up(self) -> None: + logger.Log("Info", f"{ConsoleFrontColor.GREEN}NewsAIPlugin 新闻AI智能问答插件已加载{ConsoleFrontColor.RESET}") + self.register_plugin("default") + + @override + async def callback(self, message: str, chat_id: int, user_id: int) -> str|None: + """处理用户问题""" + try: + if not message or message.strip() == "": + help_text = """# 📰 新闻AI智能问答使用帮助 + +**直接提问即可,智能体会自动:** +1. 识别你想查询的日期 +2. 获取对应日期的新闻内容 +3. 基于新闻内容回答你的问题 + +**支持的日期格式:** +- 今天、昨天、今日、昨日 +- 2025年11月19日 +- 2025-11-19 +- 2025/11/19 + +**示例问题:** +- `今天有什么重要新闻?` +- `2025年11月17日有什么新闻?` +- `昨天的新闻中有关于经济的内容吗?` +- `今天习近平主席有什么活动?` +- `请总结今天新闻联播的主要内容` + +**特性:** +✨ 智能理解问题意图 +🤖 自动调用工具获取信息 +💾 新闻内容自动缓存 +🚀 基于LlamaIndex + Ollama""" + return await self.send_markdown_message(help_text, chat_id, user_id) + + # 使用智能体回答问题 + answer = await self.ai_agent.answer_question(message) + + # 格式化返回结果 + formatted_answer = f"""📰 **新闻AI智能问答** + +{answer} + +--- +*由 LlamaIndex + Ollama 驱动*""" + + return await self.send_markdown_message(formatted_answer, chat_id, user_id) + + except Exception as e: + logger.Log("Error", f"新闻AI问答异常: {e}") + import traceback + error_detail = traceback.format_exc() + logger.Log("Error", f"详细错误: {error_detail}") + error_msg = f"""❌ **处理问题时出错** + +错误信息:{str(e)} + +请稍后重试或联系管理员。""" + return await self.send_markdown_message(error_msg, chat_id, user_id) + +logger.SaveProperties() \ No newline at end of file diff --git a/Plugins/__init__.py b/Plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py new file mode 100644 index 0000000..ba3bb88 --- /dev/null +++ b/app.py @@ -0,0 +1,3 @@ +from PWF.Application.app import main + +main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1ab785e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi +uvicorn +httpx +requests +llama-index +llama-index-llms-ollama +llama-index-embeddings-ollama +beautifulsoup4 +lxml +