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 = `
+
+
+
+
+ ${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.conversation.form
+ ai.conversation
+
+
+
+
+
+
+
+ ai.conversation.search
+ ai.conversation
+
+
+
+
+
+
+
+
+
+
+ AI Chat
+ yuthon_ai_agent_17.chat
+
+
+
+
+ AI 对话
+ ai.conversation
+ list,form
+ {"search_default_my": 1}
+
+
+
+
+
+
+
diff --git a/yuthon_ai_agent_17/views/ai_provider_views.xml b/yuthon_ai_agent_17/views/ai_provider_views.xml
new file mode 100644
index 000000000..9d925cdf9
--- /dev/null
+++ b/yuthon_ai_agent_17/views/ai_provider_views.xml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ai.provider.list
+ ai.provider
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ai.provider.form
+ ai.provider
+
+
+
+
+
+
+
+ 服务商配置
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+