详情页侧边栏AI聊天抽屉窗口支持自动获取当前页面数据作为上下文
This commit is contained in:
parent
426b440477
commit
bb88eee814
@ -105,12 +105,13 @@ export const sendMessageSSE = async (
|
||||
content: string,
|
||||
callbacks: SSECallbacks,
|
||||
signal?: AbortSignal,
|
||||
includeContext = true,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch('/api/action/jingrow.ai.api.chat.chat_stream_sse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chat_name: chatName, content }),
|
||||
body: JSON.stringify({ chat_name: chatName, content, include_context: includeContext }),
|
||||
signal,
|
||||
})
|
||||
|
||||
|
||||
@ -108,7 +108,7 @@ export function useChat() {
|
||||
sending.value = false
|
||||
}
|
||||
|
||||
async function sendStream(chatName: string, content: string): Promise<{ title?: string }> {
|
||||
async function sendStream(chatName: string, content: string, includeContext = true): Promise<{ title?: string }> {
|
||||
sending.value = true
|
||||
abortController = new AbortController()
|
||||
addLocalMessage(chatName, content)
|
||||
@ -146,6 +146,7 @@ export function useChat() {
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
includeContext,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@ -11,9 +11,11 @@ import FileUploadDialog from '@/views/pagetype/file/FileUploadDialog.vue'
|
||||
const props = withDefaults(defineProps<{
|
||||
chatName?: string
|
||||
entity?: string
|
||||
includeContext?: boolean
|
||||
}>(), {
|
||||
chatName: '',
|
||||
entity: 'File',
|
||||
includeContext: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -54,7 +56,7 @@ async function onChatInputSend(content: string) {
|
||||
emit('create-and-send', content)
|
||||
return
|
||||
}
|
||||
const result = await sendStream(props.chatName, content)
|
||||
const result = await sendStream(props.chatName, content, props.includeContext)
|
||||
if (result?.title) {
|
||||
emit('title-changed', result.title)
|
||||
}
|
||||
@ -62,7 +64,7 @@ async function onChatInputSend(content: string) {
|
||||
|
||||
/** 外部调用:创建对话后立即发送(用于欢迎页场景) */
|
||||
async function sendMessage(chatName: string, content: string) {
|
||||
const result = await sendStream(chatName, content)
|
||||
const result = await sendStream(chatName, content, props.includeContext)
|
||||
if (result?.title) {
|
||||
emit('title-changed', result.title)
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onMounted, onUnmounted, type CSSProperties } from 'vue'
|
||||
import { NDrawer, NDrawerContent, NButton, NIcon, NDropdown } from 'naive-ui'
|
||||
import { NDrawer, NDrawerContent, NButton, NIcon, NDropdown, NSwitch, NCollapse, NCollapseItem, NTooltip } from 'naive-ui'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { t } from '@/shared/i18n'
|
||||
import { createChat, getChatList, type AiChat } from '@/shared/api/chat'
|
||||
import { updateRecord, deleteRecord } from '@/shared/api/common'
|
||||
import { api } from '@/shared/api/common'
|
||||
import ChatContainer from './ChatContainer.vue'
|
||||
|
||||
// Naive UI Drawer body 样式常量(避免内联对象每次渲染重建)
|
||||
@ -29,6 +30,12 @@ const chatList = ref<AiChat[]>([])
|
||||
const showSidebar = ref(false)
|
||||
const chatContainerRef = ref<any>(null)
|
||||
|
||||
// Phase 3: 上下文控制
|
||||
const includeContext = ref(true)
|
||||
const showContextPreview = ref(false)
|
||||
const contextPreview = ref('')
|
||||
const contextLoading = ref(false)
|
||||
|
||||
// 行内编辑状态
|
||||
const editingChat = ref<string | null>(null)
|
||||
const editTitle = ref('')
|
||||
@ -229,6 +236,40 @@ function onChatTitleChanged(title: string) {
|
||||
const chat = chatList.value.find(c => c.name === chatName.value)
|
||||
if (chat) chat.title = title
|
||||
}
|
||||
|
||||
// Phase 3: 加载上下文预览
|
||||
async function loadContextPreview() {
|
||||
if (!chatName.value || !referencePagetype || !referenceName) return
|
||||
contextLoading.value = true
|
||||
try {
|
||||
const result = await api.call('jingrow.ai.api.context.get_chat_context', { chat_name: chatName.value })
|
||||
const data = result?.message || result
|
||||
if (data?.context_text) {
|
||||
contextPreview.value = data.context_text
|
||||
} else {
|
||||
contextPreview.value = ''
|
||||
}
|
||||
} catch {
|
||||
contextPreview.value = ''
|
||||
} finally {
|
||||
contextLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: 切换上下文预览
|
||||
function toggleContextPreview() {
|
||||
showContextPreview.value = !showContextPreview.value
|
||||
if (showContextPreview.value && !contextPreview.value) {
|
||||
loadContextPreview()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听对话切换时刷新上下文预览
|
||||
watch(() => chatName.value, () => {
|
||||
if (showContextPreview.value) {
|
||||
loadContextPreview()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -256,11 +297,31 @@ function onChatTitleChanged(title: string) {
|
||||
</template>
|
||||
</n-button>
|
||||
<span class="chat-header-title">{{ t('AI Chat') }}</span>
|
||||
<n-button quaternary circle size="small" @click="handleClose" :title="t('Close')">
|
||||
<template #icon>
|
||||
<n-icon :size="18"><Icon icon="lucide:x" /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
<div class="chat-header-actions">
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button quaternary circle size="small" @click="toggleContextPreview" :class="{ 'context-active': showContextPreview }">
|
||||
<template #icon>
|
||||
<n-icon :size="18"><Icon icon="lucide:info" /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
<span>{{ t('Page Context') }}</span>
|
||||
</n-tooltip>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<div class="context-toggle-wrapper">
|
||||
<n-switch v-model:value="includeContext" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ includeContext ? t('Context enabled') : t('Context disabled') }}</span>
|
||||
</n-tooltip>
|
||||
<n-button quaternary circle size="small" @click="handleClose" :title="t('Close')">
|
||||
<template #icon>
|
||||
<n-icon :size="18"><Icon icon="lucide:x" /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -307,7 +368,29 @@ function onChatTitleChanged(title: string) {
|
||||
</Transition>
|
||||
|
||||
<!-- 右侧聊天区域 -->
|
||||
<ChatContainer ref="chatContainerRef" :chat-name="chatName" @create-and-send="handleCreateAndSend" @title-changed="onChatTitleChanged" />
|
||||
<ChatContainer ref="chatContainerRef" :chat-name="chatName" :include-context="includeContext" @create-and-send="handleCreateAndSend" @title-changed="onChatTitleChanged" />
|
||||
|
||||
<!-- Phase 3: 上下文预览面板 -->
|
||||
<n-collapse v-if="showContextPreview && referencePagetype && referenceName" :default-expanded-names="['context']" class="context-preview-collapse">
|
||||
<n-collapse-item title="Page Context" name="context">
|
||||
<div class="context-preview-content">
|
||||
<div class="context-preview-info">
|
||||
<span class="context-label">{{ t('Reference') }}:</span>
|
||||
<span class="context-value">{{ referencePagetype }} / {{ referenceName }}</span>
|
||||
</div>
|
||||
<div class="context-preview-info">
|
||||
<span class="context-label">{{ t('Status') }}:</span>
|
||||
<span class="context-value">
|
||||
<n-switch v-model:value="includeContext" size="small" />
|
||||
<span class="context-status-text">{{ includeContext ? t('Enabled') : t('Disabled') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contextLoading" class="context-loading">{{ t('Loading...') }}</div>
|
||||
<pre v-else-if="contextPreview" class="context-preview-text">{{ contextPreview }}</pre>
|
||||
<div v-else class="context-loading">{{ t('Preview not available') }}</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
@ -473,4 +556,80 @@ function onChatTitleChanged(title: string) {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ---- Phase 3: 上下文面板 ---- */
|
||||
.chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.context-toggle-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.context-preview-collapse {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--n-border-color, #efeff5);
|
||||
max-height: 40%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.context-preview-content {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.context-preview-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.context-label {
|
||||
color: var(--n-text-color-3, #999);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-value {
|
||||
color: var(--n-text-color, #333);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.context-status-text {
|
||||
font-size: 11px;
|
||||
color: var(--n-text-color-3, #999);
|
||||
}
|
||||
|
||||
.context-loading {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--n-text-color-3, #999);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.context-preview-text {
|
||||
background: var(--n-color-embedded, #f5f5f5);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
color: var(--n-text-color, #333);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.context-active {
|
||||
color: var(--primary-color, #18a058);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -5,7 +5,8 @@ import json
|
||||
import time
|
||||
from werkzeug.wrappers import Response
|
||||
import jingrow
|
||||
from jingrow.ai.utils.utils import call_jingrow_model_stream_sync
|
||||
from jingrow.ai.utils.utils import call_jingrow_model_stream_sync, call_jingrow_model_sync
|
||||
from jingrow.ai.utils.tools import TOOL_DEFINITIONS, execute_tool
|
||||
|
||||
# 默认上下文窗口 token 上限(适配主流 32K~128K 窗口模型)
|
||||
DEFAULT_MAX_CONTEXT_TOKENS = 32768
|
||||
@ -18,7 +19,7 @@ CHARS_PER_TOKEN = 2.0
|
||||
|
||||
|
||||
@jingrow.whitelist()
|
||||
def chat_stream_sse(chat_name, content):
|
||||
def chat_stream_sse(chat_name, content, include_context=True):
|
||||
"""SSE 流式聊天端点
|
||||
|
||||
返回 werkzeug.Response(text/event-stream),逐 chunk 推送 AI 回复。
|
||||
@ -44,10 +45,46 @@ def chat_stream_sse(chat_name, content):
|
||||
user_msg.insert(ignore_permissions=True)
|
||||
jingrow.db.commit()
|
||||
|
||||
# 2. 构建消息历史
|
||||
messages = _build_message_history(chat_name, chat)
|
||||
# 2. 构建页面上下文并注入消息历史
|
||||
page_context = ""
|
||||
if include_context:
|
||||
page_context = _get_page_context(chat)
|
||||
messages = _build_message_history(chat_name, chat, extra_context=page_context)
|
||||
|
||||
# 3. SSE 生成器
|
||||
# 3. Function Calling 循环
|
||||
# 非流式调用 + tools 检测 → 执行工具 → 循环
|
||||
# 直到 LLM 返回纯文本回复,再进入 SSE 流式输出
|
||||
MAX_FUNCTION_ROUNDS = 5
|
||||
for _round in range(MAX_FUNCTION_ROUNDS):
|
||||
response = call_jingrow_model_sync(
|
||||
messages=messages,
|
||||
tools=TOOL_DEFINITIONS,
|
||||
max_tokens=MAX_TOKENS,
|
||||
)
|
||||
if not response or not response.get("success"):
|
||||
break
|
||||
|
||||
tool_calls = response.get("tool_calls")
|
||||
if not tool_calls:
|
||||
# 纯文本回复 → 中断循环,进入 SSE 流式输出
|
||||
break
|
||||
|
||||
for tc in tool_calls:
|
||||
if tc.get("type") != "function":
|
||||
continue
|
||||
func = tc.get("function", {})
|
||||
tool_name = func.get("name", "")
|
||||
tool_args = func.get("arguments", "{}")
|
||||
tool_call_id = tc.get("id", "")
|
||||
|
||||
result = execute_tool(tool_name, tool_args)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": json.dumps(result, ensure_ascii=False),
|
||||
})
|
||||
|
||||
# 4. SSE 生成器:流式输出最终文本回复
|
||||
def generate():
|
||||
full_content = ""
|
||||
start_time = time.time()
|
||||
@ -128,9 +165,14 @@ def _estimate_tokens(text: str) -> int:
|
||||
return max(1, int(len(text) / CHARS_PER_TOKEN))
|
||||
|
||||
|
||||
def _build_message_history(chat_name, chat):
|
||||
def _build_message_history(chat_name, chat, extra_context=""):
|
||||
"""构建结构化消息历史(OpenAI 兼容的 messages 数组)
|
||||
|
||||
Args:
|
||||
chat_name: 对话名称
|
||||
chat: Ai Chat Page 实例
|
||||
extra_context: 额外的页面上下文(追加到 system prompt 后方)
|
||||
|
||||
返回 List[Dict],每项包含 role 和 content,
|
||||
并按滑动窗口截断以适配上下文窗口。
|
||||
"""
|
||||
@ -143,6 +185,15 @@ def _build_message_history(chat_name, chat):
|
||||
|
||||
system_prompt = chat.system_prompt or "你是一个有用的AI助手。"
|
||||
|
||||
# Phase 1: 附加页面上下文到 system prompt
|
||||
if extra_context:
|
||||
system_prompt = system_prompt + (
|
||||
"\n\n=== 当前页面上下文 ===\n"
|
||||
"以下是用户正在查看的页面信息,你可以利用这些数据回答用户的问题。\n"
|
||||
f"{extra_context}\n"
|
||||
"=== 上下文结束 ==="
|
||||
)
|
||||
|
||||
# 构建结构化消息列表
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for msg in db_messages:
|
||||
@ -170,3 +221,140 @@ def _build_message_history(chat_name, chat):
|
||||
if cutoff_idx < len(messages):
|
||||
return [messages[0]] + messages[cutoff_idx:]
|
||||
return [messages[0]]
|
||||
|
||||
|
||||
def _get_page_context(chat):
|
||||
"""获取页面上下文,优先使用缓存
|
||||
|
||||
从 AiChat.reference_pagetype/name 读取关联记录数据,
|
||||
组装为结构化描述。结果缓存到 AiChat.context 字段避免重复查询。
|
||||
"""
|
||||
if not chat.reference_pagetype or not chat.reference_name:
|
||||
return ""
|
||||
|
||||
# 检查缓存:同一 reference 且已有内容
|
||||
if chat.context:
|
||||
try:
|
||||
ctx = chat.context
|
||||
if isinstance(ctx, str):
|
||||
cached = json.loads(ctx)
|
||||
else:
|
||||
cached = ctx
|
||||
if (cached.get("reference_pagetype") == chat.reference_pagetype
|
||||
and cached.get("reference_name") == chat.reference_name
|
||||
and cached.get("page_context")):
|
||||
return cached["page_context"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 构建上下文
|
||||
context_str = _build_page_context(chat.reference_pagetype, chat.reference_name)
|
||||
|
||||
# 写入缓存
|
||||
if context_str:
|
||||
try:
|
||||
chat.context = json.dumps({
|
||||
"page_context": context_str,
|
||||
"reference_pagetype": chat.reference_pagetype,
|
||||
"reference_name": chat.reference_name,
|
||||
})
|
||||
chat.db_update()
|
||||
jingrow.db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return context_str
|
||||
|
||||
|
||||
def _build_page_context(reference_pagetype, reference_name):
|
||||
"""从引用记录构建结构化页面上下文
|
||||
|
||||
遍历字段定义和值,排除内部字段和空值,
|
||||
同时提取子表数据。返回纯文本 XML-ish 格式描述。
|
||||
"""
|
||||
if not reference_pagetype or not reference_name:
|
||||
return ""
|
||||
|
||||
try:
|
||||
meta = jingrow.get_meta(reference_pagetype)
|
||||
pg = jingrow.get_pg(reference_pagetype, reference_name)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not pg:
|
||||
return ""
|
||||
|
||||
# 收集 fieldname → label 映射,区分普通字段和子表
|
||||
field_labels = {}
|
||||
child_table_fields = set()
|
||||
if hasattr(meta, 'fields'):
|
||||
for f in meta.fields:
|
||||
fn = getattr(f, 'fieldname', '') or ''
|
||||
label = getattr(f, 'label', '') or fn
|
||||
field_labels[fn] = label
|
||||
if getattr(f, 'fieldtype', '') == 'Table':
|
||||
child_table_fields.add(fn)
|
||||
|
||||
lines = []
|
||||
lines.append("<PAGE_CONTEXT>")
|
||||
lines.append(f" <PAGE_TYPE>{_escape_xml(reference_pagetype)}</PAGE_TYPE>")
|
||||
lines.append(f" <PAGE_NAME>{_escape_xml(reference_name)}</PAGE_NAME>")
|
||||
|
||||
# 字段值
|
||||
lines.append(" <FIELDS>")
|
||||
# 排除的内部字段前缀
|
||||
skip_prefixes = ('_', 'amended_from')
|
||||
for fn, label in field_labels.items():
|
||||
if fn in child_table_fields:
|
||||
continue
|
||||
if any(fn.startswith(p) for p in skip_prefixes):
|
||||
continue
|
||||
try:
|
||||
value = pg.get(fn)
|
||||
except Exception:
|
||||
continue
|
||||
if value is None or value == '' or value == []:
|
||||
continue
|
||||
lines.append(f' <FIELD label="{_escape_xml(str(label))}">{_escape_xml(str(value))}</FIELD>')
|
||||
lines.append(" </FIELDS>")
|
||||
|
||||
# 子表数据
|
||||
lines.append(" <CHILD_TABLES>")
|
||||
for ct_field in child_table_fields:
|
||||
ct_label = field_labels.get(ct_field, ct_field)
|
||||
try:
|
||||
child_rows = jingrow.get_all(
|
||||
ct_field,
|
||||
filters={"parent": reference_name, "parenttype": reference_pagetype},
|
||||
limit=50,
|
||||
)
|
||||
except Exception:
|
||||
child_rows = []
|
||||
if child_rows:
|
||||
lines.append(f' <TABLE name="{_escape_xml(ct_label)}">')
|
||||
for row in child_rows:
|
||||
lines.append(" <ROW>")
|
||||
for k, v in row.items():
|
||||
if k in ('name', 'parent', 'parenttype', 'parentfield', 'idx',
|
||||
'creation', 'modified', 'owner', 'modified_by'):
|
||||
continue
|
||||
if v is None or v == '':
|
||||
continue
|
||||
row_label = field_labels.get(k, k)
|
||||
lines.append(f' <FIELD label="{_escape_xml(str(row_label))}">{_escape_xml(str(v))}</FIELD>')
|
||||
lines.append(" </ROW>")
|
||||
lines.append(f' </TABLE>')
|
||||
lines.append(" </CHILD_TABLES>")
|
||||
lines.append("</PAGE_CONTEXT>")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _escape_xml(text: str) -> str:
|
||||
"""转义 XML 特殊字符"""
|
||||
text = text.replace("&", "&")
|
||||
text = text.replace("<", "<")
|
||||
text = text.replace(">", ">")
|
||||
text = text.replace('"', """)
|
||||
text = text.replace("'", "'")
|
||||
return text
|
||||
|
||||
70
jingrow/ai/api/context.py
Normal file
70
jingrow/ai/api/context.py
Normal file
@ -0,0 +1,70 @@
|
||||
# Copyright (c) 2025, JINGROW
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
"""
|
||||
context.py — AI Chat 上下文预览 API
|
||||
|
||||
提供对当前 AI Chat 正在使用的页面上下文的只读访问,
|
||||
供前端展示给用户预览,建立用户对 AI 上下文的信任感。
|
||||
"""
|
||||
|
||||
import json
|
||||
import jingrow
|
||||
from jingrow.ai.api.chat import _get_page_context
|
||||
|
||||
|
||||
@jingrow.whitelist()
|
||||
def get_chat_context(chat_name: str) -> dict:
|
||||
"""获取指定 AI Chat 的当前页面上下文(用于前端预览)
|
||||
|
||||
返回当前对话的 reference 记录数据和上下文内容,
|
||||
让用户可见 AI 正在使用哪些页面信息。
|
||||
|
||||
Args:
|
||||
chat_name: AI Chat 记录名称
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
- reference_pagetype: str or None
|
||||
- reference_name: str or None
|
||||
- context_text: 组装后的上下文纯文本
|
||||
- field_count: int(字段数量)
|
||||
- has_child_tables: bool
|
||||
"""
|
||||
if not chat_name:
|
||||
return {"success": False, "error": "chat_name is required"}
|
||||
|
||||
try:
|
||||
chat = jingrow.get_pg("Ai Chat", chat_name)
|
||||
if not chat:
|
||||
return {"success": False, "error": "Chat not found"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
if not chat.reference_pagetype or not chat.reference_name:
|
||||
return {
|
||||
"success": True,
|
||||
"has_context": False,
|
||||
"message": "No page reference associated with this chat",
|
||||
}
|
||||
|
||||
# 构建上下文(复用 Phase 1 的逻辑)
|
||||
context_text = _get_page_context(chat)
|
||||
|
||||
# 统计上下文包含的字段信息
|
||||
field_count = 0
|
||||
has_child_tables = False
|
||||
if context_text:
|
||||
field_count = context_text.count("<FIELD")
|
||||
has_child_tables = "<TABLE" in context_text
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"has_context": bool(context_text),
|
||||
"reference_pagetype": chat.reference_pagetype,
|
||||
"reference_name": chat.reference_name,
|
||||
"context_text": context_text,
|
||||
"field_count": field_count,
|
||||
"has_child_tables": has_child_tables,
|
||||
"cached": bool(chat.context),
|
||||
}
|
||||
332
jingrow/ai/utils/tools.py
Normal file
332
jingrow/ai/utils/tools.py
Normal file
@ -0,0 +1,332 @@
|
||||
# Copyright (c) 2025, JINGROW
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
"""
|
||||
tools.py — AI Chat Function Calling 工具注册表
|
||||
|
||||
为 AI Chat 提供 OpenAI-compatible function calling 支持。
|
||||
工具定义遵循 OpenAI tool 规范,handler 函数使用 jingrow 原生数据访问能力。
|
||||
|
||||
使用方式(在 chat_stream_sse 中):
|
||||
from jingrow.ai.utils.tools import TOOL_DEFINITIONS, execute_tool
|
||||
|
||||
tools = TOOL_DEFINITIONS
|
||||
# 将 tools 传入 API payload
|
||||
# 当 LLM 返回 tool_calls 时调用 execute_tool()
|
||||
"""
|
||||
|
||||
import json
|
||||
import jingrow
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 工具定义(OpenAI-compatible function schema)
|
||||
# =========================================================================
|
||||
|
||||
TOOL_DEFINITIONS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_record",
|
||||
"description": "获取指定 PageType 的一条完整记录。可指定 fields 只返回需要的字段。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pagetype": {
|
||||
"type": "string",
|
||||
"description": "PageType 名称,如 'Customer'、'User'、'Ai Chat'"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "记录的唯一名称/ID"
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "要返回的字段列表(可选,不传则返回所有有值的字段)"
|
||||
}
|
||||
},
|
||||
"required": ["pagetype", "name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_records",
|
||||
"description": "按条件搜索 PageType 记录。支持按字段值过滤、排序和分页。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pagetype": {
|
||||
"type": "string",
|
||||
"description": "PageType 名称"
|
||||
},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"description": "过滤条件,如 {\"status\": \"Open\", \"owner\": \"admin\"}"
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "要返回的字段列表(可选,不传则返回关键字段)"
|
||||
},
|
||||
"order_by": {
|
||||
"type": "string",
|
||||
"description": "排序方式,如 'modified desc'(可选,默认按修改时间降序)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "最多返回的记录数(默认 10,最大 50)"
|
||||
}
|
||||
},
|
||||
"required": ["pagetype"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_record",
|
||||
"description": "更新指定记录的一个或多个字段值。注意:此操作会修改数据库中的数据。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pagetype": {
|
||||
"type": "string",
|
||||
"description": "PageType 名称"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "记录的唯一名称/ID"
|
||||
},
|
||||
"field_values": {
|
||||
"type": "object",
|
||||
"description": "要更新的字段键值对,如 {\"status\": \"已完成\", \"title\": \"新标题\"}"
|
||||
}
|
||||
},
|
||||
"required": ["pagetype", "name", "field_values"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_meta",
|
||||
"description": "获取 PageType 的字段定义信息,包括字段名、字段类型、标签、选项列表等。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pagetype": {
|
||||
"type": "string",
|
||||
"description": "PageType 名称"
|
||||
}
|
||||
},
|
||||
"required": ["pagetype"]
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 工具处理器(handler)
|
||||
# =========================================================================
|
||||
|
||||
def _handle_get_record(pagetype: str, name: str, fields: list = None) -> dict:
|
||||
"""获取单条记录"""
|
||||
try:
|
||||
pg = jingrow.get_pg(pagetype, name)
|
||||
if not pg:
|
||||
return {"success": False, "error": f"Record '{name}' not found in {pagetype}"}
|
||||
|
||||
meta = jingrow.get_meta(pagetype)
|
||||
# 构建 fieldname -> label 映射
|
||||
label_map = {}
|
||||
if hasattr(meta, 'fields'):
|
||||
for f in meta.fields:
|
||||
fn = getattr(f, 'fieldname', '')
|
||||
if fn:
|
||||
label_map[fn] = getattr(f, 'label', '') or fn
|
||||
|
||||
result = {"name": name, "pagetype": pagetype, "fields": {}}
|
||||
|
||||
if fields:
|
||||
# 只返回请求的字段
|
||||
for fn in fields:
|
||||
try:
|
||||
val = pg.get(fn)
|
||||
label = label_map.get(fn, fn)
|
||||
result["fields"][fn] = {"label": label, "value": val}
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# 返回所有有值的非内部字段
|
||||
skip_prefixes = ('_', 'amended_from')
|
||||
if hasattr(meta, 'fields'):
|
||||
for f in meta.fields:
|
||||
fn = getattr(f, 'fieldname', '')
|
||||
if not fn or any(fn.startswith(p) for p in skip_prefixes):
|
||||
continue
|
||||
if getattr(f, 'fieldtype', '') == 'Table':
|
||||
continue
|
||||
try:
|
||||
val = pg.get(fn)
|
||||
if val is not None and val != '':
|
||||
label = getattr(f, 'label', '') or fn
|
||||
result["fields"][fn] = {"label": label, "value": val}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"success": True, "record": result}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _handle_search_records(pagetype: str, filters: dict = None,
|
||||
fields: list = None, order_by: str = None,
|
||||
limit: int = 10) -> dict:
|
||||
"""搜索记录"""
|
||||
try:
|
||||
if filters is None:
|
||||
filters = {}
|
||||
if order_by is None:
|
||||
order_by = "modified desc"
|
||||
limit = min(limit, 50)
|
||||
|
||||
# 确定返回字段
|
||||
if fields:
|
||||
query_fields = fields
|
||||
else:
|
||||
# 默认返回通用信息字段 + 可能的关键字段
|
||||
meta = jingrow.get_meta(pagetype)
|
||||
query_fields = ["name"]
|
||||
if hasattr(meta, 'fields'):
|
||||
for f in meta.fields:
|
||||
fn = getattr(f, 'fieldname', '')
|
||||
if fn and fn not in ('_',) and not fn.startswith('_'):
|
||||
if getattr(f, 'fieldtype', '') in ('Data', 'Text', 'Small Text',
|
||||
'Select', 'Link', 'Int', 'Float',
|
||||
'Currency', 'Check', 'Date',
|
||||
'Datetime', 'Phone', 'Dynamic Link'):
|
||||
query_fields.append(fn)
|
||||
if len(query_fields) >= 8:
|
||||
break
|
||||
|
||||
records = jingrow.get_all(
|
||||
pagetype,
|
||||
fields=query_fields,
|
||||
filters=filters,
|
||||
order_by=order_by,
|
||||
limit_page_length=limit,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"records": records,
|
||||
"total": len(records),
|
||||
"pagetype": pagetype,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _handle_update_record(pagetype: str, name: str,
|
||||
field_values: dict) -> dict:
|
||||
"""更新记录字段"""
|
||||
try:
|
||||
pg = jingrow.get_pg(pagetype, name)
|
||||
if not pg:
|
||||
return {"success": False, "error": f"Record '{name}' not found in {pagetype}"}
|
||||
|
||||
updated_fields = {}
|
||||
for fn, value in field_values.items():
|
||||
try:
|
||||
setattr(pg, fn, value)
|
||||
updated_fields[fn] = value
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Failed to set field '{fn}': {str(e)}"}
|
||||
|
||||
pg.save(ignore_permissions=True)
|
||||
jingrow.db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Record '{name}' updated successfully",
|
||||
"pagetype": pagetype,
|
||||
"name": name,
|
||||
"updated_fields": updated_fields,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _handle_get_meta(pagetype: str) -> dict:
|
||||
"""获取 PageType 字段定义"""
|
||||
try:
|
||||
meta = jingrow.get_meta(pagetype)
|
||||
fields_info = []
|
||||
if hasattr(meta, 'fields'):
|
||||
for f in meta.fields:
|
||||
info = {
|
||||
"fieldname": getattr(f, 'fieldname', ''),
|
||||
"label": getattr(f, 'label', ''),
|
||||
"fieldtype": getattr(f, 'fieldtype', ''),
|
||||
"reqd": bool(getattr(f, 'reqd', False)),
|
||||
"options": getattr(f, 'options', None),
|
||||
}
|
||||
# Select 类型时返回 options 列表
|
||||
if info["fieldtype"] == "Select" and isinstance(info["options"], str):
|
||||
info["options"] = [o.strip() for o in info["options"].split('\n') if o.strip()]
|
||||
fields_info.append(info)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pagetype": pagetype,
|
||||
"fields": fields_info,
|
||||
"field_count": len(fields_info),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 工具名称 → 处理器映射
|
||||
# =========================================================================
|
||||
|
||||
_TOOL_HANDLERS = {
|
||||
"get_record": _handle_get_record,
|
||||
"search_records": _handle_search_records,
|
||||
"update_record": _handle_update_record,
|
||||
"get_meta": _handle_get_meta,
|
||||
}
|
||||
|
||||
|
||||
def execute_tool(name: str, arguments: str) -> dict:
|
||||
"""执行指定的工具
|
||||
|
||||
Args:
|
||||
name: 工具名称(对应 TOOL_DEFINITIONS 中的 function.name)
|
||||
arguments: JSON 字符串形式的参数
|
||||
|
||||
Returns:
|
||||
工具执行结果 dict
|
||||
"""
|
||||
handler = _TOOL_HANDLERS.get(name)
|
||||
if not handler:
|
||||
return {"success": False, "error": f"Unknown tool: '{name}'"}
|
||||
|
||||
try:
|
||||
args = json.loads(arguments) if isinstance(arguments, str) else arguments
|
||||
except json.JSONDecodeError as e:
|
||||
return {"success": False, "error": f"Invalid tool arguments JSON: {str(e)}"}
|
||||
|
||||
if not isinstance(args, dict):
|
||||
return {"success": False, "error": "Tool arguments must be a JSON object"}
|
||||
|
||||
try:
|
||||
return handler(**args)
|
||||
except TypeError as e:
|
||||
return {"success": False, "error": f"Invalid arguments for tool '{name}': {str(e)}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Tool '{name}' execution error: {str(e)}"}
|
||||
@ -307,7 +307,8 @@ def call_jingrow_model_stream_sync(
|
||||
ai_temperature: Optional[float] = None,
|
||||
ai_top_p: Optional[float] = None,
|
||||
ai_system_message: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None
|
||||
max_tokens: Optional[int] = None,
|
||||
tools: Optional[list] = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""同步流式调用 Jingrow 模型(requests stream=True)
|
||||
|
||||
@ -316,6 +317,7 @@ def call_jingrow_model_stream_sync(
|
||||
Args:
|
||||
messages: OpenAI 兼容的结构化消息列表 [{role, content}, ...]
|
||||
若为字符串则自动包装为单条 user 消息(向后兼容)
|
||||
tools: OpenAI 兼容的工具定义列表(可选),用于 function calling
|
||||
"""
|
||||
api_url = f"{get_jingrow_api_url()}/jchat/chat/stream"
|
||||
headers = get_jingrow_api_headers()
|
||||
@ -361,6 +363,9 @@ def call_jingrow_model_stream_sync(
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
|
||||
try:
|
||||
resp = requests.post(api_url, json=payload, headers=headers, stream=True, timeout=300)
|
||||
if resp.status_code != 200:
|
||||
@ -394,6 +399,80 @@ def call_jingrow_model_stream_sync(
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def call_jingrow_model_sync(
|
||||
messages: list,
|
||||
ai_temperature: Optional[float] = None,
|
||||
ai_top_p: Optional[float] = None,
|
||||
ai_system_message: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
tools: Optional[list] = None,
|
||||
) -> Optional[dict]:
|
||||
"""非流式调用模型,返回完整响应(用于 function calling 场景)
|
||||
|
||||
发送 OpenAI 兼容的 messages + tools 到模型 API,
|
||||
返回完整的 response dict,包含 tool_calls 或 content。
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
- "content": str or None(纯文本回复时)
|
||||
- "tool_calls": list or None(function calling 时)
|
||||
- "finish_reason": str
|
||||
- "success": bool
|
||||
None if request failed
|
||||
"""
|
||||
api_url = f"{get_jingrow_api_url()}/jchat/chat"
|
||||
headers = get_jingrow_api_headers()
|
||||
if not headers:
|
||||
send_notification("Jingrow API 未设置,请在 AI设置 中设置 Jingrow Api Key 和 Jingrow Api Secret")
|
||||
return None
|
||||
|
||||
temperature = ai_temperature or jingrow.db.get_single_value("Ai Settings", "temperature") or 0.7
|
||||
top_p = ai_top_p or jingrow.db.get_single_value("Ai Settings", "top_p") or 0.9
|
||||
system_message = ai_system_message or "你是一个有用的AI助手。"
|
||||
|
||||
# 兼容:若传入字符串,自动包装为结构化消息
|
||||
if isinstance(messages, str):
|
||||
messages = [{"role": "user", "content": messages}]
|
||||
|
||||
# 确保消息列表以 system 消息开头(若未包含)
|
||||
if not messages or messages[0].get("role") != "system":
|
||||
messages = [{"role": "system", "content": system_message}] + messages
|
||||
|
||||
model = jingrow.db.get_single_value("Ai Settings", "jingrow_api_model") or "jingrow-chat"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
"max_tokens": max_tokens or 2048,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
|
||||
try:
|
||||
resp = requests.post(api_url, json=payload, headers=headers, timeout=300)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
return None
|
||||
|
||||
msg = choices[0].get("message", {})
|
||||
result = {
|
||||
"content": msg.get("content"),
|
||||
"tool_calls": msg.get("tool_calls"),
|
||||
"finish_reason": choices[0].get("finish_reason"),
|
||||
"success": True,
|
||||
}
|
||||
return result
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def call_jingrow_model(
|
||||
prompt: str,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user