ai_agent
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user