This commit is contained in:
李鹏宇
2026-05-28 10:11:35 +08:00
parent 260f9704d2
commit 17f72913cc
25 changed files with 3051 additions and 0 deletions
+128
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers
+42
View File
@@ -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,
}
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import main
+167
View File
@@ -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,
}
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<!-- noupdate=1: 仅首次安装时创建,升级不会覆盖已有数据 -->
<!-- DeepSeek V3 -->
<record id="provider_deepseek" model="ai.provider">
<field name="name">DeepSeek V3</field>
<field name="provider_type">openai</field>
<field name="model_name">deepseek-chat</field>
<field name="api_base">https://api.deepseek.com/v1</field>
<field name="api_key"></field>
<field name="max_tokens">4096</field>
<field name="temperature">0.7</field>
<field name="timeout">20</field>
<field name="enable_tools" eval="True"/>
<field name="active" eval="True"/>
</record>
<!-- DeepSeek R1 推理模型 -->
<record id="provider_deepseek_r1" model="ai.provider">
<field name="name">DeepSeek R1(推理)</field>
<field name="provider_type">openai</field>
<field name="model_name">deepseek-reasoner</field>
<field name="api_base">https://api.deepseek.com/v1</field>
<field name="api_key"></field>
<field name="max_tokens">4096</field>
<field name="temperature">0.7</field>
<field name="timeout">60</field>
<field name="enable_tools" eval="False"/>
<field name="active" eval="False"/>
</record>
</odoo>
+104
View File
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<!-- noupdate=1: 仅首次安装时创建,升级不会覆盖已有数据 -->
<!-- ===================== 回复指令类 ===================== -->
<record id="rule_concise_reply" model="ai.rule">
<field name="name">简洁回复</field>
<field name="rule_type">instruction</field>
<field name="priority">5</field>
<field name="content">所有回复控制在 200 字以内,重点突出结论,避免冗长的铺垫和解释。如果内容较多,优先使用表格或列表形式呈现。</field>
<field name="provider_ids" eval="[(4, ref('provider_deepseek'))]"/>
</record>
<record id="rule_data_format" model="ai.rule">
<field name="name">数据展示规范</field>
<field name="rule_type">instruction</field>
<field name="priority">10</field>
<field name="content">当查询到多条数据时,必须用 Markdown 表格呈现,表头使用中文名称。金额字段保留两位小数并加上 ¥ 前缀,日期使用 YYYY-MM-DD 格式。</field>
<field name="provider_ids" eval="[(4, ref('provider_deepseek'))]"/>
</record>
<record id="rule_professional_tone" model="ai.rule">
<field name="name">专业商务风格</field>
<field name="rule_type">instruction</field>
<field name="priority">15</field>
<field name="content">使用专业、正式的商务语气回复,避免口语化表达、网络用语和表情符号。对不确定的信息要明确说明"暂无相关数据",不得猜测或编造。</field>
</record>
<!-- ===================== 限制规则类 ===================== -->
<record id="rule_no_salary" model="ai.rule">
<field name="name">禁止薪资话题</field>
<field name="rule_type">restriction</field>
<field name="priority">1</field>
<field name="content">严禁回答任何关于员工薪资、工资条、社保缴纳基数、个税明细、年终奖等薪酬福利相关的问题。统一回复"薪酬信息属于保密数据,请联系人力资源部门查询。"</field>
<field name="provider_ids" eval="[(4, ref('provider_deepseek'))]"/>
</record>
<record id="rule_no_internal_secret" model="ai.rule">
<field name="name">禁止泄露商业机密</field>
<field name="rule_type">restriction</field>
<field name="priority">2</field>
<field name="content">不得回答涉及公司核心商业策略、融资信息、未公开的合作协议、供应商底价等商业机密类问题。统一回复"该信息涉及商业机密,无法提供,请联系相关负责人。"</field>
</record>
<record id="rule_no_system_config" model="ai.rule">
<field name="name">禁止修改系统配置</field>
<field name="rule_type">restriction</field>
<field name="priority">3</field>
<field name="content">禁止协助用户修改系统参数(ir.config_parameter)、用户权限组(res.groups)、安全规则(ir.rule)等系统级配置。统一回复"系统配置修改需由管理员在后台操作,请联系 IT 部门。"</field>
</record>
<!-- ===================== 技能接口类 ===================== -->
<record id="rule_skill_weather" model="ai.rule">
<field name="name">天气查询技能</field>
<field name="rule_type">skill</field>
<field name="priority">10</field>
<field name="skill_name">query_weather</field>
<field name="skill_description">当用户询问某个城市的天气状况时调用此技能,获取实时天气数据。</field>
<field name="skill_parameters">{
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "要查询的城市名,如:北京、上海、深圳"
}
},
"required": ["city"]
}</field>
<field name="skill_endpoint">https://api.example.com/weather</field>
<field name="skill_method">GET</field>
<field name="skill_headers">{}</field>
<field name="skill_timeout">5</field>
<field name="active" eval="False"/>
</record>
<record id="rule_skill_knowledge_base" model="ai.rule">
<field name="name">企业知识库检索</field>
<field name="rule_type">skill</field>
<field name="priority">5</field>
<field name="skill_name">search_knowledge</field>
<field name="skill_description">当用户询问公司制度、流程规范、操作手册等内部知识时,调用此技能从企业知识库中检索相关内容。</field>
<field name="skill_parameters">{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "用户的问题或关键词"
},
"category": {
"type": "string",
"enum": ["制度规范", "操作手册", "常见问题", "全部"],
"description": "知识分类,默认全部"
}
},
"required": ["query"]
}</field>
<field name="skill_endpoint">https://api.example.com/knowledge/search</field>
<field name="skill_method">POST</field>
<field name="skill_headers">{"Authorization": "Bearer YOUR_API_KEY"}</field>
<field name="skill_timeout">10</field>
<field name="active" eval="False"/>
</record>
</odoo>
+5
View File
@@ -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
+581
View File
@@ -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)
+137
View File
@@ -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,
},
}
@@ -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
);
""")
@@ -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)
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- AI Agent 用户组 -->
<record id="group_ai_agent_user" model="res.groups">
<field name="name">AI Agent 用户</field>
<field name="category_id" ref="base.module_category_hidden"/>
<field name="comment">使用 AI 对话功能</field>
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
</record>
<!-- AI Agent 管理员组 -->
<record id="group_ai_agent_admin" model="res.groups">
<field name="name">AI Agent 管理员</field>
<field name="category_id" ref="base.module_category_hidden"/>
<field name="comment">管理 AI 服务商配置</field>
<field name="implied_ids" eval="[(4, ref('yuthon_ai_agent_17.group_ai_agent_user'))]"/>
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
</record>
<!-- 记录规则:用户只能看自己的对话 -->
<record id="rule_ai_conversation_personal" model="ir.rule">
<field name="name">AI 对话:仅本人</field>
<field name="model_id" ref="model_ai_conversation"/>
<field name="domain_force">[('create_uid','=',user.id)]</field>
<field name="groups" eval="[(4, ref('yuthon_ai_agent_17.group_ai_agent_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- 记录规则:用户只能看自己的 AI 画像 -->
<record id="rule_ai_profile_personal" model="ir.rule">
<field name="name">AI 画像:仅本人</field>
<field name="model_id" ref="model_ai_user_profile"/>
<field name="domain_force">[('user_id','=',user.id)]</field>
<field name="groups" eval="[(4, ref('yuthon_ai_agent_17.group_ai_agent_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- 记录规则:用户只能看自己的 AI 记忆 -->
<record id="rule_ai_memory_personal" model="ir.rule">
<field name="name">AI 记忆:仅本人</field>
<field name="model_id" ref="model_ai_user_memory"/>
<field name="domain_force">[('profile_id.user_id','=',user.id)]</field>
<field name="groups" eval="[(4, ref('yuthon_ai_agent_17.group_ai_agent_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- 记录规则:用户只能看自己的 Token 用量 -->
<record id="rule_ai_token_usage_personal" model="ir.rule">
<field name="name">Token 用量:仅本人</field>
<field name="model_id" ref="model_ai_token_usage"/>
<field name="domain_force">[('user_id','=',user.id)]</field>
<field name="groups" eval="[(4, ref('yuthon_ai_agent_17.group_ai_agent_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_provider_read AI服务商-只读 model_ai_provider yuthon_ai_agent_17.group_ai_agent_user 1 0 0 0
3 access_ai_provider_admin AI服务商-管理员 model_ai_provider yuthon_ai_agent_17.group_ai_agent_admin 1 1 1 1
4 access_ai_conversation_user AI对话-用户 model_ai_conversation yuthon_ai_agent_17.group_ai_agent_user 1 1 1 1
5 access_ai_message_user AI消息-用户 model_ai_message yuthon_ai_agent_17.group_ai_agent_user 1 1 1 1
6 access_ai_conversation_admin AI对话-管理员 model_ai_conversation yuthon_ai_agent_17.group_ai_agent_admin 1 1 1 1
7 access_ai_message_admin AI消息-管理员 model_ai_message yuthon_ai_agent_17.group_ai_agent_admin 1 1 1 1
8 access_ai_user_profile_user AI画像-用户 model_ai_user_profile yuthon_ai_agent_17.group_ai_agent_user 1 1 1 0
9 access_ai_user_profile_admin AI画像-管理员 model_ai_user_profile yuthon_ai_agent_17.group_ai_agent_admin 1 1 1 1
10 access_ai_user_memory_user AI记忆-用户 model_ai_user_memory yuthon_ai_agent_17.group_ai_agent_user 1 1 1 1
11 access_ai_user_memory_admin AI记忆-管理员 model_ai_user_memory yuthon_ai_agent_17.group_ai_agent_admin 1 1 1 1
12 access_ai_token_usage_user Token用量-用户 model_ai_token_usage yuthon_ai_agent_17.group_ai_agent_user 1 0 1 0
13 access_ai_token_usage_admin Token用量-管理员 model_ai_token_usage yuthon_ai_agent_17.group_ai_agent_admin 1 1 1 1
14 access_ai_token_usage_summary_user Token汇总-用户 model_ai_token_usage_summary yuthon_ai_agent_17.group_ai_agent_user 1 0 0 0
15 access_ai_token_usage_summary_admin Token汇总-管理员 model_ai_token_usage_summary yuthon_ai_agent_17.group_ai_agent_admin 1 0 0 0
16 access_ai_rule_user AI规则-用户只读 model_ai_rule yuthon_ai_agent_17.group_ai_agent_user 1 0 0 0
17 access_ai_rule_admin AI规则-管理员 model_ai_rule yuthon_ai_agent_17.group_ai_agent_admin 1 1 1 1
@@ -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); }
}
@@ -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;
}
@@ -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);
@@ -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 = `
<div class="o_ai_floating_chat">
<div class="o_ai_chat_header">
<div class="o_ai_header_left">
<button id="yuthon_conv_switch" class="o_ai_btn_icon o_ai_conv_switch" title="切换对话">
<i class="fa fa-list"></i>
</button>
<span class="o_ai_chat_title">${escapeHtml(convName || 'AI 助手')}</span>
</div>
<div class="o_ai_header_actions">
<button id="yuthon_conv_new" class="o_ai_btn_icon" title="新对话">
<i class="fa fa-plus"></i>
</button>
<button id="yuthon_chat_expand" class="o_ai_btn_icon" title="${isExpanded ? '缩小' : '展开'}">
<i class="fa ${isExpanded ? 'fa-compress' : 'fa-expand'}"></i>
</button>
<button id="yuthon_chat_close" class="o_ai_btn_icon" title="关闭">
<i class="fa fa-times"></i>
</button>
</div>
</div>
<div id="yuthon_conv_list" class="o_ai_conv_list" style="display:${showConvList ? 'block' : 'none'}">
<div class="o_ai_conv_loading"><i class="fa fa-spinner fa-spin"></i> 加载中...</div>
</div>
<div id="yuthon_chat_msgs" class="o_ai_messages" style="display:${showConvList ? 'none' : 'flex'}">
${msgs.map(m => `
<div class="o_ai_msg ${m.role === 'user' ? 'o_ai_msg_user' : 'o_ai_msg_ai'}">
<div class="o_ai_msg_bubble">${escapeHtml(m.content)}</div>
<div class="o_ai_msg_time">${m.date || ''}</div>
</div>
`).join('')}
</div>
<div class="o_ai_input_area" style="display:${showConvList ? 'none' : 'flex'}">
<textarea id="yuthon_chat_input" class="o_ai_input"
placeholder="输入消息..." rows="1"></textarea>
<button id="yuthon_chat_send" class="o_ai_send_btn">
<i class="fa fa-paper-plane"></i>
</button>
</div>
</div>
`;
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 = `<div class="o_ai_conv_empty">暂无对话,点击 + 新建</div>`;
return;
}
listEl.innerHTML = convs.map(c => `
<div class="o_ai_conv_item ${c.id === convId ? 'active' : ''}" data-id="${c.id}" data-name="${escapeHtml(c.name)}">
<span class="o_ai_conv_item_name">${escapeHtml(c.name)}</span>
<span class="o_ai_conv_item_count">${c.message_count} 条</span>
<button class="o_ai_conv_del" data-id="${c.id}" title="删除">
<i class="fa fa-trash-o"></i>
</button>
</div>
`).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 += `
<div class="o_ai_msg o_ai_msg_user">
<div class="o_ai_msg_bubble">${escapeHtml(text)}</div>
<div class="o_ai_msg_time">刚刚</div>
</div>
<div id="yuthon_loading" class="o_ai_loading">
<i class="fa fa-spinner fa-spin"></i> AI 思考中...
</div>
`;
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 += `
<div class="o_ai_msg o_ai_msg_ai">
<div class="o_ai_msg_bubble">${escapeHtml(content)}</div>
<div class="o_ai_msg_time">刚刚</div>
</div>
`;
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 += `
<div class="o_ai_msg o_ai_msg_ai">
<div class="o_ai_msg_bubble">网络错误,请重试</div>
</div>
`;
}
}
}
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 });
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="yuthon_ai_agent_17.AiChatTemplate">
<div class="o_ai_chat_action d-flex flex-column h-100">
<!-- 头部 -->
<div class="ai-chat-header p-2 border-bottom d-flex justify-content-between align-items-center">
<strong t-esc="state.title"/>
<button class="btn btn-sm btn-outline-danger" t-on-click="clearConversation">清空</button>
</div>
<!-- 消息列表 -->
<div class="ai-chat-messages flex-grow-1 overflow-auto p-3">
<div t-if="!resId and state.messages.length === 0" class="text-muted text-center py-5">
请先创建或选择一个对话
</div>
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
<div t-att-class="'ai-chat-msg ' + msg.role">
<div class="ai-chat-bubble" t-out="msg.content"/>
<div class="ai-chat-meta" t-if="msg.date" t-esc="msg.date"/>
</div>
</t>
<div t-if="state.loading" class="ai-chat-loading text-muted py-2">
<i class="fa fa-spinner fa-spin me-1"/> AI 思考中...
</div>
</div>
<!-- 输入区 -->
<div class="ai-chat-input-area p-2 border-top d-flex gap-2">
<input type="text" class="form-control"
placeholder="输入消息,按 Enter 发送..."
t-model="state.inputText"
t-on-keydown="onKeydown"
t-att-disabled="state.loading"/>
<button class="btn btn-primary"
t-on-click="sendMessage"
t-att-disabled="state.loading">发送</button>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- 右上角托盘按钮 -->
<t t-name="yuthon_ai_agent_17.AiSystray">
<div class="o_ai_systray">
<button class="o_ai_systray_btn" t-on-click="toggleChat"
title="AI 助手" aria-label="AI 助手">
<span class="o_ai_icon_wrap">
<i class="fa fa-bolt"/>
</span>
</button>
</div>
</t>
</templates>
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- list 视图 -->
<record id="view_ai_conversation_list" model="ir.ui.view">
<field name="name">ai.conversation.list</field>
<field name="model">ai.conversation</field>
<field name="arch" type="xml">
<list string="AI 对话" decoration-muted="message_count == 0">
<field name="name"/>
<field name="provider_id"/>
<field name="message_count"/>
<field name="create_uid" string="创建人"/>
<field name="create_date" string="创建时间"/>
<button name="open_chat" type="object" string="聊天" icon="fa-comments" class="btn-primary"/>
</list>
</field>
</record>
<!-- Form 视图(详细版) -->
<record id="view_ai_conversation_form" model="ir.ui.view">
<field name="name">ai.conversation.form</field>
<field name="model">ai.conversation</field>
<field name="arch" type="xml">
<form string="AI 对话">
<header>
<button name="open_chat" type="object" string="打开聊天"
class="btn-primary" icon="fa-comments"/>
<button name="action_clear" type="object" string="清空消息"
confirm="确定要清空本对话的所有消息吗?"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="对话标题"/></h1>
</div>
<group>
<group string="助手配置">
<field name="provider_id" options="{'no_create': True}"/>
<field name="message_count"/>
</group>
<group string="当前服务商参数">
<field name="provider_model_name"/>
<field name="provider_timeout"/>
<field name="provider_enable_tools" widget="boolean_toggle"/>
<field name="provider_allow_write" widget="boolean_toggle"/>
</group>
</group>
<notebook>
<page string="消息历史" name="messages">
<field name="message_ids">
<list>
<field name="create_date" string="时间"/>
<field name="role"/>
<field name="content"/>
<field name="create_uid" string="发送人"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- 搜索视图 -->
<record id="view_ai_conversation_search" model="ir.ui.view">
<field name="name">ai.conversation.search</field>
<field name="model">ai.conversation</field>
<field name="arch" type="xml">
<search string="搜索对话">
<field name="name"/>
<filter name="my" string="我的对话"
domain="[('create_uid','=',uid)]"/>
</search>
</field>
</record>
<!-- Client Action: 聊天界面 -->
<record id="action_ai_chat_client" model="ir.actions.client">
<field name="name">AI Chat</field>
<field name="tag">yuthon_ai_agent_17.chat</field>
</record>
<!-- 标准 Window Action(list/form) -->
<record id="action_ai_conversation" model="ir.actions.act_window">
<field name="name">AI 对话</field>
<field name="res_model">ai.conversation</field>
<field name="view_mode">list,form</field>
<field name="context">{"search_default_my": 1}</field>
</record>
<!-- 旧菜单 xmlid 保留但隐藏(menu_ai_root 已在 ai_provider_views.xml 定义) -->
<menuitem id="menu_ai_chat" name="对话管理"
parent="menu_ai_root" action="action_ai_conversation" sequence="99"
groups="yuthon_ai_agent_17.group_ai_agent_admin" active="0"/>
<menuitem id="menu_ai_config" name="服务商配置"
parent="menu_ai_root" action="action_ai_provider" sequence="99"
groups="yuthon_ai_agent_17.group_ai_agent_admin" active="0"/>
</odoo>
@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ============================================ -->
<!-- 根菜单 & 系统配置父菜单 -->
<!-- ============================================ -->
<menuitem id="menu_ai_root" name="AI 助手" sequence="70"
groups="yuthon_ai_agent_17.group_ai_agent_user,yuthon_ai_agent_17.group_ai_agent_admin"/>
<menuitem id="menu_system_config" name="系统配置"
parent="menu_ai_root" sequence="50"
groups="yuthon_ai_agent_17.group_ai_agent_admin"/>
<!-- ============================================ -->
<!-- AI 服务商 - 列表视图 -->
<!-- ============================================ -->
<record id="view_ai_provider_list" model="ir.ui.view">
<field name="name">ai.provider.list</field>
<field name="model">ai.provider</field>
<field name="arch" type="xml">
<list string="AI 服务商">
<field name="name"/>
<field name="provider_type"/>
<field name="model_name"/>
<field name="api_base"/>
<field name="timeout"/>
<field name="enable_tools" widget="boolean_toggle"/>
<field name="allow_write" widget="boolean_toggle"/>
<field name="provider_total_tokens" string="Token 用量"/>
<field name="provider_call_count" string="调用次数"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ============================================ -->
<!-- AI 服务商 - 表单视图 -->
<!-- ============================================ -->
<record id="view_ai_provider_form" model="ir.ui.view">
<field name="name">ai.provider.form</field>
<field name="model">ai.provider</field>
<field name="arch" type="xml">
<form string="AI 服务商">
<sheet>
<widget name="web_ribbon" title="已停用"
bg_color="text-bg-danger" invisible="active"/>
<!-- Stat Buttons -->
<div class="oe_button_box" name="button_box">
<button name="action_view_provider_token_usage" type="object"
class="oe_stat_button" icon="fa-bar-chart">
<div class="o_stat_info">
<field name="provider_total_tokens" class="o_stat_value"/>
<span class="o_stat_text">Token</span>
</div>
</button>
<button name="action_view_provider_token_usage" type="object"
class="oe_stat_button" icon="fa-bolt">
<div class="o_stat_info">
<field name="provider_call_count" class="o_stat_value"/>
<span class="o_stat_text">调用</span>
</div>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" placeholder="如:OpenAI GPT-4、本地 Ollama"/>
</h1>
</div>
<!-- 连接配置 -->
<group string="连接配置">
<group>
<field name="provider_type"/>
<field name="api_base"/>
<field name="api_key" password="True"/>
</group>
<group>
<field name="active"/>
<field name="model_name"
placeholder="gpt-4o-mini / qwen-plus / deepseek-chat ..."/>
</group>
</group>
<!-- 模型参数 -->
<group string="模型参数">
<group>
<field name="max_tokens"/>
<field name="temperature"/>
</group>
<group>
<field name="timeout"/>
</group>
</group>
<!-- 权限 / 工具 -->
<group string="工具调用 (Function Calling)">
<group>
<field name="enable_tools"/>
</group>
<group>
<field name="allow_write"/>
</group>
</group>
<div class="alert alert-warning" role="alert"
invisible="not allow_write">
⚠ 已开启写入权限:AI 可以创建 / 修改 / 删除业务数据,请仅在受信任环境使用。
</div>
<!-- 关联规则 -->
<notebook>
<page string="关联规则" name="rules">
<field name="rule_ids" widget="many2many_tags"
options="{'no_create': False, 'color_field': 'rule_type'}"/>
<p class="text-muted">
绑定的规则将根据用户组自动生效:回复指令 → 注入 AI 系统提示词;限制规则 → 禁止回答特定话题;技能接口 → 注册为 AI 可调用工具。
</p>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_ai_provider" model="ir.actions.act_window">
<field name="name">服务商配置</field>
<field name="res_model">ai.provider</field>
<field name="view_mode">list,form</field>
</record>
<!-- 服务商配置菜单 → 归入系统配置 -->
<menuitem id="menu_ai_provider" name="服务商配置"
parent="menu_system_config" action="action_ai_provider" sequence="1"
groups="yuthon_ai_agent_17.group_ai_agent_admin"/>
</odoo>
+139
View File
@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ============================================ -->
<!-- AI 规则 - 列表视图 -->
<!-- ============================================ -->
<record id="view_ai_rule_list" model="ir.ui.view">
<field name="name">ai.rule.list</field>
<field name="model">ai.rule</field>
<field name="arch" type="xml">
<list string="AI 规则">
<field name="priority" widget="handle"/>
<field name="name"/>
<field name="rule_type" widget="badge"
decoration-info="rule_type == 'instruction'"
decoration-warning="rule_type == 'restriction'"
decoration-success="rule_type == 'skill'"/>
<field name="provider_ids" widget="many2many_tags"/>
<field name="group_ids" widget="many2many_tags"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ============================================ -->
<!-- AI 规则 - 表单视图 -->
<!-- ============================================ -->
<record id="view_ai_rule_form" model="ir.ui.view">
<field name="name">ai.rule.form</field>
<field name="model">ai.rule</field>
<field name="arch" type="xml">
<form string="AI 规则">
<sheet>
<widget name="web_ribbon" title="已停用"
bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<h1>
<field name="name" placeholder="输入规则名称"/>
</h1>
</div>
<group>
<group string="基本设置">
<field name="rule_type"/>
<field name="priority"/>
<field name="active"/>
</group>
<group string="适用范围">
<field name="provider_ids" widget="many2many_tags"
options="{'no_create': True}"
placeholder="留空 = 所有服务商"/>
<field name="group_ids" widget="many2many_tags"
options="{'no_create': True}"
placeholder="留空 = 所有用户"/>
</group>
</group>
<notebook>
<!-- 回复指令 / 限制规则 -->
<page string="规则内容" name="content"
invisible="rule_type == 'skill'">
<field name="content" placeholder="写给 AI 的指令内容,如:所有回复不超过 200 字 / 不得回答薪资相关问题" nolabel="1"/>
</page>
<!-- 技能接口配置 -->
<page string="技能接口配置" name="skill"
invisible="rule_type != 'skill'">
<group>
<group string="技能定义">
<field name="skill_name"
placeholder="如:query_weather"/>
<field name="skill_description"
placeholder="当用户询问天气时调用此技能"/>
</group>
<group string="接口配置">
<field name="skill_endpoint"
placeholder="https://api.example.com/weather"/>
<field name="skill_method"/>
<field name="skill_timeout"/>
</group>
</group>
<group string="参数定义">
<field name="skill_parameters" nolabel="1"
placeholder='{"type": "object", "properties": {"city": {"type": "string", "description": "城市名"}}, "required": ["city"]}'/>
</group>
<group string="请求头">
<field name="skill_headers" nolabel="1"
placeholder='{"Authorization": "Bearer xxx"}'/>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ============================================ -->
<!-- AI 规则 - 搜索视图 -->
<!-- ============================================ -->
<record id="view_ai_rule_search" model="ir.ui.view">
<field name="name">ai.rule.search</field>
<field name="model">ai.rule</field>
<field name="arch" type="xml">
<search string="搜索规则">
<field name="name"/>
<field name="rule_type"/>
<filter name="filter_instruction" string="回复指令"
domain="[('rule_type','=','instruction')]"/>
<filter name="filter_restriction" string="限制规则"
domain="[('rule_type','=','restriction')]"/>
<filter name="filter_skill" string="技能接口"
domain="[('rule_type','=','skill')]"/>
<separator/>
<filter name="filter_active" string="已启用"
domain="[('active','=',True)]"/>
<filter name="filter_inactive" string="已停用"
domain="[('active','=',False)]"/>
<group expand="0" string="分组">
<filter name="group_type" string="规则类型"
context="{'group_by': 'rule_type'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_ai_rule" model="ir.actions.act_window">
<field name="name">规则配置</field>
<field name="res_model">ai.rule</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_filter_active': 1}</field>
</record>
<!-- 菜单 → 系统配置 -->
<menuitem id="menu_ai_rule" name="规则配置"
parent="menu_system_config" action="action_ai_rule" sequence="2"
groups="yuthon_ai_agent_17.group_ai_agent_admin"/>
</odoo>
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ============================================ -->
<!-- Token 使用明细 - 列表视图 -->
<!-- ============================================ -->
<record id="view_ai_token_usage_list" model="ir.ui.view">
<field name="name">ai.token.usage.list</field>
<field name="model">ai.token.usage</field>
<field name="arch" type="xml">
<list string="Token 使用明细" default_order="create_date desc">
<field name="create_date" string="时间"/>
<field name="user_id"/>
<field name="provider_id"/>
<field name="model_name"/>
<field name="prompt_tokens" sum="合计"/>
<field name="completion_tokens" sum="合计"/>
<field name="total_tokens" sum="合计"/>
<field name="conversation_id" optional="hide"/>
</list>
</field>
</record>
<!-- Token 使用明细 - 搜索视图 -->
<record id="view_ai_token_usage_search" model="ir.ui.view">
<field name="name">ai.token.usage.search</field>
<field name="model">ai.token.usage</field>
<field name="arch" type="xml">
<search string="Token 使用搜索">
<field name="user_id"/>
<field name="provider_id"/>
<field name="model_name"/>
<separator/>
<filter name="filter_today" string="今天"
domain="[('create_date','>=', (context_today()).strftime('%Y-%m-%d'))]"/>
<filter name="filter_week" string="本周"
domain="[('create_date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<filter name="filter_month" string="本月"
domain="[('create_date','>=', (context_today()).strftime('%Y-%m-01'))]"/>
<separator/>
<group expand="0" string="分组">
<filter name="group_user" string="用户" context="{'group_by': 'user_id'}"/>
<filter name="group_provider" string="服务商" context="{'group_by': 'provider_id'}"/>
<filter name="group_model" string="模型" context="{'group_by': 'model_name'}"/>
<filter name="group_date" string="日期" context="{'group_by': 'create_date:day'}"/>
</group>
</search>
</field>
</record>
<!-- ============================================ -->
<!-- Token 使用汇总 - 列表视图 -->
<!-- ============================================ -->
<record id="view_ai_token_usage_summary_list" model="ir.ui.view">
<field name="name">ai.token.usage.summary.list</field>
<field name="model">ai.token.usage.summary</field>
<field name="arch" type="xml">
<list string="Token 使用汇总" create="false" edit="false" delete="false">
<field name="user_id"/>
<field name="provider_id"/>
<field name="model_name"/>
<field name="call_count" sum="合计"/>
<field name="prompt_tokens" sum="合计"/>
<field name="completion_tokens" sum="合计"/>
<field name="total_tokens" sum="合计"/>
</list>
</field>
</record>
<!-- ============================================ -->
<!-- Actions -->
<!-- ============================================ -->
<!-- 所有用户 Token 用量(管理员) -->
<record id="action_all_token_usage" model="ir.actions.act_window">
<field name="name">Token 明细</field>
<field name="res_model">ai.token.usage</field>
<field name="view_mode">list</field>
<field name="context">{'search_default_group_user': 1}</field>
</record>
<!-- Token 汇总(管理员) -->
<record id="action_token_usage_summary" model="ir.actions.act_window">
<field name="name">Token 汇总</field>
<field name="res_model">ai.token.usage.summary</field>
<field name="view_mode">list</field>
</record>
<!-- ============================================ -->
<!-- 菜单项(全部隐藏,通过 stat button 穿透) -->
<!-- ============================================ -->
<menuitem id="menu_all_token_usage" name="Token 明细"
parent="menu_system_config" action="action_all_token_usage" sequence="10"
groups="yuthon_ai_agent_17.group_ai_agent_admin" active="0"/>
<menuitem id="menu_token_usage_summary" name="Token 汇总"
parent="menu_system_config" action="action_token_usage_summary" sequence="15"
groups="yuthon_ai_agent_17.group_ai_agent_admin" active="0"/>
<!-- 兼容旧 XML ID(升级时不残留) -->
<record id="action_my_token_usage" model="ir.actions.act_window">
<field name="name">Token 明细</field>
<field name="res_model">ai.token.usage</field>
<field name="view_mode">list</field>
<field name="domain">[('user_id', '=', uid)]</field>
</record>
<menuitem id="menu_my_token_usage" name="Token 明细"
parent="menu_ai_root" sequence="99"
groups="yuthon_ai_agent_17.group_ai_agent_admin" active="0"/>
<menuitem id="menu_token_management" name="Token 管理"
parent="menu_ai_root" sequence="99"
groups="yuthon_ai_agent_17.group_ai_agent_admin" active="0"/>
</odoo>
@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ============================================ -->
<!-- 我的 AI - 用户画像表单 -->
<!-- ============================================ -->
<record id="view_ai_user_profile_form" model="ir.ui.view">
<field name="name">ai.user.profile.form</field>
<field name="model">ai.user.profile</field>
<field name="arch" type="xml">
<form string="我的 AI">
<header>
<button name="action_start_chat" type="object"
string="发起聊天" class="btn-primary" icon="fa-comments"/>
</header>
<sheet>
<!-- Stat Buttons -->
<div class="oe_button_box" name="button_box">
<button name="action_view_conversations" type="object"
class="oe_stat_button" icon="fa-comments">
<div class="o_stat_info">
<field name="conversation_count" class="o_stat_value"/>
<span class="o_stat_text">对话</span>
</div>
</button>
<button name="action_view_token_usage" type="object"
class="oe_stat_button" icon="fa-bar-chart">
<div class="o_stat_info">
<field name="total_tokens_used" class="o_stat_value"/>
<span class="o_stat_text">Token</span>
</div>
</button>
<button name="action_view_token_usage" type="object"
class="oe_stat_button" icon="fa-bolt">
<div class="o_stat_info">
<field name="token_call_count" class="o_stat_value"/>
<span class="o_stat_text">调用</span>
</div>
</button>
</div>
<div class="oe_title">
<h1>
<field name="nickname" placeholder="AI 如何称呼你?"/>
</h1>
<h3 class="text-muted">
<field name="user_id" readonly="1" options="{'no_open': True}"/>
</h3>
</div>
<group string="基础信息">
<group>
<field name="employee_id"
options="{'no_create': True}"
domain="[('user_id', '!=', False)]"/>
<field name="role" placeholder="项目经理 / 开发工程师 ..."/>
<field name="department" placeholder="技术部 / 销售部 ..."/>
</group>
<group>
<field name="style_preference" widget="radio"/>
</group>
</group>
<group string="工作与指令">
<field name="work_scope" placeholder="简述你主要负责的工作,帮助 AI 更好理解你的意图..."
widget="text"/>
<field name="custom_instruction"
placeholder="给 AI 的额外指令,如:回答不超过 200 字、优先用表格展示..."
widget="text"/>
</group>
<notebook>
<page string="AI 记忆" name="memories">
<div class="alert alert-info mb-3" role="alert">
AI 在对话中会自动记住你的偏好和工作习惯。你也可以手动添加或删除记忆条目。
</div>
<field name="memory_ids">
<list editable="bottom">
<field name="category"/>
<field name="content"/>
<field name="source" readonly="1"/>
<field name="write_date" string="更新时间" readonly="1"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
<div class="text-muted mt-2">
共 <field name="memory_count" class="oe_inline"/> 条记忆
</div>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- 列表视图(管理员看所有人) -->
<record id="view_ai_user_profile_list" model="ir.ui.view">
<field name="name">ai.user.profile.list</field>
<field name="model">ai.user.profile</field>
<field name="arch" type="xml">
<list string="用户列表">
<field name="user_id"/>
<field name="employee_id"/>
<field name="nickname"/>
<field name="role"/>
<field name="department"/>
<field name="style_preference"/>
<field name="memory_count"/>
<field name="total_tokens_used" string="Token 用量"/>
<field name="token_call_count" string="调用次数"/>
</list>
</field>
</record>
<!-- ============================================ -->
<!-- Actions -->
<!-- ============================================ -->
<!-- 我的 AI(普通用户 → 自动跳转自己的画像表单) -->
<record id="action_my_ai_profile" model="ir.actions.server">
<field name="name">我的 AI</field>
<field name="model_id" ref="model_ai_user_profile"/>
<field name="state">code</field>
<field name="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',
}
</field>
</record>
<!-- 管理员:用户列表 -->
<record id="action_all_ai_profiles" model="ir.actions.act_window">
<field name="name">用户列表</field>
<field name="res_model">ai.user.profile</field>
<field name="view_mode">list,form</field>
</record>
<!-- ============================================ -->
<!-- 菜单项 -->
<!-- ============================================ -->
<!-- 普通用户:我的 AI(唯一入口) -->
<menuitem id="menu_my_ai" name="我的 AI"
parent="menu_ai_root" action="action_my_ai_profile" sequence="5"/>
<!-- 管理员:用户列表 → 归入系统配置 -->
<menuitem id="menu_ai_profiles" name="用户列表"
parent="menu_system_config" action="action_all_ai_profiles" sequence="5"
groups="yuthon_ai_agent_17.group_ai_agent_admin"/>
<!-- 旧 xmlid 保留但隐藏(兼容升级) -->
<menuitem id="menu_user_management" name="用户管理"
parent="menu_ai_root" sequence="99"
groups="yuthon_ai_agent_17.group_ai_agent_admin" active="0"/>
</odoo>