详情页侧边栏AI聊天抽屉窗口支持自动获取当前页面数据作为上下文

This commit is contained in:
jingrow 2026-06-15 22:56:24 +08:00
parent 426b440477
commit bb88eee814
8 changed files with 850 additions and 18 deletions

View File

@ -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,
})

View File

@ -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

View File

@ -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)
}

View File

@ -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>

View File

@ -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.Responsetext/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("&", "&amp;")
text = text.replace("<", "&lt;")
text = text.replace(">", "&gt;")
text = text.replace('"', "&quot;")
text = text.replace("'", "&apos;")
return text

70
jingrow/ai/api/context.py Normal file
View 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
View 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)}"}

View File

@ -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 Nonefunction 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,