Compare commits
8 Commits
research/t
...
hotfix/v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa4be10e51 | ||
|
|
dcb7b496d3 | ||
|
|
9535545947 | ||
|
|
59f5c7a8bb | ||
|
|
1305a08c86 | ||
|
|
fe29141437 | ||
|
|
17d3c81c02 | ||
|
|
baf02e4faa |
@@ -1,5 +1,3 @@
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -18,18 +16,6 @@ from app.core.logging_config import get_api_logger
|
||||
# 获取API专用日志器
|
||||
api_logger = get_api_logger()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def timer(label: str, user_count: int = 0):
|
||||
"""上下文管理器:用于测量代码块执行时间"""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
elapsed = (time.perf_counter() - start) * 1000 # 转换为毫秒
|
||||
extra_info = f", 用户数: {user_count}" if user_count > 0 else ""
|
||||
api_logger.info(f"[性能统计] {label}: {elapsed:.2f}ms{extra_info}")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/dashboard",
|
||||
tags=["Dashboard"],
|
||||
@@ -66,7 +52,7 @@ async def get_workspace_end_users(
|
||||
):
|
||||
"""
|
||||
获取工作空间的宿主列表(高性能优化版本 v2)
|
||||
|
||||
|
||||
优化策略:
|
||||
1. 批量查询 end_users(一次查询而非循环)
|
||||
2. 并发查询所有用户的记忆数量(Neo4j)
|
||||
@@ -74,7 +60,7 @@ async def get_workspace_end_users(
|
||||
4. 只返回必要字段减少数据传输
|
||||
5. 添加短期缓存减少重复查询
|
||||
6. 并发执行配置查询和记忆数量查询
|
||||
|
||||
|
||||
返回格式:
|
||||
{
|
||||
"end_user": {"id": "uuid", "other_name": "名称"},
|
||||
@@ -84,149 +70,129 @@ async def get_workspace_end_users(
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
# from app.aioRedis import aio_redis_get, aio_redis_set
|
||||
|
||||
# 总耗时统计
|
||||
total_start = time.perf_counter()
|
||||
|
||||
from app.aioRedis import aio_redis_get, aio_redis_set
|
||||
|
||||
workspace_id = current_user.current_workspace_id
|
||||
|
||||
# # 尝试从缓存获取(30秒缓存)- 暂时注释以便进行性能测试
|
||||
# with timer("Redis缓存读取"):
|
||||
# cache_key = f"end_users:workspace:{workspace_id}"
|
||||
# try:
|
||||
# cached_data = await aio_redis_get(cache_key)
|
||||
# if cached_data:
|
||||
# api_logger.info(f"从缓存获取宿主列表: workspace_id={workspace_id}")
|
||||
# return success(data=json.loads(cached_data), msg="宿主列表获取成功")
|
||||
# except Exception as e:
|
||||
# api_logger.warning(f"Redis 缓存读取失败: {str(e)}")
|
||||
|
||||
|
||||
# 尝试从缓存获取(30秒缓存)
|
||||
cache_key = f"end_users:workspace:{workspace_id}"
|
||||
try:
|
||||
cached_data = await aio_redis_get(cache_key)
|
||||
if cached_data:
|
||||
api_logger.info(f"从缓存获取宿主列表: workspace_id={workspace_id}")
|
||||
return success(data=json.loads(cached_data), msg="宿主列表获取成功")
|
||||
except Exception as e:
|
||||
api_logger.warning(f"Redis 缓存读取失败: {str(e)}")
|
||||
|
||||
# 获取当前空间类型
|
||||
with timer("获取空间类型"):
|
||||
current_workspace_type = memory_dashboard_service.get_current_workspace_type(db, workspace_id, current_user)
|
||||
api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {workspace_id} 的宿主列表, 类型: {current_workspace_type}")
|
||||
|
||||
current_workspace_type = memory_dashboard_service.get_current_workspace_type(db, workspace_id, current_user)
|
||||
api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {workspace_id} 的宿主列表")
|
||||
|
||||
# 获取 end_users(已优化为批量查询)
|
||||
with timer("获取用户列表"):
|
||||
end_users = memory_dashboard_service.get_workspace_end_users(
|
||||
db=db,
|
||||
workspace_id=workspace_id,
|
||||
current_user=current_user
|
||||
)
|
||||
end_users = memory_dashboard_service.get_workspace_end_users(
|
||||
db=db,
|
||||
workspace_id=workspace_id,
|
||||
current_user=current_user
|
||||
)
|
||||
if not end_users:
|
||||
api_logger.info("工作空间下没有宿主")
|
||||
# # 缓存空结果,避免重复查询 - 暂时注释
|
||||
# try:
|
||||
# await aio_redis_set(cache_key, json.dumps([]), expire=30)
|
||||
# except Exception as e:
|
||||
# api_logger.warning(f"Redis 缓存写入失败: {str(e)}")
|
||||
# 缓存空结果,避免重复查询
|
||||
try:
|
||||
await aio_redis_set(cache_key, json.dumps([]), expire=30)
|
||||
except Exception as e:
|
||||
api_logger.warning(f"Redis 缓存写入失败: {str(e)}")
|
||||
return success(data=[], msg="宿主列表获取成功")
|
||||
|
||||
|
||||
end_user_ids = [str(user.id) for user in end_users]
|
||||
user_count = len(end_user_ids)
|
||||
api_logger.info(f"需要处理的用户数: {user_count}")
|
||||
|
||||
|
||||
# 并发执行两个独立的查询任务
|
||||
async def get_memory_configs():
|
||||
"""获取记忆配置(在线程池中执行同步查询)"""
|
||||
with timer("功能模块-获取记忆配置", user_count):
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
get_end_users_connected_configs_batch,
|
||||
end_user_ids, db
|
||||
)
|
||||
except Exception as e:
|
||||
api_logger.error(f"批量获取记忆配置失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
get_end_users_connected_configs_batch,
|
||||
end_user_ids, db
|
||||
)
|
||||
except Exception as e:
|
||||
api_logger.error(f"批量获取记忆配置失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
async def get_memory_nums():
|
||||
"""获取记忆数量"""
|
||||
with timer(f"功能模块-获取记忆数量[{current_workspace_type}]", user_count):
|
||||
if current_workspace_type == "rag":
|
||||
# RAG 模式:批量查询
|
||||
with timer(" - RAG批量查询chunks"):
|
||||
if current_workspace_type == "rag":
|
||||
# RAG 模式:批量查询
|
||||
try:
|
||||
chunk_map = await asyncio.to_thread(
|
||||
memory_dashboard_service.get_users_total_chunk_batch,
|
||||
end_user_ids, db, current_user
|
||||
)
|
||||
return {uid: {"total": count} for uid, count in chunk_map.items()}
|
||||
except Exception as e:
|
||||
api_logger.error(f"批量获取 RAG chunk 数量失败: {str(e)}")
|
||||
return {uid: {"total": 0} for uid in end_user_ids}
|
||||
|
||||
elif current_workspace_type == "neo4j":
|
||||
# Neo4j 模式:并发查询(带并发限制)
|
||||
# 使用信号量限制并发数,避免大量用户时压垮 Neo4j
|
||||
MAX_CONCURRENT_QUERIES = 10
|
||||
semaphore = asyncio.Semaphore(MAX_CONCURRENT_QUERIES)
|
||||
|
||||
async def get_neo4j_memory_num(end_user_id: str):
|
||||
async with semaphore:
|
||||
try:
|
||||
chunk_map = await asyncio.to_thread(
|
||||
memory_dashboard_service.get_users_total_chunk_batch,
|
||||
end_user_ids, db, current_user
|
||||
)
|
||||
return {uid: {"total": count} for uid, count in chunk_map.items()}
|
||||
return await memory_storage_service.search_all(end_user_id)
|
||||
except Exception as e:
|
||||
api_logger.error(f"批量获取 RAG chunk 数量失败: {str(e)}")
|
||||
return {uid: {"total": 0} for uid in end_user_ids}
|
||||
|
||||
elif current_workspace_type == "neo4j":
|
||||
# Neo4j 模式:并发查询(带并发限制)
|
||||
# 使用信号量限制并发数,避免大量用户时压垮 Neo4j
|
||||
MAX_CONCURRENT_QUERIES = 10
|
||||
semaphore = asyncio.Semaphore(MAX_CONCURRENT_QUERIES)
|
||||
|
||||
async def get_neo4j_memory_num(end_user_id: str):
|
||||
async with semaphore:
|
||||
single_start = time.perf_counter()
|
||||
try:
|
||||
result = await memory_storage_service.search_all(end_user_id)
|
||||
elapsed = (time.perf_counter() - single_start) * 1000
|
||||
api_logger.info(f" - Neo4j单用户查询[{end_user_id}]: {elapsed:.2f}ms")
|
||||
return result
|
||||
except Exception as e:
|
||||
api_logger.error(f"获取用户 {end_user_id} Neo4j 记忆数量失败: {str(e)}")
|
||||
return {"total": 0}
|
||||
|
||||
with timer(" - Neo4j并发查询所有用户"):
|
||||
memory_nums_list = await asyncio.gather(*[get_neo4j_memory_num(uid) for uid in end_user_ids])
|
||||
return {end_user_ids[i]: memory_nums_list[i] for i in range(len(end_user_ids))}
|
||||
|
||||
return {uid: {"total": 0} for uid in end_user_ids}
|
||||
api_logger.error(f"获取用户 {end_user_id} Neo4j 记忆数量失败: {str(e)}")
|
||||
return {"total": 0}
|
||||
|
||||
memory_nums_list = await asyncio.gather(*[get_neo4j_memory_num(uid) for uid in end_user_ids])
|
||||
return {end_user_ids[i]: memory_nums_list[i] for i in range(len(end_user_ids))}
|
||||
|
||||
return {uid: {"total": 0} for uid in end_user_ids}
|
||||
|
||||
# 触发按需初始化:为 implicit_emotions_storage 中没有记录的用户异步生成数据
|
||||
with timer("触发Celery初始化任务"):
|
||||
try:
|
||||
from app.celery_app import celery_app as _celery_app
|
||||
_celery_app.send_task(
|
||||
"app.tasks.init_implicit_emotions_for_users",
|
||||
kwargs={"end_user_ids": end_user_ids},
|
||||
)
|
||||
_celery_app.send_task(
|
||||
"app.tasks.init_interest_distribution_for_users",
|
||||
kwargs={"end_user_ids": end_user_ids},
|
||||
)
|
||||
api_logger.info(f"已触发按需初始化任务,候选用户数: {len(end_user_ids)}")
|
||||
except Exception as e:
|
||||
api_logger.warning(f"触发按需初始化任务失败(不影响主流程): {e}")
|
||||
try:
|
||||
from app.celery_app import celery_app as _celery_app
|
||||
_celery_app.send_task(
|
||||
"app.tasks.init_implicit_emotions_for_users",
|
||||
kwargs={"end_user_ids": end_user_ids},
|
||||
)
|
||||
_celery_app.send_task(
|
||||
"app.tasks.init_interest_distribution_for_users",
|
||||
kwargs={"end_user_ids": end_user_ids},
|
||||
)
|
||||
api_logger.info(f"已触发按需初始化任务,候选用户数: {len(end_user_ids)}")
|
||||
except Exception as e:
|
||||
api_logger.warning(f"触发按需初始化任务失败(不影响主流程): {e}")
|
||||
|
||||
# 并发执行配置查询和记忆数量查询
|
||||
with timer("并发执行两个功能模块"):
|
||||
memory_configs_map, memory_nums_map = await asyncio.gather(
|
||||
get_memory_configs(),
|
||||
get_memory_nums()
|
||||
)
|
||||
|
||||
memory_configs_map, memory_nums_map = await asyncio.gather(
|
||||
get_memory_configs(),
|
||||
get_memory_nums()
|
||||
)
|
||||
|
||||
# 构建结果(优化:使用列表推导式)
|
||||
with timer("构建返回结果"):
|
||||
result = []
|
||||
for end_user in end_users:
|
||||
user_id = str(end_user.id)
|
||||
config_info = memory_configs_map.get(user_id, {})
|
||||
result.append({
|
||||
'end_user': {
|
||||
'id': user_id,
|
||||
'other_name': end_user.other_name
|
||||
},
|
||||
'memory_num': memory_nums_map.get(user_id, {"total": 0}),
|
||||
'memory_config': {
|
||||
"memory_config_id": config_info.get("memory_config_id"),
|
||||
"memory_config_name": config_info.get("memory_config_name")
|
||||
}
|
||||
})
|
||||
|
||||
# # 写入缓存(30秒过期)- 暂时注释以便进行性能测试
|
||||
# with timer("Redis缓存写入"):
|
||||
# try:
|
||||
# await aio_redis_set(cache_key, json.dumps(result), expire=30)
|
||||
# except Exception as e:
|
||||
# api_logger.warning(f"Redis 缓存写入失败: {str(e)}")
|
||||
result = []
|
||||
for end_user in end_users:
|
||||
user_id = str(end_user.id)
|
||||
config_info = memory_configs_map.get(user_id, {})
|
||||
result.append({
|
||||
'end_user': {
|
||||
'id': user_id,
|
||||
'other_name': end_user.other_name
|
||||
},
|
||||
'memory_num': memory_nums_map.get(user_id, {"total": 0}),
|
||||
'memory_config': {
|
||||
"memory_config_id": config_info.get("memory_config_id"),
|
||||
"memory_config_name": config_info.get("memory_config_name")
|
||||
}
|
||||
})
|
||||
|
||||
# 写入缓存(30秒过期)
|
||||
try:
|
||||
await aio_redis_set(cache_key, json.dumps(result), expire=30)
|
||||
except Exception as e:
|
||||
api_logger.warning(f"Redis 缓存写入失败: {str(e)}")
|
||||
|
||||
# 触发社区聚类补全任务(异步,不阻塞接口响应)
|
||||
try:
|
||||
@@ -236,8 +202,6 @@ async def get_workspace_end_users(
|
||||
except Exception as e:
|
||||
api_logger.warning(f"触发社区聚类补全任务失败(不影响主流程): {str(e)}")
|
||||
|
||||
total_elapsed = (time.perf_counter() - total_start) * 1000
|
||||
api_logger.info(f"[性能统计] 接口总耗时: {total_elapsed:.2f}ms, 用户数: {user_count}")
|
||||
api_logger.info(f"成功获取 {len(end_users)} 个宿主记录")
|
||||
return success(data=result, msg="宿主列表获取成功")
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export const getModelTypeList = async () => {
|
||||
return response as any[];
|
||||
};
|
||||
// 获取模型列表
|
||||
export const getModelList = async (types: string[], pageInfo: PageRequest) => {
|
||||
export const getModelList = async (pageInfo: PageRequest, types?: string[]) => {
|
||||
const response = await request.get(`${apiPrefix}/models`, { ...pageInfo, type: types?.join(','), is_active: true });
|
||||
return response as any;
|
||||
};
|
||||
|
||||
@@ -1508,7 +1508,7 @@ export const en = {
|
||||
EPISODIC_MEMORY: 'Episodic Memory',
|
||||
FORGET_MEMORY: 'Forget Memory',
|
||||
|
||||
endUserProfile: 'Profile',
|
||||
endUserProfile: 'Permanent Memory',
|
||||
editEndUserProfile: 'Edit',
|
||||
other_name: 'Name',
|
||||
position: 'Position',
|
||||
@@ -1827,6 +1827,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
memoryTipTitle: 'Are you sure you want to enable conversation memory? Conversations will be saved to the memory store.',
|
||||
stopAudioRecorder: 'Stop Recording',
|
||||
startAudioRecorder: 'Start Recording',
|
||||
citations: 'Citations',
|
||||
},
|
||||
login: {
|
||||
title: 'Red Bear Memory Science',
|
||||
|
||||
@@ -1506,7 +1506,7 @@ export const zh = {
|
||||
EPISODIC_MEMORY: '情景记忆',
|
||||
FORGET_MEMORY: '遗忘记忆',
|
||||
|
||||
endUserProfile: '核心档案',
|
||||
endUserProfile: '永久记忆',
|
||||
editEndUserProfile: '编辑',
|
||||
other_name: '名称',
|
||||
position: '职位',
|
||||
|
||||
@@ -162,7 +162,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
// If model data hasn't been fetched yet, fetch it once
|
||||
if (!models) {
|
||||
try {
|
||||
models = await getModelList({ page: 1, pagesize: 100 });
|
||||
models = await getModelList({ page: 1, pagesize: 100 }, ['llm', 'embedding', 'rerank', 'chat']);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch models:', error);
|
||||
models = { items: [] };
|
||||
|
||||
@@ -207,7 +207,7 @@ const KnowledgeBaseManagement: FC = () => {
|
||||
};
|
||||
const fetchModelList = async () => {
|
||||
try {
|
||||
const response = await getModelList(['llm', 'embedding', 'rerank', 'chat'], { page: 1, pagesize: 100 });
|
||||
const response = await getModelList({ page: 1, pagesize: 100 }, ['llm', 'embedding', 'rerank', 'chat']);
|
||||
// 缓存模型列表,建立 id -> name 的映射
|
||||
if (response?.items && Array.isArray(response.items)) {
|
||||
const cache: Record<string, string> = {};
|
||||
|
||||
185
web/src/views/Workflow/components/Editor/Jinja2Editor.tsx
Normal file
185
web/src/views/Workflow/components/Editor/Jinja2Editor.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-04-02 15:15:36
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-02 15:15:36
|
||||
*/
|
||||
import { type FC, useEffect, useMemo } from 'react';
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||
|
||||
import { type Suggestion } from './plugin/AutocompletePlugin';
|
||||
import CharacterCountPlugin from './plugin/CharacterCountPlugin';
|
||||
import Jinja2InitialValuePlugin from './plugin/Jinja2InitialValuePlugin';
|
||||
import Jinja2AutocompletePlugin from './plugin/Jinja2AutocompletePlugin';
|
||||
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
|
||||
import Jinja2BlurPlugin from './plugin/Jinja2BlurPlugin';
|
||||
import LineNumberPlugin from './plugin/LineNumberPlugin';
|
||||
|
||||
const jinja2Theme = {
|
||||
paragraph: 'editor-paragraph',
|
||||
code: 'jinja2-expression',
|
||||
text: {
|
||||
bold: 'editor-text-bold',
|
||||
italic: 'editor-text-italic',
|
||||
code: 'jinja2-inline',
|
||||
},
|
||||
};
|
||||
|
||||
const initialConfig = {
|
||||
namespace: 'AutocompleteEditor',
|
||||
theme: jinja2Theme,
|
||||
nodes: [],
|
||||
onError: (error: Error) => console.error(error),
|
||||
};
|
||||
|
||||
const STYLE_ID = 'code-editor-styles';
|
||||
const JINJA2_STYLES = `
|
||||
.jinja2-expression {
|
||||
background-color: #f6f8fa !important;
|
||||
border: 1px solid #d1d9e0 !important;
|
||||
border-radius: 3px !important;
|
||||
padding: 2px 4px !important;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
|
||||
font-size: 13px !important;
|
||||
color: #0969da !important;
|
||||
}
|
||||
.jinja2-inline {
|
||||
background-color: #f6f8fa !important;
|
||||
padding: 1px 3px !important;
|
||||
border-radius: 2px !important;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
|
||||
font-size: 13px !important;
|
||||
color: #0969da !important;
|
||||
}
|
||||
.editor-paragraph { margin: 0; }
|
||||
.editor-with-line-numbers { display: flex; }
|
||||
.line-numbers {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 4px 8px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.line-numbers > div { min-height: 20px; display: flex; align-items: flex-start; }
|
||||
.editor-content-wrapper { flex: 1; }
|
||||
.editor-content-with-numbers {
|
||||
white-space: pre-wrap;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
}
|
||||
.editor-content-with-numbers p { margin: 0; min-height: 20px; }
|
||||
`;
|
||||
|
||||
export interface Jinja2EditorProps {
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
options?: Suggestion[];
|
||||
variant?: 'outlined' | 'borderless';
|
||||
height?: number;
|
||||
size?: 'default' | 'small';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Jinja2Editor: FC<Jinja2EditorProps> = ({
|
||||
placeholder = '请输入内容...',
|
||||
value = '',
|
||||
onChange,
|
||||
options = [],
|
||||
variant = 'borderless',
|
||||
size = 'default',
|
||||
height,
|
||||
className,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = JINJA2_STYLES;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const minheight = useMemo(
|
||||
() => `${height ?? (size === 'small' ? 60 : 120)}px`,
|
||||
[height, size],
|
||||
);
|
||||
|
||||
const fontSize = size === 'small' ? '12px' : '14px';
|
||||
|
||||
const lineHeight = useMemo(
|
||||
() => `${height ? height - 10 : size === 'small' ? 16 : 20}px`,
|
||||
[height, size],
|
||||
);
|
||||
|
||||
const placeHolderMinheight = `${height ? 16 : size === 'small' ? 16 : 30}px`;
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div style={{ position: 'relative' }} className={className}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<div
|
||||
className="editor-with-line-numbers"
|
||||
style={{
|
||||
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
|
||||
borderRadius: '6px',
|
||||
minHeight: minheight,
|
||||
}}
|
||||
>
|
||||
<div className="line-numbers">
|
||||
<div>1</div>
|
||||
</div>
|
||||
<div className="editor-content-wrapper">
|
||||
<ContentEditable
|
||||
className="editor-content-with-numbers"
|
||||
style={{
|
||||
minHeight: minheight,
|
||||
padding: variant === 'borderless' ? '0' : '4px 0',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
fontSize,
|
||||
lineHeight,
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
placeholder={
|
||||
<div
|
||||
style={{
|
||||
minHeight: placeHolderMinheight,
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
left: '16px',
|
||||
color: '#A8A9AA',
|
||||
fontSize,
|
||||
lineHeight: placeHolderMinheight,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<Jinja2HighlightPlugin />
|
||||
<LineNumberPlugin />
|
||||
<Jinja2AutocompletePlugin options={options} />
|
||||
<CharacterCountPlugin setCount={() => {}} onChange={onChange} waitForInit />
|
||||
<Jinja2InitialValuePlugin value={value} />
|
||||
<Jinja2BlurPlugin />
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Jinja2Editor;
|
||||
@@ -4,26 +4,20 @@
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-25 10:58:47
|
||||
*/
|
||||
import { type FC, useState, useEffect, useMemo } from 'react';
|
||||
import { type FC, useState, useMemo } from 'react';
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||
// import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||
// import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
||||
// import { ListItemNode, ListNode } from '@lexical/list';
|
||||
// import { LinkNode } from '@lexical/link';
|
||||
// import { CodeNode } from '@lexical/code';
|
||||
|
||||
import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin'
|
||||
import CharacterCountPlugin from './plugin/CharacterCountPlugin'
|
||||
import InitialValuePlugin from './plugin/InitialValuePlugin';
|
||||
import CommandPlugin from './plugin/CommandPlugin';
|
||||
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
|
||||
import LineNumberPlugin from './plugin/LineNumberPlugin';
|
||||
import BlurPlugin from './plugin/BlurPlugin';
|
||||
import { VariableNode } from './nodes/VariableNode'
|
||||
import Jinja2Editor from './Jinja2Editor';
|
||||
|
||||
// Props interface for Lexical Editor component
|
||||
export interface LexicalEditorProps {
|
||||
@@ -39,6 +33,7 @@ export interface LexicalEditorProps {
|
||||
type?: 'input' | 'textarea';
|
||||
language?: 'string' | 'jinja2';
|
||||
className?: string;
|
||||
waitForInit?: boolean;
|
||||
}
|
||||
|
||||
// Default theme for editor
|
||||
@@ -50,16 +45,6 @@ const theme = {
|
||||
},
|
||||
};
|
||||
|
||||
// Theme with Jinja2 syntax highlighting
|
||||
const jinja2Theme = {
|
||||
...theme,
|
||||
code: 'jinja2-expression',
|
||||
text: {
|
||||
...theme.text,
|
||||
code: 'jinja2-inline',
|
||||
},
|
||||
};
|
||||
|
||||
// Main Lexical Editor component
|
||||
const Editor: FC<LexicalEditorProps> =({
|
||||
placeholder = "请输入内容...",
|
||||
@@ -71,100 +56,32 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
type = 'textarea',
|
||||
language = 'string',
|
||||
height,
|
||||
className
|
||||
className,
|
||||
waitForInit = false,
|
||||
}) => {
|
||||
console.log('Editor value', value)
|
||||
const [_count, setCount] = useState(0);
|
||||
const [enableJinja2, setEnableJinja2] = useState(false)
|
||||
const [enableLineNumbers, setEnableLineNumbers] = useState(false)
|
||||
|
||||
// Setup Jinja2 mode and inject styles when language changes
|
||||
useEffect(() => {
|
||||
const needsLineNumbers = language === 'jinja2';
|
||||
setEnableJinja2(language === 'jinja2');
|
||||
setEnableLineNumbers(needsLineNumbers);
|
||||
|
||||
if (needsLineNumbers) {
|
||||
const styleId = 'code-editor-styles';
|
||||
let existingStyle = document.getElementById(styleId);
|
||||
|
||||
if (!existingStyle) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.jinja2-expression {
|
||||
background-color: #f6f8fa !important;
|
||||
border: 1px solid #d1d9e0 !important;
|
||||
border-radius: 3px !important;
|
||||
padding: 2px 4px !important;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
|
||||
font-size: 13px !important;
|
||||
color: #0969da !important;
|
||||
}
|
||||
.jinja2-inline {
|
||||
background-color: #f6f8fa !important;
|
||||
padding: 1px 3px !important;
|
||||
border-radius: 2px !important;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
|
||||
font-size: 13px !important;
|
||||
color: #0969da !important;
|
||||
}
|
||||
.editor-paragraph {
|
||||
margin: 0;
|
||||
}
|
||||
.editor-paragraph:has-text('{') .editor-text,
|
||||
.editor-paragraph:has-text('[') .editor-text {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
|
||||
}
|
||||
.editor-with-line-numbers {
|
||||
display: flex;
|
||||
}
|
||||
.line-numbers {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 4px 8px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.line-numbers > div {
|
||||
min-height: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.editor-content-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
.editor-content-with-numbers {
|
||||
white-space: pre-wrap;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
}
|
||||
.editor-content-with-numbers p {
|
||||
margin: 0;
|
||||
min-height: 20px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
}, [language])
|
||||
if (language === 'jinja2') {
|
||||
return (
|
||||
<Jinja2Editor
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
variant={variant}
|
||||
size={size}
|
||||
height={height}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Lexical editor configuration
|
||||
const initialConfig = {
|
||||
namespace: 'AutocompleteEditor',
|
||||
theme: enableJinja2 ? jinja2Theme : theme,
|
||||
nodes: enableJinja2 ? [
|
||||
// When Jinja2 is enabled, use plain text instead of VariableNode
|
||||
] : [
|
||||
// HeadingNode,
|
||||
// QuoteNode,
|
||||
// ListItemNode,
|
||||
// ListNode,
|
||||
// LinkNode,
|
||||
// CodeNode,
|
||||
VariableNode,
|
||||
],
|
||||
theme,
|
||||
nodes: [VariableNode],
|
||||
onError: (error: Error) => {
|
||||
console.error(error);
|
||||
},
|
||||
@@ -198,54 +115,26 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
<div style={{ position: 'relative' }} className={className}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
enableLineNumbers ? (
|
||||
// Editor with line numbers for Jinja2 mode
|
||||
<div className="editor-with-line-numbers" style={{
|
||||
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
|
||||
borderRadius: '6px',
|
||||
<ContentEditable
|
||||
style={{
|
||||
minHeight: minheight,
|
||||
}}>
|
||||
<div className="line-numbers">
|
||||
<div>1</div>
|
||||
</div>
|
||||
<div className="editor-content-wrapper">
|
||||
<ContentEditable
|
||||
className="editor-content-with-numbers"
|
||||
style={{
|
||||
minHeight: minheight,
|
||||
padding: variant === 'borderless' ? '0' : '4px 0',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
fontSize: fontSize,
|
||||
lineHeight: lineHeight,
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Standard editor without line numbers
|
||||
<ContentEditable
|
||||
style={{
|
||||
minHeight: minheight,
|
||||
padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px',
|
||||
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB',
|
||||
borderRadius: '8px',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
fontSize: fontSize,
|
||||
lineHeight: lineHeight,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px',
|
||||
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB',
|
||||
borderRadius: '8px',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
fontSize: fontSize,
|
||||
lineHeight: lineHeight,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
<div
|
||||
style={{
|
||||
minHeight: placeHolderMinheight,
|
||||
position: 'absolute',
|
||||
top: enableLineNumbers ? '4px' : variant === 'borderless' ? '0' : '6px',
|
||||
left: enableLineNumbers ? '16px' : (variant === 'borderless' ? '0' : '11px'),
|
||||
top: variant === 'borderless' ? '0' : '6px',
|
||||
left: variant === 'borderless' ? '0' : '11px',
|
||||
color: '#A8A9AA',
|
||||
fontSize: fontSize,
|
||||
lineHeight: placeHolderMinheight,
|
||||
@@ -257,15 +146,12 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
{/* Editor plugins */}
|
||||
<HistoryPlugin />
|
||||
<CommandPlugin />
|
||||
{language === 'jinja2' && <Jinja2HighlightPlugin />}
|
||||
{enableLineNumbers && <LineNumberPlugin />}
|
||||
<AutocompletePlugin options={options} enableJinja2={enableJinja2} />
|
||||
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
|
||||
<InitialValuePlugin value={value} options={options} enableLineNumbers={enableLineNumbers} />
|
||||
<BlurPlugin enableJinja2={enableJinja2} />
|
||||
<AutocompletePlugin options={options} />
|
||||
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} waitForInit={waitForInit || !!value} />
|
||||
<InitialValuePlugin value={value} options={options} />
|
||||
<BlurPlugin />
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-23 16:22:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-25 16:13:37
|
||||
* @Last Modified time: 2026-04-02 17:12:41
|
||||
*/
|
||||
import { useEffect, useState, useRef, type FC } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
|
||||
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
|
||||
import { Space, Flex } from 'antd';
|
||||
|
||||
import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||
@@ -26,7 +26,7 @@ export interface Suggestion {
|
||||
}
|
||||
|
||||
// Autocomplete plugin for variable suggestions triggered by '/' character
|
||||
const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> = ({ options, enableJinja2 = false }) => {
|
||||
const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -129,34 +129,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
|
||||
// Insert selected suggestion into editor
|
||||
const insertMention = (suggestion: Suggestion) => {
|
||||
if (enableJinja2) {
|
||||
// In Jinja2 mode, insert {{variable}} format text
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const anchorOffset = selection.anchor.offset;
|
||||
const nodeText = anchorNode.getTextContent();
|
||||
|
||||
// Remove trigger character '/'
|
||||
const textBefore = nodeText.substring(0, anchorOffset - 1);
|
||||
const textAfter = nodeText.substring(anchorOffset);
|
||||
const newText = textBefore + `{{${suggestion.value}}}` + textAfter;
|
||||
|
||||
if ($isTextNode(anchorNode)) {
|
||||
anchorNode.setTextContent(newText);
|
||||
}
|
||||
|
||||
// Set cursor position after inserted text
|
||||
const newOffset = textBefore.length + `{{${suggestion.value}}}`.length;
|
||||
selection.anchor.offset = newOffset;
|
||||
selection.focus.offset = newOffset;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// In normal mode, use VariableNode
|
||||
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
|
||||
}
|
||||
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,64 +1,33 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-01-20 10:42:13
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 10:12:10
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-02 17:13:08
|
||||
*/
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { useEffect } from 'react';
|
||||
import { $setSelection } from 'lexical';
|
||||
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||
|
||||
// Plugin to handle blur events and close autocomplete when clicking outside
|
||||
export default function BlurPlugin({ enableJinja2 }: { enableJinja2: boolean }) {
|
||||
export default function BlurPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
// Close autocomplete when clicking outside the popup
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target?.closest('[data-autocomplete-popup="true"]')) {
|
||||
return;
|
||||
}
|
||||
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
|
||||
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return editor.registerRootListener((rootElement) => {
|
||||
if (rootElement) {
|
||||
const handleBlur = (e: FocusEvent) => {
|
||||
if (enableJinja2) {
|
||||
// Check if autocomplete popup was clicked
|
||||
const target = e.target as HTMLElement;
|
||||
if (target?.closest('[data-autocomplete-popup="true"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if blur was caused by paste operation
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!relatedTarget || relatedTarget === document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear selection on blur
|
||||
editor.update(() => {
|
||||
$setSelection(null);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
rootElement.addEventListener('blur', handleBlur);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
rootElement.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
return () => { document.removeEventListener('mousedown', handleClickOutside); };
|
||||
});
|
||||
}, [editor, enableJinja2]);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,73 @@
|
||||
import { useEffect } from 'react';
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-23 16:22:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-02 17:13:45
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { $getRoot, $isParagraphNode } from 'lexical';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
|
||||
import { $isVariableNode } from '../nodes/VariableNode';
|
||||
|
||||
const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => {
|
||||
const serialize = (root: ReturnType<typeof $getRoot>): string => {
|
||||
const paragraphs: string[] = [];
|
||||
root.getChildren().forEach(child => {
|
||||
if ($isParagraphNode(child)) {
|
||||
let content = '';
|
||||
child.getChildren().forEach(node => {
|
||||
content += $isVariableNode(node) ? node.getTextContent() : node.getTextContent();
|
||||
});
|
||||
paragraphs.push(content);
|
||||
}
|
||||
});
|
||||
return paragraphs.join('\n');
|
||||
};
|
||||
|
||||
const CharacterCountPlugin = ({
|
||||
setCount,
|
||||
onChange,
|
||||
waitForInit = false,
|
||||
}: {
|
||||
setCount: (count: number) => void;
|
||||
onChange?: (value: string) => void;
|
||||
waitForInit?: boolean;
|
||||
}) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
// lastProgrammaticValue tracks what InitialValuePlugin wrote, so we can
|
||||
// suppress onChange when the content hasn't actually changed from that value.
|
||||
const lastProgrammaticValueRef = useRef<string | null>(null);
|
||||
const isReadyRef = useRef(!waitForInit);
|
||||
const isFirstUpdateRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const root = $getRoot();
|
||||
let serializedContent = '';
|
||||
|
||||
// Traverse all nodes and serialize properly
|
||||
const paragraphs: string[] = [];
|
||||
root.getChildren().forEach(child => {
|
||||
if ($isParagraphNode(child)) {
|
||||
let paragraphContent = '';
|
||||
child.getChildren().forEach(node => {
|
||||
if ($isVariableNode(node)) {
|
||||
paragraphContent += node.getTextContent();
|
||||
} else {
|
||||
paragraphContent += node.getTextContent();
|
||||
}
|
||||
});
|
||||
paragraphs.push(paragraphContent);
|
||||
}
|
||||
return editor.registerUpdateListener(({ editorState, tags }) => {
|
||||
if (tags.has('programmatic')) {
|
||||
isReadyRef.current = true;
|
||||
isFirstUpdateRef.current = false;
|
||||
editorState.read(() => {
|
||||
lastProgrammaticValueRef.current = serialize($getRoot());
|
||||
});
|
||||
|
||||
serializedContent = paragraphs.join('\n');
|
||||
|
||||
setCount(serializedContent.length);
|
||||
onChange?.(serializedContent);
|
||||
return;
|
||||
}
|
||||
if (!isReadyRef.current) return;
|
||||
editorState.read(() => {
|
||||
const content = serialize($getRoot());
|
||||
// Skip the first update if content is empty (editor initial render)
|
||||
if (isFirstUpdateRef.current) {
|
||||
isFirstUpdateRef.current = false;
|
||||
if (content === '') return;
|
||||
}
|
||||
// Skip if content is identical to what was programmatically written
|
||||
if (content === lastProgrammaticValueRef.current) return;
|
||||
lastProgrammaticValueRef.current = null;
|
||||
setCount(content.length);
|
||||
onChange?.(content);
|
||||
});
|
||||
});
|
||||
}, [editor, setCount, onChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default CharacterCountPlugin
|
||||
export default CharacterCountPlugin;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-23 16:22:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-02 17:14:15
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
|
||||
@@ -8,19 +14,17 @@ import { type Suggestion } from '../plugin/AutocompletePlugin'
|
||||
interface InitialValuePluginProps {
|
||||
value: string;
|
||||
options?: Suggestion[];
|
||||
enableLineNumbers?: boolean;
|
||||
}
|
||||
|
||||
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [], enableLineNumbers = false }) => {
|
||||
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [] }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const prevValueRef = useRef<string>('');
|
||||
const prevEnableLineNumbersRef = useRef<boolean>(enableLineNumbers);
|
||||
const isUserInputRef = useRef(false);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = editor.registerUpdateListener(({ editorState, tags }) => {
|
||||
return editor.registerUpdateListener(({ editorState, tags }) => {
|
||||
if (tags.has('programmatic')) return;
|
||||
editorState.read(() => {
|
||||
const root = $getRoot();
|
||||
@@ -31,21 +35,16 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return removeListener;
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) {
|
||||
// Skip reset if the change was triggered by user input (avoid cursor jump)
|
||||
if (isUserInputRef.current && enableLineNumbers === prevEnableLineNumbersRef.current) {
|
||||
if (value !== prevValueRef.current) {
|
||||
if (isUserInputRef.current) {
|
||||
prevValueRef.current = value;
|
||||
isUserInputRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Update refs BEFORE editor.update to prevent re-entry
|
||||
prevValueRef.current = value;
|
||||
prevEnableLineNumbersRef.current = enableLineNumbers;
|
||||
isUserInputRef.current = false;
|
||||
|
||||
queueMicrotask(() => {
|
||||
@@ -54,16 +53,7 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
||||
root.clear();
|
||||
|
||||
const parts = value.split(/(\{\{[^}]+\}\}|\n)/);
|
||||
|
||||
if (enableLineNumbers) {
|
||||
const lines = value.split('\n');
|
||||
lines.forEach((line) => {
|
||||
const paragraph = $createParagraphNode();
|
||||
paragraph.append($createTextNode(line));
|
||||
root.append(paragraph);
|
||||
});
|
||||
} else {
|
||||
let paragraph = $createParagraphNode();
|
||||
let paragraph = $createParagraphNode();
|
||||
|
||||
parts.forEach(part => {
|
||||
if (part === '\n') {
|
||||
@@ -118,15 +108,10 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
||||
}
|
||||
});
|
||||
root.append(paragraph);
|
||||
}
|
||||
}, { tag: 'programmatic' });
|
||||
});
|
||||
} else {
|
||||
prevValueRef.current = value;
|
||||
prevEnableLineNumbersRef.current = enableLineNumbers;
|
||||
isUserInputRef.current = false;
|
||||
}
|
||||
}, [value, editor, enableLineNumbers]);
|
||||
}, [value, editor]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-04-02 17:10:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-02 17:10:59
|
||||
*/
|
||||
import { useEffect, useState, useRef, type FC } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
$getSelection, $isRangeSelection, $isTextNode,
|
||||
COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND,
|
||||
KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND,
|
||||
} from 'lexical';
|
||||
import { Space, Flex } from 'antd';
|
||||
|
||||
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||
import type { Suggestion } from './AutocompletePlugin';
|
||||
|
||||
const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollSelectedIntoView = () => {
|
||||
if (!popupRef.current) return;
|
||||
const selectedElement = popupRef.current.querySelector('[data-selected="true"]');
|
||||
if (!selectedElement) return;
|
||||
const container = popupRef.current;
|
||||
const element = selectedElement as HTMLElement;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
if (elementRect.bottom > containerRect.bottom) {
|
||||
container.scrollTop += elementRect.bottom - containerRect.bottom;
|
||||
} else if (elementRect.top < containerRect.top) {
|
||||
container.scrollTop -= containerRect.top - elementRect.top;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection();
|
||||
if (!selection || !$isRangeSelection(selection)) {
|
||||
setShowSuggestions(false);
|
||||
return;
|
||||
}
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const anchorOffset = selection.anchor.offset;
|
||||
const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset);
|
||||
const shouldShow = textBeforeCursor.endsWith('/');
|
||||
setShowSuggestions(shouldShow);
|
||||
if (!shouldShow) { setSelectedIndex(0); return; }
|
||||
|
||||
const domSelection = window.getSelection();
|
||||
if (domSelection && domSelection.rangeCount > 0) {
|
||||
const rect = domSelection.getRangeAt(0).getBoundingClientRect();
|
||||
const popupWidth = 280, popupHeight = 200;
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
let left = Math.min(Math.max(rect.left, 10), vw - popupWidth - 10);
|
||||
let top = rect.top - 10;
|
||||
if (top - popupHeight < 10) {
|
||||
top = Math.min(rect.bottom + 10, vh - popupHeight - 10);
|
||||
}
|
||||
setPopupPosition({ top, left });
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
CLOSE_AUTOCOMPLETE_COMMAND,
|
||||
() => { setShowSuggestions(false); return true; },
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
const insertMention = (suggestion: Suggestion) => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if (!$isRangeSelection(selection)) return;
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const anchorOffset = selection.anchor.offset;
|
||||
const nodeText = anchorNode.getTextContent();
|
||||
const textBefore = nodeText.substring(0, anchorOffset - 1);
|
||||
const textAfter = nodeText.substring(anchorOffset);
|
||||
const inserted = `{{${suggestion.value}}}`;
|
||||
if ($isTextNode(anchorNode)) {
|
||||
anchorNode.setTextContent(textBefore + inserted + textAfter);
|
||||
const newOffset = textBefore.length + inserted.length;
|
||||
selection.anchor.offset = newOffset;
|
||||
selection.focus.offset = newOffset;
|
||||
}
|
||||
});
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => {
|
||||
const id = s.nodeData.id as string;
|
||||
if (!groups[id]) groups[id] = [];
|
||||
groups[id].push(s);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
const allOptions = Object.values(groupedSuggestions).flat();
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
return editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
const opt = allOptions[selectedIndex];
|
||||
if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; }
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
);
|
||||
}, [showSuggestions, selectedIndex, allOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
const down = editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => {
|
||||
e?.preventDefault();
|
||||
setSelectedIndex(prev => {
|
||||
let next = prev + 1;
|
||||
while (next < allOptions.length && allOptions[next].disabled) next++;
|
||||
setTimeout(scrollSelectedIntoView, 0);
|
||||
return next >= allOptions.length ? prev : next;
|
||||
});
|
||||
return true;
|
||||
}, COMMAND_PRIORITY_HIGH);
|
||||
const up = editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => {
|
||||
e?.preventDefault();
|
||||
setSelectedIndex(prev => {
|
||||
let p = prev - 1;
|
||||
while (p >= 0 && allOptions[p].disabled) p--;
|
||||
setTimeout(scrollSelectedIntoView, 0);
|
||||
return p < 0 ? prev : p;
|
||||
});
|
||||
return true;
|
||||
}, COMMAND_PRIORITY_HIGH);
|
||||
const esc = editor.registerCommand(KEY_ESCAPE_COMMAND, (e) => {
|
||||
e?.preventDefault(); setShowSuggestions(false); return true;
|
||||
}, COMMAND_PRIORITY_HIGH);
|
||||
return () => { down(); up(); esc(); };
|
||||
}, [showSuggestions, selectedIndex, allOptions, editor]);
|
||||
|
||||
if (!showSuggestions || Object.keys(groupedSuggestions).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
data-autocomplete-popup="true"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="rb:fixed rb:z-1000 rb:py-1 rb:bg-white rb:rounded-xl rb:min-w-70 rb:max-h-50 rb:overflow-y-auto rb:transform-[translateY(-100%)] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
||||
style={{ top: popupPosition.top, left: popupPosition.left }}
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => (
|
||||
<div key={nodeId}>
|
||||
<Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]">
|
||||
{nodeOptions[0]?.nodeData?.icon && <img src={nodeOptions[0].nodeData.icon} className="rb:size-3" alt="" />}
|
||||
{nodeOptions[0]?.nodeData?.name || nodeId}
|
||||
</Flex>
|
||||
{nodeOptions.map((option) => {
|
||||
const globalIndex = allOptions.indexOf(option);
|
||||
return (
|
||||
<Flex
|
||||
key={option.key}
|
||||
data-selected={selectedIndex === globalIndex}
|
||||
className="rb:pl-6! rb:pr-3! rb:py-2!"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
|
||||
opacity: option.disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !option.disabled && insertMention(option)}
|
||||
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
||||
>
|
||||
<Space size={4}>
|
||||
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : '{x}'}</span>
|
||||
<span>{option.label}</span>
|
||||
</Space>
|
||||
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Jinja2AutocompletePlugin;
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-04-02 17:11:04
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-02 17:11:04
|
||||
*/
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { useEffect } from 'react';
|
||||
import { $setSelection } from 'lexical';
|
||||
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||
|
||||
export default function Jinja2BlurPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
|
||||
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return editor.registerRootListener((rootElement) => {
|
||||
if (rootElement) {
|
||||
const handleBlur = (e: FocusEvent) => {
|
||||
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!relatedTarget || relatedTarget === document.body) return;
|
||||
editor.update(() => { $setSelection(null); });
|
||||
};
|
||||
rootElement.addEventListener('blur', handleBlur);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
rootElement.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}
|
||||
return () => { document.removeEventListener('mousedown', handleClickOutside); };
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-04-02 17:11:07
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-04-02 17:11:07
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
|
||||
|
||||
interface Jinja2InitialValuePluginProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const Jinja2InitialValuePlugin: React.FC<Jinja2InitialValuePluginProps> = ({ value }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const prevValueRef = useRef<string>('');
|
||||
const isUserInputRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState, tags }) => {
|
||||
if (tags.has('programmatic')) return;
|
||||
editorState.read(() => {
|
||||
const textContent = $getRoot().getTextContent();
|
||||
if (textContent !== prevValueRef.current) {
|
||||
isUserInputRef.current = true;
|
||||
prevValueRef.current = textContent;
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === prevValueRef.current) return;
|
||||
|
||||
if (isUserInputRef.current) {
|
||||
prevValueRef.current = value;
|
||||
isUserInputRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
prevValueRef.current = value;
|
||||
isUserInputRef.current = false;
|
||||
|
||||
queueMicrotask(() => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
root.clear();
|
||||
value.split('\n').forEach((line) => {
|
||||
const paragraph = $createParagraphNode();
|
||||
paragraph.append($createTextNode(line));
|
||||
root.append(paragraph);
|
||||
});
|
||||
}, { tag: 'programmatic' });
|
||||
});
|
||||
}, [value, editor]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Jinja2InitialValuePlugin;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-09 18:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-20 11:32:44
|
||||
* @Last Modified time: 2026-04-02 17:17:06
|
||||
*/
|
||||
import { type FC, useRef, useState } from "react";
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -114,6 +114,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
|
||||
<Col span={16}>
|
||||
<Form.Item name="url">
|
||||
<Editor
|
||||
key="url"
|
||||
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')}
|
||||
variant="outlined"
|
||||
type="input"
|
||||
@@ -212,13 +213,15 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
|
||||
}
|
||||
{values?.body?.content_type === 'binary' &&
|
||||
<Form.Item name={['body', 'data']}
|
||||
className="rb:bg-[#F6F6F6] rb:border-[#F6F6F6]! rb:hover:bg-white rb:hover:border-[#171719]! rb:border rb:rounded-lg rb:px-2! rb:py-1.5! rb:mb-0!"
|
||||
className="rb:bg-[#F6F6F6] rb:border-[#F6F6F6]! rb:hover:bg-white rb:hover:border-[#171719]! rb:border rb:rounded-lg rb:mb-0!"
|
||||
>
|
||||
<Editor
|
||||
key={['body', 'data'].join('_')}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options.filter(vo => vo.dataType.includes('file'))}
|
||||
type="input"
|
||||
size="small"
|
||||
height={28}
|
||||
/>
|
||||
</Form.Item>
|
||||
}
|
||||
|
||||
@@ -490,32 +490,36 @@ export const useWorkflowGraph = ({
|
||||
* @param node - Clicked node
|
||||
*/
|
||||
const nodeClick = ({ node }: { node: Node }) => {
|
||||
// Ignore add-node type node clicks
|
||||
const nodeData = node.getData()
|
||||
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
|
||||
setSelectedNode(null)
|
||||
return;
|
||||
}
|
||||
blankClick()
|
||||
|
||||
const nodes = graphRef.current?.getNodes();
|
||||
|
||||
nodes?.forEach(vo => {
|
||||
const data = vo.getData();
|
||||
if (data.isSelected) {
|
||||
vo.setData({
|
||||
...data,
|
||||
isSelected: false,
|
||||
});
|
||||
setTimeout(() => {
|
||||
// Ignore add-node type node clicks
|
||||
const nodeData = node.getData()
|
||||
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
|
||||
setSelectedNode(null)
|
||||
return;
|
||||
}
|
||||
});
|
||||
node.setData({
|
||||
...nodeData,
|
||||
isSelected: true,
|
||||
});
|
||||
clearEdgeSelect()
|
||||
if (nodeData.type !== 'notes') {
|
||||
setSelectedNode(node);
|
||||
}
|
||||
|
||||
const nodes = graphRef.current?.getNodes();
|
||||
|
||||
nodes?.forEach(vo => {
|
||||
const data = vo.getData();
|
||||
if (data.isSelected) {
|
||||
vo.setData({
|
||||
...data,
|
||||
isSelected: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
node.setData({
|
||||
...nodeData,
|
||||
isSelected: true,
|
||||
});
|
||||
clearEdgeSelect()
|
||||
if (nodeData.type !== 'notes') {
|
||||
setSelectedNode(node);
|
||||
}
|
||||
}, 0)
|
||||
};
|
||||
/**
|
||||
* Handle edge click event
|
||||
|
||||
Reference in New Issue
Block a user