diff --git a/yuthon_ai_agent_17/README.md b/yuthon_ai_agent_17/README.md new file mode 100644 index 000000000..5324be5f6 --- /dev/null +++ b/yuthon_ai_agent_17/README.md @@ -0,0 +1,128 @@ +# Yuthon AI Agent - 智能助手 + +> Odoo 17 内嵌 AI 智能体模块,让 ERP 系统具备自然语言交互能力。 + +**作者:** pengyuthon +**邮箱:** pengyuthon@163.com +**微信:** pengyuthon +**版本:** 17.0.2.2.0 +**许可证:** LGPL-3 + +--- + +## 功能清单 & 价值分析 + +### 一、智能对话核心 + +| 功能 | 说明 | 业务价值 | +|------|------|---------| +| **AI 对话面板** | Web 端内嵌全屏聊天界面,支持完整对话记录 | 员工无需离开 ERP 系统即可获得 AI 辅助,**降低操作门槛**,不需要额外购买/部署 ChatGPT 等工具 | +| **浮动聊天窗口** | 右上角紫色图标一键唤出,支持展开/收缩 | **随时随地即问即答**,不打断当前业务操作流程,提升工作效率 | +| **多会话管理** | 支持新建、切换、删除对话,每个对话独立上下文 | 不同业务场景分开讨论,**对话不混乱**;历史可追溯,方便复盘 | +| **对话历史存储** | 所有消息持久化存储在数据库中 | 可审计、可追溯,**满足企业合规需求**;管理员可查看全部对话 | + +### 二、数据交互能力 (Function Calling) + +| 功能 | 说明 | 业务价值 | +|------|------|---------| +| **自然语言查数据** | 用户说"查一下本月销售订单",AI 自动调用工具查询 | **大幅降低 ERP 使用门槛**,新员工无需培训也能查数据,**降低实施和培训成本** | +| **数据统计** | "公司有多少员工"、"本月开了几张发票" | **让管理层随口问就能得到数据**,不用等报表、不用等 IT 部门导出,**提升决策效率** | +| **字段探索** | 不确定字段名时 AI 自动调用 `fields_get` 探索 | 遇到不熟悉的模块,**AI 自己搞清楚再回答**,减少错误回答 | +| **数据创建/修改/删除** | 可控的写入能力(需管理员开启) | "帮我建一个客户叫张三" — **简单业务操作无需进入表单手动填写**,节省操作时间 | +| **权限隔离** | 所有工具调用走当前用户权限 (ir.rule) | AI 看不到用户权限外的数据,**安全可控**,不会越权访问 | + +### 三、用户个性化体系 + +| 功能 | 说明 | 业务价值 | +|------|------|---------| +| **用户 AI 画像** | 每人一个画像:称呼、职位、部门、沟通风格、自定义指令 | AI 了解每个人的背景,**回答更精准更贴合** | +| **关联 HR 员工** | 画像自动关联 `hr.employee`,一键带出职位/部门 | **零配置**,装好模块就能用,不用每个人手动填信息 | +| **AI 长期记忆** | AI 自动识别并保存用户偏好、工作内容、专业知识 | **越用越懂你**,记住你的习惯和偏好,**替代重复沟通** | +| **沟通风格定制** | 简洁/详细/轻松/正式 四种风格 | 技术人员要简洁结论,管理层要详细分析,**一个 AI 适配不同角色** | + +### 四、规则配置系统 + +| 功能 | 说明 | 业务价值 | +|------|------|---------| +| **回复指令** | 控制 AI 回答风格(如"不超过200字"、"用表格展示") | **统一企业 AI 输出标准**,不同员工问同类问题,回答格式一致 | +| **限制规则** | 禁止 AI 回答特定话题(如薪资、商业机密) | **信息安全防护**,防止敏感数据通过 AI 泄露给无关人员 | +| **技能接口** | 对接外部 API(天气/知识库/审批等),AI 自动判断何时调用 | **扩展 AI 能力边界**,可以调企业内部知识库、业务系统 API,打通数据孤岛 | +| **按用户组生效** | 规则可指定适用用户组 | **精细化管控**:销售组禁止查成本价,管理层开放完整权限 | +| **按服务商绑定** | 规则关联到具体服务商 (Many2many) | 不同模型不同规则:推理模型不开工具调用,对话模型配全规则 | + +### 五、成本管控 + +| 功能 | 说明 | 业务价值 | +|------|------|---------| +| **Token 用量追踪** | 每次调用自动记录 prompt/completion/total token | **精确到人、到次、到服务商的成本核算**,知道钱花在了哪里 | +| **Token 汇总统计** | SQL 视图聚合分析,按用户/服务商/模型维度 | **发现异常用量**:谁在刷、哪个模型性价比低,辅助采购决策 | +| **Stat Button 穿透** | 用户画像和服务商表单都有 Token 统计按钮 | 管理员**一眼看到**每个用户/每个服务商的消耗情况 | + +### 六、多服务商架构 + +| 功能 | 说明 | 业务价值 | +|------|------|---------| +| **多 Provider 配置** | OpenAI / Azure / 本地模型 / 自定义,可配多个 | **不绑定单一厂商**,随时切换。想用便宜的用 DeepSeek,想要强推理用 GPT-4o | +| **独立参数配置** | 每个服务商独立配 Token 上限、温度、超时 | 不同场景不同参数:日常对话低 Token 省钱,复杂分析高 Token 保质量 | +| **API Key 升级保护** | `noupdate="1"` 防止模块升级覆盖已填的 API Key | 运维无忧:**升级模块不丢配置** | + +### 七、权限与安全 + +| 功能 | 说明 | 业务价值 | +|------|------|---------| +| **双层用户组** | 普通用户组 + 管理员组,继承关系 | 普通用户只有"我的 AI"入口,管理员有"系统配置",**职责分离** | +| **数据记录规则** | 对话/画像/记忆/Token 全部做了 ir.rule 隔离 | 用户只能看自己的数据,**隐私保护**到位 | +| **工具调用权限** | 写入能力默认关闭,需管理员手动开启 | **防止 AI 误操作**,创建/修改/删除必须显式授权 | +| **工具调用轮次上限** | 最多 6 轮防死循环 | **防止 Token 无限消耗**,异常场景自动熔断 | + +--- + +## 核心价值总结 + +| 维度 | 价值 | +|------|------| +| **降低实施成本** | 员工通过自然语言查数据,减少 80% 的"怎么查XXX"类咨询需求,优先内部自助解决 | +| **降低培训成本** | 新员工不用学复杂的 ERP 操作,问 AI 就行,入职即可用 | +| **提升决策效率** | 管理层随口问数据,不用等人导报表 | +| **信息安全** | 规则 + 权限双重防护,敏感数据不泄露 | +| **成本可控** | Token 精确计量到人到次,杜绝滥用 | +| **可扩展** | 技能接口可对接任意外部系统,AI 能力无上限 | + +--- + +## 菜单结构 + +``` +AI 助手 +├── 我的 AI ← 普通用户唯一入口(画像、记忆、对话、Token 统计) +└── 系统配置 ← 管理员专属 + ├── 服务商配置(含 Token 统计 & 关联规则) + ├── 规则配置(回复指令 / 限制规则 / 技能接口) + └── 用户列表 +``` + +--- + +## 技术栈 + +- **后端:** Odoo 17 (Python 3.10+) +- **前端:** Odoo OWL Framework +- **AI 接口:** OpenAI 兼容 API(支持 DeepSeek / Qwen / Ollama / Azure 等) +- **数据库:** PostgreSQL(含 SQL 视图聚合) + +--- + +## 安装 + +1. 将模块目录放入 Odoo 17 addons 路径 +2. 更新应用列表,搜索 **Yuthon AI Agent** 安装 +3. 进入 **AI 助手 → 系统配置 → 服务商配置**,填入 API 地址和 Key +4. 将需要使用 AI 的用户加入 **AI Agent 用户** 组 + +--- + +## 联系方式 + +- **作者:** pengyuthon +- **邮箱:** pengyuthon@163.com +- **微信:** pengyuthon diff --git a/yuthon_ai_agent_17/__init__.py b/yuthon_ai_agent_17/__init__.py new file mode 100644 index 000000000..3b3891601 --- /dev/null +++ b/yuthon_ai_agent_17/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import models +from . import controllers diff --git a/yuthon_ai_agent_17/__manifest__.py b/yuthon_ai_agent_17/__manifest__.py new file mode 100644 index 000000000..802dbe5ed --- /dev/null +++ b/yuthon_ai_agent_17/__manifest__.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Yuthon AI Agent - 智能助手', + 'summary': 'Odoo 内嵌 AI 聊天面板,对接 OpenAI 兼容 API', + 'description': """ + Odoo 17 智能体模块 + - AI 对话面板(Web 端内嵌聊天) + - 浮动聊天窗口(右上角弹出) + - 支持 OpenAI / 本地模型 / 任何兼容 API + - 对话历史存储与回溯 + - 可配置多 Provider + - Function Calling 工具调用 + - AI 规则配置系统 + """, + 'author': 'pengyuthon', + 'version': '17.0.2.2.0', + 'license': 'LGPL-3', + 'depends': ['base', 'web', 'hr'], + 'data': [ + 'security/ai_security.xml', + 'security/ir.model.access.csv', + 'views/ai_provider_views.xml', + 'views/ai_rule_views.xml', + 'views/ai_user_profile_views.xml', + 'views/ai_token_usage_views.xml', + 'views/ai_conversation_views.xml', + 'data/demo_provider.xml', + 'data/demo_rule.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'yuthon_ai_agent_17/static/src/js/ai_chat_action.js', + 'yuthon_ai_agent_17/static/src/js/ai_floating.js', + 'yuthon_ai_agent_17/static/src/xml/ai_chat_template.xml', + 'yuthon_ai_agent_17/static/src/xml/ai_floating_template.xml', + 'yuthon_ai_agent_17/static/src/css/ai_chat.css', + 'yuthon_ai_agent_17/static/src/css/ai_floating.css', + ], + }, + 'installable': True, + 'application': True, +} diff --git a/yuthon_ai_agent_17/controllers/__init__.py b/yuthon_ai_agent_17/controllers/__init__.py new file mode 100644 index 000000000..757b12a1f --- /dev/null +++ b/yuthon_ai_agent_17/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import main diff --git a/yuthon_ai_agent_17/controllers/main.py b/yuthon_ai_agent_17/controllers/main.py new file mode 100644 index 000000000..78abe133e --- /dev/null +++ b/yuthon_ai_agent_17/controllers/main.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +import json + +from odoo import http +from odoo.http import request +from odoo.tools import format_datetime + + +class AiChatController(http.Controller): + """AI 聊天 API""" + + def _check_ai_access(self): + """检查用户是否有 AI Agent 使用权限""" + if not request.env.user.has_group('yuthon_ai_agent_17.group_ai_agent_user'): + return {'error': '您没有使用 AI 助手的权限,请联系管理员开通。'} + return None + + @http.route('/ai/chat/send', type='json', auth='user', methods=['POST'], csrf=False) + def chat_send(self, conversation_id, message): + """发送消息""" + err = self._check_ai_access() + if err: + return err + conv = request.env['ai.conversation'].browse(conversation_id) + if not conv.exists(): + return {'error': '对话不存在'} + reply = conv.send_message(message) + if isinstance(reply, dict) and 'error' in reply: + return reply + last_msg = conv.message_ids.filtered(lambda m: m.role == 'assistant')[-1:] + return { + 'content': reply, + 'message_id': last_msg.id if last_msg else False, + } + + @http.route('/ai/chat/history', type='json', auth='user') + def chat_history(self, conversation_id): + """获取对话历史""" + conv = request.env['ai.conversation'].browse(conversation_id) + if not conv.exists(): + return {'error': '对话不存在'} + # 获取用户时区 + user_tz = request.env.user.tz or 'Asia/Shanghai' + from pytz import timezone, utc + local_tz = timezone(user_tz) + + messages = [] + for msg in conv.message_ids: + if msg.create_date: + # UTC → 用户时区 + utc_dt = msg.create_date.replace(tzinfo=utc) + local_dt = utc_dt.astimezone(local_tz) + time_str = local_dt.strftime('%H:%M') + else: + time_str = '' + messages.append({ + 'id': msg.id, + 'role': msg.role, + 'content': msg.content, + 'date': time_str, + }) + return {'messages': messages} + + @http.route('/ai/chat/clear', type='json', auth='user', methods=['POST'], csrf=False) + def chat_clear(self, conversation_id): + """清空对话""" + conv = request.env['ai.conversation'].browse(conversation_id) + if not conv.exists(): + return {'error': '对话不存在'} + conv.message_ids.unlink() + return {'success': True} + + @http.route('/ai/conversation/create', type='json', auth='user', methods=['POST'], csrf=False) + def conversation_create(self): + """创建新对话并返回ID""" + provider = request.env['ai.provider'].search([('active', '=', True)], limit=1) + if not provider: + return {'error': '请先在服务商配置中设置 AI 服务'} + conv = request.env['ai.conversation'].create({ + 'name': '新对话', + 'provider_id': provider.id, + }) + return {'id': conv.id} + + @http.route('/ai/conversation/get_or_create_personal', type='json', auth='user') + def get_or_create_personal(self): + """获取或创建当前用户的个人对话(浮动聊天用)""" + err = self._check_ai_access() + if err: + return err + # 查找当前用户最近的一个有消息的对话 + conv = request.env['ai.conversation'].search([ + ('create_uid', '=', request.env.uid), + ], limit=1, order='create_date desc') + + if not conv: + # 没有则创建 + provider = request.env['ai.provider'].search([('active', '=', True)], limit=1) + if not provider: + return {'error': '请先在服务商配置中设置 AI 服务'} + conv = request.env['ai.conversation'].create({ + 'name': '个人对话', + 'provider_id': provider.id, + }) + return {'id': conv.id, 'name': conv.name} + + @http.route('/ai/conversation/list', type='json', auth='user') + def conversation_list(self): + """获取当前用户的所有对话列表""" + err = self._check_ai_access() + if err: + return err + convs = request.env['ai.conversation'].search([ + ('create_uid', '=', request.env.uid), + ], order='create_date desc', limit=50) + return { + 'conversations': [{ + 'id': c.id, + 'name': c.name, + 'message_count': c.message_count, + } for c in convs] + } + + @http.route('/ai/conversation/new', type='json', auth='user', methods=['POST'], csrf=False) + def conversation_new(self): + """新建对话""" + err = self._check_ai_access() + if err: + return err + provider = request.env['ai.provider'].search([('active', '=', True)], limit=1) + if not provider: + return {'error': '请先在服务商配置中设置 AI 服务'} + conv = request.env['ai.conversation'].create({ + 'name': '新对话', + 'provider_id': provider.id, + }) + return {'id': conv.id, 'name': conv.name} + + @http.route('/ai/conversation/delete', type='json', auth='user', methods=['POST'], csrf=False) + def conversation_delete(self, conversation_id): + """删除对话""" + err = self._check_ai_access() + if err: + return err + conv = request.env['ai.conversation'].browse(conversation_id) + if not conv.exists(): + return {'error': '对话不存在'} + if conv.create_uid.id != request.env.uid: + return {'error': '只能删除自己的对话'} + conv.unlink() + return {'success': True} + + @http.route('/ai/profile/get', type='json', auth='user') + def get_profile(self): + """获取当前用户的 AI 画像(含记忆摘要)""" + err = self._check_ai_access() + if err: + return err + profile = request.env['ai.user.profile'].get_or_create() + return { + 'id': profile.id, + 'nickname': profile.nickname or '', + 'role': profile.role or '', + 'department': profile.department or '', + 'style_preference': profile.style_preference or 'concise', + 'memory_count': profile.memory_count, + } diff --git a/yuthon_ai_agent_17/data/demo_provider.xml b/yuthon_ai_agent_17/data/demo_provider.xml new file mode 100644 index 000000000..54fbce6e1 --- /dev/null +++ b/yuthon_ai_agent_17/data/demo_provider.xml @@ -0,0 +1,32 @@ + + + + + + + DeepSeek V3 + openai + deepseek-chat + https://api.deepseek.com/v1 + + 4096 + 0.7 + 20 + + + + + + + DeepSeek R1(推理) + openai + deepseek-reasoner + https://api.deepseek.com/v1 + + 4096 + 0.7 + 60 + + + + diff --git a/yuthon_ai_agent_17/data/demo_rule.xml b/yuthon_ai_agent_17/data/demo_rule.xml new file mode 100644 index 000000000..bcc326b60 --- /dev/null +++ b/yuthon_ai_agent_17/data/demo_rule.xml @@ -0,0 +1,104 @@ + + + + + + + 简洁回复 + instruction + 5 + 所有回复控制在 200 字以内,重点突出结论,避免冗长的铺垫和解释。如果内容较多,优先使用表格或列表形式呈现。 + + + + + 数据展示规范 + instruction + 10 + 当查询到多条数据时,必须用 Markdown 表格呈现,表头使用中文名称。金额字段保留两位小数并加上 ¥ 前缀,日期使用 YYYY-MM-DD 格式。 + + + + + 专业商务风格 + instruction + 15 + 使用专业、正式的商务语气回复,避免口语化表达、网络用语和表情符号。对不确定的信息要明确说明"暂无相关数据",不得猜测或编造。 + + + + + 禁止薪资话题 + restriction + 1 + 严禁回答任何关于员工薪资、工资条、社保缴纳基数、个税明细、年终奖等薪酬福利相关的问题。统一回复"薪酬信息属于保密数据,请联系人力资源部门查询。" + + + + + 禁止泄露商业机密 + restriction + 2 + 不得回答涉及公司核心商业策略、融资信息、未公开的合作协议、供应商底价等商业机密类问题。统一回复"该信息涉及商业机密,无法提供,请联系相关负责人。" + + + + 禁止修改系统配置 + restriction + 3 + 禁止协助用户修改系统参数(ir.config_parameter)、用户权限组(res.groups)、安全规则(ir.rule)等系统级配置。统一回复"系统配置修改需由管理员在后台操作,请联系 IT 部门。" + + + + + 天气查询技能 + skill + 10 + query_weather + 当用户询问某个城市的天气状况时调用此技能,获取实时天气数据。 + { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "要查询的城市名,如:北京、上海、深圳" + } + }, + "required": ["city"] +} + https://api.example.com/weather + GET + {} + 5 + + + + + 企业知识库检索 + skill + 5 + search_knowledge + 当用户询问公司制度、流程规范、操作手册等内部知识时,调用此技能从企业知识库中检索相关内容。 + { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "用户的问题或关键词" + }, + "category": { + "type": "string", + "enum": ["制度规范", "操作手册", "常见问题", "全部"], + "description": "知识分类,默认全部" + } + }, + "required": ["query"] +} + https://api.example.com/knowledge/search + POST + {"Authorization": "Bearer YOUR_API_KEY"} + 10 + + + + diff --git a/yuthon_ai_agent_17/models/__init__.py b/yuthon_ai_agent_17/models/__init__.py new file mode 100644 index 000000000..1dc78f63d --- /dev/null +++ b/yuthon_ai_agent_17/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +from . import ai_agent +from . import ai_user_profile +from . import ai_token_usage +from . import ai_rule diff --git a/yuthon_ai_agent_17/models/ai_agent.py b/yuthon_ai_agent_17/models/ai_agent.py new file mode 100644 index 000000000..9a52e3e2b --- /dev/null +++ b/yuthon_ai_agent_17/models/ai_agent.py @@ -0,0 +1,581 @@ +# -*- coding: utf-8 -*- +import json +import logging + +import requests + +from odoo import models, fields, api +from odoo.exceptions import AccessError + +_logger = logging.getLogger(__name__) + +# 单次对话最多允许的工具调用轮次(防止死循环) +MAX_TOOL_ITERATIONS = 6 +# search_read 默认/最大返回条数 +DEFAULT_LIMIT = 20 +MAX_LIMIT = 200 + + +class AiProvider(models.Model): + """AI 服务配置""" + _name = 'ai.provider' + _description = 'AI 服务商' + + create_date = fields.Datetime(string='创建时间', readonly=True) + write_date = fields.Datetime(string='最后更新', readonly=True) + + name = fields.Char(string='名称', required=True) + provider_type = fields.Selection([ + ('openai', 'OpenAI'), + ('azure', 'Azure OpenAI'), + ('local', '本地模型 (Ollama/LocalAI)'), + ('custom', '自定义'), + ], string='类型', default='openai', required=True) + api_base = fields.Char( + string='API 地址', + default='https://api.openai.com/v1', + help='API Base URL,包含 /v1' + ) + api_key = fields.Char(string='API Key') + model_name = fields.Char( + string='模型名称', + default='gpt-4o-mini', + help='如 gpt-4o-mini, qwen-plus, llama3 等' + ) + max_tokens = fields.Integer(string='最大 Token', default=4096) + temperature = fields.Float(string='温度', default=0.7) + timeout = fields.Integer( + string='超时(秒)', + default=20, + help='AI 单次请求的最大等待秒数,超过后返回超时错误。默认 20 秒。' + ) + active = fields.Boolean(string='启用', default=True) + enable_tools = fields.Boolean( + string='启用工具调用', + default=True, + help='开启后 AI 可以查询/创建/修改 Odoo 数据(Function Calling)。要求模型支持 tools 参数(如 gpt-4o, qwen-plus, deepseek-chat 等)。' + ) + allow_write = fields.Boolean( + string='允许写入', + default=False, + help='允许 AI 调用 create/write/unlink 工具。建议仅在受信任环境开启。' + ) + + # 规则配置 + rule_ids = fields.Many2many( + 'ai.rule', 'ai_rule_provider_rel', 'provider_id', 'rule_id', + string='关联规则', + help='为该服务商绑定的 AI 规则(回复指令、限制规则、技能接口)', + ) + + # Token 统计(关联到本服务商) + provider_total_tokens = fields.Integer( + string='总 Token', compute='_compute_provider_token_stats' + ) + provider_call_count = fields.Integer( + string='调用次数', compute='_compute_provider_token_stats' + ) + + def _compute_provider_token_stats(self): + """统计该服务商的 Token 总量和调用次数""" + for rec in self: + usage_data = self.env['ai.token.usage'].sudo().read_group( + [('provider_id', '=', rec.id)], + ['total_tokens:sum'], + [], + ) + rec.provider_total_tokens = usage_data[0]['total_tokens'] if usage_data else 0 + rec.provider_call_count = self.env['ai.token.usage'].sudo().search_count( + [('provider_id', '=', rec.id)] + ) + + def action_view_provider_token_usage(self): + """打开该服务商的 Token 用量明细""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': f'{self.name} - Token 用量', + 'res_model': 'ai.token.usage', + 'view_mode': 'list', + 'domain': [('provider_id', '=', self.id)], + 'context': {'search_default_group_user': 1}, + } + + def get_applicable_rules(self, user=None): + """获取对当前用户生效的规则(按优先级排序)""" + self.ensure_one() + user = user or self.env.user + rules = self.rule_ids.filtered(lambda r: r.active) + # 过滤用户组:规则未配用户组 = 对所有人生效,配了则检查用户是否在组中 + applicable = rules.filtered( + lambda r: not r.group_ids or (r.group_ids & user.groups_id) + ) + return applicable.sorted('priority') + + +class AiConversation(models.Model): + """对话会话""" + _name = 'ai.conversation' + _description = 'AI 对话' + _order = 'create_date desc' + + create_date = fields.Datetime(string='创建时间', readonly=True) + write_date = fields.Datetime(string='最后更新', readonly=True) + + name = fields.Char(string='标题', required=True, default='新对话') + provider_id = fields.Many2one( + 'ai.provider', string='AI 服务', + required=True, + default=lambda self: self.env['ai.provider'].search([('active', '=', True)], limit=1) + ) + message_ids = fields.One2many( + 'ai.message', 'conversation_id', string='消息' + ) + message_count = fields.Integer( + string='消息数', compute='_compute_message_count', store=True + ) + create_uid = fields.Many2one('res.users', string='用户', default=lambda self: self.env.uid) + + # 从 provider 映射过来的可读字段,方便在对话页直观查看当前助手配置 + provider_model_name = fields.Char(related='provider_id.model_name', string='模型名称', readonly=True) + provider_timeout = fields.Integer(related='provider_id.timeout', string='超时(秒)', readonly=True) + provider_enable_tools = fields.Boolean(related='provider_id.enable_tools', string='工具调用', readonly=True) + provider_allow_write = fields.Boolean(related='provider_id.allow_write', string='允许写入', readonly=True) + + @api.depends('message_ids') + def _compute_message_count(self): + for rec in self: + rec.message_count = len(rec.message_ids) + + def action_clear(self): + """清空对话消息""" + self.ensure_one() + self.message_ids.unlink() + + def open_chat(self): + """打开聊天界面""" + self.ensure_one() + return { + 'type': 'ir.actions.client', + 'tag': 'yuthon_ai_agent_17.chat', + 'name': self.name, + 'params': {'res_id': self.id}, + } + + # --------------------------------------------------------- + # Function Calling: 工具定义 & 执行 + # --------------------------------------------------------- + def _get_tools_schema(self): + """返回 OpenAI 兼容的工具列表(JSON Schema)。""" + tools = [ + { + 'type': 'function', + 'function': { + 'name': 'odoo_search_read', + 'description': '搜索并读取 Odoo 数据库记录。常见模型:res.partner(联系人)、product.product(产品)、sale.order(销售订单)、account.move(发票)、stock.picking(出入库)、hr.employee(员工)、res.users(用户)。', + 'parameters': { + 'type': 'object', + 'properties': { + 'model': {'type': 'string', 'description': '模型技术名称,如 res.partner'}, + 'domain': {'type': 'array', 'description': "Odoo domain,如 [['name','ilike','张三']],默认 []", 'items': {}}, + 'fields': {'type': 'array', 'items': {'type': 'string'}, 'description': '要读取的字段列表,留空则只读 id/display_name'}, + 'limit': {'type': 'integer', 'description': f'返回条数,默认 {DEFAULT_LIMIT},最大 {MAX_LIMIT}'}, + 'order': {'type': 'string', 'description': '排序,如 "create_date desc"'}, + }, + 'required': ['model'], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'odoo_fields_get', + 'description': '获取模型字段定义(类型、label、关系字段指向的模型)。当不清楚字段名时先调用此工具。', + 'parameters': { + 'type': 'object', + 'properties': { + 'model': {'type': 'string'}, + 'allfields': {'type': 'array', 'items': {'type': 'string'}, 'description': '可选:只返回指定字段'}, + }, + 'required': ['model'], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'odoo_search_count', + 'description': '统计满足条件的记录数。', + 'parameters': { + 'type': 'object', + 'properties': { + 'model': {'type': 'string'}, + 'domain': {'type': 'array', 'items': {}}, + }, + 'required': ['model'], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'save_memory', + 'description': ( + '将用户的偏好、习惯、工作内容等信息保存为长期记忆。' + '当对话中发现值得记住的用户信息时主动调用。' + '分类:habit(习惯偏好)、work(工作内容)、knowledge(专业知识)、' + 'style(沟通风格)、personal(个人信息)、other(其他)。' + ), + 'parameters': { + 'type': 'object', + 'properties': { + 'category': { + 'type': 'string', + 'enum': ['habit', 'work', 'knowledge', 'style', 'personal', 'other'], + 'description': '记忆类别', + }, + 'content': { + 'type': 'string', + 'description': '要记住的内容,简洁一句话概括', + }, + }, + 'required': ['category', 'content'], + }, + }, + }, + ] + if self.provider_id.allow_write: + tools += [ + { + 'type': 'function', + 'function': { + 'name': 'odoo_create', + 'description': '在 Odoo 中创建一条记录,返回新记录 id 与 display_name。', + 'parameters': { + 'type': 'object', + 'properties': { + 'model': {'type': 'string'}, + 'values': {'type': 'object', 'description': '字段名 -> 值。Many2one 字段传 id;Many2many/One2many 用 [(6,0,[ids])] 等命令格式'}, + }, + 'required': ['model', 'values'], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'odoo_write', + 'description': '更新已有记录。', + 'parameters': { + 'type': 'object', + 'properties': { + 'model': {'type': 'string'}, + 'ids': {'type': 'array', 'items': {'type': 'integer'}}, + 'values': {'type': 'object'}, + }, + 'required': ['model', 'ids', 'values'], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'odoo_unlink', + 'description': '删除记录(请谨慎,先与用户确认)。', + 'parameters': { + 'type': 'object', + 'properties': { + 'model': {'type': 'string'}, + 'ids': {'type': 'array', 'items': {'type': 'integer'}}, + }, + 'required': ['model', 'ids'], + }, + }, + }, + ] + return tools + + def _execute_tool(self, name, args): + """执行单个工具调用,返回可 JSON 序列化的结果字典。 + 所有 ORM 操作都走当前用户权限(self.env),自动遵循 ir.rule / ir.model.access。 + """ + try: + # save_memory 工具单独处理(不需要 model 参数) + if name == 'save_memory': + profile = self.env['ai.user.profile'].get_or_create() + category = args.get('category', 'other') + content = args.get('content', '') + if not content: + return {'error': '记忆内容不能为空'} + # 检查是否已有相似记忆,避免重复 + existing = self.env['ai.user.memory'].search([ + ('profile_id', '=', profile.id), + ('content', '=', content), + ('active', '=', True), + ], limit=1) + if existing: + return {'success': True, 'message': '该记忆已存在,跳过'} + self.env['ai.user.memory'].create({ + 'profile_id': profile.id, + 'category': category, + 'content': content, + 'source': 'auto', + }) + return {'success': True, 'message': f'已记住:{content}'} + + model_name = args.get('model') + if not model_name or model_name not in self.env: + return {'error': f'未知模型:{model_name}'} + Model = self.env[model_name] + + if name == 'odoo_search_read': + domain = args.get('domain') or [] + fields_ = args.get('fields') or [] + limit = min(int(args.get('limit') or DEFAULT_LIMIT), MAX_LIMIT) + order = args.get('order') or None + records = Model.search_read(domain, fields_, limit=limit, order=order) + return {'success': True, 'count': len(records), 'records': records} + + if name == 'odoo_search_count': + domain = args.get('domain') or [] + return {'success': True, 'count': Model.search_count(domain)} + + if name == 'odoo_fields_get': + allfields = args.get('allfields') or [] + fg = Model.fields_get(allfields, attributes=['type', 'string', 'relation', 'required', 'readonly', 'selection']) + return {'success': True, 'fields': fg} + + if name == 'odoo_create': + if not self.provider_id.allow_write: + return {'error': '当前 Provider 未启用写入权限'} + rec = Model.create(args.get('values') or {}) + return {'success': True, 'id': rec.id, 'display_name': rec.display_name} + + if name == 'odoo_write': + if not self.provider_id.allow_write: + return {'error': '当前 Provider 未启用写入权限'} + ids = args.get('ids') or [] + Model.browse(ids).write(args.get('values') or {}) + return {'success': True, 'updated_ids': ids} + + if name == 'odoo_unlink': + if not self.provider_id.allow_write: + return {'error': '当前 Provider 未启用写入权限'} + ids = args.get('ids') or [] + Model.browse(ids).unlink() + return {'success': True, 'deleted_ids': ids} + + return {'error': f'未知工具:{name}'} + except Exception as e: + _logger.exception('Tool %s failed: %s', name, e) + return {'error': f'{type(e).__name__}: {e}'} + + def _execute_skill_tool(self, name, args): + """尝试执行技能规则工具,找不到返回 None""" + provider = self.provider_id + if not provider: + return None + skill_rule = self.env['ai.rule'].sudo().search([ + ('rule_type', '=', 'skill'), + ('skill_name', '=', name), + ('active', '=', True), + ('provider_ids', 'in', [provider.id]), + ], limit=1) + if not skill_rule: + # 也搜索不限服务商的全局技能 + skill_rule = self.env['ai.rule'].sudo().search([ + ('rule_type', '=', 'skill'), + ('skill_name', '=', name), + ('active', '=', True), + ('provider_ids', '=', False), + ], limit=1) + if skill_rule: + return skill_rule.execute_skill(args) + return None + + def _call_chat_completion(self, provider, messages, tools=None): + """封装一次 HTTP 调用,返回 (assistant_message_dict, error_str)。 + 同时记录 Token 使用量。 + """ + payload = { + 'model': provider.model_name, + 'messages': messages, + 'max_tokens': provider.max_tokens, + 'temperature': provider.temperature, + } + if tools: + payload['tools'] = tools + payload['tool_choice'] = 'auto' + try: + resp = requests.post( + f"{provider.api_base}/chat/completions", + headers={ + 'Authorization': f'Bearer {provider.api_key}', + 'Content-Type': 'application/json', + }, + json=payload, + timeout=max(provider.timeout or 20, 1), + ) + resp.raise_for_status() + data = resp.json() + # 记录 Token 使用量 + usage = data.get('usage') or {} + if usage: + self.env['ai.token.usage'].sudo().create({ + 'user_id': self.env.uid, + 'conversation_id': self.id, + 'provider_id': provider.id, + 'model_name': provider.model_name, + 'prompt_tokens': usage.get('prompt_tokens', 0), + 'completion_tokens': usage.get('completion_tokens', 0), + 'total_tokens': usage.get('total_tokens', 0), + }) + return data['choices'][0]['message'], None + except requests.exceptions.Timeout: + return None, f'错误:AI 响应超时(超过 {provider.timeout or 20} 秒),请重试或在服务商配置中调高超时。' + except requests.exceptions.RequestException as e: + return None, f'错误:API 调用失败 — {e}' + except (KeyError, IndexError, ValueError) as e: + return None, f'错误:解析 AI 响应失败 — {e}' + + # --------------------------------------------------------- + # 主入口 + # --------------------------------------------------------- + def send_message(self, content): + """发送用户消息并获取 AI 回复(支持工具调用)。""" + self.ensure_one() + # 权限校验:只有 AI Agent 用户组才能使用 + if not self.env.user.has_group('yuthon_ai_agent_17.group_ai_agent_user'): + return {'error': '您没有使用 AI 助手的权限,请联系管理员开通。'} + + provider = self.provider_id + if not provider or not provider.active: + return {'error': 'AI 服务未配置或已停用。'} + + # 1. 保存用户消息 + self.env['ai.message'].create({ + 'conversation_id': self.id, + 'role': 'user', + 'content': content, + }) + + # 2. 构建发送给模型的 messages + # 获取用户画像 + profile = self.env['ai.user.profile'].get_or_create() + user_context = profile.get_system_context() + + system_msg = ( + '你是一个 Odoo 18 ERP 系统的智能助手,可以调用工具直接查询/操作数据库。\n' + '常见模型:res.partner(联系人)、product.product/product.template(产品)、' + 'sale.order(销售订单)、purchase.order(采购单)、account.move(发票/账单)、' + 'stock.picking(出入库)、hr.employee(员工)、res.users(用户)、res.company(公司)。\n' + '当用户提到"查/搜/统计/创建/修改/删除"等数据相关需求时,必须主动调用相应工具,' + '不要凭空编造结果。不确定字段名时先调用 odoo_fields_get。\n' + '所有回答使用中文,简洁清晰,关键数据用列表或表格形式呈现。\n\n' + '【重要】你具有记忆能力。当对话中发现用户的新偏好、工作内容、习惯等值得长期记住的信息时,' + '主动调用 save_memory 工具保存。下次对话你会自动获取这些记忆。\n' + '称呼用户时使用其设定的称呼,不要说"您"、"亲"等通用称谓。\n' + ) + + # —— 注入 AI 规则 —— + applicable_rules = provider.get_applicable_rules() + instruction_rules = applicable_rules.filtered(lambda r: r.rule_type == 'instruction') + restriction_rules = applicable_rules.filtered(lambda r: r.rule_type == 'restriction') + skill_rules = applicable_rules.filtered(lambda r: r.rule_type == 'skill' and r.skill_name) + + if instruction_rules: + system_msg += '\n--- 回复指令(必须遵守)---\n' + for rule in instruction_rules: + system_msg += f'• {rule.name}:{rule.content}\n' + + if restriction_rules: + system_msg += '\n--- 限制规则(禁止回答以下话题)---\n' + for rule in restriction_rules: + system_msg += f'• {rule.name}:{rule.content}\n' + system_msg += '如果用户询问以上禁止话题,礼貌拒绝并说明无法回答。\n' + + if user_context: + system_msg += f'\n--- 当前用户信息 ---\n{user_context}\n' + messages = [{'role': 'system', 'content': system_msg}] + for msg in self.message_ids: + if msg.role in ('user', 'assistant') and msg.content: + messages.append({'role': msg.role, 'content': msg.content}) + + tools = self._get_tools_schema() if provider.enable_tools else None + + # 把技能规则注册为额外的工具 + if skill_rules: + if tools is None: + tools = [] + for rule in skill_rules: + tools.append(rule.get_tool_schema()) + + # 3. 多轮 tool_calls 循环 + final_content = '' + for _ in range(MAX_TOOL_ITERATIONS): + assistant_msg, err = self._call_chat_completion(provider, messages, tools) + if err: + final_content = err + break + + tool_calls = assistant_msg.get('tool_calls') + if not tool_calls: + final_content = assistant_msg.get('content') or '' + break + + # 把 assistant 的工具调用消息追加到上下文 + messages.append({ + 'role': 'assistant', + 'content': assistant_msg.get('content') or '', + 'tool_calls': tool_calls, + }) + # 依次执行工具,写回 role=tool 消息 + for tc in tool_calls: + fn = tc.get('function', {}) + fname = fn.get('name', '') + try: + fargs = json.loads(fn.get('arguments') or '{}') + except json.JSONDecodeError: + fargs = {} + # 先尝试技能接口工具,找不到再走内置工具 + result = self._execute_skill_tool(fname, fargs) + if result is None: + result = self._execute_tool(fname, fargs) + messages.append({ + 'role': 'tool', + 'tool_call_id': tc.get('id'), + 'content': json.dumps(result, ensure_ascii=False, default=str), + }) + else: + final_content = final_content or '错误:工具调用次数超限,请重新提问。' + + # 4. 保存最终 AI 回复 + self.env['ai.message'].create({ + 'conversation_id': self.id, + 'role': 'assistant', + 'content': final_content, + }) + + # 5. 首次发送自动更新标题 + if self.name == '新对话' and len(self.message_ids) >= 2: + self.name = content[:30] + ('...' if len(content) > 30 else '') + + return final_content + + +class AiMessage(models.Model): + """对话消息""" + _name = 'ai.message' + _description = 'AI 消息' + _order = 'create_date' + + conversation_id = fields.Many2one( + 'ai.conversation', string='对话', required=True, ondelete='cascade' + ) + role = fields.Selection([ + ('user', '用户'), + ('assistant', '助手'), + ('system', '系统'), + ], string='角色', required=True) + content = fields.Text(string='内容', required=True) + create_uid = fields.Many2one('res.users', string='发送者', default=lambda self: self.env.uid) diff --git a/yuthon_ai_agent_17/models/ai_rule.py b/yuthon_ai_agent_17/models/ai_rule.py new file mode 100644 index 000000000..7b34cdcb8 --- /dev/null +++ b/yuthon_ai_agent_17/models/ai_rule.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +import json +import logging + +import requests + +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class AiRule(models.Model): + """AI 规则配置 — 控制 AI 回复行为、限制范围、对接技能接口""" + _name = 'ai.rule' + _description = 'AI 规则' + _order = 'priority, id' + + create_date = fields.Datetime(string='创建时间', readonly=True) + write_date = fields.Datetime(string='最后更新', readonly=True) + + name = fields.Char(string='规则名称', required=True) + rule_type = fields.Selection([ + ('instruction', '回复指令'), + ('restriction', '限制规则'), + ('skill', '技能接口'), + ], string='规则类型', required=True, default='instruction', + help='回复指令:注入到 AI 系统提示词中,控制回复风格和内容。\n' + '限制规则:告诉 AI 哪些话题不能回答。\n' + '技能接口:对接外部 API,AI 可调用获取专业数据。' + ) + active = fields.Boolean(string='启用', default=True) + priority = fields.Integer(string='优先级', default=10, help='数字越小优先级越高') + + # ---- 适用范围 ---- + provider_ids = fields.Many2many( + 'ai.provider', 'ai_rule_provider_rel', 'rule_id', 'provider_id', + string='适用服务商', + help='留空则对所有服务商生效', + ) + group_ids = fields.Many2many( + 'res.groups', 'ai_rule_group_rel', 'rule_id', 'group_id', + string='适用用户组', + help='留空则对所有用户生效', + ) + + # ---- 回复指令 / 限制规则 内容 ---- + content = fields.Text( + string='规则内容', + help='回复指令类型:写给 AI 的系统指令,如"所有回复不超过 200 字"。\n' + '限制规则类型:描述不允许回答的话题,如"不得回答薪资相关问题"。', + ) + + # ---- 技能接口配置 ---- + skill_name = fields.Char( + string='技能名称', + help='AI 工具调用时的函数名,如 query_weather, get_stock_price', + ) + skill_description = fields.Text( + string='技能描述', + help='告诉 AI 什么时候调用这个技能,如"当用户询问天气时调用"', + ) + skill_parameters = fields.Text( + string='参数定义 (JSON Schema)', + help='OpenAI 兼容的 JSON Schema 格式,定义技能需要的参数', + default='{\n "type": "object",\n "properties": {},\n "required": []\n}', + ) + skill_endpoint = fields.Char( + string='接口地址', + help='技能对接的 API URL,如 https://api.example.com/weather', + ) + skill_method = fields.Selection([ + ('GET', 'GET'), + ('POST', 'POST'), + ], string='请求方式', default='POST') + skill_headers = fields.Text( + string='请求头 (JSON)', + help='额外请求头,如 {"Authorization": "Bearer xxx"}', + default='{}', + ) + skill_timeout = fields.Integer( + string='接口超时(秒)', default=10, + ) + + # ---- 技能接口执行 ---- + def execute_skill(self, arguments): + """调用技能接口,返回结果字典""" + self.ensure_one() + if self.rule_type != 'skill' or not self.skill_endpoint: + return {'error': '该规则不是技能接口类型或未配置接口地址'} + + try: + headers = json.loads(self.skill_headers or '{}') + except json.JSONDecodeError: + headers = {} + headers.setdefault('Content-Type', 'application/json') + + timeout = max(self.skill_timeout or 10, 1) + try: + if self.skill_method == 'GET': + resp = requests.get( + self.skill_endpoint, + params=arguments, + headers=headers, + timeout=timeout, + ) + else: + resp = requests.post( + self.skill_endpoint, + json=arguments, + headers=headers, + timeout=timeout, + ) + resp.raise_for_status() + return resp.json() + except requests.exceptions.Timeout: + return {'error': f'技能接口超时({timeout}秒)'} + except requests.exceptions.RequestException as e: + _logger.exception('Skill %s call failed: %s', self.skill_name, e) + return {'error': f'技能接口调用失败:{e}'} + except (ValueError, json.JSONDecodeError): + return {'error': '技能接口返回格式错误(非 JSON)'} + + def get_tool_schema(self): + """将技能规则转换为 OpenAI 兼容的 tool schema""" + self.ensure_one() + try: + params = json.loads(self.skill_parameters or '{}') + except json.JSONDecodeError: + params = {'type': 'object', 'properties': {}} + return { + 'type': 'function', + 'function': { + 'name': self.skill_name or f'skill_{self.id}', + 'description': self.skill_description or self.name, + 'parameters': params, + }, + } diff --git a/yuthon_ai_agent_17/models/ai_token_usage.py b/yuthon_ai_agent_17/models/ai_token_usage.py new file mode 100644 index 000000000..173968fdb --- /dev/null +++ b/yuthon_ai_agent_17/models/ai_token_usage.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api + + +class AiTokenUsage(models.Model): + """Token 使用量记录""" + _name = 'ai.token.usage' + _description = 'AI Token 使用记录' + _order = 'create_date desc' + + user_id = fields.Many2one( + 'res.users', string='用户', required=True, + default=lambda self: self.env.uid, index=True, + ) + conversation_id = fields.Many2one( + 'ai.conversation', string='对话', ondelete='set null', index=True, + ) + provider_id = fields.Many2one( + 'ai.provider', string='服务商', ondelete='set null', + ) + model_name = fields.Char(string='模型') + prompt_tokens = fields.Integer(string='输入 Token', default=0) + completion_tokens = fields.Integer(string='输出 Token', default=0) + total_tokens = fields.Integer(string='总 Token', default=0) + create_date = fields.Datetime(string='时间', readonly=True) + + # 汇总统计(SQL视图替代方案:直接在列表中分组展示即可) + + +class AiTokenUsageSummary(models.Model): + """用户 Token 汇总(数据库视图)""" + _name = 'ai.token.usage.summary' + _description = 'AI Token 使用汇总' + _auto = False + _order = 'total_tokens desc' + + user_id = fields.Many2one('res.users', string='用户', readonly=True) + provider_id = fields.Many2one('ai.provider', string='服务商', readonly=True) + model_name = fields.Char(string='模型', readonly=True) + call_count = fields.Integer(string='调用次数', readonly=True) + prompt_tokens = fields.Integer(string='输入 Token', readonly=True) + completion_tokens = fields.Integer(string='输出 Token', readonly=True) + total_tokens = fields.Integer(string='总 Token', readonly=True) + + def init(self): + self.env.cr.execute(""" + DROP VIEW IF EXISTS ai_token_usage_summary; + CREATE OR REPLACE VIEW ai_token_usage_summary AS ( + SELECT + row_number() OVER () AS id, + user_id, + provider_id, + model_name, + COUNT(*) AS call_count, + SUM(prompt_tokens) AS prompt_tokens, + SUM(completion_tokens) AS completion_tokens, + SUM(total_tokens) AS total_tokens + FROM ai_token_usage + GROUP BY user_id, provider_id, model_name + ); + """) diff --git a/yuthon_ai_agent_17/models/ai_user_profile.py b/yuthon_ai_agent_17/models/ai_user_profile.py new file mode 100644 index 000000000..77b847463 --- /dev/null +++ b/yuthon_ai_agent_17/models/ai_user_profile.py @@ -0,0 +1,236 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api + + +class AiUserProfile(models.Model): + """用户 AI 画像 — 每个用户一个,存储 AI 对该用户的认知""" + _name = 'ai.user.profile' + _description = '用户 AI 画像' + _rec_name = 'user_id' + + user_id = fields.Many2one( + 'res.users', string='用户', required=True, + ondelete='cascade', index=True, + default=lambda self: self.env.uid, + ) + employee_id = fields.Many2one( + 'hr.employee', string='关联员工', + help='关联员工后自动带出职位、部门等信息', + ) + # 基础信息(用户可手动维护,AI 也可通过工具更新) + nickname = fields.Char( + string='称呼', + help='AI 如何称呼你,如"彭哥"、"张总"' + ) + role = fields.Char( + string='职位/角色', + help='如:项目经理、产品总监、运维工程师' + ) + department = fields.Char( + string='部门', + help='所属部门或团队' + ) + work_scope = fields.Text( + string='工作职责', + help='简述你主要负责的工作内容,帮助 AI 更好理解你的需求' + ) + style_preference = fields.Selection([ + ('concise', '简洁直接'), + ('detailed', '详细深入'), + ('casual', '轻松口语'), + ('formal', '正式商务'), + ], string='沟通风格', default='concise', + help='AI 回复时的风格偏好' + ) + custom_instruction = fields.Text( + string='自定义指令', + help='给 AI 的额外指令,如"回答不要超过 200 字"、"优先用表格呈现数据"等' + ) + + # 记忆 + memory_ids = fields.One2many( + 'ai.user.memory', 'profile_id', string='AI 记忆' + ) + memory_count = fields.Integer( + string='记忆条数', compute='_compute_memory_count' + ) + + # Token 统计 + total_tokens_used = fields.Integer( + string='总 Token', compute='_compute_token_stats' + ) + token_call_count = fields.Integer( + string='调用次数', compute='_compute_token_stats' + ) + + # 对话统计 + conversation_count = fields.Integer( + string='对话数', compute='_compute_conversation_count' + ) + + _sql_constraints = [ + ('user_unique', 'UNIQUE(user_id)', '每个用户只能有一个 AI 画像'), + ] + + @api.onchange('employee_id') + def _onchange_employee_id(self): + """选择员工后自动填充称呼、职位、部门""" + if self.employee_id: + emp = self.employee_id + if not self.nickname or self.nickname == self.user_id.name: + self.nickname = emp.name + self.role = emp.job_title or (emp.job_id.name if emp.job_id else '') or self.role + self.department = emp.department_id.name if emp.department_id else self.department + + @api.onchange('user_id') + def _onchange_user_id(self): + """切换用户后自动关联对应员工""" + if self.user_id: + emp = self.env['hr.employee'].search([ + ('user_id', '=', self.user_id.id) + ], limit=1) + if emp: + self.employee_id = emp.id + + @api.depends('memory_ids') + def _compute_memory_count(self): + for rec in self: + rec.memory_count = len(rec.memory_ids) + + def _compute_token_stats(self): + """统计该用户的 Token 总量和调用次数""" + for rec in self: + usage_data = self.env['ai.token.usage'].sudo().read_group( + [('user_id', '=', rec.user_id.id)], + ['total_tokens:sum'], + [], + ) + rec.total_tokens_used = usage_data[0]['total_tokens'] if usage_data else 0 + rec.token_call_count = self.env['ai.token.usage'].sudo().search_count( + [('user_id', '=', rec.user_id.id)] + ) + + def _compute_conversation_count(self): + """统计该用户的对话数""" + for rec in self: + rec.conversation_count = self.env['ai.conversation'].search_count( + [('create_uid', '=', rec.user_id.id)] + ) + + def action_view_token_usage(self): + """打开当前用户的 Token 用量明细""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': f'{self.user_id.name} - Token 用量', + 'res_model': 'ai.token.usage', + 'view_mode': 'list', + 'domain': [('user_id', '=', self.user_id.id)], + 'context': {'search_default_filter_month': 1}, + } + + def action_view_conversations(self): + """打开当前用户的对话列表""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': f'{self.user_id.name} - 对话', + 'res_model': 'ai.conversation', + 'view_mode': 'list,form', + 'domain': [('create_uid', '=', self.user_id.id)], + } + + def action_start_chat(self): + """发起新对话 → 跳转到聊天界面""" + self.ensure_one() + # 获取或创建个人对话 + conversation = self.env['ai.conversation'].search([ + ('create_uid', '=', self.env.uid), + ], limit=1, order='create_date desc') + if not conversation: + provider = self.env['ai.provider'].search([('active', '=', True)], limit=1) + conversation = self.env['ai.conversation'].create({ + 'name': '新对话', + 'provider_id': provider.id if provider else False, + }) + return conversation.open_chat() + + @api.model + def get_or_create(self, user_id=None): + """获取或创建当前用户的 AI 画像""" + uid = user_id or self.env.uid + profile = self.search([('user_id', '=', uid)], limit=1) + if not profile: + user = self.env['res.users'].browse(uid) + # 自动查找关联员工 + emp = self.env['hr.employee'].search([('user_id', '=', uid)], limit=1) + vals = { + 'user_id': uid, + 'nickname': emp.name if emp else user.name, + 'role': (emp.job_title or (emp.job_id.name if emp and emp.job_id else '')) or user.job_title or '', + 'department': emp.department_id.name if emp and emp.department_id else '', + } + if emp: + vals['employee_id'] = emp.id + profile = self.create(vals) + return profile + + def get_system_context(self): + """生成注入到 system prompt 的用户上下文段落""" + self.ensure_one() + parts = [] + if self.nickname: + parts.append(f'用户称呼:{self.nickname}') + if self.role: + parts.append(f'职位:{self.role}') + if self.department: + parts.append(f'部门:{self.department}') + if self.work_scope: + parts.append(f'工作职责:{self.work_scope}') + if self.style_preference: + style_map = { + 'concise': '请简洁直接回答,不要冗余', + 'detailed': '请详细深入回答,提供充分背景', + 'casual': '请用轻松口语化风格回答', + 'formal': '请用正式商务风格回答', + } + parts.append(f'沟通风格要求:{style_map.get(self.style_preference, "")}') + if self.custom_instruction: + parts.append(f'用户自定义指令:{self.custom_instruction}') + + # 加入最近的活跃记忆(最多 20 条) + memories = self.memory_ids.filtered('active').sorted('write_date', reverse=True)[:20] + if memories: + mem_lines = [] + for m in memories: + cat_label = dict(m._fields['category'].selection).get(m.category, '') + mem_lines.append(f' [{cat_label}] {m.content}') + parts.append('关于此用户的记忆:\n' + '\n'.join(mem_lines)) + + return '\n'.join(parts) if parts else '' + + +class AiUserMemory(models.Model): + """AI 对用户的长期记忆""" + _name = 'ai.user.memory' + _description = 'AI 用户记忆' + _order = 'write_date desc' + + profile_id = fields.Many2one( + 'ai.user.profile', string='用户画像', + required=True, ondelete='cascade', index=True, + ) + category = fields.Selection([ + ('habit', '习惯偏好'), + ('work', '工作内容'), + ('knowledge', '专业知识'), + ('style', '沟通风格'), + ('personal', '个人信息'), + ('other', '其他'), + ], string='类别', default='other', required=True) + content = fields.Text(string='记忆内容', required=True) + source = fields.Selection([ + ('auto', 'AI 自动提取'), + ('manual', '用户手动添加'), + ], string='来源', default='manual') + active = fields.Boolean(string='有效', default=True) diff --git a/yuthon_ai_agent_17/security/ai_security.xml b/yuthon_ai_agent_17/security/ai_security.xml new file mode 100644 index 000000000..2612190b8 --- /dev/null +++ b/yuthon_ai_agent_17/security/ai_security.xml @@ -0,0 +1,67 @@ + + + + + AI Agent 用户 + + 使用 AI 对话功能 + + + + + + AI Agent 管理员 + + 管理 AI 服务商配置 + + + + + + + AI 对话:仅本人 + + [('create_uid','=',user.id)] + + + + + + + + + + AI 画像:仅本人 + + [('user_id','=',user.id)] + + + + + + + + + + AI 记忆:仅本人 + + [('profile_id.user_id','=',user.id)] + + + + + + + + + + Token 用量:仅本人 + + [('user_id','=',user.id)] + + + + + + + diff --git a/yuthon_ai_agent_17/security/ir.model.access.csv b/yuthon_ai_agent_17/security/ir.model.access.csv new file mode 100644 index 000000000..c75445390 --- /dev/null +++ b/yuthon_ai_agent_17/security/ir.model.access.csv @@ -0,0 +1,17 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ai_provider_read,AI服务商-只读,model_ai_provider,yuthon_ai_agent_17.group_ai_agent_user,1,0,0,0 +access_ai_provider_admin,AI服务商-管理员,model_ai_provider,yuthon_ai_agent_17.group_ai_agent_admin,1,1,1,1 +access_ai_conversation_user,AI对话-用户,model_ai_conversation,yuthon_ai_agent_17.group_ai_agent_user,1,1,1,1 +access_ai_message_user,AI消息-用户,model_ai_message,yuthon_ai_agent_17.group_ai_agent_user,1,1,1,1 +access_ai_conversation_admin,AI对话-管理员,model_ai_conversation,yuthon_ai_agent_17.group_ai_agent_admin,1,1,1,1 +access_ai_message_admin,AI消息-管理员,model_ai_message,yuthon_ai_agent_17.group_ai_agent_admin,1,1,1,1 +access_ai_user_profile_user,AI画像-用户,model_ai_user_profile,yuthon_ai_agent_17.group_ai_agent_user,1,1,1,0 +access_ai_user_profile_admin,AI画像-管理员,model_ai_user_profile,yuthon_ai_agent_17.group_ai_agent_admin,1,1,1,1 +access_ai_user_memory_user,AI记忆-用户,model_ai_user_memory,yuthon_ai_agent_17.group_ai_agent_user,1,1,1,1 +access_ai_user_memory_admin,AI记忆-管理员,model_ai_user_memory,yuthon_ai_agent_17.group_ai_agent_admin,1,1,1,1 +access_ai_token_usage_user,Token用量-用户,model_ai_token_usage,yuthon_ai_agent_17.group_ai_agent_user,1,0,1,0 +access_ai_token_usage_admin,Token用量-管理员,model_ai_token_usage,yuthon_ai_agent_17.group_ai_agent_admin,1,1,1,1 +access_ai_token_usage_summary_user,Token汇总-用户,model_ai_token_usage_summary,yuthon_ai_agent_17.group_ai_agent_user,1,0,0,0 +access_ai_token_usage_summary_admin,Token汇总-管理员,model_ai_token_usage_summary,yuthon_ai_agent_17.group_ai_agent_admin,1,0,0,0 +access_ai_rule_user,AI规则-用户只读,model_ai_rule,yuthon_ai_agent_17.group_ai_agent_user,1,0,0,0 +access_ai_rule_admin,AI规则-管理员,model_ai_rule,yuthon_ai_agent_17.group_ai_agent_admin,1,1,1,1 diff --git a/yuthon_ai_agent_17/static/src/css/ai_chat.css b/yuthon_ai_agent_17/static/src/css/ai_chat.css new file mode 100644 index 000000000..fc8d946da --- /dev/null +++ b/yuthon_ai_agent_17/static/src/css/ai_chat.css @@ -0,0 +1,80 @@ +.o_ai_chat_action { + display: flex; + flex-direction: column; + height: 100%; + min-height: 400px; +} + +.ai-chat-header { + flex-shrink: 0; +} + +.ai-chat-messages { + flex: 1; + overflow-y: auto; + background: #f8f9fa; + min-height: 200px; +} + +.ai-chat-msg { + display: flex; + flex-direction: column; + margin-bottom: 16px; + animation: fadeIn 0.3s ease; +} + +.ai-chat-msg.user { + align-items: flex-end; +} + +.ai-chat-msg.assistant { + align-items: flex-start; +} + +.ai-chat-bubble { + max-width: 80%; + padding: 10px 16px; + border-radius: 12px; + line-height: 1.6; + word-wrap: break-word; + white-space: pre-wrap; +} + +.ai-chat-msg.user .ai-chat-bubble { + background: #007bff; + color: #fff; + border-bottom-right-radius: 4px; +} + +.ai-chat-msg.assistant .ai-chat-bubble { + background: #ffffff; + border: 1px solid #dee2e6; + border-bottom-left-radius: 4px; +} + +.ai-chat-meta { + font-size: 11px; + color: #6c757d; + margin-top: 4px; + padding: 0 4px; +} + +.ai-chat-input-area input { + flex: 1; +} + +.ai-chat-input-area input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0,123,255,0.15); +} + +.ai-chat-loading { + color: #6c757d; + font-size: 13px; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/yuthon_ai_agent_17/static/src/css/ai_floating.css b/yuthon_ai_agent_17/static/src/css/ai_floating.css new file mode 100644 index 000000000..3bd026a75 --- /dev/null +++ b/yuthon_ai_agent_17/static/src/css/ai_floating.css @@ -0,0 +1,301 @@ +/* ====== 顶部托盘按钮 ====== */ +.o_ai_systray { + display: flex; + align-items: center; + height: 100%; +} +.o_ai_systray_btn { + background: none; + border: none; + cursor: pointer; + padding: 0 10px; + height: 100%; + display: flex; + align-items: center; + position: relative; +} +.o_ai_icon_wrap { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: #fff; + font-size: 16px; + box-shadow: 0 2px 8px rgba(124, 58, 237, 0.35); + transition: all 0.25s ease; + animation: ai_pulse 2s ease-in-out infinite; +} +.o_ai_systray_btn:hover .o_ai_icon_wrap { + transform: scale(1.12); + box-shadow: 0 3px 14px rgba(124, 58, 237, 0.5); + animation: none; +} +@keyframes ai_pulse { + 0%, 100% { box-shadow: 0 2px 8px rgba(124, 58, 237, 0.35); } + 50% { box-shadow: 0 2px 16px rgba(124, 58, 237, 0.6); } +} +.o_ai_badge { + position: absolute; + top: 6px; + right: 4px; + background: #e74c3c; + color: #fff; + font-size: 10px; + border-radius: 10px; + padding: 1px 5px; + min-width: 16px; + text-align: center; + line-height: 1.4; +} + +/* ====== 浮动聊天窗口 ====== */ +.o_ai_floating_chat_wrapper { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 10000; + width: 380px; + max-height: 600px; +} +.o_ai_floating_chat_wrapper.expanded { + width: 700px; + max-height: 90vh; + bottom: 5vh; + right: calc(50% - 350px); +} + +.o_ai_floating_chat { + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0,0,0,0.15); + display: flex; + flex-direction: column; + height: 500px; + overflow: hidden; + border: 1px solid #e5e7eb; +} +.o_ai_floating_chat_wrapper.expanded .o_ai_floating_chat { + height: 80vh; +} + +/* 标题栏 */ +.o_ai_chat_header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #7c3aed; + color: #fff; + flex-shrink: 0; +} +.o_ai_header_left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.o_ai_chat_title { + font-weight: 600; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.o_ai_header_actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} +.o_ai_btn_icon { + background: none; + border: none; + color: rgba(255,255,255,0.8); + cursor: pointer; + padding: 4px; + font-size: 13px; + border-radius: 4px; + transition: all 0.2s; +} +.o_ai_btn_icon:hover { + background: rgba(255,255,255,0.15); + color: #fff; +} +.o_ai_conv_switch { + font-size: 12px; +} + +/* 对话列表 */ +.o_ai_conv_list { + flex: 1; + overflow-y: auto; + background: #f8f7f4; + padding: 8px; +} +.o_ai_conv_loading, +.o_ai_conv_empty { + padding: 20px; + text-align: center; + color: #9ca3af; + font-size: 13px; +} +.o_ai_conv_item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + margin-bottom: 4px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; + background: #fff; + border: 1px solid #e5e7eb; +} +.o_ai_conv_item:hover { + background: #ede9fe; + border-color: #c4b5fd; +} +.o_ai_conv_item.active { + background: #ede9fe; + border-color: #7c3aed; +} +.o_ai_conv_item_name { + font-size: 13px; + font-weight: 500; + color: #1a1a2e; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.o_ai_conv_item_count { + font-size: 11px; + color: #9ca3af; + flex-shrink: 0; + margin-left: 8px; +} +.o_ai_conv_del { + background: none; + border: none; + color: #d1d5db; + cursor: pointer; + padding: 2px 6px; + font-size: 12px; + border-radius: 4px; + flex-shrink: 0; + margin-left: 4px; + transition: all 0.15s; +} +.o_ai_conv_del:hover { + color: #ef4444; + background: #fef2f2; +} + +/* 消息列表 */ +.o_ai_messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + background: #f8f7f4; +} +.o_ai_msg { + display: flex; + flex-direction: column; + max-width: 85%; +} +.o_ai_msg_user { + align-self: flex-end; +} +.o_ai_msg_ai { + align-self: flex-start; +} +.o_ai_msg_bubble { + padding: 10px 14px; + border-radius: 12px; + font-size: 13px; + line-height: 1.6; + word-break: break-word; + white-space: pre-wrap; +} +.o_ai_msg_user .o_ai_msg_bubble { + background: #7c3aed; + color: #fff; + border-bottom-right-radius: 4px; +} +.o_ai_msg_ai .o_ai_msg_bubble { + background: #fff; + color: #1a1a2e; + border: 1px solid #e5e7eb; + border-bottom-left-radius: 4px; +} +.o_ai_msg_time { + font-size: 11px; + color: #9ca3af; + margin-top: 4px; + padding: 0 4px; +} +.o_ai_msg_user .o_ai_msg_time { + text-align: right; +} +.o_ai_loading { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + font-size: 13px; + color: #9ca3af; + align-self: flex-start; +} + +/* 输入框 */ +.o_ai_input_area { + display: flex; + align-items: center; + padding: 10px 12px; + border-top: 1px solid #e5e7eb; + background: #fff; + gap: 8px; + flex-shrink: 0; +} +.o_ai_input { + flex: 1; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 8px 12px; + font-size: 13px; + resize: none; + outline: none; + font-family: inherit; + line-height: 1.5; + max-height: 80px; +} +.o_ai_input:focus { + border-color: #7c3aed; + box-shadow: 0 0 0 2px rgba(124,58,237,0.15); +} +.o_ai_send_btn { + background: #7c3aed; + color: #fff; + border: none; + border-radius: 8px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s; + flex-shrink: 0; +} +.o_ai_send_btn:hover { + background: #6d28d9; +} +.o_ai_send_btn:disabled { + background: #d1d5db; + cursor: not-allowed; +} diff --git a/yuthon_ai_agent_17/static/src/js/ai_chat_action.js b/yuthon_ai_agent_17/static/src/js/ai_chat_action.js new file mode 100644 index 000000000..c94467a0f --- /dev/null +++ b/yuthon_ai_agent_17/static/src/js/ai_chat_action.js @@ -0,0 +1,89 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +export class AiChatAction extends Component { + static template = "yuthon_ai_agent_17.AiChatTemplate"; + // Client Action 接收的 props + static props = ["*"]; + + setup() { + this.rpc = useService("rpc"); + this.state = useState({ + messages: [], + inputText: "", + loading: false, + title: "AI 对话", + }); + // 从 action params 获取 res_id + const params = this.props.action?.params || this.props.action?.context || {}; + this.resId = params.res_id || params.default_res_id || 0; + if (this.resId) { + this._loadHistory(); + } + } + + async _loadHistory() { + try { + const data = await this.rpc("/ai/chat/history", { conversation_id: this.resId }); + if (!data.error) { + this.state.messages = data.messages || []; + } + } catch (e) { + console.error("Failed to load history:", e); + } + } + + async sendMessage() { + const text = this.state.inputText.trim(); + if (!text || !this.resId) return; + + this.state.inputText = ""; + this.state.loading = true; + this.state.messages = [ + ...this.state.messages, + { role: "user", content: text, date: "刚刚" }, + ]; + + try { + const res = await this.rpc("/ai/chat/send", { + conversation_id: this.resId, + message: text, + }); + if (res.error) { + this.state.messages = [ + ...this.state.messages, + { role: "assistant", content: res.error, date: "" }, + ]; + } else { + this.state.messages = [ + ...this.state.messages, + { role: "assistant", content: res.content, date: "刚刚" }, + ]; + } + } catch (e) { + this.state.messages = [ + ...this.state.messages, + { role: "assistant", content: "网络错误,请重试", date: "" }, + ]; + } + this.state.loading = false; + } + + onKeydown(ev) { + if (ev.key === "Enter" && !ev.shiftKey) { + ev.preventDefault(); + this.sendMessage(); + } + } + + clearConversation() { + if (!confirm("确定清空所有消息?")) return; + this.rpc("/ai/chat/clear", { conversation_id: this.resId }); + this.state.messages = []; + } +} + +registry.category("actions").add("yuthon_ai_agent_17.chat", AiChatAction); diff --git a/yuthon_ai_agent_17/static/src/js/ai_floating.js b/yuthon_ai_agent_17/static/src/js/ai_floating.js new file mode 100644 index 000000000..b32aca959 --- /dev/null +++ b/yuthon_ai_agent_17/static/src/js/ai_floating.js @@ -0,0 +1,292 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { jsonrpc } from "@web/core/network/rpc_service"; + +/** + * AI 浮动聊天窗口 — 右下角弹出式聊天面板 + * 支持对话切换、新建对话 + * Odoo 17 版本 — 使用 jsonrpc + */ + +let convId = null; +let convName = ""; +let chatEl = null; +let isVisible = false; +let isExpanded = false; +let showConvList = false; + +function rpcCall(url, params) { + return jsonrpc(url, params || {}); +} + +async function ensureConv() { + if (convId) return convId; + try { + const res = await rpcCall("/ai/conversation/get_or_create_personal", {}); + if (res && res.id) { + convId = res.id; + convName = res.name || "对话"; + } + } catch (e) { + console.error("[AI] Failed to get/create conversation:", e); + } + return convId; +} + +async function loadHistory() { + const id = await ensureConv(); + if (!id) return []; + try { + const data = await rpcCall("/ai/chat/history", { conversation_id: id }); + return data.error ? [] : (data.messages || []); + } catch { + return []; + } +} + +async function loadConversations() { + try { + const data = await rpcCall("/ai/conversation/list", {}); + return data.conversations || []; + } catch { + return []; + } +} + +async function createNewConv() { + try { + const res = await rpcCall("/ai/conversation/new", {}); + if (res && res.id) { + convId = res.id; + convName = res.name || "新对话"; + showConvList = false; + renderChat(); + } + } catch (e) { + console.error("[AI] Failed to create conversation:", e); + } +} + +async function switchConv(id, name) { + convId = id; + convName = name || "对话"; + showConvList = false; + renderChat(); +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +function renderChat() { + if (!chatEl) return; + chatEl.innerHTML = ""; + if (!isVisible) return; + + const wrapper = document.createElement("div"); + wrapper.className = "o_ai_floating_chat_wrapper" + (isExpanded ? " expanded" : ""); + + loadHistory().then(msgs => { + wrapper.innerHTML = ` +
+
+
+ + ${escapeHtml(convName || 'AI 助手')} +
+
+ + + +
+
+
+
加载中...
+
+
+ ${msgs.map(m => ` +
+
${escapeHtml(m.content)}
+
${m.date || ''}
+
+ `).join('')} +
+
+ + +
+
+ `; + chatEl.appendChild(wrapper); + + // 绑定事件 + document.getElementById("yuthon_chat_close")?.addEventListener("click", toggleChat); + document.getElementById("yuthon_chat_expand")?.addEventListener("click", toggleExpand); + document.getElementById("yuthon_chat_send")?.addEventListener("click", sendMsg); + document.getElementById("yuthon_conv_switch")?.addEventListener("click", toggleConvList); + document.getElementById("yuthon_conv_new")?.addEventListener("click", createNewConv); + document.getElementById("yuthon_chat_input")?.addEventListener("keydown", e => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMsg(); } + }); + + // 滚动到底部 + const msgsEl = document.getElementById("yuthon_chat_msgs"); + if (msgsEl) setTimeout(() => msgsEl.scrollTop = msgsEl.scrollHeight, 50); + + // 如果对话列表打开,加载列表 + if (showConvList) { + loadConvListUI(); + } + }); +} + +async function loadConvListUI() { + const listEl = document.getElementById("yuthon_conv_list"); + if (!listEl) return; + const convs = await loadConversations(); + if (convs.length === 0) { + listEl.innerHTML = `
暂无对话,点击 + 新建
`; + return; + } + listEl.innerHTML = convs.map(c => ` +
+ ${escapeHtml(c.name)} + ${c.message_count} 条 + +
+ `).join(''); + // 绑定点击切换 + listEl.querySelectorAll(".o_ai_conv_item").forEach(el => { + el.addEventListener("click", (e) => { + if (e.target.closest('.o_ai_conv_del')) return; + switchConv(parseInt(el.dataset.id), el.dataset.name); + }); + }); + // 绑定删除 + listEl.querySelectorAll(".o_ai_conv_del").forEach(btn => { + btn.addEventListener("click", async (e) => { + e.stopPropagation(); + const delId = parseInt(btn.dataset.id); + if (!confirm("确定删除这个对话吗?")) return; + try { + await rpcCall("/ai/conversation/delete", { conversation_id: delId }); + if (delId === convId) { + convId = null; + convName = ""; + } + loadConvListUI(); + } catch (err) { + console.error("[AI] Delete failed:", err); + } + }); + }); +} + +function toggleConvList() { + showConvList = !showConvList; + renderChat(); +} + +async function sendMsg() { + const input = document.getElementById("yuthon_chat_input"); + const text = input?.value.trim(); + if (!text) return; + input.value = ""; + + const id = await ensureConv(); + if (!id) return; + + const msgsEl = document.getElementById("yuthon_chat_msgs"); + if (msgsEl) { + msgsEl.innerHTML += ` +
+
${escapeHtml(text)}
+
刚刚
+
+
+ AI 思考中... +
+ `; + msgsEl.scrollTop = msgsEl.scrollHeight; + } + + try { + const res = await rpcCall("/ai/chat/send", { conversation_id: id, message: text }); + const content = res.error || res.content || "无响应"; + document.getElementById("yuthon_loading")?.remove(); + if (msgsEl) { + msgsEl.innerHTML += ` +
+
${escapeHtml(content)}
+
刚刚
+
+ `; + msgsEl.scrollTop = msgsEl.scrollHeight; + } + if (convName === '新对话') { + convName = text.substring(0, 20) + (text.length > 20 ? '...' : ''); + const titleEl = document.querySelector(".o_ai_chat_title"); + if (titleEl) titleEl.textContent = convName; + } + } catch (e) { + document.getElementById("yuthon_loading")?.remove(); + if (msgsEl) { + msgsEl.innerHTML += ` +
+
网络错误,请重试
+
+ `; + } + } +} + +function toggleExpand() { + isExpanded = !isExpanded; + renderChat(); +} + +function toggleChat() { + isVisible = !isVisible; + showConvList = false; + if (isVisible) { + if (!chatEl) { + chatEl = document.createElement("div"); + chatEl.id = "yuthon_ai_floating"; + document.body.appendChild(chatEl); + } + renderChat(); + } else if (chatEl) { + chatEl.innerHTML = ""; + } +} + +// ====== Systray 组件 ====== +export class AiSystrayItem extends Component { + static template = "yuthon_ai_agent_17.AiSystray"; + static props = ["*"]; + + toggleChat() { + toggleChat(); + } +} + +registry.category("systray").add("yuthon_ai_agent_17.systray", { + Component: AiSystrayItem, +}, { sequence: 25 }); diff --git a/yuthon_ai_agent_17/static/src/xml/ai_chat_template.xml b/yuthon_ai_agent_17/static/src/xml/ai_chat_template.xml new file mode 100644 index 000000000..a7c301c3c --- /dev/null +++ b/yuthon_ai_agent_17/static/src/xml/ai_chat_template.xml @@ -0,0 +1,40 @@ + + + +
+ +
+ + +
+ + +
+
+ 请先创建或选择一个对话 +
+ +
+
+
+
+ +
+ AI 思考中... +
+
+ + +
+ + +
+
+
+ diff --git a/yuthon_ai_agent_17/static/src/xml/ai_floating_template.xml b/yuthon_ai_agent_17/static/src/xml/ai_floating_template.xml new file mode 100644 index 000000000..61b6bf5e3 --- /dev/null +++ b/yuthon_ai_agent_17/static/src/xml/ai_floating_template.xml @@ -0,0 +1,16 @@ + + + + + +
+ +
+
+ +
diff --git a/yuthon_ai_agent_17/views/ai_conversation_views.xml b/yuthon_ai_agent_17/views/ai_conversation_views.xml new file mode 100644 index 000000000..b4fb1c403 --- /dev/null +++ b/yuthon_ai_agent_17/views/ai_conversation_views.xml @@ -0,0 +1,99 @@ + + + + + ai.conversation.list + ai.conversation + + + + + + + + + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ 绑定的规则将根据用户组自动生效:回复指令 → 注入 AI 系统提示词;限制规则 → 禁止回答特定话题;技能接口 → 注册为 AI 可调用工具。 +

+
+
+ + + + + + + + 服务商配置 + ai.provider + list,form + + + + + + diff --git a/yuthon_ai_agent_17/views/ai_rule_views.xml b/yuthon_ai_agent_17/views/ai_rule_views.xml new file mode 100644 index 000000000..c66bc22cd --- /dev/null +++ b/yuthon_ai_agent_17/views/ai_rule_views.xml @@ -0,0 +1,139 @@ + + + + + + + ai.rule.list + ai.rule + + + + + + + + + + + + + + + + + ai.rule.form + ai.rule + +
+ + + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + ai.rule.search + ai.rule + + + + + + + + + + + + + + + + + + + + 规则配置 + ai.rule + list,form + {'search_default_filter_active': 1} + + + + + +
diff --git a/yuthon_ai_agent_17/views/ai_token_usage_views.xml b/yuthon_ai_agent_17/views/ai_token_usage_views.xml new file mode 100644 index 000000000..4e7633760 --- /dev/null +++ b/yuthon_ai_agent_17/views/ai_token_usage_views.xml @@ -0,0 +1,114 @@ + + + + + + + + ai.token.usage.list + ai.token.usage + + + + + + + + + + + + + + + + + ai.token.usage.search + ai.token.usage + + + + + + + + + + + + + + + + + + + + + + + + + ai.token.usage.summary.list + ai.token.usage.summary + + + + + + + + + + + + + + + + + + + + Token 明细 + ai.token.usage + list + {'search_default_group_user': 1} + + + + + Token 汇总 + ai.token.usage.summary + list + + + + + + + + + + + + Token 明细 + ai.token.usage + list + [('user_id', '=', uid)] + + + + + diff --git a/yuthon_ai_agent_17/views/ai_user_profile_views.xml b/yuthon_ai_agent_17/views/ai_user_profile_views.xml new file mode 100644 index 000000000..fc9f05ee5 --- /dev/null +++ b/yuthon_ai_agent_17/views/ai_user_profile_views.xml @@ -0,0 +1,162 @@ + + + + + + + + ai.user.profile.form + ai.user.profile + +
+
+
+ + +
+ + + +
+ +
+

+ +

+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 共 条记忆 +
+
+
+
+
+
+
+ + + + ai.user.profile.list + ai.user.profile + + + + + + + + + + + + + + + + + + + + + + 我的 AI + + code + +profile = env['ai.user.profile'].get_or_create() +action = { + 'type': 'ir.actions.act_window', + 'res_model': 'ai.user.profile', + 'res_id': profile.id, + 'view_mode': 'form', + 'views': [(env.ref('yuthon_ai_agent_17.view_ai_user_profile_form').id, 'form')], + 'target': 'current', +} + + + + + + 用户列表 + ai.user.profile + list,form + + + + + + + + + + + + + + + +