feat: ai-service ui

This commit is contained in:
maggieyyy
2024-09-26 21:01:51 +08:00
parent 259a1b9566
commit 096adebb48
85 changed files with 7153 additions and 233 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
"license": "ISC",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@ant-design/pro-components": "2.7.9",
"@ant-design/pro-components": "2.7.19",
"@originjs/vite-plugin-federation": "^1.3.3",
"@rollup/plugin-dynamic-import-vars": "^2.1.2",
"@types/lodash-es": "^4.17.12",
+10 -1
View File
@@ -8,17 +8,26 @@
"test": "node ./__tests__/common.test.js"
},
"dependencies": {
"@floating-ui/react": "^0.26.24",
"@formkit/auto-animate": "^0.8.1",
"@lexical/code": "^0.17.1",
"@lexical/react": "^0.17.1",
"@lexical/selection": "^0.17.1",
"@lexical/text": "^0.17.1",
"@lexical/utils": "^0.17.1",
"@mui/icons-material": "^5.15.6",
"@mui/lab": "5.0.0-alpha.150",
"@mui/material": "5.14.14",
"@mui/x-data-grid-pro": "6.18.1",
"ahooks": "^3.8.1",
"allotment": "^1.20.0",
"echarts": "^5.5.0",
"lexical": "^0.17.1",
"mockjs": "^1.1.0",
"rc-picker": "^4.1.1",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.49.3"
"react-hook-form": "^7.49.3",
"use-context-selector": "^2.0.0"
},
"devDependencies": {
"@formily/antd-v5": "^1.2.1",
@@ -46,13 +46,14 @@ const themeToken = {
const mainPage = project === 'core' ?'/service/list':'/serviceHub/list'
const TOTAL_MENU_ITEMS:MenuProps['items'] = useMemo(() => [
getNavItem($t('工作空间'), 'workspace','/guide',<Icon icon="ic:baseline-space-dashboard" width="18" height="18"/>, [
getNavItem($t('我的'), 'my','/guide',null,[
getNavItem(<a>{$t('首页')}</a>, 'guide','/guide',<Icon icon="ic:baseline-home" width="18" height="18"/>,undefined,undefined,''),
getNavItem(<a>{$t('应用')}</a>, 'tenantManagement','/tenantManagement',<Icon icon="ic:baseline-apps" width="18" height="18"/>,undefined,undefined,''),
getNavItem(<a>{$t('服务')}</a>, 'service','/service',<Icon icon="ic:baseline-blinds-closed" width="18" height="18"/>,undefined,undefined,''),
getNavItem(<a>{$t('团队')}</a>, 'team','/team',<Icon icon="ic:baseline-people-alt" width="18" height="18"/>,undefined,undefined,''),
],undefined,''),
getNavItem($t('工作空间'), 'workspace','/guide/page',<Icon icon="ic:baseline-space-dashboard" width="18" height="18"/>, [
getNavItem(<a>{$t('首页')}</a>, 'guide','/guide/page',<Icon icon="ic:baseline-home" width="18" height="18"/>,undefined,undefined,'all'),
getNavItem(<a>{$t('应用')}</a>, 'tenantManagement','/tenantManagement',<Icon icon="ic:baseline-apps" width="18" height="18"/>,undefined,undefined,'all'),
getNavItem(<a>{$t('团队')}</a>, 'team','/team',<Icon icon="ic:baseline-people-alt" width="18" height="18"/>,undefined,undefined,'all'),
getNavItem($t('服务'), 'my','/service',null,[
getNavItem(<a>{$t('REST 服务')}</a>, 'service','/service',<Icon icon="ic:baseline-blinds-closed" width="18" height="18"/>,undefined,undefined,''),
getNavItem(<a>{$t('AI 服务')}</a>, 'aiservice','/aiservice',<Icon icon="eos-icons:ai" width="18" height="18"/>,undefined,undefined,''),
],undefined,''),
]),
getNavItem(<a>{$t('API 市场')}</a>, 'serviceHub','/serviceHub',<Icon icon="ic:baseline-hub" width="18" height="18"/>,undefined,undefined,'system.workspace.api_market.view'),
@@ -72,6 +73,7 @@ const themeToken = {
getNavItem($t('运维与集成'), 'maintenanceCenter','/cluster', null, [
getNavItem(<a>{$t('集群')}</a>, 'cluster','/cluster',<Icon icon="ic:baseline-device-hub" width="18" height="18"/>,undefined,undefined,'system.devops.cluster.view'),
getNavItem(<a>{$t('AI 配置')}</a>, 'aisetting','/aisetting',<Icon icon="hugeicons:ai-network" width="18" height="18"/>,undefined,undefined,'system.devops.cluster.view'),
getNavItem(<a>{$t('数据源')}</a>, 'datasourcing','/datasourcing',<Icon icon="ic:baseline-monitor-heart" width="18" height="18"/>,undefined,undefined,'system.devops.data_source.view'),
getNavItem(<a>{$t('证书')}</a>, 'cert','/cert',<Icon icon="ic:baseline-security" width="18" height="18"/>,undefined,undefined,'system.devops.ssl_certificate.view'),
getNavItem(<a>{$t('日志')}</a>, 'logsettings','/logsettings',<Icon icon="ic:baseline-sticky-note-2" width="18" height="18"/>,undefined,undefined,'system.devops.log_configuration.view'),
@@ -109,7 +111,7 @@ const themeToken = {
}
// 处理没有 routes 的菜单项
if (item.access) {
return hasAccess(item.access) ? item : null;
return (item.access === 'all' || hasAccess(item.access)) ? item : null;
}
// 如果没有 access 和 routes,则保留
@@ -0,0 +1,105 @@
import { EditableFormInstance, EditableProTable } from "@ant-design/pro-components";
import { useState, useEffect, useMemo, useRef, MutableRefObject } from "react";
import { v4 as uuidv4} from 'uuid';
import { PageProColumns } from "./PageList";
import TableBtnWithPermission from "./TableBtnWithPermission";
import { $t } from "@common/locales";
import { useGlobalContext } from "@common/contexts/GlobalStateContext";
import { Form } from "antd";
import { debounce } from "lodash-es";
interface EditableTableProps<T> {
configFields: PageProColumns<T>[];
value?: T[]; // 外部传入的值
className?: string;
onChange?: (newConfigItems: T[]) => void; // 当配置项变化时,外部传入的回调函数
// tableProps?: TableProps<T>;
disabled?:boolean
getFromRef?:(form:MutableRefObject<EditableFormInstance<T> | undefined>)=>void
}
const EditableTableNotAutoGen = <T extends { _id: string }>({
configFields,
value, // value 现在是外部传入的配置项数组
onChange, // onChange 现在是当配置项数组变化时的回调函数
// tableProps,
disabled,
className,
getFromRef
}: EditableTableProps<T>) => {
const [configurations, setConfigurations] = useState<(T | {_id:string})[]>(value ||[{_id:'1234'}]);
const {state} = useGlobalContext()
const form =useRef<EditableFormInstance<T>>();
const [tableForm] = Form.useForm();
const [editableKeys, setEditableRowKeys] = useState<React.Key[]>(() =>
value?.map((item) => item._id) || ['1234']
);
useEffect(()=>{
getFromRef?.(form)
},[form])
useEffect(() => {
const newValue = value?.map((x)=>x._id ? x : {...x,_id:uuidv4()}) || [{_id:uuidv4()}]
setConfigurations(newValue);
setTimeout(()=>validateForm(),1000)
}, [value]);
const validateForm = async ()=>{
await tableForm.validateFields();
}
const translatedColumns = useMemo(()=>configFields.map((x)=>(
{...x,
title:$t(x.title as string),
formItemProps:{
...(x. formItemProps || {}),
rules:[...(x.formItemProps?.rules || []).map((r:Record<string, string>)=>{
if(r.message){
r.message = $t(r.message)
}
return r
})],
}})),[state.language,configFields])
const debouncedOnChange = useMemo(() => debounce((value) => {
onChange?.(value);
}, 500), [onChange]);
return (
<EditableProTable<T>
className={className}
columns={translatedColumns}
onChange={debouncedOnChange}
controlled={true}
rowKey="_id"
value={configurations as T[]}
size="small"
editableFormRef={form}
bordered={true}
recordCreatorProps={false}
editable={ {
type: 'multiple',
form: tableForm,
// errorType:'default',
editableKeys:disabled ? [] : configurations?.map(x=>x._id),
actionRender: (row, config) => {
return [
<TableBtnWithPermission key="delete" btnType="delete" btnTitle="删除"
onClick={() => {
setConfigurations((prev)=>{
const tmpPreData = [...prev];
tmpPreData.splice(Number(config.index), 1);
onChange?.(tmpPreData);
return tmpPreData});
setEditableRowKeys((prev)=>(prev.filter(x=>x !== config._id)))
}}/>,
];
},
onChange: setEditableRowKeys
}}
/>
)
}
export default EditableTableNotAutoGen;
@@ -24,9 +24,10 @@ class InsidePageProps {
headerClassName?:string=''
/** 整个页面滚动 */
scrollPage?:boolean = true
customBtn?:ReactNode
}
const InsidePage:FC<InsidePageProps> = ({showBanner=true,pageTitle,tagList,showBtn,btnTitle,btnAccess,description,children,onBtnClick,backUrl,showBorder=true,className='',contentClassName='',headerClassName='',scrollPage=true})=>{
const InsidePage:FC<InsidePageProps> = ({showBanner=true,pageTitle,tagList,showBtn,btnTitle,btnAccess,description,children,onBtnClick,backUrl,showBorder=true,className='',contentClassName='',headerClassName='',scrollPage=true,customBtn})=>{
const navigate = useNavigate();
const goBack = () => {
@@ -50,6 +51,7 @@ const InsidePage:FC<InsidePageProps> = ({showBanner=true,pageTitle,tagList,showB
{showBtn && <WithPermission access={btnAccess}><Button type="primary" onClick={()=> {
onBtnClick&&onBtnClick()
}}>{btnTitle}</Button></WithPermission>}
{customBtn}
</div>
<p >
{description}
@@ -2,7 +2,7 @@ import {App, Col, Form, Input, Row, Table, Tooltip} from "antd";
import {forwardRef, useEffect, useImperativeHandle, useMemo} from "react";
import {PublishApprovalInfoType, PublishApprovalModalHandle, PublishApprovalModalProps, PublishVersionTableListItem} from "@common/const/approval/type.tsx";
import {useFetch} from "@common/hooks/http.ts";
import {BasicResponse, FORM_ERROR_TIPS, PLACEHOLDER, RESPONSE_TIPS, STATUS_CODE, STATUS_COLOR, VALIDATE_MESSAGE} from "@common/const/const.tsx";
import {BasicResponse, FORM_ERROR_TIPS, PLACEHOLDER, RESPONSE_TIPS, STATUS_CODE, STATUS_COLOR} from "@common/const/const.tsx";
import WithPermission from "@common/components/aoplatform/WithPermission.tsx";
import { SYSTEM_PUBLISH_ONLINE_COLUMNS } from "@core/const/system/const.tsx";
import { $t } from "@common/locales";
@@ -14,7 +14,7 @@ import { SystemInsidePublishOnlineItems } from "@core/pages/system/publish/Syste
export const PublishApprovalModalContent = forwardRef<PublishApprovalModalHandle,PublishApprovalModalProps>((props, ref) => {
const { message } = App.useApp()
const { type,data,insideSystem = false,serviceId, teamId} = props
const { type,data,insidePage = false, serviceType = 'rest', serviceId, teamId} = props
const [form] = Form.useForm();
const {fetchData} = useFetch()
const {state} = useGlobalContext()
@@ -109,7 +109,7 @@ export const PublishApprovalModalContent = forwardRef<PublishApprovalModalHandle
})),[state.language])
const translatedRouteColumns = useMemo(()=>ApprovalRouteColumns.map((x)=>({
const translatedRouteColumns = useMemo(()=>ApprovalRouteColumns.filter(x=> serviceType === 'rest' ? x.dataIndex !== 'name' : x.dataIndex !== 'methods').map((x)=>({
...x,
...(x.dataIndex === 'change' ? {
render:(_,entity)=>(
@@ -122,7 +122,7 @@ export const PublishApprovalModalContent = forwardRef<PublishApprovalModalHandle
}:{}
),
title: typeof x.title === 'string' ? $t(x.title) : x.title,
})),[state.language])
})),[state.language, serviceType])
const translatedPublishColumns = useMemo(()=>SYSTEM_PUBLISH_ONLINE_COLUMNS.map((x)=>{
if(x.dataIndex === 'status'){
@@ -142,7 +142,7 @@ export const PublishApprovalModalContent = forwardRef<PublishApprovalModalHandle
return (
<>
{!insideSystem && <>
{!insidePage && <>
<Row className="my-mbase">
<Col className="text-left" span={4}><span >{$t('申请系统')}</span></Col>
<Col span={18}>{(data as PublishApprovalInfoType).project || '-'}</Col>
@@ -177,7 +177,7 @@ export const PublishApprovalModalContent = forwardRef<PublishApprovalModalHandle
>
{
insideSystem &&
insidePage &&
<>
<Form.Item
label={$t("版本号")}
@@ -205,16 +205,20 @@ export const PublishApprovalModalContent = forwardRef<PublishApprovalModalHandle
dataSource={data.diffs?.routers || []}
pagination={false}
/></Row>
<Row className="mt-mbase pb-[8px] h-[32px] font-bold" ><span >{$t('上游列表')}</span></Row>
<Row className="mb-mbase ">
<Table
bordered={true}
columns={translatedUpstreamColumns}
size="small"
rowKey="id"
dataSource={data.diffs?.upstreams || []}
pagination={false}
/></Row>
{
serviceType === 'rest' && <>
<Row className="mt-mbase pb-[8px] h-[32px] font-bold" ><span >{$t('上游列表')}</span></Row>
<Row className="mb-mbase ">
<Table
bordered={true}
columns={translatedUpstreamColumns}
size="small"
rowKey="id"
dataSource={data.diffs?.upstreams || []}
pagination={false}
/></Row>
</>
}
<Form.Item
label={$t("备注")}
name="remark"
@@ -8,6 +8,8 @@ export const TranslateWord = ()=>{
{$t('替换文件')}
{$t('是否放行')}
{$t('监控')}
{$t('必填')}
{$t('字符非法,仅支持英文')}
</>
)
}
@@ -0,0 +1,221 @@
'use client'
import type { FC } from 'react'
import { useEffect, useMemo } from 'react'
import type {
EditorState,
} from 'lexical'
import {
$getRoot,
TextNode,
} from 'lexical'
import { CodeNode } from '@lexical/code'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
// import TreeView from './plugins/tree-view'
import Placeholder from './plugins/placeholder'
import ComponentPickerBlock from './plugins/component-picker-block/index'
import {
ContextBlock,
ContextBlockNode,
ContextBlockReplacementBlock,
} from './plugins/context-block/index'
import {
QueryBlock,
QueryBlockNode,
QueryBlockReplacementBlock,
} from './plugins/query-block/index'
import {
HistoryBlock,
HistoryBlockNode,
HistoryBlockReplacementBlock,
} from './plugins/history-block/index'
import {
WorkflowVariableBlock,
WorkflowVariableBlockNode,
WorkflowVariableBlockReplacementBlock,
} from './plugins/workflow-variable-block/index'
import VariableBlock from './plugins/variable-block/index'
import VariableValueBlock from './plugins/variable-value-block/index'
import { VariableValueBlockNode } from './plugins/variable-value-block/node'
import { CustomTextNode } from './plugins/custom-text/node'
import OnBlurBlock from './plugins/on-blur-or-focus-block'
import UpdateBlock from './plugins/update-block'
import { textToEditorState } from './utils'
import type {
ContextBlockType,
ExternalToolBlockType,
HistoryBlockType,
QueryBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from './types'
import {
UPDATE_DATASETS_EVENT_EMITTER,
UPDATE_HISTORY_EVENT_EMITTER,
} from './constants'
import { useEventEmitterContextContext } from '@common/contexts/event-emitter'
export type PromptEditorProps = {
instanceId?: string
compact?: boolean
className?: string
placeholder?: string
placeholderClassName?: string
style?: React.CSSProperties
value?: string
editable?: boolean
onChange?: (text: string) => void
onBlur?: () => void
onFocus?: () => void
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
historyBlock?: HistoryBlockType
variableBlock?: VariableBlockType
externalToolBlock?: ExternalToolBlockType
workflowVariableBlock?: WorkflowVariableBlockType
}
const PromptEditor: FC<PromptEditorProps> = ({
instanceId,
compact,
className,
placeholder,
placeholderClassName,
style,
value,
editable = true,
onChange,
onBlur,
onFocus,
contextBlock,
queryBlock,
historyBlock,
variableBlock,
externalToolBlock,
workflowVariableBlock,
}) => {
const { eventEmitter } = useEventEmitterContextContext()
const initialConfig = {
namespace: 'prompt-editor',
nodes: [
CodeNode,
CustomTextNode,
{
replace: TextNode,
with: (node: TextNode) => new CustomTextNode(node.__text),
},
ContextBlockNode,
HistoryBlockNode,
QueryBlockNode,
WorkflowVariableBlockNode,
VariableValueBlockNode,
],
editorState: textToEditorState(value || ''),
onError: (error: Error) => {
throw error
},
}
const handleEditorChange = (editorState: EditorState) => {
const text = editorState.read(() => {
return $getRoot().getChildren().map(p => p.getTextContent()).join('\n')
})
if (onChange)
onChange(text)
}
useEffect(() => {
eventEmitter?.emit({
type: UPDATE_DATASETS_EVENT_EMITTER,
payload: contextBlock?.datasets,
} as any)
}, [eventEmitter, contextBlock?.datasets])
useEffect(() => {
eventEmitter?.emit({
type: UPDATE_HISTORY_EVENT_EMITTER,
payload: historyBlock?.history,
} as any)
}, [eventEmitter, historyBlock?.history])
return (
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
<div className='relative min-h-5'>
<RichTextPlugin
contentEditable={<ContentEditable className={`${className} outline-none ${compact ? 'leading-5 text-[13px]' : 'leading-6 text-sm'} text-gray-700`} style={style || {}} />}
placeholder={<Placeholder value={placeholder} className={placeholderClassName} compact={compact} />}
ErrorBoundary={LexicalErrorBoundary}
/>
<ComponentPickerBlock
triggerString='/'
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
/>
<ComponentPickerBlock
triggerString='{'
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
/>
{
contextBlock?.show && (
<>
<ContextBlock {...contextBlock} />
<ContextBlockReplacementBlock {...contextBlock} />
</>
)
}
{
queryBlock?.show && (
<>
<QueryBlock {...queryBlock} />
<QueryBlockReplacementBlock />
</>
)
}
{
historyBlock?.show && (
<>
<HistoryBlock {...historyBlock} />
<HistoryBlockReplacementBlock {...historyBlock} />
</>
)
}
{
(variableBlock?.show || externalToolBlock?.show) && (
<>
<VariableBlock />
<VariableValueBlock />
</>
)
}
{
workflowVariableBlock?.show && (
<>
<WorkflowVariableBlock {...workflowVariableBlock} />
<WorkflowVariableBlockReplacementBlock {...workflowVariableBlock} />
</>
)
}
<OnChangePlugin onChange={handleEditorChange} />
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
<HistoryPlugin />
{/* <TreeView /> */}
</div>
</LexicalComposer>
)
}
export default PromptEditor
@@ -0,0 +1,82 @@
import PromptEditor from '@common/components/aoplatform/prompt-editor/PromptEditor.tsx';
import PromptEditorHeightResizeWrap from '@common/components/aoplatform/prompt-editor/prompt-editor-height-resize-wrap.tsx';
import { useEffect, useState } from 'react';
import { getVars } from './utils';
import { VariableItems } from '@core/const/ai-service/type';
const PromptEditorResizable = (props:{value?:string, onChange?:(value:string)=>void, variablesChange?:(keys:string[])=>void,promptVariables:VariableItems[]}) =>{
const {value , onChange,variablesChange,promptVariables} = props
const minHeight = 68
const [editorHeight, setEditorHeight] = useState(minHeight)
const [previousKeys, setPreviousKeys] = useState<string[]>([])
const handleChange = (newTemplates: string, keys: string[]) => {
onChange?.(newTemplates)
}
return ( <PromptEditorHeightResizeWrap
className='px-4 pt-2 min-h-[94px] bg-white rounded-t-xl text-sm text-gray-700'
height={editorHeight}
minHeight={minHeight}
onHeightChange={setEditorHeight}
hideResize={false}
footer={(
<div className='pl-4 pb-2 flex bg-white rounded-b-xl'>
<div className="h-[18px] leading-[18px] px-1 rounded-md bg-gray-100 text-xs text-gray-500">{value?.length || 0}</div>
</div>
)}
><>
{value !== undefined && <PromptEditor
className='min-h-[68px]'
compact
value={value}
contextBlock={{
show: false,
selectable: true,
// datasets: dataSets.map(item => ({
// id: item.id,
// name: item.name,
// type: item.data_source_type,
// })),
// onAddContext: ()=>{console.log('?onAddContext')},
}}
variableBlock={{
show: true,
variables:promptVariables?.map(x=>({name:x.key, value:x.key})) || [],
}}
externalToolBlock={{
show: false,
externalTools: [],
// onAddExternalTool: handleOpenExternalDataToolModal,
}}
historyBlock={{
show: false,
selectable: false,
history: {
user: '',
assistant: '',
},
onEditRole: () => { },
}}
queryBlock={{
show: false,
selectable: true,
}}
onChange={(value) => {
handleChange?.(value, [])
}}
onBlur={() => {
const keys = getVars(value)
handleChange(value, keys)
if(keys.filter(key => !previousKeys.includes(key)).length > 0){
variablesChange?.(keys)
setPreviousKeys(keys)
}
}}
editable={true}
/>
}</>
</PromptEditorHeightResizeWrap>)
}
export default PromptEditorResizable
@@ -0,0 +1,52 @@
// import type { ValueSelector } from '../../workflow/types'
export const CONTEXT_PLACEHOLDER_TEXT = '{{#context#}}'
export const HISTORY_PLACEHOLDER_TEXT = '{{#histories#}}'
export const QUERY_PLACEHOLDER_TEXT = '{{#query#}}'
export const PRE_PROMPT_PLACEHOLDER_TEXT = '{{#pre_prompt#}}'
export const UPDATE_DATASETS_EVENT_EMITTER = 'prompt-editor-context-block-update-datasets'
export const UPDATE_HISTORY_EVENT_EMITTER = 'prompt-editor-history-block-update-role'
export const MAX_VAR_KEY_LENGTH = 30
export const checkHasContextBlock = (text: string) => {
if (!text)
return false
return text.includes(CONTEXT_PLACEHOLDER_TEXT)
}
export const checkHasHistoryBlock = (text: string) => {
if (!text)
return false
return text.includes(HISTORY_PLACEHOLDER_TEXT)
}
export const checkHasQueryBlock = (text: string) => {
if (!text)
return false
return text.includes(QUERY_PLACEHOLDER_TEXT)
}
/*
* {{#1711617514996.name#}} => [1711617514996, name]
* {{#1711617514996.sys.query#}} => [sys, query]
*/
export const getInputVars = (text: string) => {
if (!text)
return []
const allVars = text.match(/{{#([^#]*)#}}/g)
if (allVars && allVars?.length > 0) {
// {{#context#}}, {{#query#}} is not input vars
const inputVars = allVars
.filter(item => item.includes('.'))
.map((item) => {
const valueSelector = item.replace('{{#', '').replace('#}}', '').split('.')
if (valueSelector[1] === 'sys' && /^\d+$/.test(valueSelector[0]))
return valueSelector.slice(1)
return valueSelector
})
return inputVars
}
return []
}
@@ -0,0 +1,185 @@
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import type { Dispatch, RefObject, SetStateAction } from 'react'
import type {
Klass,
LexicalCommand,
LexicalEditor,
TextNode,
} from 'lexical'
import {
$getNodeByKey,
$getSelection,
$isDecoratorNode,
$isNodeSelection,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} from 'lexical'
import type { EntityMatch } from '@lexical/text'
import {
mergeRegister,
} from '@lexical/utils'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $isContextBlockNode } from './plugins/context-block/node'
import { DELETE_CONTEXT_BLOCK_COMMAND } from './plugins/context-block'
import { $isHistoryBlockNode } from './plugins/history-block/node'
import { DELETE_HISTORY_BLOCK_COMMAND } from './plugins/history-block'
import { $isQueryBlockNode } from './plugins/query-block/node'
import { DELETE_QUERY_BLOCK_COMMAND } from './plugins/query-block'
import type { CustomTextNode } from './plugins/custom-text/node'
import { registerLexicalTextEntity } from './utils'
export type UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand<undefined>) => [RefObject<HTMLDivElement>, boolean]
export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand<undefined>) => {
const ref = useRef<HTMLDivElement>(null)
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
const handleDelete = useCallback(
(event: KeyboardEvent) => {
const selection = $getSelection()
const nodes = selection?.getNodes()
if (
!isSelected
&& nodes?.length === 1
&& (
($isContextBlockNode(nodes[0]) && command === DELETE_CONTEXT_BLOCK_COMMAND)
|| ($isHistoryBlockNode(nodes[0]) && command === DELETE_HISTORY_BLOCK_COMMAND)
|| ($isQueryBlockNode(nodes[0]) && command === DELETE_QUERY_BLOCK_COMMAND)
)
)
editor.dispatchCommand(command, undefined)
if (isSelected && $isNodeSelection(selection)) {
event.preventDefault()
const node = $getNodeByKey(nodeKey)
if ($isDecoratorNode(node)) {
if (command)
editor.dispatchCommand(command, undefined)
node.remove()
return true
}
}
return false
},
[isSelected, nodeKey, command, editor],
)
const handleSelect = useCallback((e: MouseEvent) => {
e.stopPropagation()
clearSelection()
setSelected(true)
}, [setSelected, clearSelection])
useEffect(() => {
const ele = ref.current
if (ele)
ele.addEventListener('click', handleSelect)
return () => {
if (ele)
ele.removeEventListener('click', handleSelect)
}
}, [handleSelect])
useEffect(() => {
return mergeRegister(
editor.registerCommand(
KEY_DELETE_COMMAND,
handleDelete,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
handleDelete,
COMMAND_PRIORITY_LOW,
),
)
}, [editor, clearSelection, handleDelete])
return [ref, isSelected]
}
export type UseTriggerHandler = () => [RefObject<HTMLDivElement>, boolean, Dispatch<SetStateAction<boolean>>]
export const useTrigger: UseTriggerHandler = () => {
const triggerRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const handleOpen = useCallback((e: MouseEvent) => {
e.stopPropagation()
setOpen(v => !v)
}, [])
useEffect(() => {
const trigger = triggerRef.current
if (trigger)
trigger.addEventListener('click', handleOpen)
return () => {
if (trigger)
trigger.removeEventListener('click', handleOpen)
}
}, [handleOpen])
return [triggerRef, open, setOpen]
}
export function useLexicalTextEntity<T extends TextNode>(
getMatch: (text: string) => null | EntityMatch,
targetNode: Klass<T>,
createNode: (textNode: CustomTextNode) => T,
) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return mergeRegister(...registerLexicalTextEntity(editor, getMatch, targetNode, createNode))
}, [createNode, editor, getMatch, targetNode])
}
export type MenuTextMatch = {
leadOffset: number
matchingString: string
replaceableString: string
}
export type TriggerFn = (
text: string,
editor: LexicalEditor,
) => MenuTextMatch | null
export const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
export function useBasicTypeaheadTriggerMatch(
trigger: string,
{ minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number },
): TriggerFn {
return useCallback(
(text: string) => {
const validChars = `[${PUNCTUATION}\\s]`
const TypeaheadTriggerRegex = new RegExp(
'(.*)('
+ `[${trigger}]`
+ `((?:${validChars}){0,${maxLength}})`
+ ')$',
)
const match = TypeaheadTriggerRegex.exec(text)
if (match !== null) {
const maybeLeadingWhitespace = match[1]
const matchingString = match[3]
if (matchingString.length >= minLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2],
}
}
}
return null
},
[maxLength, minLength, trigger],
)
}
@@ -0,0 +1,294 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { $insertNodes } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import type {
ContextBlockType,
ExternalToolBlockType,
HistoryBlockType,
QueryBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block'
import { INSERT_HISTORY_BLOCK_COMMAND } from '../history-block'
import { INSERT_QUERY_BLOCK_COMMAND } from '../query-block'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
import { $createCustomTextNode } from '../custom-text/node'
import { PromptMenuItem } from './prompt-option'
import { VariableMenuItem } from './variable-option'
import { PickerBlockMenuOption } from './menu'
import { $t } from '@common/locales'
// import { File05 } from '@/app/components/base/icons/src/vender/solid/files'
// import {
// MessageClockCircle,
// Tool03,
// } from '@/app/components/base/icons/src/vender/solid/general'
// import { BracketsX } from '@/app/components/base/icons/src/vender/line/development'
// import { UserEdit02 } from '@/app/components/base/icons/src/vender/solid/users'
// import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
// import AppIcon from '@/app/components/base/app-icon'
export const usePromptOptions = (
contextBlock?: ContextBlockType,
queryBlock?: QueryBlockType,
historyBlock?: HistoryBlockType,
) => {
const [editor] = useLexicalComposerContext()
const promptOptions: PickerBlockMenuOption[] = []
if (contextBlock?.show) {
promptOptions.push(new PickerBlockMenuOption({
key: $t('上下文'),
group: 'prompt context',
render: ({ isSelected, onSelect, onSetHighlight }) => {
return <PromptMenuItem
title={$t('上下文')}
icon={<></>}
// icon={<File05 className='w-4 h-4 text-[#6938EF]' />}
disabled={!contextBlock.selectable}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
},
onSelect: () => {
if (!contextBlock?.selectable)
return
editor.dispatchCommand(INSERT_CONTEXT_BLOCK_COMMAND, undefined)
},
}))
}
if (queryBlock?.show) {
promptOptions.push(
new PickerBlockMenuOption({
key: $t('查询内容'),
group: 'prompt query',
render: ({ isSelected, onSelect, onSetHighlight }) => {
return (
<PromptMenuItem
title={$t('查询内容')}
icon={<></>}
// icon={<UserEdit02 className='w-4 h-4 text-[#FD853A]' />}
disabled={!queryBlock.selectable}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
if (!queryBlock?.selectable)
return
editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined)
},
}),
)
}
if (historyBlock?.show) {
promptOptions.push(
new PickerBlockMenuOption({
key: $t('会话历史'),
group: 'prompt history',
render: ({ isSelected, onSelect, onSetHighlight }) => {
return (
<PromptMenuItem
title={$t('会话历史')}
icon={<></>}
// icon={<MessageClockCircle className='w-4 h-4 text-[#DD2590]' />}
disabled={!historyBlock.selectable
}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
if (!historyBlock?.selectable)
return
editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined)
},
}),
)
}
return promptOptions
}
export const useVariableOptions = (
variableBlock?: VariableBlockType,
queryString?: string,
): PickerBlockMenuOption[] => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const options = useMemo(() => {
if (!variableBlock?.variables)
return []
const baseOptions = (variableBlock.variables).map((item) => {
return new PickerBlockMenuOption({
key: item.value,
group: 'prompt variable',
render: ({ queryString, isSelected, onSelect, onSetHighlight }) => {
return (
<VariableMenuItem
title={item.value}
icon={<></>}
// icon={<BracketsX className='w-[14px] h-[14px] text-[#2970FF]' />}
queryString={queryString}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.value}}}`)
},
})
})
if (!queryString)
return baseOptions
const regex = new RegExp(queryString, 'i')
return baseOptions.filter(option => regex.test(option.key))
}, [editor, queryString, variableBlock])
const addOption = useMemo(() => {
return new PickerBlockMenuOption({
key: $t('添加新变量'),
group: 'prompt variable',
render: ({ queryString, isSelected, onSelect, onSetHighlight }) => {
return (
<VariableMenuItem
title={$t('添加新变量')}
icon={<></>}
// icon={<BracketsX className='mr-2 w-[14px] h-[14px] text-[#2970FF]' />}
queryString={queryString}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
editor.update(() => {
const prefixNode = $createCustomTextNode('{{')
const suffixNode = $createCustomTextNode('}}')
$insertNodes([prefixNode, suffixNode])
prefixNode.select()
})
},
})
}, [editor, t])
return useMemo(() => {
return variableBlock?.show ? [...options, addOption] : []
}, [options, addOption, variableBlock?.show])
}
export const useExternalToolOptions = (
externalToolBlockType?: ExternalToolBlockType,
queryString?: string,
) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const options = useMemo(() => {
if (!externalToolBlockType?.externalTools)
return []
const baseToolOptions = (externalToolBlockType.externalTools).map((item) => {
return new PickerBlockMenuOption({
key: item.name,
group: 'external tool',
render: ({ queryString, isSelected, onSelect, onSetHighlight }) => {
return (
<VariableMenuItem
title={item.name}
icon={<></>}
// icon={
// <AppIcon
// className='!w-[14px] !h-[14px]'
// icon={item.icon}
// background={item.icon_background}
// />
// }
extraElement={<div className='text-xs text-gray-400'>{item.variableName}</div>}
queryString={queryString}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.variableName}}}`)
},
})
})
if (!queryString)
return baseToolOptions
const regex = new RegExp(queryString, 'i')
return baseToolOptions.filter(option => regex.test(option.key))
}, [editor, queryString, externalToolBlockType])
const addOption = useMemo(() => {
return new PickerBlockMenuOption({
key: $t('添加工具'),
group: 'external tool',
render: ({ queryString, isSelected, onSelect, onSetHighlight }) => {
return (
<VariableMenuItem
title={$t('添加工具')}
// icon={<Tool03 className='mr-2 w-[14px] h-[14px] text-[#444CE7]' />}
// extraElement={< ArrowUpRight className='w-3 h-3 text-gray-400' />}
queryString={queryString}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
externalToolBlockType?.onAddExternalTool?.()
},
})
}, [externalToolBlockType, t])
return useMemo(() => {
return externalToolBlockType?.show ? [...options, addOption] : []
}, [options, addOption, externalToolBlockType?.show])
}
export const useOptions = (
contextBlock?: ContextBlockType,
queryBlock?: QueryBlockType,
historyBlock?: HistoryBlockType,
variableBlock?: VariableBlockType,
externalToolBlockType?: ExternalToolBlockType,
workflowVariableBlockType?: WorkflowVariableBlockType,
queryString?: string,
) => {
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock)
const variableOptions = useVariableOptions(variableBlock, queryString)
const externalToolOptions = useExternalToolOptions(externalToolBlockType, queryString)
const workflowVariableOptions = useMemo(() => {
if (!workflowVariableBlockType?.show)
return []
return workflowVariableBlockType.variables || []
}, [workflowVariableBlockType])
return useMemo(() => {
return {
workflowVariableOptions,
allFlattenOptions: [...promptOptions, ...variableOptions, ...externalToolOptions],
}
}, [promptOptions, variableOptions, externalToolOptions, workflowVariableOptions])
}
@@ -0,0 +1,212 @@
import {
Fragment,
memo,
useCallback,
useState,
} from 'react'
import ReactDOM from 'react-dom'
import {
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react'
import type { TextNode } from 'lexical'
import type { MenuRenderFn } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import type {
ContextBlockType,
ExternalToolBlockType,
HistoryBlockType,
QueryBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
import { useBasicTypeaheadTriggerMatch } from '../../hooks'
import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
import { $splitNodeContainingQuery } from '../../utils'
import { useOptions } from './hooks'
import type { PickerBlockMenuOption } from './menu'
// import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { useEventEmitterContextContext } from '@common/contexts/event-emitter'
type ComponentPickerProps = {
triggerString: string
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
historyBlock?: HistoryBlockType
variableBlock?: VariableBlockType
externalToolBlock?: ExternalToolBlockType
workflowVariableBlock?: WorkflowVariableBlockType
}
const ComponentPicker = ({
triggerString,
contextBlock,
queryBlock,
historyBlock,
variableBlock,
externalToolBlock,
workflowVariableBlock,
}: ComponentPickerProps) => {
const { eventEmitter } = useEventEmitterContextContext()
const { refs, floatingStyles, isPositioned } = useFloating({
placement: 'bottom-start',
middleware: [
offset(0), // fix hide cursor
shift({
padding: 8,
}),
flip(),
],
})
const [editor] = useLexicalComposerContext()
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
minLength: 0,
maxLength: 0,
})
const [queryString, setQueryString] = useState<string | null>(null)
eventEmitter?.useSubscription((v: any) => {
if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`)
})
const {
allFlattenOptions,
workflowVariableOptions,
} = useOptions(
contextBlock,
queryBlock,
historyBlock,
variableBlock,
externalToolBlock,
workflowVariableBlock,
)
const onSelectOption = useCallback(
(
selectedOption: PickerBlockMenuOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
if (nodeToRemove && selectedOption?.key)
nodeToRemove.remove()
selectedOption.onSelectMenuOption()
closeMenu()
})
},
[editor],
)
const handleSelectWorkflowVariable = useCallback((variables: string[]) => {
editor.update(() => {
const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!)
if (needRemove)
needRemove.remove()
})
if (variables[1] === 'sys.query' || variables[1] === 'sys.files')
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [variables[1]])
else
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables)
}, [editor, checkForTriggerMatch, triggerString])
const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>((
anchorElementRef,
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) => {
if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
return null
refs.setReference(anchorElementRef.current)
return (
<>
{
ReactDOM.createPortal(
// The `LexicalMenu` will try to calculate the position of the floating menu based on the first child.
// Since we use floating ui, we need to wrap it with a div to prevent the position calculation being affected.
// See https://github.com/facebook/lexical/blob/ac97dfa9e14a73ea2d6934ff566282d7f758e8bb/packages/lexical-react/src/shared/LexicalMenu.ts#L493
<div className='w-0 h-0'>
<div
className='p-1 w-[260px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg overflow-y-auto overflow-x-hidden'
style={{
...floatingStyles,
visibility: isPositioned ? 'visible' : 'hidden',
maxHeight: 'calc(1 / 3 * 100vh)',
}}
ref={refs.setFloating}
>
{
options.map((option, index) => (
<Fragment key={option.key}>
{
// Divider
index !== 0 && options.at(index - 1)?.group !== option.group && (
<div className='h-px bg-gray-100 my-1 w-screen -translate-x-1'></div>
)
}
{option.renderMenuOption({
queryString,
isSelected: selectedIndex === index,
onSelect: () => {
selectOptionAndCleanUp(option)
},
onSetHighlight: () => {
setHighlightedIndex(index)
},
})}
</Fragment>
))
}
{
workflowVariableBlock?.show && (
<>
{
(!!options.length) && (
<div className='h-px bg-gray-100 my-1 w-screen -translate-x-1'></div>
)
}
<div className='p-1'>
{/* <VarReferenceVars
hideSearch
vars={workflowVariableOptions}
onChange={(variables: string[]) => {
handleSelectWorkflowVariable(variables)
}}
/> */}
</div>
</>
)
}
</div>
</div>,
anchorElementRef.current,
)
}
</>
)
}, [allFlattenOptions.length, workflowVariableBlock?.show, refs, isPositioned, floatingStyles, queryString, workflowVariableOptions, handleSelectWorkflowVariable])
return (
<LexicalTypeaheadMenuPlugin
options={allFlattenOptions}
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
// The `translate` class is used to workaround the issue that the `typeahead-menu` menu is not positioned as expected.
// See also https://github.com/facebook/lexical/blob/772520509308e8ba7e4a82b6cd1996a78b3298d0/packages/lexical-react/src/shared/LexicalMenu.ts#L498
//
// We no need the position function of the `LexicalTypeaheadMenuPlugin`,
// so the reference anchor should be positioned based on the range of the trigger string, and the menu will be positioned by the floating ui.
anchorClassName='z-[999999] translate-y-[calc(-100%-3px)]'
menuRenderFn={renderMenu}
triggerFn={checkForTriggerMatch}
/>
)
}
export default memo(ComponentPicker)
@@ -0,0 +1,31 @@
import { MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { Fragment } from 'react'
/**
* Corresponds to the `MenuRenderFn` type from `@lexical/react/LexicalTypeaheadMenuPlugin`.
*/
type MenuOptionRenderProps = {
isSelected: boolean
onSelect: () => void
onSetHighlight: () => void
queryString: string | null
}
export class PickerBlockMenuOption extends MenuOption {
public group?: string
constructor(
private data: {
key: string
group?: string
onSelect?: () => void
render: (menuRenderProps: MenuOptionRenderProps) => JSX.Element
},
) {
super(data.key)
this.group = data.group
}
public onSelectMenuOption = () => this.data.onSelect?.()
public renderMenuOption = (menuRenderProps: MenuOptionRenderProps) => <Fragment key={this.data.key}>{this.data.render(menuRenderProps)}</Fragment>
}
@@ -0,0 +1,45 @@
import { memo } from 'react'
type PromptMenuItemMenuItemProps = {
icon: JSX.Element
title: string
disabled?: boolean
isSelected: boolean
onClick: () => void
onMouseEnter: () => void
setRefElement?: (element: HTMLDivElement) => void
}
export const PromptMenuItem = memo(({
icon,
title,
disabled,
isSelected,
onClick,
onMouseEnter,
setRefElement,
}: PromptMenuItemMenuItemProps) => {
return (
<div
className={`
flex items-center px-3 h-6 cursor-pointer hover:bg-gray-50 rounded-md
${isSelected && !disabled && '!bg-gray-50'}
${disabled ? 'cursor-not-allowed opacity-30' : 'hover:bg-gray-50 cursor-pointer'}
`}
tabIndex={-1}
ref={setRefElement}
onMouseEnter={() => {
if (disabled)
return
onMouseEnter()
}}
onClick={() => {
if (disabled)
return
onClick()
}}>
{icon}
<div className='ml-1 text-[13px] text-gray-900'>{title}</div>
</div>
)
})
PromptMenuItem.displayName = 'PromptMenuItem'
@@ -0,0 +1,60 @@
import { memo } from 'react'
type VariableMenuItemProps = {
title: string
icon?: JSX.Element
extraElement?: JSX.Element
isSelected: boolean
queryString: string | null
onClick: () => void
onMouseEnter: () => void
setRefElement?: (element: HTMLDivElement) => void
}
export const VariableMenuItem = memo(({
title,
icon,
extraElement,
isSelected,
queryString,
onClick,
onMouseEnter,
setRefElement,
}: VariableMenuItemProps) => {
let before = title
let middle = ''
let after = ''
if (queryString) {
const regex = new RegExp(queryString, 'i')
const match = regex.exec(title)
if (match) {
before = title.substring(0, match.index)
middle = match[0]
after = title.substring(match.index + match[0].length)
}
}
return (
<div
className={`
flex items-center px-3 h-6 rounded-md hover:bg-[#EBEEF2] cursor-pointer
${isSelected && 'bg-[#EBEEF2]'}
`}
tabIndex={-1}
ref={setRefElement}
onMouseEnter={onMouseEnter}
onClick={onClick}>
<div className='mr-2'>
{icon}
</div>
<div className='grow text-[13px] text-gray-900 truncate' title={title}>
{before}
<span className='text-[#2970FF]'>{middle}</span>
{after}
</div>
{extraElement}
</div>
)
})
VariableMenuItem.displayName = 'VariableMenuItem'
@@ -0,0 +1,56 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
// import {
// RiAddLine,
// } from '@remixicon/react'
import { useSelectOrDelete, useTrigger } from '../../hooks'
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../constants'
import type { Dataset } from './index'
import { DELETE_CONTEXT_BLOCK_COMMAND } from './index'
// import { File05, Folder } from '@/app/components/base/icons/src/vender/solid/files'
// import {
// PortalToFollowElem,
// PortalToFollowElemContent,
// PortalToFollowElemTrigger,
// } from '@/app/components/base/portal-to-follow-elem'
import { useEventEmitterContextContext } from '@common/contexts/event-emitter'
import { $t } from '@common/locales'
type ContextBlockComponentProps = {
nodeKey: string
datasets?: Dataset[]
onAddContext: () => void
canNotAddContext?: boolean
}
const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
nodeKey,
datasets = [],
onAddContext,
canNotAddContext,
}) => {
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_CONTEXT_BLOCK_COMMAND)
const [triggerRef, open, setOpen] = useTrigger()
const { eventEmitter } = useEventEmitterContextContext()
const [localDatasets, setLocalDatasets] = useState<Dataset[]>(datasets)
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_DATASETS_EVENT_EMITTER)
setLocalDatasets(v.payload)
})
return (
<div className={`
group inline-flex items-center pl-1 pr-0.5 h-6 border border-transparent bg-[#F4F3FF] text-[#6938EF] rounded-[5px] hover:bg-[#EBE9FE]
${open ? 'bg-[#EBE9FE]' : 'bg-[#F4F3FF]'}
${isSelected && '!border-[#9B8AFB]'}
`} ref={ref}>
{/* <File05 className='mr-1 w-[14px] h-[14px]' /> */}
<div className='mr-1 text-xs font-medium'>{$t('上下文')}</div>
</div>
)
}
export default ContextBlockComponent
@@ -0,0 +1,63 @@
import {
memo,
useCallback,
useEffect,
} from 'react'
import { $applyNodeReplacement } from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { decoratorTransform } from '../../utils'
import { CONTEXT_PLACEHOLDER_TEXT } from '../../constants'
import type { ContextBlockType } from '../../types'
import {
$createContextBlockNode,
ContextBlockNode,
} from './node'
import { CustomTextNode } from '../custom-text/node'
const REGEX = new RegExp(CONTEXT_PLACEHOLDER_TEXT)
const ContextBlockReplacementBlock = ({
datasets = [],
onAddContext = () => {},
onInsert,
canNotAddContext,
}: ContextBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([ContextBlockNode]))
throw new Error('ContextBlockNodePlugin: ContextBlockNode not registered on editor')
}, [editor])
const createContextBlockNode = useCallback((): ContextBlockNode => {
if (onInsert)
onInsert()
return $applyNodeReplacement($createContextBlockNode(datasets, onAddContext, canNotAddContext))
}, [datasets, onAddContext, onInsert, canNotAddContext])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
if (matchArr === null)
return null
const startOffset = matchArr.index
const endOffset = startOffset + CONTEXT_PLACEHOLDER_TEXT.length
return {
end: endOffset,
start: startOffset,
}
}, [])
useEffect(() => {
REGEX.lastIndex = 0
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createContextBlockNode)),
)
}, [])
return null
}
export default memo(ContextBlockReplacementBlock)
@@ -0,0 +1,74 @@
import {
memo,
useEffect,
} from 'react'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import type { ContextBlockType } from '../../types'
import {
$createContextBlockNode,
ContextBlockNode,
} from './node'
export const INSERT_CONTEXT_BLOCK_COMMAND = createCommand('INSERT_CONTEXT_BLOCK_COMMAND')
export const DELETE_CONTEXT_BLOCK_COMMAND = createCommand('DELETE_CONTEXT_BLOCK_COMMAND')
export type Dataset = {
id: string
name: string
type: string
}
const ContextBlock = memo(({
datasets = [],
onAddContext = () => {},
onInsert,
onDelete,
canNotAddContext,
}: ContextBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([ContextBlockNode]))
throw new Error('ContextBlockPlugin: ContextBlock not registered on editor')
return mergeRegister(
editor.registerCommand(
INSERT_CONTEXT_BLOCK_COMMAND,
() => {
const contextBlockNode = $createContextBlockNode(datasets, onAddContext, canNotAddContext)
$insertNodes([contextBlockNode])
if (onInsert)
onInsert()
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DELETE_CONTEXT_BLOCK_COMMAND,
() => {
if (onDelete)
onDelete()
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, datasets, onAddContext, onInsert, onDelete, canNotAddContext])
return null
})
ContextBlock.displayName = 'ContextBlock'
export { ContextBlock }
export { ContextBlockNode } from './node'
export { default as ContextBlockReplacementBlock } from './context-block-replacement-block'
@@ -0,0 +1,100 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical'
import ContextBlockComponent from './component'
import type { Dataset } from './index'
export type SerializedNode = SerializedLexicalNode & { datasets: Dataset[]; onAddContext: () => void; canNotAddContext: boolean }
export class ContextBlockNode extends DecoratorNode<JSX.Element> {
__datasets: Dataset[]
__onAddContext: () => void
__canNotAddContext: boolean
static getType(): string {
return 'context-block'
}
static clone(node: ContextBlockNode): ContextBlockNode {
return new ContextBlockNode(node.__datasets, node.__onAddContext, node.getKey(), node.__canNotAddContext)
}
isInline(): boolean {
return true
}
constructor(datasets: Dataset[], onAddContext: () => void, key?: NodeKey, canNotAddContext?: boolean) {
super(key)
this.__datasets = datasets
this.__onAddContext = onAddContext
this.__canNotAddContext = canNotAddContext || false
}
createDOM(): HTMLElement {
const div = document.createElement('div')
div.classList.add('inline-flex', 'items-center', 'align-middle')
return div
}
updateDOM(): false {
return false
}
decorate(): JSX.Element {
return (
<ContextBlockComponent
nodeKey={this.getKey()}
datasets={this.getDatasets()}
onAddContext={this.getOnAddContext()}
canNotAddContext={this.getCanNotAddContext()}
/>
)
}
getDatasets(): Dataset[] {
const self = this.getLatest()
return self.__datasets
}
getOnAddContext(): () => void {
const self = this.getLatest()
return self.__onAddContext
}
getCanNotAddContext(): boolean {
const self = this.getLatest()
return self.__canNotAddContext
}
static importJSON(serializedNode: SerializedNode): ContextBlockNode {
const node = $createContextBlockNode(serializedNode.datasets, serializedNode.onAddContext, serializedNode.canNotAddContext)
return node
}
exportJSON(): SerializedNode {
return {
type: 'context-block',
version: 1,
datasets: this.getDatasets(),
onAddContext: this.getOnAddContext(),
canNotAddContext: this.getCanNotAddContext(),
}
}
getTextContent(): string {
return '{{#context#}}'
}
}
export function $createContextBlockNode(datasets: Dataset[], onAddContext: () => void, canNotAddContext?: boolean): ContextBlockNode {
return new ContextBlockNode(datasets, onAddContext, undefined, canNotAddContext)
}
export function $isContextBlockNode(
node: ContextBlockNode | LexicalNode | null | undefined,
): boolean {
return node instanceof ContextBlockNode
}
@@ -0,0 +1,52 @@
import type { EditorConfig, NodeKey, SerializedTextNode } from 'lexical'
import { $createTextNode, TextNode } from 'lexical'
export class CustomTextNode extends TextNode {
static getType() {
return 'custom-text'
}
static clone(node: CustomTextNode) {
return new CustomTextNode(node.__text, node.__key)
}
constructor(text: string, key?: NodeKey) {
super(text, key)
}
createDOM(config: EditorConfig) {
const dom = super.createDOM(config)
dom.classList.add('align-middle')
return dom
}
static importJSON(serializedNode: SerializedTextNode): TextNode {
const node = $createTextNode(serializedNode.text)
node.setFormat(serializedNode.format)
node.setDetail(serializedNode.detail)
node.setMode(serializedNode.mode)
node.setStyle(serializedNode.style)
return node
}
exportJSON(): SerializedTextNode {
return {
detail: this.getDetail(),
format: this.getFormat(),
mode: this.getMode(),
style: this.getStyle(),
text: this.getTextContent(),
type: 'custom-text',
version: 1,
}
}
isSimpleText() {
return (
(this.__type === 'text' || this.__type === 'custom-text') && this.__mode === 0)
}
}
export function $createCustomTextNode(text: string): CustomTextNode {
return new CustomTextNode(text)
}
@@ -0,0 +1,53 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
// import {
// RiMoreFill,
// } from '@remixicon/react'
import { useSelectOrDelete, useTrigger } from '../../hooks'
import { UPDATE_HISTORY_EVENT_EMITTER } from '../../constants'
import type { RoleName } from './index'
import { DELETE_HISTORY_BLOCK_COMMAND } from './index'
// import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
// import {
// PortalToFollowElem,
// PortalToFollowElemContent,
// PortalToFollowElemTrigger,
// } from '@/app/components/base/portal-to-follow-elem'
import { useEventEmitterContextContext } from '@common/contexts/event-emitter'
import { $t } from '@common/locales'
type HistoryBlockComponentProps = {
nodeKey: string
roleName?: RoleName
onEditRole: () => void
}
const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
nodeKey,
roleName = { user: '', assistant: '' },
onEditRole,
}) => {
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_HISTORY_BLOCK_COMMAND)
const [triggerRef, open, setOpen] = useTrigger()
const { eventEmitter } = useEventEmitterContextContext()
const [localRoleName, setLocalRoleName] = useState<RoleName>(roleName)
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_HISTORY_EVENT_EMITTER)
setLocalRoleName(v.payload)
})
return (
<div className={`
group inline-flex items-center pl-1 pr-0.5 h-6 border border-transparent text-[#DD2590] rounded-[5px] hover:bg-[#FCE7F6]
${open ? 'bg-[#FCE7F6]' : 'bg-[#FDF2FA]'}
${isSelected && '!border-[#F670C7]'}
`} ref={ref}>
{/* <MessageClockCircle className='mr-1 w-[14px] h-[14px]' /> */}
<div className='mr-1 text-xs font-medium'>{$t('会话历史')}</div>
</div>
)
}
export default HistoryBlockComponent
@@ -0,0 +1,61 @@
import {
useCallback,
useEffect,
} from 'react'
import { $applyNodeReplacement } from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { decoratorTransform } from '../../utils'
import { HISTORY_PLACEHOLDER_TEXT } from '../../constants'
import type { HistoryBlockType } from '../../types'
import {
$createHistoryBlockNode,
HistoryBlockNode,
} from './node'
import { CustomTextNode } from '../custom-text/node'
const REGEX = new RegExp(HISTORY_PLACEHOLDER_TEXT)
const HistoryBlockReplacementBlock = ({
history = { user: '', assistant: '' },
onEditRole = () => {},
onInsert,
}: HistoryBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([HistoryBlockNode]))
throw new Error('HistoryBlockNodePlugin: HistoryBlockNode not registered on editor')
}, [editor])
const createHistoryBlockNode = useCallback((): HistoryBlockNode => {
if (onInsert)
onInsert()
return $applyNodeReplacement($createHistoryBlockNode(history, onEditRole))
}, [history, onEditRole, onInsert])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
if (matchArr === null)
return null
const startOffset = matchArr.index
const endOffset = startOffset + HISTORY_PLACEHOLDER_TEXT.length
return {
end: endOffset,
start: startOffset,
}
}, [])
useEffect(() => {
REGEX.lastIndex = 0
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHistoryBlockNode)),
)
}, [])
return null
}
export default HistoryBlockReplacementBlock
@@ -0,0 +1,79 @@
import {
memo,
useEffect,
} from 'react'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import type { HistoryBlockType } from '../../types'
import {
$createHistoryBlockNode,
HistoryBlockNode,
} from './node'
export const INSERT_HISTORY_BLOCK_COMMAND = createCommand('INSERT_HISTORY_BLOCK_COMMAND')
export const DELETE_HISTORY_BLOCK_COMMAND = createCommand('DELETE_HISTORY_BLOCK_COMMAND')
export type RoleName = {
user: string
assistant: string
}
export type HistoryBlockProps = {
roleName: RoleName
onEditRole: () => void
onInsert?: () => void
onDelete?: () => void
}
const HistoryBlock = memo(({
history = { user: '', assistant: '' },
onEditRole = () => {},
onInsert,
onDelete,
}: HistoryBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([HistoryBlockNode]))
throw new Error('HistoryBlockPlugin: HistoryBlock not registered on editor')
return mergeRegister(
editor.registerCommand(
INSERT_HISTORY_BLOCK_COMMAND,
() => {
const historyBlockNode = $createHistoryBlockNode(history, onEditRole)
$insertNodes([historyBlockNode])
if (onInsert)
onInsert()
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DELETE_HISTORY_BLOCK_COMMAND,
() => {
if (onDelete)
onDelete()
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, history, onEditRole, onInsert, onDelete])
return null
})
HistoryBlock.displayName = 'HistoryBlock'
export { HistoryBlock }
export { HistoryBlockNode } from './node'
export { default as HistoryBlockReplacementBlock } from './history-block-replacement-block'
@@ -0,0 +1,90 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical'
import HistoryBlockComponent from './component'
import type { RoleName } from './index'
export type SerializedNode = SerializedLexicalNode & { roleName: RoleName; onEditRole: () => void }
export class HistoryBlockNode extends DecoratorNode<JSX.Element> {
__roleName: RoleName
__onEditRole: () => void
static getType(): string {
return 'history-block'
}
static clone(node: HistoryBlockNode): HistoryBlockNode {
return new HistoryBlockNode(node.__roleName, node.__onEditRole)
}
constructor(roleName: RoleName, onEditRole: () => void, key?: NodeKey) {
super(key)
this.__roleName = roleName
this.__onEditRole = onEditRole
}
isInline(): boolean {
return true
}
createDOM(): HTMLElement {
const div = document.createElement('div')
div.classList.add('inline-flex', 'items-center', 'align-middle')
return div
}
updateDOM(): false {
return false
}
decorate(): JSX.Element {
return (
<HistoryBlockComponent
nodeKey={this.getKey()}
roleName={this.getRoleName()}
onEditRole={this.getOnEditRole()}
/>
)
}
getRoleName(): RoleName {
const self = this.getLatest()
return self.__roleName
}
getOnEditRole(): () => void {
const self = this.getLatest()
return self.__onEditRole
}
static importJSON(serializedNode: SerializedNode): HistoryBlockNode {
const node = $createHistoryBlockNode(serializedNode.roleName, serializedNode.onEditRole)
return node
}
exportJSON(): SerializedNode {
return {
type: 'history-block',
version: 1,
roleName: this.getRoleName(),
onEditRole: this.getOnEditRole,
}
}
getTextContent(): string {
return '{{#histories#}}'
}
}
export function $createHistoryBlockNode(roleName: RoleName, onEditRole: () => void): HistoryBlockNode {
return new HistoryBlockNode(roleName, onEditRole)
}
export function $isHistoryBlockNode(
node: HistoryBlockNode | LexicalNode | null | undefined,
): node is HistoryBlockNode {
return node instanceof HistoryBlockNode
}
@@ -0,0 +1,67 @@
import type { FC } from 'react'
import { useEffect, useRef } from 'react'
import {
BLUR_COMMAND,
COMMAND_PRIORITY_EDITOR,
FOCUS_COMMAND,
KEY_ESCAPE_COMMAND,
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
type OnBlurBlockProps = {
onBlur?: () => void
onFocus?: () => void
}
const OnBlurBlock: FC<OnBlurBlockProps> = ({
onBlur,
onFocus,
}) => {
const [editor] = useLexicalComposerContext()
const ref = useRef<any>(null)
useEffect(() => {
return mergeRegister(
editor.registerCommand(
CLEAR_HIDE_MENU_TIMEOUT,
() => {
if (ref.current) {
clearTimeout(ref.current)
ref.current = null
}
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
BLUR_COMMAND,
() => {
ref.current = setTimeout(() => {
editor.dispatchCommand(KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown', { key: 'Escape' }))
}, 200)
if (onBlur)
onBlur()
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
FOCUS_COMMAND,
() => {
if (onFocus)
onFocus()
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, onBlur, onFocus])
return null
}
export default OnBlurBlock
@@ -0,0 +1,22 @@
import { $t } from '@common/locales'
import { memo } from 'react'
const Placeholder = ({
compact,
value
}: {
compact?: boolean
value?: string
className?: string
}) => {
return (
<div
className={`absolute top-0 left-0 h-full w-full text-sm text-[#BBB] select-none pointer-events-none ${compact ? 'leading-5 text-[13px]' : 'leading-6 text-sm'}`}
>
{value || $t('AI 模型调用默认仅使用 Query 变量,可输入 “{” 增加新变量。')}
</div>
)
}
export default memo(Placeholder)
@@ -0,0 +1,33 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelectOrDelete } from '../../hooks'
import { DELETE_QUERY_BLOCK_COMMAND } from './index'
import { $t } from '@common/locales'
// import { UserEdit02 } from '@/app/components/base/icons/src/vender/solid/users'
type QueryBlockComponentProps = {
nodeKey: string
}
const QueryBlockComponent: FC<QueryBlockComponentProps> = ({
nodeKey,
}) => {
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_QUERY_BLOCK_COMMAND)
return (
<div
className={`
inline-flex items-center pl-1 pr-0.5 h-6 bg-[#FFF6ED] border border-transparent rounded-[5px] hover:bg-[#FFEAD5]
${isSelected && '!border-[#FD853A]'}
`}
ref={ref}
>
{/* <UserEdit02 className='mr-1 w-[14px] h-[14px] text-[#FD853A]' /> */}
<div className='text-xs font-medium text-[#EC4A0A] opacity-60'>{'{{'}</div>
<div className='text-xs font-medium text-[#EC4A0A]'>{$t('查询内容')}</div>
<div className='text-xs font-medium text-[#EC4A0A] opacity-60'>{'}}'}</div>
</div>
)
}
export default QueryBlockComponent
@@ -0,0 +1,68 @@
import {
memo,
useEffect,
} from 'react'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import type { QueryBlockType } from '../../types'
import {
$createQueryBlockNode,
QueryBlockNode,
} from './node'
export const INSERT_QUERY_BLOCK_COMMAND = createCommand('INSERT_QUERY_BLOCK_COMMAND')
export const DELETE_QUERY_BLOCK_COMMAND = createCommand('DELETE_QUERY_BLOCK_COMMAND')
export type QueryBlockProps = {
onInsert?: () => void
onDelete?: () => void
}
const QueryBlock = memo(({
onInsert,
onDelete,
}: QueryBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([QueryBlockNode]))
throw new Error('QueryBlockPlugin: QueryBlock not registered on editor')
return mergeRegister(
editor.registerCommand(
INSERT_QUERY_BLOCK_COMMAND,
() => {
const contextBlockNode = $createQueryBlockNode()
$insertNodes([contextBlockNode])
if (onInsert)
onInsert()
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DELETE_QUERY_BLOCK_COMMAND,
() => {
if (onDelete)
onDelete()
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, onInsert, onDelete])
return null
})
QueryBlock.displayName = 'QueryBlock'
export { QueryBlock }
export { QueryBlockNode } from './node'
export { default as QueryBlockReplacementBlock } from './query-block-replacement-block'
@@ -0,0 +1,59 @@
import type { LexicalNode, SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical'
import QueryBlockComponent from './component'
export type SerializedNode = SerializedLexicalNode
export class QueryBlockNode extends DecoratorNode<JSX.Element> {
static getType(): string {
return 'query-block'
}
static clone(): QueryBlockNode {
return new QueryBlockNode()
}
isInline(): boolean {
return true
}
createDOM(): HTMLElement {
const div = document.createElement('div')
div.classList.add('inline-flex', 'items-center', 'align-middle')
return div
}
updateDOM(): false {
return false
}
decorate(): JSX.Element {
return <QueryBlockComponent nodeKey={this.getKey()} />
}
static importJSON(): QueryBlockNode {
const node = $createQueryBlockNode()
return node
}
exportJSON(): SerializedNode {
return {
type: 'query-block',
version: 1,
}
}
getTextContent(): string {
return '{{#query#}}'
}
}
export function $createQueryBlockNode(): QueryBlockNode {
return new QueryBlockNode()
}
export function $isQueryBlockNode(
node: QueryBlockNode | LexicalNode | null | undefined,
): node is QueryBlockNode {
return node instanceof QueryBlockNode
}
@@ -0,0 +1,60 @@
import {
memo,
useCallback,
useEffect,
} from 'react'
import { $applyNodeReplacement } from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { decoratorTransform } from '../../utils'
import { QUERY_PLACEHOLDER_TEXT } from '../../constants'
import type { QueryBlockType } from '../../types'
import {
$createQueryBlockNode,
QueryBlockNode,
} from './node'
import { CustomTextNode } from '../custom-text/node'
const REGEX = new RegExp(QUERY_PLACEHOLDER_TEXT)
const QueryBlockReplacementBlock = ({
onInsert,
}: QueryBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([QueryBlockNode]))
throw new Error('QueryBlockNodePlugin: QueryBlockNode not registered on editor')
}, [editor])
const createQueryBlockNode = useCallback((): QueryBlockNode => {
if (onInsert)
onInsert()
return $applyNodeReplacement($createQueryBlockNode())
}, [onInsert])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
if (matchArr === null)
return null
const startOffset = matchArr.index
const endOffset = startOffset + QUERY_PLACEHOLDER_TEXT.length
return {
end: endOffset,
start: startOffset,
}
}, [])
useEffect(() => {
REGEX.lastIndex = 0
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createQueryBlockNode)),
)
}, [])
return null
}
export default memo(QueryBlockReplacementBlock)
@@ -0,0 +1,19 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { TreeView } from '@lexical/react/LexicalTreeView'
const TreeViewPlugin = () => {
const [editor] = useLexicalComposerContext()
return (
<TreeView
viewClassName="tree-view-output"
treeTypeButtonClassName="debug-treetype-button"
timeTravelPanelClassName="debug-timetravel-panel"
timeTravelButtonClassName="debug-timetravel-button"
timeTravelPanelSliderClassName="debug-timetravel-panel-slider"
timeTravelPanelButtonClassName="debug-timetravel-panel-button"
editor={editor}
/>
)
}
export default TreeViewPlugin
@@ -0,0 +1,42 @@
import { $insertNodes } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { textToEditorState } from '../utils'
import { CustomTextNode } from './custom-text/node'
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
import { useEventEmitterContextContext } from '@common/contexts/event-emitter'
export const PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER = 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER'
export const PROMPT_EDITOR_INSERT_QUICKLY = 'PROMPT_EDITOR_INSERT_QUICKLY'
type UpdateBlockProps = {
instanceId?: string
}
const UpdateBlock = ({
instanceId,
}: UpdateBlockProps) => {
const { eventEmitter } = useEventEmitterContextContext()
const [editor] = useLexicalComposerContext()
eventEmitter?.useSubscription((v: any) => {
if (v.type === PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER && v.instanceId === instanceId) {
const editorState = editor.parseEditorState(textToEditorState(v.payload))
editor.setEditorState(editorState)
}
})
eventEmitter?.useSubscription((v: any) => {
if (v.type === PROMPT_EDITOR_INSERT_QUICKLY && v.instanceId === instanceId) {
editor.focus()
editor.update(() => {
const textNode = new CustomTextNode('/')
$insertNodes([textNode])
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
}
})
return null
}
export default UpdateBlock
@@ -0,0 +1,45 @@
import { useEffect } from 'react'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { CustomTextNode } from '../custom-text/node'
export const INSERT_VARIABLE_BLOCK_COMMAND = createCommand('INSERT_VARIABLE_BLOCK_COMMAND')
export const INSERT_VARIABLE_VALUE_BLOCK_COMMAND = createCommand('INSERT_VARIABLE_VALUE_BLOCK_COMMAND')
const VariableBlock = () => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return mergeRegister(
editor.registerCommand(
INSERT_VARIABLE_BLOCK_COMMAND,
() => {
const textNode = new CustomTextNode('{')
$insertNodes([textNode])
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
INSERT_VARIABLE_VALUE_BLOCK_COMMAND,
(value: string) => {
const textNode = new CustomTextNode(value)
$insertNodes([textNode])
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
return null
}
export default VariableBlock
@@ -0,0 +1,52 @@
import {
useCallback,
useEffect,
} from 'react'
import type { TextNode } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalTextEntity } from '../../hooks'
import {
$createVariableValueBlockNode,
VariableValueBlockNode,
} from './node'
import { getHashtagRegexString } from './utils'
const REGEX = new RegExp(getHashtagRegexString(), 'i')
const VariableValueBlock = () => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([VariableValueBlockNode]))
throw new Error('VariableValueBlockPlugin: VariableValueNode not registered on editor')
}, [editor])
const createVariableValueBlockNode = useCallback((textNode: TextNode): VariableValueBlockNode => {
return $createVariableValueBlockNode(textNode.getTextContent())
}, [])
const getVariableValueMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
if (matchArr === null)
return null
const hashtagLength = matchArr[0].length
const startOffset = matchArr.index
const endOffset = startOffset + hashtagLength
return {
end: endOffset,
start: startOffset,
}
}, [])
useLexicalTextEntity<VariableValueBlockNode>(
getVariableValueMatch,
VariableValueBlockNode,
createVariableValueBlockNode,
)
return null
}
export default VariableValueBlock
@@ -0,0 +1,65 @@
import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical'
import {
$applyNodeReplacement,
TextNode,
} from 'lexical'
export class VariableValueBlockNode extends TextNode {
static getType(): string {
return 'variable-value-block'
}
static clone(node: VariableValueBlockNode): VariableValueBlockNode {
return new VariableValueBlockNode(node.__text, node.__key)
}
constructor(text: string, key?: NodeKey) {
super(text, key)
}
createDOM(config: EditorConfig): HTMLElement {
const element = super.createDOM(config)
element.classList.add('inline-flex', 'items-center', 'px-0.5', 'h-[22px]', 'text-[#155EEF]', 'rounded-[5px]', 'align-middle')
return element
}
static importJSON(serializedNode: SerializedTextNode): TextNode {
const node = $createVariableValueBlockNode(serializedNode.text)
node.setFormat(serializedNode.format)
node.setDetail(serializedNode.detail)
node.setMode(serializedNode.mode)
node.setStyle(serializedNode.style)
return node
}
exportJSON(): SerializedTextNode {
return {
detail: this.getDetail(),
format: this.getFormat(),
mode: this.getMode(),
style: this.getStyle(),
text: this.getTextContent(),
type: 'variable-value-block',
version: 1,
}
}
canInsertTextBefore(): boolean {
return false
}
}
export function $createVariableValueBlockNode(text = ''): VariableValueBlockNode {
return $applyNodeReplacement(new VariableValueBlockNode(text))
}
export function $isVariableValueNodeBlock(
node: LexicalNode | null | undefined,
): node is VariableValueBlockNode {
return node instanceof VariableValueBlockNode
}
@@ -0,0 +1,5 @@
export function getHashtagRegexString(): string {
const hashtag = '\\{\\{[a-zA-Z_][a-zA-Z0-9_]{0,29}\\}\\}'
return hashtag
}
@@ -0,0 +1,114 @@
import {
memo,
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
COMMAND_PRIORITY_EDITOR,
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
// import {
// RiErrorWarningFill,
// } from '@remixicon/react'
import { useSelectOrDelete } from '../../hooks'
import type { WorkflowNodesMap } from './node'
import { WorkflowVariableBlockNode } from './node'
import {
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
UPDATE_WORKFLOW_NODES_MAP,
} from './index'
// import cn from '@/utils/classnames'
// import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
// import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
// import { VarBlockIcon } from '@/app/components/workflow/block-icon'
// import { Line3 } from '@/app/components/base/icons/src/public/common'
// import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
// import Tooltip from '@/app/components/base/tooltip'
type WorkflowVariableBlockComponentProps = {
nodeKey: string
variables: string[]
workflowNodesMap: WorkflowNodesMap
}
const WorkflowVariableBlockComponent = ({
nodeKey,
variables,
workflowNodesMap = {},
}: WorkflowVariableBlockComponentProps) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND)
const variablesLength = variables.length
// const varName = (
// () => {
// const isSystem = isSystemVar(variables)
// const varName = variablesLength >= 3 ? (variables).slice(-2).join('.') : variables[variablesLength - 1]
// return `${isSystem ? 'sys.' : ''}${varName}`
// }
// )()
const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState<WorkflowNodesMap>(workflowNodesMap)
const node = localWorkflowNodesMap![variables[0]]
// const isEnv = isENV(variables)
// const isChatVar = isConversationVar(variables)
useEffect(() => {
if (!editor.hasNodes([WorkflowVariableBlockNode]))
throw new Error('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
return mergeRegister(
editor.registerCommand(
UPDATE_WORKFLOW_NODES_MAP,
(workflowNodesMap: WorkflowNodesMap) => {
setLocalWorkflowNodesMap(workflowNodesMap)
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
const Item = (
<div
className={`mx-0.5 relative group/wrap flex items-center h-[18px] pl-0.5 pr-[3px] rounded-[5px] border select-none ${isSelected ? 'border-[#84ADFF] bg-[#F5F8FF]' : 'border-black/5 bg-white'}` }
ref={ref}
>
{/* {!isEnv && !isChatVar && (
<div className='flex items-center'>
{
node?.type && (
<div className='p-[1px]'>
<VarBlockIcon
className='!text-gray-500'
type={node?.type}
/>
</div>
)
}
<div className='shrink-0 mx-0.5 max-w-[60px] text-xs font-medium text-gray-500 truncate' title={node?.title} style={{
}}>{node?.title}</div>
<Line3 className='mr-0.5 text-gray-300'></Line3>
</div>
)} */}
<div className='flex items-center text-primary-600'>
{/* {!isEnv && !isChatVar && <Variable02 className='shrink-0 w-3.5 h-3.5' />}
{isEnv && <Env className='shrink-0 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
<div className={cn('shrink-0 ml-0.5 text-xs font-medium truncate', (isEnv || isChatVar) && 'text-gray-900')} title={varName}>{varName}</div>
{
!node && !isEnv && !isChatVar && (
<RiErrorWarningFill className='ml-0.5 w-3 h-3 text-[#D92D20]' />
)
} */}
</div>
</div>
)
return Item
}
export default memo(WorkflowVariableBlockComponent)
@@ -0,0 +1,80 @@
import {
memo,
useEffect,
} from 'react'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import type { WorkflowVariableBlockType } from '../../types'
import {
$createWorkflowVariableBlockNode,
WorkflowVariableBlockNode,
} from './node'
// import type { Node } from '@/app/components/workflow/types'
export const INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND')
export const DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND')
export const CLEAR_HIDE_MENU_TIMEOUT = createCommand('CLEAR_HIDE_MENU_TIMEOUT')
export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
export type WorkflowVariableBlockProps = {
getWorkflowNode: (nodeId: string) => Node
onInsert?: () => void
onDelete?: () => void
}
const WorkflowVariableBlock = memo(({
workflowNodesMap,
onInsert,
onDelete,
}: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
editor.update(() => {
editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
})
}, [editor, workflowNodesMap])
useEffect(() => {
if (!editor.hasNodes([WorkflowVariableBlockNode]))
throw new Error('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
return mergeRegister(
editor.registerCommand(
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
(variables: string[]) => {
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap)
$insertNodes([workflowVariableBlockNode])
if (onInsert)
onInsert()
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
() => {
if (onDelete)
onDelete()
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, onInsert, onDelete, workflowNodesMap])
return null
})
WorkflowVariableBlock.displayName = 'WorkflowVariableBlock'
export { WorkflowVariableBlock }
export { WorkflowVariableBlockNode } from './node'
export { default as WorkflowVariableBlockReplacementBlock } from './workflow-variable-block-replacement-block'
@@ -0,0 +1,92 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical'
import type { WorkflowVariableBlockType } from '../../types'
import WorkflowVariableBlockComponent from './component'
export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap']
export type SerializedNode = SerializedLexicalNode & {
variables: string[]
workflowNodesMap: WorkflowNodesMap
}
export class WorkflowVariableBlockNode extends DecoratorNode<JSX.Element> {
__variables: string[]
__workflowNodesMap: WorkflowNodesMap
static getType(): string {
return 'workflow-variable-block'
}
static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap)
}
isInline(): boolean {
return true
}
constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, key?: NodeKey) {
super(key)
this.__variables = variables
this.__workflowNodesMap = workflowNodesMap
}
createDOM(): HTMLElement {
const div = document.createElement('div')
div.classList.add('inline-flex', 'items-center', 'align-middle')
return div
}
updateDOM(): false {
return false
}
decorate(): JSX.Element {
return (
<WorkflowVariableBlockComponent
nodeKey={this.getKey()}
variables={this.__variables}
workflowNodesMap={this.__workflowNodesMap}
/>
)
}
static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode {
const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap)
return node
}
exportJSON(): SerializedNode {
return {
type: 'workflow-variable-block',
version: 1,
variables: this.getVariables(),
workflowNodesMap: this.getWorkflowNodesMap(),
}
}
getVariables(): string[] {
const self = this.getLatest()
return self.__variables
}
getWorkflowNodesMap(): WorkflowNodesMap {
const self = this.getLatest()
return self.__workflowNodesMap
}
getTextContent(): string {
return `{{#${this.getVariables().join('.')}#}}`
}
}
export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(variables, workflowNodesMap)
}
export function $isWorkflowVariableBlockNode(
node: WorkflowVariableBlockNode | LexicalNode | null | undefined,
): node is WorkflowVariableBlockNode {
return node instanceof WorkflowVariableBlockNode
}
@@ -0,0 +1,66 @@
import {
memo,
useCallback,
useEffect,
} from 'react'
import type { TextNode } from 'lexical'
import { $applyNodeReplacement } from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { decoratorTransform } from '../../utils'
import type { WorkflowVariableBlockType } from '../../types'
import { CustomTextNode } from '../custom-text/node'
import { $createWorkflowVariableBlockNode } from './node'
import { WorkflowVariableBlockNode } from './index'
// import { VAR_REGEX as REGEX } from '@/config'
export const REGEX = /\{\{(#[a-zA-Z0-9_-]{1,50}(\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10}#)\}\}/gi
const WorkflowVariableBlockReplacementBlock = ({
workflowNodesMap,
onInsert,
}: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([WorkflowVariableBlockNode]))
throw new Error('WorkflowVariableBlockNodePlugin: WorkflowVariableBlockNode not registered on editor')
}, [editor])
const createWorkflowVariableBlockNode = useCallback((textNode: TextNode): WorkflowVariableBlockNode => {
if (onInsert)
onInsert()
const nodePathString = textNode.getTextContent().slice(3, -3)
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap))
}, [onInsert, workflowNodesMap])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
if (matchArr === null)
return null
const startOffset = matchArr.index
const endOffset = startOffset + matchArr[0].length
return {
end: endOffset,
start: startOffset,
}
}, [])
const transformListener = useCallback((textNode: any) => {
return decoratorTransform(textNode, getMatch, createWorkflowVariableBlockNode)
}, [createWorkflowVariableBlockNode, getMatch])
useEffect(() => {
REGEX.lastIndex = 0
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, transformListener),
)
}, [])
return null
}
export default memo(WorkflowVariableBlockReplacementBlock)
@@ -0,0 +1,94 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import type { FC } from 'react'
import { useDebounceFn } from 'ahooks'
type Props = {
className?: string
height: number
minHeight: number
onHeightChange: (height: number) => void
children: JSX.Element
footer?: JSX.Element
hideResize?: boolean
}
const PromptEditorHeightResizeWrap: FC<Props> = ({
className,
height,
minHeight,
onHeightChange,
children,
footer,
hideResize,
}) => {
const [clientY, setClientY] = useState(0)
const [isResizing, setIsResizing] = useState(false)
const [prevUserSelectStyle, setPrevUserSelectStyle] = useState(getComputedStyle(document.body).userSelect)
const handleStartResize = useCallback((e: React.MouseEvent<HTMLElement>) => {
setClientY(e.clientY)
setIsResizing(true)
setPrevUserSelectStyle(getComputedStyle(document.body).userSelect)
document.body.style.userSelect = 'none'
}, [])
const handleStopResize = useCallback(() => {
setIsResizing(false)
document.body.style.userSelect = prevUserSelectStyle
}, [prevUserSelectStyle])
const { run: didHandleResize } = useDebounceFn((e) => {
if (!isResizing)
return
const offset = e.clientY - clientY
let newHeight = height + offset
setClientY(e.clientY)
if (newHeight < minHeight)
newHeight = minHeight
onHeightChange(newHeight)
}, {
wait: 0,
})
const handleResize = useCallback(didHandleResize, [isResizing, height, minHeight, clientY])
useEffect(() => {
document.addEventListener('mousemove', handleResize)
return () => {
document.removeEventListener('mousemove', handleResize)
}
}, [handleResize])
useEffect(() => {
document.addEventListener('mouseup', handleStopResize)
return () => {
document.removeEventListener('mouseup', handleStopResize)
}
}, [handleStopResize])
return (
<div
className='relative rounded ant-input-outlined'
>
<div className={`${className} overflow-y-auto`}
style={{
height,
}}
>
{children}
</div>
{/* resize handler */}
{footer}
{!hideResize && (
<div
className='absolute bottom-0 left-0 w-full flex justify-center h-2 cursor-row-resize'
onMouseDown={handleStartResize}>
<div className='w-5 h-[3px] rounded-sm bg-gray-300'></div>
</div>
)}
</div>
)
}
export default React.memo(PromptEditorHeightResizeWrap)
@@ -0,0 +1,77 @@
import type { Dataset } from './plugins/context-block/index'
import type { RoleName } from './plugins/history-block/index'
// import type {
// Node,
// } from '@/app/components/workflow/types'
export type NodeOutPutVar = {
nodeId: string
title: string
vars: Var[]
isStartNode?: boolean
}
export type Option = {
value: string
name: string
}
export type ExternalToolOption = {
name: string
variableName: string
icon?: string
icon_background?: string
}
export type ContextBlockType = {
show?: boolean
selectable?: boolean
datasets?: Dataset[]
canNotAddContext?: boolean
onAddContext?: () => void
onInsert?: () => void
onDelete?: () => void
}
export type QueryBlockType = {
show?: boolean
selectable?: boolean
onInsert?: () => void
onDelete?: () => void
}
export type HistoryBlockType = {
show?: boolean
selectable?: boolean
history?: RoleName
onInsert?: () => void
onDelete?: () => void
onEditRole?: () => void
}
export type VariableBlockType = {
show?: boolean
variables?: Option[]
}
export type ExternalToolBlockType = {
show?: boolean
externalTools?: ExternalToolOption[]
onAddExternalTool?: () => void
}
unknown
export type WorkflowVariableBlockType = {
show?: boolean
variables?: NodeOutPutVar[]
workflowNodesMap?: Record<string, unknown>
onInsert?: () => void
onDelete?: () => void
}
export type MenuTextMatch = {
leadOffset: number
matchingString: string
replaceableString: string
}
@@ -0,0 +1,354 @@
import { $isAtNodeEnd } from '@lexical/selection'
import type {
ElementNode,
Klass,
LexicalEditor,
LexicalNode,
RangeSelection,
TextNode,
} from 'lexical'
import {
$createTextNode,
$getSelection,
$isRangeSelection,
$isTextNode,
} from 'lexical'
import type { EntityMatch } from '@lexical/text'
import { CustomTextNode } from './plugins/custom-text/node'
import type { MenuTextMatch } from './types'
import { CONTEXT_PLACEHOLDER_TEXT, HISTORY_PLACEHOLDER_TEXT, QUERY_PLACEHOLDER_TEXT, PRE_PROMPT_PLACEHOLDER_TEXT, MAX_VAR_KEY_LENGTH } from './constants'
export function getSelectedNode(
selection: RangeSelection,
): TextNode | ElementNode {
const anchor = selection.anchor
const focus = selection.focus
const anchorNode = selection.anchor.getNode()
const focusNode = selection.focus.getNode()
if (anchorNode === focusNode)
return anchorNode
const isBackward = selection.isBackward()
if (isBackward)
return $isAtNodeEnd(focus) ? anchorNode : focusNode
else
return $isAtNodeEnd(anchor) ? anchorNode : focusNode
}
export function registerLexicalTextEntity<T extends TextNode>(
editor: LexicalEditor,
getMatch: (text: string) => null | EntityMatch,
targetNode: Klass<T>,
createNode: (textNode: TextNode) => T,
) {
const isTargetNode = (node: LexicalNode | null | undefined): node is T => {
return node instanceof targetNode
}
const replaceWithSimpleText = (node: TextNode): void => {
const textNode = $createTextNode(node.getTextContent())
textNode.setFormat(node.getFormat())
node.replace(textNode)
}
const getMode = (node: TextNode): number => {
return node.getLatest().__mode
}
const textNodeTransform = (node: TextNode) => {
if (!node.isSimpleText())
return
const prevSibling = node.getPreviousSibling()
let text = node.getTextContent()
let currentNode = node
let match
if ($isTextNode(prevSibling)) {
const previousText = prevSibling.getTextContent()
const combinedText = previousText + text
const prevMatch = getMatch(combinedText)
if (isTargetNode(prevSibling)) {
if (prevMatch === null || getMode(prevSibling) !== 0) {
replaceWithSimpleText(prevSibling)
return
}
else {
const diff = prevMatch.end - previousText.length
if (diff > 0) {
const concatText = text.slice(0, diff)
const newTextContent = previousText + concatText
prevSibling.select()
prevSibling.setTextContent(newTextContent)
if (diff === text.length) {
node.remove()
}
else {
const remainingText = text.slice(diff)
node.setTextContent(remainingText)
}
return
}
}
}
else if (prevMatch === null || prevMatch.start < previousText.length) {
return
}
}
while (true) {
match = getMatch(text)
let nextText = match === null ? '' : text.slice(match.end)
text = nextText
if (nextText === '') {
const nextSibling = currentNode.getNextSibling()
if ($isTextNode(nextSibling)) {
nextText = currentNode.getTextContent() + nextSibling.getTextContent()
const nextMatch = getMatch(nextText)
if (nextMatch === null) {
if (isTargetNode(nextSibling))
replaceWithSimpleText(nextSibling)
else
nextSibling.markDirty()
return
}
else if (nextMatch.start !== 0) {
return
}
}
}
else {
const nextMatch = getMatch(nextText)
if (nextMatch !== null && nextMatch.start === 0)
return
}
if (match === null)
return
if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity())
continue
let nodeToReplace
if (match.start === 0)
[nodeToReplace, currentNode] = currentNode.splitText(match.end)
else
[, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end)
const replacementNode = createNode(nodeToReplace)
replacementNode.setFormat(nodeToReplace.getFormat())
nodeToReplace.replace(replacementNode)
if (currentNode == null)
return
}
}
const reverseNodeTransform = (node: T) => {
const text = node.getTextContent()
const match = getMatch(text)
if (match === null || match.start !== 0) {
replaceWithSimpleText(node)
return
}
if (text.length > match.end) {
// This will split out the rest of the text as simple text
node.splitText(match.end)
return
}
const prevSibling = node.getPreviousSibling()
if ($isTextNode(prevSibling) && prevSibling.isTextEntity()) {
replaceWithSimpleText(prevSibling)
replaceWithSimpleText(node)
}
const nextSibling = node.getNextSibling()
if ($isTextNode(nextSibling) && nextSibling.isTextEntity()) {
replaceWithSimpleText(nextSibling) // This may have already been converted in the previous block
if (isTargetNode(node))
replaceWithSimpleText(node)
}
}
const removePlainTextTransform = editor.registerNodeTransform(CustomTextNode, textNodeTransform)
const removeReverseNodeTransform = editor.registerNodeTransform(targetNode, reverseNodeTransform)
return [removePlainTextTransform, removeReverseNodeTransform]
}
export const decoratorTransform = (
node: CustomTextNode,
getMatch: (text: string) => null | EntityMatch,
createNode: (textNode: TextNode) => LexicalNode,
) => {
if (!node.isSimpleText())
return
const prevSibling = node.getPreviousSibling()
let text = node.getTextContent()
let currentNode = node
let match
while (true) {
match = getMatch(text)
let nextText = match === null ? '' : text.slice(match.end)
text = nextText
if (nextText === '') {
const nextSibling = currentNode.getNextSibling()
if ($isTextNode(nextSibling)) {
nextText = currentNode.getTextContent() + nextSibling.getTextContent()
const nextMatch = getMatch(nextText)
if (nextMatch === null) {
nextSibling.markDirty()
return
}
else if (nextMatch.start !== 0) {
return
}
}
}
else {
const nextMatch = getMatch(nextText)
if (nextMatch !== null && nextMatch.start === 0)
return
}
if (match === null)
return
if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity())
continue
let nodeToReplace
if (match.start === 0)
[nodeToReplace, currentNode] = currentNode.splitText(match.end)
else
[, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end)
const replacementNode = createNode(nodeToReplace)
nodeToReplace.replace(replacementNode)
if (currentNode == null)
return
}
}
function getFullMatchOffset(
documentText: string,
entryText: string,
offset: number,
): number {
let triggerOffset = offset
for (let i = triggerOffset; i <= entryText.length; i++) {
if (documentText.substr(-i) === entryText.substr(0, i))
triggerOffset = i
}
return triggerOffset
}
export function $splitNodeContainingQuery(match: MenuTextMatch): TextNode | null {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed())
return null
const anchor = selection.anchor
if (anchor.type !== 'text')
return null
const anchorNode = anchor.getNode()
if (!anchorNode.isSimpleText())
return null
const selectionOffset = anchor.offset
const textContent = anchorNode.getTextContent().slice(0, selectionOffset)
const characterOffset = match.replaceableString.length
const queryOffset = getFullMatchOffset(
textContent,
match.matchingString,
characterOffset,
)
const startOffset = selectionOffset - queryOffset
if (startOffset < 0)
return null
let newNode
if (startOffset === 0)
[newNode] = anchorNode.splitText(selectionOffset)
else
[, newNode] = anchorNode.splitText(startOffset, selectionOffset)
return newNode
}
export function textToEditorState(text: string) {
const paragraph = text.split('\n')
return JSON.stringify({
root: {
children: paragraph.map((p) => {
return {
children: [{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: p,
type: 'custom-text',
version: 1,
}],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
}
}),
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
})
}
const varRegex = /\{\{(.+?)\}\}/g
export const getVars = (value: string) => {
if (!value)
return []
const keys = value.match(varRegex)?.filter((item) => {
return ![CONTEXT_PLACEHOLDER_TEXT, HISTORY_PLACEHOLDER_TEXT, QUERY_PLACEHOLDER_TEXT, PRE_PROMPT_PLACEHOLDER_TEXT].includes(item)
}).map((item) => {
return item.replace('{{', '').replace('}}', '')
}).filter(key => key.length <= MAX_VAR_KEY_LENGTH) || []
const keyObj: Record<string, boolean> = {}
// remove duplicate keys
const res: string[] = []
keys.forEach((key) => {
if (keyObj[key])
return
keyObj[key] = true
res.push(key)
})
return res
}
@@ -219,6 +219,11 @@ export const ApprovalRouteColumns = [
ellipsis:true,
render:(value)=>value?.join(', ')
},
{
title:('名称'),
dataIndex:'name',
ellipsis:true,
},
{
title:('路径'),
dataIndex:'path',
@@ -106,11 +106,11 @@ export type PublishTableListItem = {
export type PublishApprovalModalProps = {
type:'approval'|'view'|'add'|'publish'|'online'
data:PublishApprovalInfoType | PublishApprovalInfoType &{id?:string} | PublishVersionTableListItem
insideSystem?:boolean
insidePage?:boolean
serviceId:string
teamId:string
clusterPublishStatus?:SystemInsidePublishOnlineItems[]
serviceType?:'rest'|'ai'
}
export type PublishApprovalModalHandle = {
+3 -2
View File
@@ -26,7 +26,7 @@ export const routerKeyMap = new Map<string, string[]|string>([
['operationCenter',['member','user','role','servicecategories']],
['organization',['member','user','role']],
['serviceHubSetting',['servicecategories']],
['maintenanceCenter',['datasourcing','cluster','cert','logsettings','resourcesettings','openapi']
['maintenanceCenter',['aisetting','datasourcing','cluster','cert','logsettings','resourcesettings','openapi']
]])
@@ -43,7 +43,8 @@ export const routerKeyMap = new Map<string, string[]|string>([
input:('请输入'),
select:('请选择'),
startWithAlphabet:('英文数字下划线任意一种,首字母必须为英文'),
specialStartWithAlphabet:('支持字母开头、英文数字中横线下划线组合')
specialStartWithAlphabet:('支持字母开头、英文数字中横线下划线组合'),
onlyAlphabet:('字符非法,仅支持英文'),
}
export const FORM_ERROR_TIPS = {
@@ -174,6 +174,16 @@ export const PERMISSION_DEFINITION = [
"anyOf": [{ "backend": ["system.devops.cluster.manager"] }]
}
},
"system.devops.ai_provider.view": {
"granted": {
"anyOf": [{ "backend": ["system.devops.ai_provider.view"] }]
}
},
"system.devops.ai_provider.edit": {
"granted": {
"anyOf": [{ "backend": ["system.devops.ai_provider.manager"] }]
}
},
"system.devops.ssl_certificate.view": {
"granted": {
"anyOf": [{ "backend": ["system.devops.ssl_certificate.view"] }]
@@ -4,6 +4,7 @@ import { App } from "antd";
import { BasicResponse, RESPONSE_TIPS, STATUS_CODE } from "@common/const/const";
import { checkAccess } from "@common/utils/permission";
import { PERMISSION_DEFINITION } from "@common/const/permissions";
import { $t } from "@common/locales";
interface GlobalState {
isAuthenticated: boolean;
@@ -46,7 +47,8 @@ export const GlobalContext = createContext<{
checkPermission:(access:keyof typeof PERMISSION_DEFINITION[0] | Array<keyof typeof PERMISSION_DEFINITION[0]>)=>boolean
teamDataFlushed:boolean
accessInit:boolean
aiConfigFlushed:boolean
setAiConfigFlushed:(flush:boolean)=>void
} | undefined>(undefined);
const globalReducer = (state: GlobalState, action: GlobalAction): GlobalState => {
@@ -107,13 +109,14 @@ export const GlobalProvider: FC<{children:ReactNode}> = ({ children }) => {
version: '1.0.0',
updateDate: '2024-07-01',
powered:'Powered by https://apipark.com',
mainPage:'/guide',
mainPage:'/guide/page',
language:'en'
});
const [accessData,setAccessData] = useState<Map<string,string[]>>(new Map())
const [pluginAccessDictionary, setPluginAccessDictionary] = useState<{[k:string]:string}>({})
const [teamDataFlushed, setTeamDataFlushed] = useState<boolean>(false)
const [accessInit, setAccessInit] = useState<boolean>(false)
const [aiConfigFlushed, setAiConfigFlushed] = useState<boolean>(false)
let getGlobalAccessPromise: Promise<BasicResponse<{ access:string[] }>> | null = null
const getGlobalAccessData = ()=>{
@@ -153,7 +156,6 @@ export const GlobalProvider: FC<{children:ReactNode}> = ({ children }) => {
setAccessData(prevData => prevData.set('team',[]))
}
const getPluginAccessDictionary = (pluginData:{[k:string]:string})=>{
setPluginAccessDictionary(pluginData)
}
@@ -174,14 +176,15 @@ export const GlobalProvider: FC<{children:ReactNode}> = ({ children }) => {
return revs
}
return (
<GlobalContext.Provider value={
{ state, dispatch,accessData,pluginAccessDictionary,
getGlobalAccessData,getPluginAccessDictionary,
getGlobalAccessData,
getPluginAccessDictionary,
getTeamAccessData,teamDataFlushed,
cleanTeamAccessData,
resetAccess ,checkPermission,accessInit}}>
resetAccess ,checkPermission,accessInit,
aiConfigFlushed, setAiConfigFlushed}}>
{children}
</GlobalContext.Provider>
);
@@ -0,0 +1,28 @@
'use client'
import { createContext, useContext } from 'use-context-selector'
import { useEventEmitter } from 'ahooks'
import type { EventEmitter } from 'ahooks/lib/useEventEmitter'
const EventEmitterContext = createContext<{ eventEmitter: EventEmitter<string> | null }>({
eventEmitter: null,
})
export const useEventEmitterContextContext = () => useContext(EventEmitterContext)
type EventEmitterContextProviderProps = {
children: React.ReactNode
}
export const EventEmitterContextProvider = ({
children,
}: EventEmitterContextProviderProps) => {
const eventEmitter = useEventEmitter<string>()
return (
<EventEmitterContext.Provider value={{ eventEmitter }}>
{children}
</EventEmitterContext.Provider>
)
}
export default EventEmitterContext
@@ -678,5 +678,29 @@
"K1362a512": "Disable Member",
"K6e1289b1": "Enable Member",
"K1f4b5385": "Delete Member",
"Kf85b83a0": "Enter URL to Search Route"
"Kf85b83a0": "Enter URL to Search Route",
"K62840d62": "REST Service",
"Kd2c34e2c": "AI Service",
"K4d5960c1": "AI Setting",
"Kc6340091": "Context",
"K74ecb1fa": "Query",
"K79f2e2f9": "Conversation History",
"K3a8912ee": "New variable",
"Kb291a19": "New tool",
"K27ece71d": "AI Model Invocation Defaults to Using Only Query Variables; Enter '{' to Add New Variables",
"K14700c7": "AI Model Provider",
"K1786a4c8": "Add AI Service",
"K66060758": "Name",
"K2bb86fb4": "Prompt",
"K13ffbe88": "Variable",
"K79c8cfaf": "Enter the Description for This Interface",
"K8a35059b": "Model Configuration",
"Kfede1c7c": "Model",
"Ke99513a0": "Parameters",
"K18dccc1a": "Synchronize Latest Model",
"Ke66a17dd": "Required",
"Kb3e34847": "Get API Key from (0)",
"Kd9a46c29": "Default",
"K66a7d24c": "Configured",
"K28b68036": "Invalid characters, only alphabetical characters are allowed"
}
@@ -1,7 +1,2 @@
{
"K7c97c5df": "移出当前部门",
"K1362a512": "禁用成员",
"K6e1289b1": "启用成员",
"K1f4b5385": "删除成员",
"Kf85b83a0": "输入 URL 查找路由"
}
@@ -1,7 +1,3 @@
{
"K7c97c5df": "移出当前部门",
"K1362a512": "禁用成员",
"K6e1289b1": "启用成员",
"K1f4b5385": "删除成员",
"Kf85b83a0": "输入 URL 查找路由"
"K28b68036": "字符非法,仅支持英文"
}
+16 -2
View File
@@ -94,6 +94,10 @@
color:#fff !important;
}
}
li.ant-menu-submenu-horizontal.ant-menu-overflow-item-rest .ant-menu-submenu-title{
color:#fff !important;
}
}
.ant-layout-sider.apipark-layout-sider{
height:calc(100vh - var(--layout-header-height)) !important;
@@ -108,6 +112,9 @@
.ant-menu-item{
margin-block:0 !important;
}
.ant-menu-light:not(.ant-menu-horizontal) .ant-menu-item:not(.ant-menu-item-selected):active{
background-color: unset;
}
}
.apipark-layout-sider-collapsed-button{
@@ -138,9 +145,9 @@
> li:active{
background-color: transparent;
}
/* > li.ant-menu-item-active {
> li.ant-menu-item-active {
color:#fff !important;
} */
}
> li.ant-menu-item-selected {
background-color: #fff !important;
border: 1px solid #fff !important;
@@ -221,4 +228,11 @@ a{
}
}
}
.ant-pro-table .ant-popover .ant-popover-inner-content{
.ant-form-item{
background-color: transparent;
border:none;
}
}
@@ -5,14 +5,15 @@ import BasicLayout from '@common/components/aoplatform/BasicLayout';
import {createElement, ReactElement,ReactNode,Suspense} from 'react';
import { v4 as uuidv4 } from 'uuid'
import {App, Skeleton} from "antd";
import ApprovalPage from "@core/pages/approval/ApprovalPage.tsx";
import {SystemProvider} from "@core/contexts/SystemContext.tsx";
import {useGlobalContext} from "@common/contexts/GlobalStateContext.tsx";
import {FC,lazy} from 'react';
import { TeamProvider } from '@core/contexts/TeamContext.tsx';
import SystemOutlet from '@core/pages/system/SystemOutlet.tsx';
import { TenantManagementProvider } from '@market/contexts/TenantManagementContext.tsx';
import Guide from '@core/pages/guide/Guide';
import { AiServiceProvider } from '@core/contexts/AiServiceContext';
import AiServiceOutlet from '@core/pages/aiService/AiServiceOutlet';
import SystemOutlet from '@core/pages/system/SystemOutlet';
import { SystemProvider } from '@core/contexts/SystemContext';
type RouteConfig = {
path:string
@@ -42,6 +43,7 @@ export type RouterParams = {
appId:string
roleType:string
roleId:string
routeId:string
}
const PUBLIC_ROUTES:RouteConfig[] = [
@@ -219,6 +221,120 @@ const PUBLIC_ROUTES:RouteConfig[] = [
}
]
},
{
path:'aiservice',
component:<AiServiceOutlet />,
key: uuidv4(),
provider: AiServiceProvider,
children:[
{
path:'',
key:uuidv4(),
component:<Navigate to="list" />
},
{
path:'list',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/AiServiceList.tsx')),
},
{
path:'list/:teamId',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/AiServiceList.tsx')),
},
{
path:':teamId',
component:<Outlet/>,
key: uuidv4(),
children:[
{
path:'inside/:serviceId',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/AiServiceInsidePage.tsx')),
children:[
{
path:'api',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/api/AiServiceInsideApiDocument')),
},
{
path:'route/create',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/api/AiServiceInsideRouterCreate')),
},
{
path:'route/:routeId',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/api/AiServiceInsideRouterCreate')),
},
{
path:'route',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/api/AiServiceInsideRouterList')),
},
{
path:'document',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/AiServiceInsideDocument.tsx')),
},
{
path:'subscriber',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/AiServiceInsideSubscriber.tsx')),
children:[
]
},
{
path:'approval',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/approval/AiServiceInsideApproval')),
children:[
{
path:'',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/approval/AiServiceInsideApprovalList')),
},
{
path:'*',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/approval/AiServiceInsideApprovalList')),
}
]
},
{
path:'publish',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/publish/AiServiceInsidePublish')),
children:[
{
path:'',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/publish/AiServiceInsidePublishList')),
},
{
path:'*',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/publish/AiServiceInsidePublishList')),
}
]
},
{
path:'setting',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiService/AiServiceConfig.tsx')),
children:[
]
},
]
}
]
}
]
},
{
path:'datasourcing',
key: uuidv4(),
@@ -229,6 +345,11 @@ const PUBLIC_ROUTES:RouteConfig[] = [
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/partitions/PartitionInsideCluster.tsx')),
},
{
path:'aisetting',
key: uuidv4(),
lazy:lazy(() => import(/* webpackChunkName: "[request]" */ '@core/pages/aiSetting/AiSettingList.tsx')),
},
{
path:'cert',
key: uuidv4(),
@@ -0,0 +1,170 @@
import { AiServiceTableListItem, AiServiceRouterTableListItem, VariableItems } from "./type";
import { TabsProps } from "antd";
import { frontendTimeSorter } from "@common/utils/dataTransfer";
import { COLUMNS_TITLE, PLACEHOLDER } from "@common/const/const";
import { PageProColumns } from "@common/components/aoplatform/PageList";
export const AI_SERVICE_TABLE_COLUMNS: PageProColumns<AiServiceTableListItem>[] = [
{
title:('服务名称'),
dataIndex: 'name',
ellipsis:true,
width:160,
fixed:'left',
sorter: (a,b)=> {
return a.name.localeCompare(b.name)
},
},
{
title:('服务 ID'),
dataIndex: 'id',
width: 140,
ellipsis:true,
},
{
title:('AI 模型供应商'),
dataIndex: ['provider','name'],
ellipsis:true,
},
{
title:('所属团队'),
dataIndex: ['team','name'],
ellipsis:true,
// filters: true,
// onFilter: true,
// filterSearch: true,
},
{
title:('API 数量'),
dataIndex: 'apiNum',
ellipsis:true,
sorter: (a,b)=> {
return a.apiNum - b.apiNum
},
},
{
title: ('描述'),
dataIndex: 'description',
ellipsis:true,
},
{
title:('创建时间'),
dataIndex: 'createTime',
width:182,
ellipsis:true,
sorter: (a,b)=>frontendTimeSorter(a,b,'createTime')
}
];
export const AI_SERVICE_ROUTER_TABLE_COLUMNS: PageProColumns<AiServiceRouterTableListItem>[] = [
{
title:('URL'),
dataIndex: 'requestPath',
ellipsis:true
},
{
title:('名称'),
dataIndex: 'name',
ellipsis:true,
},
{
title:('模型'),
dataIndex: ['model','logo'],
ellipsis:true,
render: (_: React.ReactNode, entity: AiServiceRouterTableListItem) =><div className="flex items-center gap-[2px]" > <div className="flex items-center" dangerouslySetInnerHTML={{ __html: entity.model.logo }}></div><span>{entity.model.id}</span></div>
},
{
title:('描述'),
dataIndex: 'description',
ellipsis:true
},
{
title:('创建者'),
dataIndex: ['creator','name'],
ellipsis: true,
filters: true,
onFilter: true,
valueType: 'select',
filterSearch: true,
},
{
title:('更新时间'),
dataIndex: 'updateTime',
ellipsis:true,
hideInSearch: true,
width:182,
sorter: (a,b)=>frontendTimeSorter(a,b,'updateTime')
},
];
export const AI_SERVICE_VARIABLES_TABLE_COLUMNS: PageProColumns<VariableItems & {_id:string}>[] = [
{
title:('Key'),
dataIndex: 'key',
key:'key',
width: '30%',
formItemProps: {
className:'p-0 bg-transparent border-none',
rules: [
{
required: true,
whitespace: true
},
{
pattern:/^[a-zA-Z][a-zA-Z0-9-_]*$/,
message: PLACEHOLDER.onlyAlphabet
}
],
},
ellipsis:true
},
{
title:('描述'),
dataIndex: 'description',
key:'description',
formItemProps: {
className:'p-0 bg-transparent border-none'}
},
{
title:('必填'),
dataIndex: 'require',
key:'require',
valueType:'switch',
width:64,
formItemProps: {
className:'p-0 bg-transparent border-none'}
},
{
title: COLUMNS_TITLE.operate,
valueType: 'option',
width:34,
render: ()=>null
},
];
export const AiService_INSIDE_APPROVAL_TAB_ITEMS: TabsProps['items'] = [
{
key: '0',
label:('待审批'),
},
{
key: '1',
label: ('已审批'),
}
];
export const AiService_PUBLISH_TAB_ITEMS: TabsProps['items'] = [
{
key: '0',
label: ('发布版本'),
},
{
key: '1',
label: ('发布申请记录'),
}
];
@@ -0,0 +1,293 @@
import { FormInstance, UploadFile } from "antd";
import { EntityItem } from "@common/const/type";
import { SubscribeEnum, SubscribeFromEnum } from "./const";
export type AiServiceTableListItem = {
id:string;
name: string;
team: EntityItem;
apiNum: number;
description:string;
createTime:string;
updateTime:string;
canDelete:boolean;
provider:EntityItem
}
export type AiServiceConfigFieldType = {
name?: string;
id?: string;
provider?:string
prefix?:string;
logo?:string;
logoFile?:UploadFile;
tags?:Array<string>;
description?: string;
team?:string;
master?:string;
serviceType?:'public'|'inner';
catalogue?:string | string[];
};
export type AiServiceSubServiceTableListItem = {
id:string;
applyStatus:typeof SubscribeEnum;
project:EntityItem;
team:EntityItem
service:EntityItem
applier:EntityItem
from:SubscribeFromEnum
createTime:string
};
export type AiServiceSubscriberTableListItem = {
id:string
service:EntityItem
applyStatus:typeof SubscribeEnum
project:EntityItem
team:EntityItem;
applier:EntityItem
approver:EntityItem;
from:SubscribeFromEnum
applyTime:string
};
export type AiServiceSubscriberConfigFieldType = {
application:string
applier:string
};
export type AiServiceSubscriberConfigProps = {
serviceId:string
teamId:string
}
export type AiServiceSubscriberConfigHandle = {
save:()=>Promise<boolean|string>
}
export type AiServiceMemberTableListItem = {
user: EntityItem;
email:string;
roles:Array<EntityItem>;
canDelete:boolean
};
export type AiServiceApiDetail = {
content:string
updateTime:string
updater:string
}
export type AiServiceInsideRouterCreateProps = {
type?:'add'|'edit'|'copy'
entity?:AiServiceRouterTableListItem
modalApiPrefix?:string
modalPrefixForce?:boolean
serviceId:string
teamId:string
}
export type AiServiceInsideRouterCreateHandle = {
copy:()=>Promise<boolean|string>;
save:()=>Promise<boolean|string>;
}
export type AiServiceRouterTableListItem = {
id:string;
name:string;
requestPath:string;
description:string
creator:EntityItem;
createTime:string;
updater:EntityItem
updateTime:string
model:{
id:string
logo:string
}
};
export type MyServiceFieldType = {
name?: string;
id?: string;
description?: string;
team?:string;
project?:string;
status?:'off'|'on'
};
export type SimpleAiServiceItem = {
id:string
name:string
team:EntityItem
}
export type ServiceApiTableListItem = {
id:string;
name: string;
method:string;
path:string;
description:string;
};
export type SimpleApiItem = {
id:string
name:string
method:string
requestPath:string
}
export type AiServiceAuthorityTableListItem = {
id:string
name: string;
driver:string;
hideCredential:boolean;
expireTime:number;
creator:EntityItem;
updater:EntityItem;
createTime:string;
updateTime:string
};
export type MyServiceTableListItem = {
id:string;
name: string;
serviceType:'public'|'inner';
apiNum:number;
status:string;
createTime:string;
updateTime:string;
};
export type AiServiceInsideApiDetailProps = {
serviceId:string;
teamId:string;
apiId:string;
}
export type AiServiceInsideApiDocumentHandle = {
save:()=>Promise<boolean|string>|undefined
}
export type AiServiceInsideApiDocumentProps = {
serviceId:string
teamId:string
apiId:string
}
export type AiServiceInsideApiProxyHandle = {
validate:()=>Promise<void>
}
export interface MyServiceInsideConfigHandle {
save:()=>Promise<boolean|string>
}
export interface MyServiceInsideConfigProps {
teamId:string
serviceId?:string
closeDrawer?:() => void
}
export type SubSubscribeApprovalModalProps = {
type:'reApply'|'view'
data?:AiServiceSubServiceTableListItem
teamId:string
serviceId?:string
}
export type SubSubscribeApprovalModalHandle = {
reApply:() =>Promise<boolean|string>
}
export type SubSubscribeApprovalModalFieldType = {
reason?:string;
opinion?:string;
};
export type AiServiceInsideUpstreamConfigProps = {
upstreamNameForm:FormInstance
setLoading:(loading:boolean) => void
}
export type AiServiceInsideUpstreamConfigHandle = {
save:()=>Promise<boolean|string>|undefined
}
export type AiServiceInsideUpstreamContentHandle = {
save:()=>Promise<boolean|string>|undefined
}
export type AiServiceConfigHandle = {
save:()=>Promise<string|boolean>|undefined
}
export type AiServiceTopologyServiceItem = EntityItem & {
project:string
}
export interface AiServiceTopologySubscriber {
project: EntityItem;
services: EntityItem[];
}
export interface AiServiceTopologyInvoke {
project: EntityItem;
services: EntityItem[];
}
// 接口返回的数据格式
export interface AiServiceTopologyResponse {
services: AiServiceTopologyServiceItem[];
subscribers: AiServiceTopologySubscriber[];
invoke: AiServiceTopologyInvoke[];
}
export enum AiServiceReleaseStatus {
'正常' = 0,
'未设置' = 1,
'缺失' = 2
}
export type AiServicePublishReleaseItem = {
api: Array<{
name: string,
method: string,
path: string,
upstream: string,
change: string,
status: {
upstreamStatus: AiServiceReleaseStatus,
docStatus: AiServiceReleaseStatus,
proxyStatus: AiServiceReleaseStatus
}
}>
upstream: Array<{
name: "",
type: "",
addr: [],
status: ""
}>
}
export type VariableItems = {
key:string,
description:string,
required:boolean
}
@@ -1,18 +1,11 @@
import { GlobalNodeItem, MyServiceTableListItem, NodeItem, ProxyHeaderItem, ServiceApiTableListItem, SimpleApiItem, SystemApiTableListItem, SystemAuthorityTableListItem, SystemMemberTableListItem, SystemSubServiceTableListItem, SystemSubscriberTableListItem, SystemTableListItem, SystemUpstreamTableListItem } from "./type";
import { Input, InputNumber, MenuProps, Select, TabsProps, Tooltip } from "antd";
import { ColumnsType } from "antd/es/table";
import { getItem } from "@common/utils/navigation";
import { MatchItem, MemberItem } from "@common/const/type";
import { GlobalNodeItem, ProxyHeaderItem, SystemApiTableListItem, SystemMemberTableListItem, SystemSubscriberTableListItem, SystemTableListItem } from "./type";
import { Input, TabsProps } from "antd";
import { MatchItem } from "@common/const/type";
import { ConfigField } from "@common/components/aoplatform/EditableTableWithModal";
import { frontendTimeSorter } from "@common/utils/dataTransfer";
import { COLUMNS_TITLE, STATUS_COLOR, VALIDATE_MESSAGE } from "@common/const/const";
import { LoadingOutlined } from "@ant-design/icons";
import { SystemInsidePublishOnlineItems } from "../../pages/system/publish/SystemInsidePublishOnline";
import dayjs from 'dayjs';
import { Link } from "react-router-dom";
import { COLUMNS_TITLE } from "@common/const/const";
import { PageProColumns } from "@common/components/aoplatform/PageList";
import { $t } from "@common/locales";
export enum SubscribeEnum{
Rejected = 0,
@@ -127,16 +120,6 @@ export const SYSTEM_TABLE_COLUMNS: PageProColumns<SystemTableListItem>[] = [
dataIndex: 'description',
ellipsis:true,
},
{
title:('负责人'),
dataIndex: ['master','name'],
ellipsis: true,
width:108,
filters: true,
onFilter: true,
valueType: 'select',
filterSearch: true,
},
{
title:('创建时间'),
dataIndex: 'createTime',
@@ -195,21 +178,6 @@ export const SYSTEM_SUBSCRIBER_TABLE_COLUMNS: PageProColumns<SystemSubscriberTab
];
export const memberModalColumn:ColumnsType<MemberItem> = [
{title:('成员'),
render:(_,entity)=>{
return <>
<div>
<p>
<span>{entity.name}</span>
{entity.email !== undefined && <span className="text-status_offline">{entity.email}</span>}
</p>
<p>{entity.department}</p>
</div>
</>
}}
]
export const SYSTEM_MEMBER_TABLE_COLUMN: PageProColumns<SystemMemberTableListItem>[] = [
{
title:('用户名'),
@@ -319,65 +287,6 @@ export const SYSTEM_API_TABLE_COLUMNS: PageProColumns<SystemApiTableListItem>[]
},
];
export const SYSTEM_UPSTREAM_TABLE_COLUMNS: PageProColumns<SystemUpstreamTableListItem>[] = [
{
title:('名称'),
dataIndex: 'name',
ellipsis:true,
width:160,
fixed:'left',
sorter: (a,b)=> {
return a.name.localeCompare(b.name)
},
},
{
title:('上游 ID'),
dataIndex: 'id',
width: 140,
ellipsis:true
},
{
title:('创建人'),
dataIndex: ['creator','name'],
ellipsis: true,
width:88,
filters: true,
onFilter: true,
valueType: 'select',
filterSearch: true,
},
{
title:('更新人'),
dataIndex: ['updater','name'],
ellipsis: true,
width:88,
filters: true,
onFilter: true,
valueType: 'select',
filterSearch: true,
},
{
title:('创建时间'),
dataIndex: 'createTime',
width:182,
ellipsis:true,
sorter: (a,b)=> {
return a.createTime.localeCompare(b.createTime)
}
},
{
title:('更新时间'),
dataIndex: 'updateTime',
width:182,
ellipsis:true,
sorter: (a,b)=> {
return a.updateTime.localeCompare(b.updateTime)
},
},
];
export const UpstreamDriverEnum = {
'static':('静态上游'),
'discoveries':('动态服务发现'),
@@ -435,80 +344,11 @@ export const PROXY_HEADER_CONFIG:ConfigField<ProxyHeaderItem>[] = [
}
]
export const NODE_CONFIG:ConfigField<NodeItem>[] = [
{
title:('集群'),
key: 'cluster',
component: <Select className="w-INPUT_NORMAL" options={[]}/>,
required: true
}, {
title:('地址'),
key: 'address',
component: <Input className="w-INPUT_NORMAL" />,
renderText: (value: string) => {
return value
},
required: true
}, {
title:('权重'),
key: 'weight',
component: <InputNumber className="w-INPUT_NORMAL"/>,
renderText: (value: string) => {
return value
},
required: true
}
]
export const SERVICE_VISUALIZATION_OPTIONS = [
{label:('内部服务:可通过网关访问,但不展示在服务广场'),value:'inner'},
{label:('公开服务:可通过网关访问,展示在服务广场,可被其他应用订阅'),value:'public'}];
export const SYSTEM_MYSERVICE_API_TABLE_COLUMNS: PageProColumns<ServiceApiTableListItem>[] = [
{
title:(' '),
dataIndex: 'id',
width:'40px',
fixed:'left'
},
{
title:('名称'),
dataIndex: 'name',
width:160,
fixed:'left',
ellipsis:true
},
{
title:('请求方式'),
dataIndex: 'method',
ellipsis:true
},
{
title:('请求路径'),
dataIndex: 'path',
ellipsis:true
},
{
title: ('描述'),
dataIndex: 'description',
ellipsis:true
}
];
export const apiModalColumn:ColumnsType<SimpleApiItem> = [
{
title:('所有 API'),
dataIndex:'method',
},
{
title:'',
dataIndex:'name',
ellipsis:true
}
]
export const SYSTEM_UPSTREAM_GLOBAL_CONFIG_TABLE_COLUMNS: PageProColumns<GlobalNodeItem & {_id:string}>[] = [
@@ -0,0 +1,30 @@
import { EntityItem } from '@common/const/type';
import { AiServiceConfigFieldType } from '@core/const/ai-service/type';
import {FC, createContext, useContext, useState, ReactNode } from 'react';
interface AiServiceContextProps {
apiPrefix:string;
setApiPrefix:React.Dispatch<React.SetStateAction<string>>;
prefixForce:boolean;
setPrefixForce:React.Dispatch<React.SetStateAction<boolean>>;
aiServiceInfo:(AiServiceConfigFieldType & {provider:EntityItem })|undefined
setAiServiceInfo:React.Dispatch<React.SetStateAction<AiServiceConfigFieldType|undefined>>;
}
const AiServiceContext = createContext<AiServiceContextProps | undefined>(undefined);
export const useAiServiceContext = () => {
const context = useContext(AiServiceContext);
if (!context) {
throw new Error('useArray must be used within a ArrayProvider');
}
return context;
};
export const AiServiceProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [apiPrefix, setApiPrefix] = useState<string>('');
const [prefixForce, setPrefixForce] = useState<boolean>(false);
const [aiServiceInfo, setAiServiceInfo] = useState<AiServiceConfigFieldType>()
return <AiServiceContext.Provider value={{apiPrefix,setApiPrefix,prefixForce,setPrefixForce,aiServiceInfo, setAiServiceInfo }}>{children}</AiServiceContext.Provider>;
};
@@ -0,0 +1,416 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { App, Button, Form, Input, Radio, Row, Select, TreeSelect, Upload } from "antd";
import { Link, useNavigate, useParams } from "react-router-dom";
import { RouterParams } from "@core/components/aoplatform/RenderRoutes.tsx";
import { BasicResponse, DELETE_TIPS, PLACEHOLDER, RESPONSE_TIPS, STATUS_CODE } from "@common/const/const.tsx";
import { useFetch} from "@common/hooks/http.ts";
import { DefaultOptionType } from "antd/es/cascader";
import { EntityItem, MemberItem, SimpleTeamItem } from "@common/const/type.ts";
import { v4 as uuidv4 } from 'uuid'
import { validateUrlSlash } from "@common/utils/validate.ts";
import { normFile } from "@common/utils/uploadPic.ts";
import { useBreadcrumb } from "@common/contexts/BreadcrumbContext.tsx";
import { SERVICE_VISUALIZATION_OPTIONS } from "@core/const/system/const.tsx";
import { RcFile, UploadChangeParam, UploadFile, UploadProps } from "antd/es/upload/interface";
import { LoadingOutlined } from "@ant-design/icons";
import { getImgBase64 } from "@common/utils/dataTransfer.ts";
import { CategorizesType } from "@market/const/serviceHub/type.ts";
import WithPermission from "@common/components/aoplatform/WithPermission.tsx";
import { Icon } from "@iconify/react/dist/iconify.js";
import { useGlobalContext } from "@common/contexts/GlobalStateContext.tsx";
import { $t } from "@common/locales/index.ts";
import { AiServiceConfigHandle, AiServiceConfigFieldType } from "@core/const/ai-service/type";
import { useAiServiceContext } from "@core/contexts/AiServiceContext";
type SimpleAiProviderItem = EntityItem & {
configured:boolean
logo:string
}
const AiServiceConfig = forwardRef<AiServiceConfigHandle>((_,ref) => {
const { message,modal } = App.useApp()
const { teamId, serviceId } = useParams<RouterParams>();
const [onEdit, setOnEdit] = useState<boolean>(!!teamId)
const [form] = Form.useForm();
const {fetchData} = useFetch()
const [teamOptionList, setTeamOptionList] = useState<DefaultOptionType[]>()
const [providerOptionList, setProviderOptionList] = useState<DefaultOptionType[]>()
const navigate = useNavigate();
const {setBreadcrumb} = useBreadcrumb()
const { setAiServiceInfo} = useAiServiceContext()
const [showClassify, setShowClassify] = useState<boolean>()
const [imageBase64, setImageBase64] = useState<string | null>(null);
const [tagOptionList, setTagOptionList] = useState<DefaultOptionType[]>([])
const [serviceClassifyOptionList, setServiceClassifyOptionList] = useState<DefaultOptionType[]>()
const [uploadLoading, setUploadLoading] = useState<boolean>(false)
const {checkPermission,accessInit, getGlobalAccessData,state, aiConfigFlushed, setAiConfigFlushed} = useGlobalContext()
useImperativeHandle(ref, () => ({
save:onFinish
}));
const beforeUpload = async (file: RcFile) => {
if (!['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) {
alert($t('只允许上传PNG、JPG或SVG格式的图片'));
return false;
}
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
setImageBase64(e.target?.result as string);
form.setFieldValue('logo', e.target?.result);
};
reader.readAsDataURL(file);
// }
return false;
};
const handleChange: UploadProps['onChange'] = (info: UploadChangeParam<UploadFile>) => {
if (info.file.status === 'uploading') {
setUploadLoading(true);
return;
}
if (info.file.status === 'done') {
getImgBase64(info.file.originFileObj as RcFile, () => {
setUploadLoading(false);
});
}
if (info.fileList.length === 0) {
form.setFieldValue( "logo", null );
}
};
const uploadButton = (
<div>
{uploadLoading ? <LoadingOutlined /> : <Icon icon="ic:baseline-add" width="24" height="24"/>}
</div>
);
const getTagAndServiceClassifyList = ()=>{
setTagOptionList([])
setServiceClassifyOptionList([])
fetchData<BasicResponse<{ catalogues:CategorizesType[],tags:EntityItem[]}>>('catalogues',{method:'GET'}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setTagOptionList(data.tags?.map((x:EntityItem)=>{return {
label:x.name, value:x.name
}})||[])
setServiceClassifyOptionList(data.catalogues)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
// 获取表单默认值
const getAiServiceInfo = () => {
fetchData<BasicResponse<{ service: AiServiceConfigFieldType }>>('ai-service/info',{method:'GET',eoParams:{team:teamId, service:serviceId},eoTransformKeys:['team_id','service_type']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setTimeout(()=>{
form.setFieldsValue({
...data.service,
team:data.service.team.id,
catalogue:data.service.catalogue?.id,
tags:data.service.tags?.map((x:EntityItem)=>x.name),
provider:data.service.provider.id,
logoFile:[
{
uid: '-1', // 文件唯一标识
name: 'image.png', // 文件名
status: 'done', // 状态有:uploading, done, error, removed
url: data.service?.logo || '', // 图片 Base64 数据
}
]
})
setImageBase64(data.service.logo)
setShowClassify(data.service.serviceType === 'public')
},0)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
};
const onFinish:()=>Promise<boolean|string> = () => {
return form.validateFields().then((value)=>{
return fetchData<BasicResponse<{service:{id:string}}>>(serviceId === undefined? 'team/ai-service':'ai-service/info',{method:serviceId === undefined? 'POST' : 'PUT',eoParams: {...(serviceId === undefined ? {team:value.team} :{service:serviceId,team:teamId})},eoBody:({...value,prefix:value.prefix?.trim()}), eoTransformKeys:['serviceType']},).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
message.success(msg || $t(RESPONSE_TIPS.success))
setAiServiceInfo(data.service)
return Promise.resolve(true)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return Promise.reject(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo)=>{
return Promise.reject(errorInfo)
})
})
};
const getProviderOptionList = ()=>{
setProviderOptionList([])
fetchData<BasicResponse<{ providers: SimpleAiProviderItem[] }>>('simple/ai/providers',{method:'GET',eoTransformKeys:[]}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setProviderOptionList(data.providers?.filter(x=>x.configured)?.map((x:SimpleAiProviderItem)=>{return {...x,
label: <div className="flex items-center" dangerouslySetInnerHTML={{ __html: x.logo }} />, value:x.id
}}))
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
const getTeamOptionList = ()=>{
setTeamOptionList([])
fetchData<BasicResponse<{ teams: SimpleTeamItem[] }>>(!checkPermission('system.workspace.team.view_all') ?'simple/teams/mine' :'simple/teams',{method:'GET',eoTransformKeys:[]}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setTeamOptionList(data.teams?.map((x:MemberItem)=>{return {...x,
label:x.name, value:x.id
}}))
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
const deleteAiService = ()=>{
fetchData<BasicResponse<null>>('team/ai-service',{method:'DELETE',eoParams:{team:teamId,service:serviceId}}).then(response=>{
const {code,msg} = response
if(code === STATUS_CODE.SUCCESS){
message.success(msg || $t(RESPONSE_TIPS.success))
navigate(`/aiservice/list`)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
useEffect(()=>{
aiConfigFlushed && getProviderOptionList()
},[aiConfigFlushed])
useEffect(() => {
getProviderOptionList()
getTagAndServiceClassifyList()
if(accessInit){
getTeamOptionList()
}else{
getGlobalAccessData()?.then(()=>{
getTeamOptionList()
})
}
if (serviceId !== undefined) {
setOnEdit(true);
getAiServiceInfo();
setBreadcrumb([
{
title: <Link to={`/aiservice/list`}>{$t('服务')}</Link>
},
{
title: $t('设置')
}])
} else {
setOnEdit(false);
form.setFieldValue('id',uuidv4());
form.setFieldValue('team',teamId);
form.setFieldValue('serviceType','inner');
}
return (form.setFieldsValue({}))
}, [serviceId]);
const deleteAiServiceModal = async ()=>{
modal.confirm({
title:$t('删除'),
content:$t(DELETE_TIPS.default),
onOk:()=> {
return deleteAiService()
},
width:600,
okText:$t('确认'),
okButtonProps:{
danger:true
},
cancelText:$t('取消'),
closable:true,
icon:<></>
})
}
const visualizationOptions = useMemo(()=>SERVICE_VISUALIZATION_OPTIONS.map((x)=>({...x, label:$t(x.label)})),[state.language])
return (
<>
<WithPermission access={onEdit ? 'team.service.service.edit' :''}>
<Form
layout='vertical'
labelAlign='left'
scrollToFirstError
form={form}
className="w-full pr-PAGE_INSIDE_X "
name="systemConfig"
onFinish={onFinish}
autoComplete="off"
>
<div>
<Form.Item<AiServiceConfigFieldType>
label={$t("服务名称")}
name="name"
rules={[{ required: true ,whitespace:true }]}
>
<Input className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
<Form.Item<AiServiceConfigFieldType>
label={$t("服务ID")}
name="id"
rules={[{ required: true ,whitespace:true }]}
>
<Input className="w-INPUT_NORMAL" disabled={onEdit} placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
<Form.Item<AiServiceConfigFieldType>
label={$t("AI 模型供应商")}
name="provider"
rules={[{ required: true }]}
>{
(providerOptionList && providerOptionList.length >0 ) ? <Select className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)} options={providerOptionList} >
</Select> : <p> AI <a href="/aisetting" target="_blank" onClick={()=>setAiConfigFlushed(false)}></a></p>
}
</Form.Item>
<Form.Item<AiServiceConfigFieldType>
label={$t("API 调用前缀")}
name="prefix"
extra={$t("选填,作为服务内所有API的前缀,比如host/{service_name}/{api_path},一旦保存无法修改")}
rules={[
{
validator: validateUrlSlash,
}]}
>
<Input prefix={onEdit ? '' : '/'} className="w-INPUT_NORMAL" disabled={onEdit} placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
<Form.Item<AiServiceConfigFieldType>
label={$t("图标")}
name="logoFile"
extra={$t("仅支持 .png .jpg .jpeg .svg 格式的图片文件, 大于 1KB 的文件将被压缩")}
valuePropName="fileList" getValueFromEvent={normFile}
>
<Upload
listType="picture"
beforeUpload={beforeUpload}
onChange={handleChange}
showUploadList={false}
maxCount={1}
accept=".png, .jpg, .jpeg, .svg"
>
<div className="h-[68px] w-[68px] border-[1px] border-dashed border-BORDER flex items-center justify-center rounded bg-bar-theme cursor-pointer" style={{ marginTop: 8 }}>
{imageBase64 ? <img src={imageBase64} alt="Logo" style={{ maxWidth: '200px', width:'68px',height:'68px'}} /> : uploadButton}
</div>
</Upload>
</Form.Item>
<Form.Item<AiServiceConfigFieldType>
label={$t("描述")}
name="description"
>
<Input.TextArea className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
<Form.Item<AiServiceConfigFieldType>
label={$t("Logo")}
name="logo"
hidden
>
</Form.Item>
{!onEdit && <Form.Item<AiServiceConfigFieldType>
label={$t("所属团队")}
name="team"
rules={[{ required: true }]}
>
<Select className="w-INPUT_NORMAL" disabled={onEdit} placeholder={$t(PLACEHOLDER.input)} options={teamOptionList} >
</Select>
</Form.Item>}
<Form.Item<AiServiceConfigFieldType>
label={$t("标签")}
name="tags"
>
<Select
className="w-INPUT_NORMAL"
mode="tags"
placeholder={$t(PLACEHOLDER.select)}
options={tagOptionList}>
</Select>
</Form.Item>
<Form.Item<AiServiceConfigFieldType>
label={$t("服务类型")}
name="serviceType"
rules={[{required: true}]}
>
<Radio.Group className="flex flex-col" options={visualizationOptions} onChange={(e)=>{setShowClassify(e.target.value === 'public')}} />
</Form.Item>
{showClassify &&
<Form.Item<AiServiceConfigFieldType>
label={$t("所属服务分类")}
name="catalogue"
extra={$t("设置服务展示在服务市场中的哪个分类下")}
rules={[{required: true}]}
>
<TreeSelect
className="w-INPUT_NORMAL"
fieldNames={{label:'name',value:'id',children:'children'}}
showSearch
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
placeholder={$t(PLACEHOLDER.select)}
allowClear
treeDefaultExpandAll
treeData={serviceClassifyOptionList}
/>
</Form.Item>
}
{onEdit && <>
<Row className="mb-[10px]"
// wrapperCol={{ offset: 5, span: 19 }}
>
<WithPermission access={onEdit ? 'team.service.service.edit' :''}>
<Button type="primary" htmlType="submit">
{$t('保存')}
</Button>
</WithPermission>
</Row></>}
</div>
{onEdit && <>
<WithPermission access="team.service.service.delete" showDisabled={false}>
<div className="bg-[rgb(255_120_117_/_5%)] rounded-[10px] mt-[50px] p-btnrbase pb-0">
<p className="text-left"><span className="font-bold">{$t('删除服务')}</span>{$t('删除操作不可恢复,请谨慎操作!')}</p>
<div className="text-left">
<WithPermission access="team.service.service.delete">
<Button className="m-auto mt-[16px] mb-[20px]" type="default" danger={true} onClick={deleteAiServiceModal}>{$t('删除服务')}</Button>
</WithPermission>
</div>
</div>
</WithPermission>
</>}
</Form>
</WithPermission>
</>
)
})
export default AiServiceConfig
@@ -0,0 +1,145 @@
import { Editor } from '@tinymce/tinymce-react';
import hljs from 'highlight.js';
import 'highlight.js/styles/default.css';
import {useEffect, useState} from "react";
import {BasicResponse, RESPONSE_TIPS, STATUS_CODE} from "@common/const/const.tsx";
import {useFetch} from "@common/hooks/http.ts";
import {App, Button} from "antd";
import { EntityItem } from '@common/const/type.ts';
import WithPermission from '@common/components/aoplatform/WithPermission.tsx';
import { RouterParams } from '@core/components/aoplatform/RenderRoutes';
import { useParams } from 'react-router-dom';
import { $t } from '@common/locales';
const ServiceInsideDocument = ()=>{
const { message } = App.useApp()
const [updater,setUpdater] = useState<string>()
const [updateTime,setUpdateTime]=useState<string>()
const [initDoc, setInitDoc] = useState<string>()
const [doc, setDoc] = useState<string>()
const {fetchData} = useFetch()
const { serviceId, teamId} = useParams<RouterParams>();
const save = ()=>{
fetchData<BasicResponse<{service:{ id:string,name:string,updater:string,updateTime:string, doc:string} }>>('service/doc',{method:'PUT',eoBody:({doc:doc}) ,eoParams:{service:serviceId,team:teamId},eoTransformKeys:['update_time']}).then(response=>{
const {code,msg} = response
if(code === STATUS_CODE.SUCCESS){
message.success(msg || $t(RESPONSE_TIPS.success))
getServiceDoc()
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
const handleEditorChange = (content:string, editor:unknown) => {
setDoc(content)
};
const setupEditor = (editor:unknown) => {
editor.on('init', () => {
editor.contentDocument.querySelectorAll('pre code').forEach((block:HTMLElement) => {
hljs.highlightBlock(block);
});
});
editor.on('SetContent', () => {
editor.contentDocument.querySelectorAll('pre code').forEach((block:HTMLElement) => {
hljs.highlightBlock(block);
});
});
};
const getServiceDoc = ()=>{
fetchData<BasicResponse<{doc:{ id:string,name:string,updater:EntityItem,updateTime:string,creater:EntityItem, doc:string} }>>('service/doc',{method:'GET',eoParams:{service:serviceId,team:teamId},eoTransformKeys:['update_time']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setUpdater(data.doc.updater.id === '' ? '-' : data.doc.updater.name)
setUpdateTime(data.doc.updater.id === '' ? '-' : data.doc.updateTime)
setInitDoc(data.doc.doc)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
useEffect(() => {
getServiceDoc()
}, []);
return (
<div className="flex flex-col h-full border-[1px] rounded-[10px] border-BORDER border-solid mr-PAGE_INSIDE_X">
<Editor
tinymceScriptSrc={'/tinymce/tinymce.min.js'}
initialValue={initDoc}
init={{
height: '100%',
menubar: false,
plugins: [
'advlist', 'autolink', 'link', 'image', 'lists', 'charmap', 'preview', 'anchor', 'pagebreak',
'searchreplace', 'wordcount', 'visualblocks', 'visualchars', 'codesample', 'fullscreen', 'insertdatetime',
'media', 'table', 'emoticons', 'help'
], toolbar: 'undo redo | styles | bold italic | alignleft aligncenter alignright alignjustify | codesample |table|' +
'bullist numlist outdent indent | link image | print preview media fullscreen | ' +
'forecolor backcolor emoticons | help',
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
setup: setupEditor,
codesample_languages:[
{
text: 'HTML/XML',
value: 'markup'
},
{
text: 'JavaScript',
value: 'javascript'
},
{
text: 'CSS',
value: 'css'
},
{
text: 'PHP',
value: 'php'
},
{
text: 'Ruby',
value: 'ruby'
},
{
text:'GO',
value:'go'
},
{
text: 'Python',
value: 'python'
},
{
text: 'Java',
value: 'java'
},
{
text: 'C',
value: 'c'
},
{
text: 'C#',
value: 'csharp'
},
{
text: 'C++',
value: 'cpp'
},
{ text: 'Bash/Shell', value: 'bash' },
{ text: 'SQL', value: 'sql' }
]
}}
onEditorChange={handleEditorChange}
/>
<div className=" pl-[8px] py-btnbase ">
<div className="flex justify-between items-center">
<p className="text-[14px] leading-[20px] text-[#999999]"><span className="mr-[20px]">{$t('最近一次更新者')}{updater || '-'}</span><span>{$t('最近一次更新时间')}{updateTime || '-'}</span></p>
<WithPermission access="team.service.service.edit"><Button type="primary" className="mr-btnbase" onClick={save}>{$t('保存')}</Button></WithPermission>
</div>
</div>
</div>)
}
export default ServiceInsideDocument
@@ -0,0 +1,158 @@
import {FC, useEffect, useMemo, useState} from "react";
import {Link, Outlet, useLocation, useNavigate, useParams} from "react-router-dom";
import {RouterParams} from "@core/components/aoplatform/RenderRoutes.tsx";
import {App, Menu, MenuProps} from "antd";
import {BasicResponse, RESPONSE_TIPS, STATUS_CODE} from "@common/const/const.tsx";
import {useFetch} from "@common/hooks/http.ts";
import { useAiServiceContext} from "../../contexts/AiServiceContext.tsx";
import { useGlobalContext } from "@common/contexts/GlobalStateContext.tsx";
import { PERMISSION_DEFINITION } from "@common/const/permissions.ts";
import InsidePage from "@common/components/aoplatform/InsidePage.tsx";
import Paragraph from "antd/es/typography/Paragraph";
import { cloneDeep } from "lodash-es";
import { $t } from "@common/locales/index.ts";
import { getItem } from "@common/utils/navigation.tsx";
import { AiServiceConfigFieldType } from "@core/const/ai-service/type.ts";
import { MenuItemGroupType, MenuItemType, ItemType } from "antd/es/menu/interface";
const APP_MODE = import.meta.env.VITE_APP_MODE;
const AiServiceInsidePage:FC = ()=> {
const { message } = App.useApp()
const { teamId,serviceId,apiId, routeId } = useParams<RouterParams>();
const location = useLocation()
const currentUrl = location.pathname
const {fetchData} = useFetch()
const { setPrefixForce,setApiPrefix ,aiServiceInfo ,setAiServiceInfo} = useAiServiceContext()
const { accessData,checkPermission,accessInit,state} = useGlobalContext()
const [activeMenu, setActiveMenu] = useState<string>()
const navigateTo = useNavigate()
const [showMenu, setShowMenu] = useState<boolean>(false)
const getAiServiceInfo = ()=>{
fetchData<BasicResponse<{ service:AiServiceConfigFieldType }>>('service/info',{method:'GET',eoParams:{team:teamId, service:serviceId}}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setAiServiceInfo(data.service)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
const getApiDefine = ()=>{
setApiPrefix('')
setPrefixForce(false)
fetchData<BasicResponse<{ prefix:string, force:boolean }>>('service/router/define',{method:'GET',eoParams:{service:serviceId,team:teamId}}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setApiPrefix(data.prefix)
setPrefixForce(data.force)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
const SYSTEM_PAGE_MENU_ITEMS = useMemo(()=>[
getItem($t('服务'), 'assets', null,
[
getItem(<Link to="./api">{$t('API')}</Link>, 'api',undefined,undefined,undefined,'team.service.api_doc.view'),
getItem(<Link to="./route">{$t('路由')}</Link>, 'route',undefined,undefined,undefined,'team.service.router.view'),
getItem(<Link to="./document">{$t('使用说明')}</Link>, 'document',undefined,undefined,undefined,''),
getItem(<Link to="./publish">{$t('发布')}</Link>, 'publish',undefined,undefined,undefined,'team.service.release.view'),
],
'group'),
getItem($t('订阅管理'), 'provideSer', null,
[
getItem(<Link to="./approval">{$t('订阅审批')}</Link>, 'approval',undefined,undefined,undefined,'team.service.subscription.view'),
getItem(<Link to="./subscriber">{$t('订阅方管理')}</Link>, 'subscriber',undefined,undefined,undefined,'team.service.subscription.view'),
],
'group'),
getItem($t('管理'), 'mng', null,
[
APP_MODE === 'pro' ? getItem(<Link to="./topology">{$t('调用拓扑图')}</Link>, 'topology',undefined,undefined,undefined,'project.myAiService.topology.view'):null,
getItem(<Link to="./setting">{$t('设置')}</Link>, 'setting',undefined,undefined,undefined,'')],
'group'),
],[state.language])
const menuData = useMemo(()=>{
const filterMenu = (menu:MenuItemGroupType<MenuItemType>[])=>{
const newMenu = cloneDeep(menu)
return newMenu!.filter((m:MenuItemGroupType )=>{
if(m.children && m.children.length > 0){
m.children = m.children.filter(
(c)=>(c&&(c as MenuItemType&{access:string} ).access ?
checkPermission((c as MenuItemType&{access:string} ).access as keyof typeof PERMISSION_DEFINITION[0]):
true))
}
return m.children && m.children.length > 0
})
}
const filteredMenu = filterMenu(SYSTEM_PAGE_MENU_ITEMS as MenuItemGroupType<MenuItemType>[])
setActiveMenu((pre)=>{
return pre ?? 'api'
})
return filteredMenu || []
},[accessData,accessInit, SYSTEM_PAGE_MENU_ITEMS])
const onMenuClick: MenuProps['onClick'] = ({key}) => {
setActiveMenu(key)
};
useEffect(() => {
setShowMenu(!routeId && !currentUrl.includes('route/create'))
if(apiId !== undefined){
setActiveMenu('api')
}else if(serviceId !== currentUrl.split('/')[currentUrl.split('/').length - 1]){
setActiveMenu(currentUrl.split('/')[currentUrl.split('/').length - 1])
}else{
setActiveMenu('api')
}
}, [currentUrl]);
useEffect(()=>{
if(accessData && accessData.get('team') && accessData.get('team')?.indexOf('team.service.router.view') !== -1){
getApiDefine()
}
},[accessData])
useEffect(()=>{
if( activeMenu && serviceId === currentUrl.split('/')[currentUrl.split('/').length - 1]){
navigateTo(`/aiservice/${teamId}/inside/${serviceId}/${activeMenu}`)
}
},[activeMenu])
useEffect(() => {
serviceId && getAiServiceInfo()
}, [serviceId]);
return (
<>{showMenu ?
<InsidePage pageTitle={aiServiceInfo?.name || '-'}
tagList={[{label:
<Paragraph className="mb-0" copyable={serviceId ? { text: serviceId } : false}>{$t('服务 ID')}{serviceId || '-'}</Paragraph>
}]}
backUrl="/aiservice/list">
<div className="flex flex-1 h-full">
<Menu
onClick={onMenuClick}
className="h-full overflow-y-auto"
style={{ width: 220 }}
selectedKeys={[activeMenu!]}
mode="inline"
items={menuData as unknown as ItemType<MenuItemType>[] }
/>
<div className={` ${['setting', 'upstream'].indexOf(activeMenu!) !== -1 ? '' :''} w-full h-full flex flex-1 flex-col overflow-auto bg-MAIN_BG pt-[20px] pl-[20px] pb-PAGE_INSIDE_B ` }>
<Outlet/>
</div>
</div>
</InsidePage>: <Outlet/> }
</>
)
}
export default AiServiceInsidePage
@@ -0,0 +1,268 @@
import {ActionType} from "@ant-design/pro-components";
import {FC, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState} from "react";
import {Link, useParams} from "react-router-dom";
import {App, Form,TreeSelect} from "antd";
import {useBreadcrumb} from "@common/contexts/BreadcrumbContext.tsx";
import {useFetch} from "@common/hooks/http.ts";
import { RouterParams } from "@core/components/aoplatform/RenderRoutes.tsx";
import {BasicResponse, COLUMNS_TITLE, DELETE_TIPS, PLACEHOLDER, RESPONSE_TIPS, STATUS_CODE, VALIDATE_MESSAGE} from "@common/const/const.tsx";
import PageList, { PageProColumns } from "@common/components/aoplatform/PageList.tsx";
import {DefaultOptionType} from "antd/es/cascader";
import { SYSTEM_SUBSCRIBER_TABLE_COLUMNS } from "../../const/system/const.tsx";
import { AiServiceSubscriberTableListItem, AiServiceSubscriberConfigFieldType, AiServiceSubscriberConfigHandle, AiServiceSubscriberConfigProps, SimpleAiServiceItem } from "../../const/system/type.ts";
import { SimpleMemberItem } from "@common/const/type.ts";
import WithPermission from "@common/components/aoplatform/WithPermission.tsx";
import TableBtnWithPermission from "@common/components/aoplatform/TableBtnWithPermission.tsx";
import { useGlobalContext } from "@common/contexts/GlobalStateContext.tsx";
import { checkAccess } from "@common/utils/permission.ts";
import { $t } from "@common/locales/index.ts";
const AiServiceInsideSubscriber:FC = ()=>{
const { setBreadcrumb } = useBreadcrumb()
const { modal,message } = App.useApp()
const {fetchData} = useFetch()
const {serviceId, teamId} = useParams<RouterParams>()
const addRef = useRef<AiServiceSubscriberConfigHandle>(null)
const pageListRef = useRef<ActionType>(null);
const [memberValueEnum, setMemberValueEnum] = useState<SimpleMemberItem[]>([])
const {accessData,state} = useGlobalContext()
const getAiServiceSubscriber = ()=>{
return fetchData<BasicResponse<{subscribers:AiServiceSubscriberTableListItem[]}>>('service/subscribers',{method:'GET',eoParams:{service:serviceId,team:teamId},eoTransformKeys:['apply_time']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
return {data:data.subscribers, success: true}
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return {data:[], success:false}
}
}).catch(() => {
return {data:[], success:false}
})
}
const getMemberList = async ()=>{
setMemberValueEnum([])
const {code,data,msg} = await fetchData<BasicResponse<{ members: SimpleMemberItem[] }>>('simple/member',{method:'GET'})
if(code === STATUS_CODE.SUCCESS){
setMemberValueEnum(data.members)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}
const manualReloadTable = () => {
pageListRef.current?.reload()
};
const deleteSubscriber = (entity:AiServiceSubscriberTableListItem)=>{
return new Promise((resolve, reject)=>{
fetchData<BasicResponse<null>>('service/subscriber',{method:'DELETE',eoParams:{application:entity!.id,service:entity!.service.id,team:teamId}}).then(response=>{
const {code,msg} = response
if(code === STATUS_CODE.SUCCESS){
message.success(msg || $t(RESPONSE_TIPS.success))
resolve(true)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
reject(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo)=> reject(errorInfo))
})
}
const openModal =async (type:'delete'|'add',entity?:AiServiceSubscriberTableListItem)=>{
let title:string = ''
let content:string|React.ReactNode = ''
switch (type){
case 'add':
title=$t('新增订阅方')
content=<AiServiceSubscriberConfig ref={addRef} serviceId={serviceId!} teamId={teamId!}/>
break;
case 'delete':
title=$t('删除')
content=$t(DELETE_TIPS.default)
break;
}
modal.confirm({
title,
content,
onOk:()=>{
switch (type){
case 'add':
return addRef.current?.save().then((res)=>{if(res === true) manualReloadTable()})
case 'delete':
return deleteSubscriber(entity!).then((res)=>{if(res === true) manualReloadTable()})
}
},
width:600,
okText:$t('确认'),
okButtonProps:{
disabled : !checkAccess( `team.service.subscription.${type}`, accessData)
},
cancelText:$t('取消'),
closable:true,
icon:<></>,
})
}
const operation:PageProColumns<AiServiceSubscriberTableListItem>[] =[
{
title: COLUMNS_TITLE.operate,
key: 'option',
btnNums:1,
fixed:'right',
valueType: 'option',
render: (_: React.ReactNode, entity: AiServiceSubscriberTableListItem) => [
<TableBtnWithPermission access="team.service.subscription.delete" key="delete" btnType="delete" onClick={()=>{openModal('delete',entity)}} btnTitle="删除"/>,
],
}
]
useEffect(() => {
setBreadcrumb([
{
title:<Link to={`/aiservice/list`}>{$t('服务')}</Link>
},
{
title:$t('订阅方管理')
}
])
getMemberList()
manualReloadTable()
}, [serviceId]);
const columns = useMemo(()=>{
return [...SYSTEM_SUBSCRIBER_TABLE_COLUMNS].map(x=>{
if(x.filters &&((x.dataIndex as string[])?.indexOf('applier') !== -1 || (x.dataIndex as string[])?.indexOf('approver') !== -1) ){
const tmpValueEnum:{[k:string]:{text:string}} = {}
memberValueEnum?.forEach((x:SimpleMemberItem)=>{
tmpValueEnum[x.name] = {text:x.name}
})
x.valueEnum = tmpValueEnum
}
if(x.dataIndex === 'from'){
x.valueEnum = new Map([
[0,<span>{$t('手动添加')}</span>],
[1,<span>{$t('订阅申请')}</span>],
])
}
return {
...x,title:typeof x.title === 'string' ? $t(x.title as string) : x.title}
}
)
},[memberValueEnum,state.language])
return (
<PageList
id="global_system_subscriber"
ref={pageListRef}
columns = {[...columns,...operation]}
request={()=>getAiServiceSubscriber()}
// dataSource={tableListDataSource}
showPagination={false}
addNewBtnTitle={$t("新增订阅方")}
onAddNewBtnClick={()=>{openModal('add')}}
addNewBtnAccess="team.service.subscription.add"
tableClass="pr-PAGE_INSIDE_X"
/>
)
}
export default AiServiceInsideSubscriber
export const AiServiceSubscriberConfig = forwardRef<AiServiceSubscriberConfigHandle,AiServiceSubscriberConfigProps>((props, ref) => {
const { message } = App.useApp()
const { serviceId, teamId} = props
const [form] = Form.useForm();
const {fetchData} = useFetch()
const [systemOptionList, setAiServiceOptionList] = useState<DefaultOptionType[]>()
const save:()=>Promise<boolean | string> = ()=>{
return new Promise((resolve, reject)=>{
form.validateFields().then((value)=>{
fetchData<BasicResponse<null>>('service/subscriber',{method:'POST',eoBody:({...value}), eoParams:{service:serviceId,team:teamId}}).then(response=>{
const {code,msg} = response
if(code === STATUS_CODE.SUCCESS){
message.success(msg || $t(RESPONSE_TIPS.success))
resolve(true)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
reject(msg || $t(RESPONSE_TIPS.error))
}
})
}).catch((errorInfo)=> reject(errorInfo))
})
}
useImperativeHandle(ref, ()=>({
save
})
)
const getAiServiceList = ()=>{
setAiServiceOptionList([])
fetchData<BasicResponse<{ apps: SimpleAiServiceItem[] }>>('simple/apps/mine',{method:'GET'}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
const teamMap = new Map<string, unknown>();
data.apps
.filter((x:SimpleAiServiceItem)=>x.id !== serviceId)
.forEach((item:SimpleAiServiceItem) => {
if (!teamMap.has(item.team.id)) {
teamMap.set(item.team.id, {
title: item.team.name,
value: item.team.id,
key: item.team.id,
children: [],
selectable: false, // 第一级不可选
disabled:true
});
}
teamMap.get(item.team.id)!.children!.push({
title: item.name,
value: item.id,
key: item.id,
selectable: true, // 子级可选
// partition:item.partition?.map((x:EntityItem)=>x.id) || []
});
});
setAiServiceOptionList(Array.from(teamMap.values()))
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
}
useEffect(() => {
getAiServiceList()
}, [serviceId]);
return (<WithPermission access="team.service.subscription.add">
<Form
layout='vertical'
labelAlign='left'
scrollToFirstError
form={form}
className="mx-auto "
name="systemInsideSubscriber"
autoComplete="off"
>
<Form.Item<AiServiceSubscriberConfigFieldType>
label={$t("订阅方")}
name="application"
rules={[{ required: true }]}
>
<TreeSelect
className="w-INPUT_NORMAL"
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
treeData={systemOptionList}
placeholder={$t(PLACEHOLDER.input)}
treeDefaultExpandAll
/>
</Form.Item>
</Form>
</WithPermission>)
})
@@ -0,0 +1,14 @@
.system-tree {
:global(.ant-tree .ant-tree-switcher){
width:8px !important;
}
:global .ant-tree-node-content-wrapper{
height:40px;
padding:0 10px;
}
:global .ant-tree-title{
line-height:40px;
}
}
@@ -0,0 +1,159 @@
import PageList from "@common/components/aoplatform/PageList.tsx"
import {ActionType} from "@ant-design/pro-components";
import {FC, useEffect, useMemo, useRef, useState} from "react";
import {useNavigate} from "react-router-dom";
import { App} from "antd";
import {useBreadcrumb} from "@common/contexts/BreadcrumbContext.tsx";
import {BasicResponse, RESPONSE_TIPS, STATUS_CODE} from "@common/const/const.tsx";
import {useFetch} from "@common/hooks/http.ts";
import { SimpleTeamItem ,SimpleMemberItem} from "@common/const/type.ts";
import { DrawerWithFooter } from "@common/components/aoplatform/DrawerWithFooter.tsx";
import AiServiceConfig from "./AiServiceConfig.tsx";
import { useGlobalContext } from "@common/contexts/GlobalStateContext.tsx";
import { $t } from "@common/locales/index.ts";
import { AiServiceTableListItem, AiServiceConfigHandle } from "@core/const/ai-service/type.ts";
import { AI_SERVICE_TABLE_COLUMNS } from "@core/const/ai-service/const.tsx";
const AiServiceList:FC = ()=>{
const navigate = useNavigate();
const [tableSearchWord, setTableSearchWord] = useState<string>('')
const { setBreadcrumb } = useBreadcrumb()
const [teamList, setTeamList] = useState<{ [k: string]: { text: string; }; }>()
const {fetchData} = useFetch()
const [tableListDataSource, setTableListDataSource] = useState<AiServiceTableListItem[]>([]);
const [tableHttpReload, setTableHttpReload] = useState(true);
const { message } = App.useApp()
const pageListRef = useRef<ActionType>(null);
const [memberValueEnum, setMemberValueEnum] = useState<{[k:string]:{text:string}}>({})
const [open, setOpen] = useState(false);
const drawerFormRef = useRef<AiServiceConfigHandle>(null)
const {checkPermission,accessInit, getGlobalAccessData,state} = useGlobalContext()
const getAiServiceList = ()=>{
if(!accessInit){
getGlobalAccessData()?.then(()=>{
getAiServiceList()
})
return
}
if(!tableHttpReload){
setTableHttpReload(true)
return Promise.resolve({
data: tableListDataSource,
success: true,
});
}
return fetchData<BasicResponse<{services:AiServiceTableListItem[]}>>(!checkPermission('system.workspace.service.view_all') ? 'my_ai_services':'ai-services',{method:'GET',eoParams:{keyword:tableSearchWord},eoTransformKeys:['api_num','can_delete','create_time']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setTableListDataSource(data.services)
setTableHttpReload(false)
return {data:data.services, success: true}
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return {data:[], success:false}
}
}).catch(() => {
return {data:[], success:false}
})
}
const getTeamsList = ()=>{
if(!accessInit){
getGlobalAccessData()?.then(()=>{
getTeamsList()
})
return
}
fetchData<BasicResponse<{ teams: SimpleTeamItem[] }>>(!checkPermission('system.workspace.team.view_all') ?'simple/teams/mine' :'simple/teams',{method:'GET',eoTransformKeys:[]}).then(response=>{
const {code,data,msg} = response
setTeamList(data.teams)
if(code === STATUS_CODE.SUCCESS){
const tmpValueEnum:{[k:string]:{text:string}} = {}
data.teams?.forEach((x:SimpleMemberItem)=>{
tmpValueEnum[x.name] = {text:x.name}
})
setTeamList(tmpValueEnum)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return {data:[], success:false}
}
})
}
const manualReloadTable = () => {
setTableHttpReload(true); // 表格数据需要从后端接口获取
pageListRef.current?.reload()
};
const getMemberList = async ()=>{
setMemberValueEnum({})
const {code,data,msg} = await fetchData<BasicResponse<{ members: SimpleMemberItem[] }>>('simple/member',{method:'GET'})
if(code === STATUS_CODE.SUCCESS){
const tmpValueEnum:{[k:string]:{text:string}} = {}
data.members?.forEach((x:SimpleMemberItem)=>{
tmpValueEnum[x.name] = {text:x.name}
})
setMemberValueEnum(tmpValueEnum)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}
useEffect(() => {
getTeamsList();
getMemberList()
setBreadcrumb([
{
title: $t('服务')
}])
}, []);
const onClose = () => {
setOpen(false);
};
const columns = useMemo(()=>{
const res = AI_SERVICE_TABLE_COLUMNS.map(x=>{
if(x.filters &&((x.dataIndex as string[])?.indexOf('master') !== -1 ) ){
x.valueEnum = memberValueEnum
}
if(x.filters &&((x.dataIndex as string[])?.indexOf('team') !== -1 ) ){
x.valueEnum = teamList
}
return {...x,title:typeof x.title === 'string' ? $t(x.title as string) : x.title}})
return res
},[memberValueEnum,teamList,state.language]);
return (
<div className="h-full w-full pr-PAGE_INSIDE_X pb-PAGE_INSIDE_B">
{/* <Joyride steps={steps} run={true} /> */}
<PageList
id="global_ai_system"
ref={pageListRef}
columns={[...columns]}
request={()=>getAiServiceList()}
addNewBtnTitle={$t("添加服务")}
addNewBtnWrapperClass={'my-first-step'}
searchPlaceholder={$t("输入名称、ID、所属团队、负责人查找服务")}
onAddNewBtnClick={() => {
setOpen(true)
}}
manualReloadTable={manualReloadTable}
onChange={() => {
setTableHttpReload(false)
}}
onSearchWordChange={(e) => {
setTableSearchWord(e.target.value)
}}
onRowClick={(row:AiServiceTableListItem)=>navigate(`/aiservice/${row.team.id}/inside/${row.id}`)}
/>
<DrawerWithFooter title={$t("添加 AI 服务")} open={open} onClose={onClose} onSubmit={()=>drawerFormRef.current?.save()?.then((res)=>{res && manualReloadTable();return res})} >
<AiServiceConfig ref={drawerFormRef} />
</DrawerWithFooter>
</div>
)
}
export default AiServiceList
@@ -0,0 +1,20 @@
import { Outlet, useParams } from "react-router-dom"
import { RouterParams } from "@core/components/aoplatform/RenderRoutes"
import { useEffect } from "react"
import { useGlobalContext } from "@common/contexts/GlobalStateContext"
export default function AiServiceOutlet(){
const {teamId} = useParams<RouterParams>()
const {getTeamAccessData,cleanTeamAccessData} = useGlobalContext()
useEffect(()=>{
teamId ? getTeamAccessData(teamId) : cleanTeamAccessData()
return ()=>{
cleanTeamAccessData()
}
},[teamId])
return (<Outlet />)
}
@@ -0,0 +1,58 @@
import {forwardRef, useEffect, useState} from "react";
import { Empty, Spin, message} from "antd";
import {BasicResponse, RESPONSE_TIPS, STATUS_CODE} from "@common/const/const.tsx";
import {useFetch} from "@common/hooks/http.ts";
import { LoadingOutlined } from "@ant-design/icons";
import EmptySVG from '@common/assets/empty.svg'
import { $t } from "@common/locales/index.ts";
import ApiDocument from '@common/components/aoplatform/ApiDocument.tsx'
import { useParams } from "react-router-dom";
import { RouterParams } from "@core/components/aoplatform/RenderRoutes.tsx";
import { AiServiceInsideApiDocumentHandle, AiServiceInsideApiDocumentProps, AiServiceApiDetail } from "@core/const/ai-service/type.ts";
const AiServiceInsideApiDocument = forwardRef<AiServiceInsideApiDocumentHandle,AiServiceInsideApiDocumentProps>(() => {
const {serviceId, teamId} = useParams<RouterParams>()
const {fetchData} = useFetch()
const [apiDetail, setApiDetail] = useState<AiServiceApiDetail>()
const [loading, setLoading] = useState<boolean>(false)
useEffect(() => {
getApiDetail()
}, []);
const getApiDetail = ()=>{
setLoading(true)
fetchData<BasicResponse<{doc:AiServiceApiDetail}>>('service/api_doc',{method:'GET',eoParams:{service:serviceId,team:teamId },eoTransformKeys:['update_time']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setApiDetail(data.doc?.content)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}).finally(()=>{setLoading(false)})
}
const ApiPreview = ({spec}:{spec?:string | object})=>{
return (
<div className="h-full overflow-hidden">
<div className="flex-1 overflow-auto pr-PAGE_INSIDE_X">
<ApiDocument spec={spec}/>
</div>
</div>
)
}
return (<>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} spinning={loading} wrapperClassName=' h-full overflow-hidden '>
<div className=" h-full">
{ apiDetail ? <ApiPreview spec={apiDetail} />
: <Empty image={EmptySVG} >
</Empty>}
</div>
</Spin>
</>)
})
export default AiServiceInsideApiDocument
@@ -0,0 +1,7 @@
import {FC} from "react";
const AiServiceInsideApiPlugin:FC = ()=>{
return (<></>)
}
export default AiServiceInsideApiPlugin
@@ -0,0 +1,304 @@
import {App, Button, Form, Input, InputNumber, Row, Spin, Tag} from "antd";
import { MutableRefObject, useEffect, useRef, useState} from "react";
import {BasicResponse, PLACEHOLDER, RESPONSE_TIPS, STATUS_CODE} from "@common/const/const.tsx";
import {useFetch} from "@common/hooks/http.ts";
import { $t } from "@common/locales/index.ts";
import { LoadingOutlined } from "@ant-design/icons";
import InsidePage from "@common/components/aoplatform/InsidePage.tsx";
import { Icon } from "@iconify/react/dist/iconify.js";
import { RouterParams } from "@core/components/aoplatform/RenderRoutes.tsx";
import { useNavigate, useParams } from "react-router-dom";
import { useAiServiceContext } from "@core/contexts/AiServiceContext.tsx";
import EditableTableNotAutoGen from "@common/components/aoplatform/EditableTableNotAutoGen.tsx";
import { AI_SERVICE_VARIABLES_TABLE_COLUMNS } from "@core/const/ai-service/const.tsx";
import { VariableItems } from "@core/const/ai-service/type.ts";
import PromptEditorResizable from '@common/components/aoplatform/prompt-editor/PromptEditorResizable.tsx';
import { DrawerWithFooter } from "@common/components/aoplatform/DrawerWithFooter";
import AiServiceRouterModelConfig, { AiServiceRouterModelConfigHandle } from "./AiServiceInsideRouterModelConfig";
import { AiProviderDefaultConfig, AiProviderLlmsItems } from "@core/pages/aiSetting/AiSettingList";
import { EditableFormInstance } from "@ant-design/pro-components";
type AiServiceRouterField = {
name:string
path:string
prompt:string
variables:Array<{key:string, description:string, require:true}>
description:string
timeout:number
retry:number
}
type AiServiceRouterConfig = {
name:string
path:string
aiPrompt:{
prompt:string
variables:Array<{key:string, description:string, require:true}>
}
aiModel:{
id:string
config:string
}
description:string
timeout:number
retry:number
}
const AiServiceInsideRouterCreate = () => {
const navigator = useNavigate()
const { message } = App.useApp()
const {serviceId, teamId,routeId} = useParams<RouterParams>()
const [form] = Form.useForm();
const {fetchData} = useFetch()
const [loading, setLoading] = useState<boolean>(false)
const {apiPrefix,prefixForce ,aiServiceInfo} = useAiServiceContext()
const [variablesTable,setVariablesTable] = useState<VariableItems[]>([])
const [drawerType,setDrawerType]= useState<'edit'|undefined>()
const [open, setOpen] = useState(false);
const drawerAddFormRef = useRef<AiServiceRouterModelConfigHandle>(null)
const [defaultLlm, setDefaultLlm] = useState<AiProviderDefaultConfig & {config:string}>()
const [llmList, setLlmList] = useState<AiProviderLlmsItems[]>([])
const [variablesTableRef, setVariablesTableRef] = useState<MutableRefObject<EditableFormInstance<T> | undefined>>()
const onFinish = ()=>{
return variablesTableRef?.current?.validateFields().then(()=>{
return form.validateFields().then((formValue)=>{
const {name, path, description, variables, prompt, timeout, retry} = formValue
const body = {name, path: !routeId && prefixForce ? `${apiPrefix}/${path}`:path , description,timeout, retry,aiPrompt:{variables:variables, prompt:prompt},aiModel:{id:defaultLlm?.id, config:defaultLlm?.config}}
return fetchData<BasicResponse<null>>('service/ai-router',{method: routeId ? 'PUT' : 'POST',eoBody:(body), eoParams: {service:serviceId,team:teamId, ...(routeId ? {router:routeId}: {})},eoTransformKeys:['aiPrompt','aiModel']}).then(response=>{
const {code,msg} = response
if(code === STATUS_CODE.SUCCESS){
message.success(msg || $t(RESPONSE_TIPS.success))
navigator(`/aiservice/${teamId}/inside/${serviceId}/route`)
return Promise.resolve(true)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return Promise.reject(msg || $t(RESPONSE_TIPS.error))
}
}).catch(errInfo=>Promise.reject(errInfo))
})
})
.catch(errInfo=>Promise.reject(errInfo))
}
const openDrawer = (type:'edit')=>{
setDrawerType(type)
}
useEffect(()=>{drawerType !== undefined ? setOpen(true):setOpen(false)},[drawerType])
const getRouterConfig = ()=>{
setLoading(true)
fetchData<BasicResponse<{api:AiServiceRouterConfig}>>('service/ai-router',{method:'GET',eoParams:{service:serviceId,team:teamId, router:routeId}, eoTransformKeys:['ai_model', 'ai_prompt']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
const {path, aiPrompt,aiModel} = data.api
form.setFieldsValue({...data.api,...aiPrompt, path:prefixForce && path?.startsWith(apiPrefix + '/')? path.slice((apiPrefix?.length || 0) + 1) : path })
setVariablesTable(aiPrompt.variables as VariableItems[])
setDefaultLlm(prev => ({...prev, id:aiModel?.id, config:aiModel.config}) as (AiProviderDefaultConfig & { config: string; }))
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo)=> console.error(errorInfo))
.finally(()=>setLoading(false))
}
const getDefaultModelConfig = ()=>{
fetchData<BasicResponse<{llms:AiProviderLlmsItems[],provider:AiProviderDefaultConfig}>>('ai/provider/llms',{method:'GET',eoParams:{provider:aiServiceInfo?.provider?.id}, eoTransformKeys:['default_llm']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setLlmList(data.llms)
setDefaultLlm(prev => {
const llmSetting = data.llms?.find((x:AiProviderLlmsItems)=>x.id ===( prev?.id ?? data.provider.defaultLlm))
return {...prev,
defaultLlm:data.provider.defaultLlm,
name:data.provider.name,
config:llmSetting?.config || '',
...(llmSetting ?? {})
} as (AiProviderDefaultConfig & { config: string; })
})
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo)=> console.error(errorInfo))
}
useEffect(()=>{
aiServiceInfo?.provider && getDefaultModelConfig()
},[
aiServiceInfo
])
useEffect(() => {
if(routeId){
getRouterConfig()
}else{
form.setFieldsValue({
prefix:apiPrefix,
variables:[{key:'Query',value:'',require:true}],
prompt:'{{Query}}',
retry:0,
timeout:300000
})
}
return (form.setFieldsValue({}))
}, []);
const addVariable = ()=>{
form.setFieldsValue({
variables:[...form.getFieldValue('variables'),{key:'',value:'',require:true}]
})
}
const handleVariablesChange = (newKeys:string[])=>{
const variables = form.getFieldValue('variables') || []
const variablesKeys = variables?.map(({key}:{key:string})=>(key))
for(const key of newKeys){
if(!variablesKeys ||variablesKeys.indexOf(key) === -1){
variables.push({key, value:'',require:true})
}
}
form.setFieldsValue({
variables:[...variables]
})
setVariablesTable(variables as VariableItems[])
}
const handleValuesChange = (changedValues:Record<string,unknown>) => {
if(changedValues.variables){
setVariablesTable(changedValues.variables as VariableItems[])
}
};
const handlerSubmit:() => Promise<boolean>|undefined= ()=>{
return drawerAddFormRef.current?.save()?.then((res:{id:string, config:string})=>{
setDefaultLlm(prev => ({...prev, id:res.id, config:res.config, logo:llmList?.find((x:AiProviderLlmsItems)=>x.id === res.id)?.logo}) as (AiProviderDefaultConfig & { config: string; }))
return true})
}
const onClose = () => {
setDrawerType(undefined);
};
return (
<InsidePage pageTitle={ 'AI 路由设置'|| '-'}
showBorder={false}
scrollPage={false}
className="overflow-y-auto"
backUrl={`/aiservice/${teamId}/inside/${serviceId}/route`}
customBtn={
<div className="flex gap-btnbase items-center">
<Button icon={<Icon icon='ic:baseline-tune' height={18} width={18} />} iconPosition='end' onClick={()=>openDrawer('edit')}>
<div className="flex items-center gap-[4px]">
<span className="flex items-center " dangerouslySetInnerHTML={{__html: defaultLlm?.logo || ''}}></span>
<span>{defaultLlm?.id || defaultLlm?.defaultLlm}</span>
{defaultLlm?.scopes?.map(x=><Tag >{x?.toLocaleUpperCase()}</Tag>)}
</div>
</Button>
<Button type="primary" onClick={onFinish}>
{$t('保存')}
</Button>
</div>
}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} spinning={loading} wrapperClassName=' pb-PAGE_INSIDE_B pr-PAGE_INSIDE_X'>
<Form
layout='vertical'
labelAlign='left'
scrollToFirstError
form={form}
className="mx-auto flex flex-col h-full"
name="AiServiceInsideRouterCreate"
onValuesChange={handleValuesChange}
onFinish={onFinish}
autoComplete="off"
>
<div className="">
<Row className="flex items-center gap-btnbase w-full justify-between">
<Form.Item<AiServiceRouterField>
className="flex-1"
label={$t("路由名称")}
name="name"
rules={[{ required: true,whitespace:true }]}
>
<Input className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
<Form.Item<AiServiceRouterField>
className="flex-1"
label={$t("请求路径")}
name="path"
rules={[{ required: true,whitespace:true }]}
>
<Input prefix={prefixForce ? `${apiPrefix}/` :"/"} className="w-INPUT_NORMAL"
placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
</Row>
<Form.Item<AiServiceRouterField>
label={$t("提示词")}
name="prompt"
>
<PromptEditorResizable variablesChange={handleVariablesChange} promptVariables={variablesTable}/>
</Form.Item>
<Form.Item<AiServiceRouterField>
label={<div className="w-full flex justify-between items-center"><span>{$t("变量")}</span><a className="flex items-center gap-[4px]" onClick={addVariable}><Icon icon="ic:baseline-add" width={16} height={16} />New</a></div>}
name="variables"
className="[&>.ant-row>.ant-col>label]:w-full"
>
<EditableTableNotAutoGen<VariableItems & {_id:string}>
getFromRef={setVariablesTableRef}
configFields={AI_SERVICE_VARIABLES_TABLE_COLUMNS}
/>
</Form.Item>
<Form.Item<AiServiceRouterField>
label={$t("描述")}
name="description"
>
<Input.TextArea className="w-INPUT_NORMAL" placeholder={$t('输入这个接口的描述')}/>
</Form.Item>
<Row className="flex items-center gap-btnbase w-full justify-between">
<Form.Item<AiServiceRouterField>
className="flex-1"
label={$t("请求超时时间")}
name={'timeout'}
rules={[{required: true}]}
>
<InputNumber className="w-INPUT_NORMAL" suffix="ms" min={1} placeholder={$t(PLACEHOLDER.input)} />
</Form.Item>
<Form.Item<AiServiceRouterField>
className="flex-1"
label={$t("重试次数")}
name={'retry'}
rules={[{required: true}]}
>
<InputNumber className="w-INPUT_NORMAL" min={0} placeholder={$t(PLACEHOLDER.input)} />
</Form.Item>
</Row>
</div>
</Form>
</Spin>
<DrawerWithFooter
title={ $t("模型配置")}
open={open}
onClose={onClose}
onSubmit={()=>handlerSubmit()}
>
<AiServiceRouterModelConfig ref={drawerAddFormRef} llmList={llmList} entity={defaultLlm!} />
</DrawerWithFooter>
</InsidePage>
)
}
export default AiServiceInsideRouterCreate
@@ -0,0 +1,182 @@
import PageList, { PageProColumns } from "@common/components/aoplatform/PageList.tsx"
import {ActionType} from "@ant-design/pro-components";
import {FC, useEffect, useMemo, useRef, useState} from "react";
import {Link, useNavigate, useParams} from "react-router-dom";
import {useBreadcrumb} from "@common/contexts/BreadcrumbContext.tsx";
import {App, Divider} from "antd";
import {BasicResponse, COLUMNS_TITLE, DELETE_TIPS, RESPONSE_TIPS, STATUS_CODE} from "@common/const/const.tsx";
import { SimpleMemberItem} from '@common/const/type.ts'
import {useFetch} from "@common/hooks/http.ts";
import {RouterParams} from "@core/components/aoplatform/RenderRoutes.tsx";
import TableBtnWithPermission from "@common/components/aoplatform/TableBtnWithPermission.tsx";
import { useGlobalContext } from "@common/contexts/GlobalStateContext.tsx";
import { checkAccess } from "@common/utils/permission.ts";
import { $t } from "@common/locales/index.ts";
import { AiServiceRouterTableListItem } from "@core/const/ai-service/type.ts";
import { AI_SERVICE_ROUTER_TABLE_COLUMNS } from "@core/const/ai-service/const.tsx";
const AiServiceInsideRouterList:FC = ()=>{
const [searchWord, setSearchWord] = useState<string>('')
const { setBreadcrumb } = useBreadcrumb()
const { modal,message } = App.useApp()
const [tableListDataSource, setTableListDataSource] = useState<AiServiceRouterTableListItem[]>([]);
const [tableHttpReload, setTableHttpReload] = useState(true);
const {fetchData} = useFetch()
const pageListRef = useRef<ActionType>(null);
const [memberValueEnum, setMemberValueEnum] = useState<SimpleMemberItem[]>([])
const {accessData,state} = useGlobalContext()
const {serviceId, teamId} = useParams<RouterParams>()
const navigator = useNavigate()
const getRoutesList = (): Promise<{ data: AiServiceRouterTableListItem[], success: boolean }>=> {
if(!tableHttpReload){
setTableHttpReload(true)
return Promise.resolve({
data: tableListDataSource,
success: true,
});
}
return fetchData<BasicResponse<{apis:AiServiceRouterTableListItem}>>('service/ai-routers',{method:'GET',eoParams:{service:serviceId,team:teamId, keyword:searchWord},eoTransformKeys:['request_path','create_time','update_time','disable']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setTableListDataSource(data.apis)
setTableHttpReload(false)
return {data:data.apis, success: true}
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return {data:[], success:false}
}
}).catch(() => {
return {data:[], success:false}
})
}
const deleteRoute = (entity:AiServiceRouterTableListItem)=>{
return new Promise((resolve, reject)=>{
fetchData<BasicResponse<null>>('service/ai-router',{method:'DELETE',eoParams:{service:serviceId,team:teamId, router:entity!.id}}).then(response=>{
const {code,msg} = response
if(code === STATUS_CODE.SUCCESS){
message.success(msg || $t(RESPONSE_TIPS.success))
resolve(true)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
reject(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo)=> reject(errorInfo))
})
}
const openModal = async (type: 'delete',entity:AiServiceRouterTableListItem) =>{
let title:string = ''
let content:string|React.ReactNode = ''
switch (type){
case 'delete':
title=$t('删除')
content=$t(DELETE_TIPS.default)
break;
}
modal.confirm({
title,
content,
onOk:()=> {
switch (type){
case 'delete':
return deleteRoute(entity).then((res)=>{if(res === true) manualReloadTable()})
}
},
width:600,
okText:$t('确认'),
okButtonProps:{
disabled : !checkAccess( `team.service.router.${type}`, accessData )
},
cancelText:$t('取消'),
closable:true,
icon:<></>,
})
}
const operation:PageProColumns<AiServiceRouterTableListItem>[] =[
{
title: COLUMNS_TITLE.operate,
key: 'option',
btnNums:2,
fixed:'right',
valueType: 'option',
render: (_: React.ReactNode, entity: AiServiceRouterTableListItem) => [
<TableBtnWithPermission access="team.service.router.edit" key="edit" btnType="edit" onClick={()=>{navigator(`/aiservice/${teamId}/inside/${serviceId}/route/${entity.id}`)}} btnTitle="编辑"/>,
<Divider type="vertical" className="mx-0" key="div3"/>,
<TableBtnWithPermission access="team.service.router.delete" key="delete" btnType="delete" onClick={()=>{openModal('delete',entity)}} btnTitle="删除"/>,
],
}
]
const manualReloadTable = () => {
setTableHttpReload(true); // 表格数据需要从后端接口获取
pageListRef.current?.reload()
};
const getMemberList = async ()=>{
setMemberValueEnum([])
const {code,data,msg} = await fetchData<BasicResponse<{ members: SimpleMemberItem[] }>>('simple/member',{method:'GET'})
if(code === STATUS_CODE.SUCCESS){
setMemberValueEnum(data.members)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}
useEffect(() => {
setBreadcrumb([
{
title:<Link to={`/aiservice/list`}>{$t('服务')}</Link>
},
{
title:$t('路由')
}
])
getMemberList()
manualReloadTable()
}, [serviceId]);
const columns = useMemo(()=>{
return [...AI_SERVICE_ROUTER_TABLE_COLUMNS].map(x=>{
if(x.filters &&((x.dataIndex as string[])?.indexOf('creator') !== -1) ){
const tmpValueEnum:{[k:string]:{text:string}} = {}
memberValueEnum?.forEach((x:SimpleMemberItem)=>{
tmpValueEnum[x.name] = {text:x.name}
})
x.valueEnum = tmpValueEnum
}
return {...x,title:typeof x.title === 'string' ? $t(x.title as string) : x.title}})
},[memberValueEnum,state.language])
return (
<>
<PageList
id="global_system_api"
ref={pageListRef}
columns = {[...columns,...operation]}
request={()=>getRoutesList()}
dataSource={tableListDataSource}
addNewBtnTitle={$t('添加路由')}
searchPlaceholder={$t('输入 URL 查找路由')}
onAddNewBtnClick={()=>{navigator(`/aiservice/${teamId}/inside/${serviceId}/route/create`)}}
addNewBtnAccess="team.service.router.add"
tableClickAccess="team.service.router.view"
manualReloadTable={manualReloadTable}
onSearchWordChange={(e)=>{setSearchWord(e.target.value)}}
onChange={() => {
setTableHttpReload(false)
}}
onRowClick={(row:AiServiceRouterTableListItem)=>navigator(`/aiservice/${teamId}/inside/${serviceId}/route/${row.id}`)}
tableClass="mr-PAGE_INSIDE_X "
/>
</>
)
}
export default AiServiceInsideRouterList
@@ -0,0 +1,74 @@
import { Codebox } from "@common/components/postcat/api/Codebox"
import { PLACEHOLDER } from "@common/const/const"
import { $t } from "@common/locales"
import { AiProviderLlmsItems } from "@core/pages/aiSetting/AiSettingList"
import { Form, Select, Tag } from "antd"
import { forwardRef, useEffect, useImperativeHandle } from "react"
export type AiServiceRouterModelConfigHandle = {
save:()=>Promise<{id:string, config:string}>
}
export type AiServiceRouterModelConfigProps = {
entity:AiServiceRouterModelConfigField
llmList:AiProviderLlmsItems[]
}
type AiServiceRouterModelConfigField = {
id:string
config:string
}
const AiServiceRouterModelConfig = forwardRef<AiServiceRouterModelConfigHandle, AiServiceRouterModelConfigProps>((props, ref)=>{
const [form] = Form.useForm();
const {llmList,entity} = props
useImperativeHandle(ref, ()=>({
save:form.validateFields
})
)
useEffect(()=>{
form.setFieldsValue(entity)
},[])
return (
<Form
layout='vertical'
labelAlign='left'
scrollToFirstError
form={form}
className="mx-auto flex flex-col h-full"
name="aiServiceInsideRouterModalConfig"
autoComplete="off"
>
<Form.Item<AiServiceRouterModelConfigField>
label={$t("模型")}
name="id"
rules={[{ required: true }]}
>
<Select className="w-INPUT_NORMAL"
placeholder={$t(PLACEHOLDER.select)}
options={llmList?.map(x=>({
value:x.id,
label:<div className="flex items-center gap-[4px]">
<div className="flex items-center" dangerouslySetInnerHTML={{ __html: x.logo }}></div>
<span>{x.id}</span>
{x?.scopes?.map(s=><Tag >{s?.toLocaleUpperCase()}</Tag>)}
</div>}))}>
</Select>
</Form.Item>
<Form.Item<AiServiceRouterModelConfigField>
label={$t("参数")}
name="config"
>
<Codebox editorTheme="vs-dark"
width="100%" height="300px" language='json' enableToolbar={false} />
</Form.Item>
</Form>
)
})
export default AiServiceRouterModelConfig
@@ -0,0 +1,9 @@
:global .ant-tabs.ant-tabs-top{
height:100%;
.ant-tabs-content.ant-tabs-content-top{
height:100%;
.ant-tabs-tabpane.ant-tabs-tabpane-active{
height:100%;
}
}
}
@@ -0,0 +1,36 @@
import {Tabs} from "antd";
import {Outlet, useLocation, useNavigate} from "react-router-dom";
import './AiServiceInsideApproval.module.css'
import {FC, useEffect, useMemo, useState} from "react";
import { SYSTEM_INSIDE_APPROVAL_TAB_ITEMS } from "../../../const/system/const";
import { useGlobalContext } from "@common/contexts/GlobalStateContext";
import { $t } from "@common/locales";
const AiServiceInsideApproval:FC = ()=>{
const navigateTo = useNavigate()
const location = useLocation()
const query =new URLSearchParams(useLocation().search)
const currentUrl = location.pathname
const [pageStatus,setPageStatus] = useState<0|1>(Number(query.get('status') ||0) as 0|1)
const {state} = useGlobalContext()
const onChange = (key: string) => {
setPageStatus(Number(key) as 0|1)
navigateTo(`${currentUrl}?status=${key}`);
};
useEffect(() => {
setPageStatus(Number(query.get('status') ||0) as 0|1)
}, [currentUrl]);
const tabItems = useMemo(()=>SYSTEM_INSIDE_APPROVAL_TAB_ITEMS?.map((x)=>({...x, label:$t(x.label as string) })),[state.language])
return (
<>
<Tabs defaultActiveKey={pageStatus.toString()} size="small" className="h-auto bg-MAIN_BG" tabBarStyle={{paddingLeft:'10px'}} tabBarGutter={20} items={tabItems} onChange={onChange} destroyInactiveTabPane={true}/>
<Outlet />
</>
)
}
export default AiServiceInsideApproval
@@ -0,0 +1,200 @@
import {ActionType} from "@ant-design/pro-components";
import {FC, useEffect, useMemo, useRef, useState} from "react";
import {Link, useLocation, useParams} from "react-router-dom";
import PageList, { PageProColumns } from "@common/components/aoplatform/PageList.tsx";
import {useBreadcrumb} from "@common/contexts/BreadcrumbContext.tsx";
import {App, Button} from "antd";
import {
SUBSCRIBE_APPROVAL_INNER_DONE_TABLE_COLUMN,
SUBSCRIBE_APPROVAL_INNER_TODO_TABLE_COLUMN,
SubscribeApprovalTableListItem, TODO_LIST_COLUMN_NOT_INCLUDE_KEY
} from "@common/const/approval/const.tsx";
import {BasicResponse, COLUMNS_TITLE, RESPONSE_TIPS, STATUS_CODE} from "@common/const/const.tsx";
import {useFetch} from "@common/hooks/http.ts";
import {RouterParams} from "@core/components/aoplatform/RenderRoutes.tsx";
import {
SubscribeApprovalModalContent,
SubscribeApprovalModalHandle
} from "@common/components/aoplatform/SubscribeApprovalModalContent.tsx";
import WithPermission from "@common/components/aoplatform/WithPermission.tsx";
import { SimpleMemberItem } from "@common/const/type.ts";
import TableBtnWithPermission from "@common/components/aoplatform/TableBtnWithPermission.tsx";
import { useGlobalContext } from "@common/contexts/GlobalStateContext.tsx";
import { checkAccess } from "@common/utils/permission.ts";
import { SubscribeApprovalInfoType } from "@common/const/approval/type.tsx";
import { $t } from "@common/locales";
const AiServiceInsideApprovalList:FC = ()=>{
const { setBreadcrumb } = useBreadcrumb()
const { modal,message } = App.useApp()
const {serviceId, teamId} = useParams<RouterParams>();
const [init, setInit] = useState<boolean>(true)
const {fetchData} = useFetch()
const [tableHttpReload, setTableHttpReload] = useState(true);
const [tableListDataSource, setTableListDataSource] = useState<SubscribeApprovalTableListItem[]>([]);
const pageListRef = useRef<ActionType>(null);
const query =new URLSearchParams(useLocation().search)
const [pageStatus,setPageStatus] = useState<0|1>(Number(query.get('status') ||0) as 0|1)
const subscribeRef = useRef<SubscribeApprovalModalHandle>(null)
const [approvalBtnLoading,setApprovalBtnLoading] = useState<boolean>(false)
const [memberValueEnum, setMemberValueEnum] = useState<SimpleMemberItem[]>([])
const {accessData,state} = useGlobalContext()
const openModal = async (type:'approval'|'view',entity:SubscribeApprovalTableListItem)=>{
message.loading($t(RESPONSE_TIPS.loading))
const {code,data,msg} = await fetchData<BasicResponse<{approval:SubscribeApprovalInfoType}>>('service/approval/subscribe',{method:'GET',eoParams:{apply:entity!.id, service:serviceId,team:teamId},eoTransformKeys:['apply_project','apply_team','apply_time','approval_time']})
message.destroy()
if(code === STATUS_CODE.SUCCESS){
const modalIns = modal.confirm({
title:type === 'approval' ? $t('审批') : $t('查看'),
content:<SubscribeApprovalModalContent ref={subscribeRef} data={{...data.approval} as SubscribeApprovalInfoType} type={type} serviceId={serviceId!} teamId={teamId!} inAiService/>,
onOk:()=>{
return subscribeRef.current?.save('pass').then((res)=>res === true && manualReloadTable())
},
width:600,
okText:type === 'approval' ? $t('通过') : $t('确认'),
cancelText:type === 'approval' ?$t('取消'):$t('关闭'),
okButtonProps:{
disabled : type === 'approval' ? !checkAccess('team.service.release.approval', accessData): false
},
closable:true,
onCancel:()=>{setApprovalBtnLoading(false)},
icon:<></>,
footer:(_, { OkBtn, CancelBtn }) =>{
return (
<>
{type === 'approval' ? <>
<CancelBtn/>
<WithPermission access="team.service.release.approval"><Button type="primary" danger loading={approvalBtnLoading} onClick={()=>{setApprovalBtnLoading(true);subscribeRef.current?.save('refuse').then((res)=>{if(res === true ){manualReloadTable();modalIns?.destroy()}}).finally(()=>{setApprovalBtnLoading(false)})}}>{$t('拒绝')}</Button></WithPermission>
<OkBtn/>
</> :
<>
<CancelBtn/>
</>
}
</>
)
},
})
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return
}
}
const operation:PageProColumns<SubscribeApprovalTableListItem>[] =[
{
title: COLUMNS_TITLE.operate,
key: 'option',
btnNums:1,
fixed:'right',
valueType: 'option',
render: (_: React.ReactNode, entity: SubscribeApprovalTableListItem) => [
pageStatus === 0 ?
<TableBtnWithPermission access="team.service.subscription.approval" key="approval" btnType="approval" onClick={()=>{openModal('approval',entity)}} btnTitle="审批"/>
:<TableBtnWithPermission access="team.service.subscription.view" key="view" btnType="view" onClick={()=>{openModal('view',entity)}} btnTitle="查看"/>,
],
}
]
const getApprovalList = ()=>{
if(!tableHttpReload){
setTableHttpReload(true)
return Promise.resolve({
data: tableListDataSource,
success: true,
});
}
return fetchData<BasicResponse<{approvals:SubscribeApprovalTableListItem[]}>>('service/approval/subscribes',{method:'GET',eoParams:{service:serviceId,team:teamId, status:(query.get('status') || 0)},eoTransformKeys:['apply_time','apply_project','approval_time']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setTableListDataSource(data.approvals)
setInit((prev)=>prev ? false : prev)
return {data:data.approvals, success: true}
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return {data:[], success:false}
}
}).catch(() => {
return {data:[], success:false}
})
}
const getMemberList = async ()=>{
setMemberValueEnum([])
const {code,data,msg} = await fetchData<BasicResponse<{ members: SimpleMemberItem[] }>>('simple/member',{method:'GET'})
if(code === STATUS_CODE.SUCCESS){
setMemberValueEnum(data.members)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}
useEffect(() => {
!init && pageListRef.current?.reload()
}, [pageStatus]);
useEffect(() => {
setPageStatus(Number(query.get('status') ||0) as 0|1)
}, [query]);
useEffect(() => {
setBreadcrumb([
{
title:<Link to={`/aiservice/list`}>{$t('服务')}</Link>
},
{
title:$t('订阅审批')
}
])
getMemberList()
manualReloadTable()
}, [serviceId]);
const manualReloadTable = () => {
setTableHttpReload(true); // 表格数据需要从后端接口获取
pageListRef.current?.reload()
};
const columns = useMemo(()=>{
const newColumns = [...(!(query.get('status'))? SUBSCRIBE_APPROVAL_INNER_TODO_TABLE_COLUMN:SUBSCRIBE_APPROVAL_INNER_DONE_TABLE_COLUMN)]
const filteredCol = pageStatus === 0 ? newColumns.filter((x)=>TODO_LIST_COLUMN_NOT_INCLUDE_KEY.indexOf(x.dataIndex as string) === -1): newColumns
return filteredCol.map(x=>{
if(x.filters &&((x.dataIndex as string[])?.indexOf('applier') !== -1 || (x.dataIndex as string[])?.indexOf('approver') !== -1) ){
const tmpValueEnum :Record<string,{text:string}>= {}
memberValueEnum?.forEach((x:SimpleMemberItem)=>{
tmpValueEnum[x.name] = {text:$t(x.name)}
})
x.valueEnum = tmpValueEnum
}
if(x.dataIndex === 'status'){
x.valueEnum = new Map([
[0, <span className="text-status_fail">{$t('拒绝')}</span>],
[2,<span className="text-status_success">{$t('通过')}</span>],
])
}
return {...x,title: typeof x.title === 'string' ? $t(x.title as string) : x.title}})
},[pageStatus,memberValueEnum,state.language])
return (
<div className="h-full not-top-padding-table">
<PageList
id="global_system_approval"
ref={pageListRef}
columns = {[...columns,...operation]}
request={()=>getApprovalList()}
onChange={() => {
setTableHttpReload(false)
}}
onRowClick={(row:SubscribeApprovalTableListItem)=>openModal(pageStatus === 0 ? 'approval': 'view',row)}
tableClickAccess={pageStatus === 0 ?'team.service.subscription.approval':'team.service.subscription.view'}
tableClass="pr-PAGE_INSIDE_X"
/>
</div>
)
}
export default AiServiceInsideApprovalList
@@ -0,0 +1,48 @@
import { Tabs } from "antd"
import { useState, useEffect, FC, useMemo } from "react"
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"
import { useBreadcrumb } from "@common/contexts/BreadcrumbContext"
import { SYSTEM_PUBLISH_TAB_ITEMS } from "../../../const/system/const"
import { $t } from "@common/locales"
import { useGlobalContext } from "@common/contexts/GlobalStateContext"
const AiServiceInsidePublic:FC = ()=>{
const { setBreadcrumb } = useBreadcrumb()
const query =new URLSearchParams(useLocation().search)
const location = useLocation()
const currentUrl = location.pathname
const [pageStatus,setPageStatus] = useState<0|1>(Number(query.get('status') ||0) as 0|1)
const navigateTo = useNavigate()
const { state } = useGlobalContext()
const onChange = (key: string) => {
setPageStatus(Number(key) as 0|1)
navigateTo(`${currentUrl}?status=${key}`);
};
useEffect(() => {
setPageStatus(Number(query.get('status') ||0) as 0|1)
}, [currentUrl]);
useEffect(() => {
setBreadcrumb([
{
title:<Link to={`/aiservice/list`}>{$t('服务')}</Link>
},
{
title:$t('发布')
}
])
}, []);
const tabItems = useMemo(()=>SYSTEM_PUBLISH_TAB_ITEMS?.map((x)=>({...x, label:$t(x.label as string) })),[state.language])
return (
<>
<Tabs defaultActiveKey={pageStatus.toString()} size="small" className="h-auto bg-MAIN_BG" tabBarStyle={{paddingLeft:'10px'}} tabBarGutter={20} items={tabItems} onChange={onChange} destroyInactiveTabPane={true}/>
<Outlet />
</>
)
}
export default AiServiceInsidePublic
@@ -0,0 +1,526 @@
import { ActionType, ParamsType } from "@ant-design/pro-components";
import { App, Button, Divider } from "antd";
import { useState, useRef, useEffect, useMemo, FC } from "react";
import { useParams, Link, useLocation } from "react-router-dom";
import PageList, { PageProColumns } from "@common/components/aoplatform/PageList";
import { PublishApprovalModalContent } from "@common/components/aoplatform/PublishApprovalModalContent";
import { RouterParams } from "@core/components/aoplatform/RenderRoutes";
import { PUBLISH_APPROVAL_RECORD_INNER_TABLE_COLUMN, PUBLISH_APPROVAL_VERSION_INNER_TABLE_COLUMN, PublishApplyStatusEnum, PublishStatusEnum, PublishTableStatusColorClass } from "@common/const/approval/const";
import { BasicResponse, COLUMNS_TITLE, DELETE_TIPS, RESPONSE_TIPS, STATUS_CODE } from "@common/const/const";
import { SimpleMemberItem } from "@common/const/type.ts";
import { MemberTableListItem } from "../../../const/member/type";
import { useBreadcrumb } from "@common/contexts/BreadcrumbContext";
import { useFetch } from "@common/hooks/http";
import WithPermission from "@common/components/aoplatform/WithPermission";
import { AiServicePublishReleaseItem } from "../../../const/system/type";
import TableBtnWithPermission from "@common/components/aoplatform/TableBtnWithPermission";
import { useGlobalContext } from "@common/contexts/GlobalStateContext";
import { PERMISSION_DEFINITION } from "@common/const/permissions";
import { checkAccess } from "@common/utils/permission";
import AiServiceInsidePublishOnline from "./AiServiceInsidePublishOnline";
import { PublishVersionTableListItem, PublishTableListItem, PublishApprovalInfoType, PublishApprovalModalHandle } from "@common/const/approval/type";
import { DrawerWithFooter } from "@common/components/aoplatform/DrawerWithFooter";
import { $t } from "@common/locales";
const AiServiceInsidePublicList:FC = ()=>{
const { setBreadcrumb } = useBreadcrumb()
const { modal,message } = App.useApp()
const pageListRef = useRef<ActionType>(null);
const [tableHttpReload, setTableHttpReload] = useState(true);
const [init, setInit] = useState<boolean>(true)
const {fetchData} = useFetch()
const [tableListDataSource, setTableListDataSource] = useState<MemberTableListItem[]>([]);
const {serviceId, teamId} = useParams<RouterParams>();
const drawerRef = useRef<PublishApprovalModalHandle>(null)
const [extraModalBtnLoading,setExtraModalBtnLoading] = useState<boolean>(false)
const [pageStatus,setPageStatus] = useState<0|1>(0 as 0|1)
const [pageType, setPageType] = useState<'insidePage'|'global'>('insidePage')
const query =new URLSearchParams(useLocation().search)
const currLocation = useLocation().pathname
const [memberValueEnum, setMemberValueEnum] = useState<SimpleMemberItem[]>([])
const {accessData,state} = useGlobalContext()
const [drawerTitle, setDrawerTitle] = useState<string>('')
const [drawerType, setDrawerType] = useState<'approval'|'view'|'add'|'publish'|'online'>('view')
const [drawerVisible, setDrawerVisible] = useState<boolean>(false)
const [drawerData, setDrawerData] = useState<PublishTableListItem|PublishVersionTableListItem >({} as PublishTableListItem)
const [drawerOkTitle, setDrawerOkTitle] = useState<string>('确认')
const [isOkToPublish, setIsOkToPublish] = useState<boolean>(false)
const getAiServicePublishList = (params?: ParamsType & {
pageSize?: number | undefined;
current?: number | undefined;
keyword?: string | undefined;
})=>{
if(!(pageType !== 'insidePage' && pageStatus !== 0 ) && !tableHttpReload){
setTableHttpReload(true)
return Promise.resolve({
data: tableListDataSource,
success: true,
});
}
return fetchData<BasicResponse<{releases?:PublishVersionTableListItem[],publishs?:PublishTableListItem[]}>>(
pageStatus === 0 ? 'service/releases':'service/publishs',
{method:'GET',eoParams:(pageType !== 'insidePage' && pageStatus !== 0 ) ? {service:serviceId,team:teamId,page:params?.current,page_size:params?.pageSize}:{service:serviceId,team:teamId},eoTransformKeys:['pageSize','apply_time','approve_time','release_status','is_valid','fail_msg','create_time','can_rollback','flow_id','can_delete']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
const finalRes = pageStatus === 0 ? data.releases.map((x:PublishVersionTableListItem)=>{if(!x.status|| x.status === 'close'){x.status = 'none'} return x}):data.publishs
setTableListDataSource(finalRes)
setInit((prev)=>prev ? false : prev)
return {data:finalRes, success: true}
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
setInit((prev)=>prev ? false : prev)
return {data:[], success:false}
}
}).catch(() => {
return {data:[], success:false}
})
}
const handlePublishAction = (type:'rollback'|'delete'|'stop',entity:PublishTableListItem | PublishVersionTableListItem)=>{
let url:string ='service/release'
let method:string
let params:{[k:string]:unknown} = {}
switch(type){
case 'rollback':
method = 'POST'
params = {service:serviceId,team:teamId, id:entity.id}
break;
case 'delete':
method = 'DELETE'
params = {service:serviceId,team:teamId,id:entity.id}
break;
case 'stop':
url = 'service/publish/stop'
method = 'DELETE'
params = {service:serviceId,team:teamId,id:(entity as PublishVersionTableListItem).flowId}
break;
}
return new Promise((resolve, reject)=>{
fetchData<BasicResponse<null>>(url,{method,eoParams:params}).then(response=>{
const {code,msg} = response
if(code === STATUS_CODE.SUCCESS){
message.success(msg || $t(RESPONSE_TIPS.success))
resolve(true)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
reject(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo)=> reject(errorInfo))
})}
const isActionAllowed = (type:'view' | 'delete' | 'add' |'stop'|'online'|'cancel'|'approval' | 'rollback'|'publish') => {
const permission :keyof typeof PERMISSION_DEFINITION[0]= `team.service.release.${type === 'publish'? 'add' : type}`;
return !checkAccess(permission, accessData);
};
const handleOnline = (entity:PublishTableListItem | PublishVersionTableListItem)=>{
modal.confirm({
title:$t('发布结果'),
content:<AiServiceInsidePublishOnline serviceId={serviceId!} teamId={teamId!} id={(entity as PublishVersionTableListItem).id}/>,
width: 600,
closable: true,
wrapClassName:'ant-modal-without-footer',
icon: <></>,
footer:null,
onCancel:()=>{
manualReloadTable()
}
});
}
const openDrawer = async(type: 'view' | 'add'|'online'|'approval'|'publish', entity?: PublishTableListItem|PublishVersionTableListItem)=>{
setIsOkToPublish(false)
switch (type) {
case 'view':{
message.loading($t(RESPONSE_TIPS.loading));
const viewPublish:boolean = pageStatus !== 0 || ((entity as PublishVersionTableListItem)?.status && (entity as PublishVersionTableListItem)?.status !== 'none')
const { code, data, msg } = await fetchData<BasicResponse<{ publish: PublishApprovalInfoType } | { release:AiServicePublishReleaseItem}>>(
viewPublish ? 'service/publish':'service/release',
{ method: 'GET', eoParams:{id: (entity as PublishVersionTableListItem)?.[viewPublish && pageStatus === 0 ? 'flowId':'id'],service:serviceId,team:teamId },eoTransformKeys:['cluster_publish_status','upstream_status','doc_status','proxy_status','version_remark'] }
);
message.destroy();
if (code === STATUS_CODE.SUCCESS) {
setDrawerTitle($t('查看详情'))
setDrawerType(type)
setDrawerData(viewPublish ? data.publish : data.release)} else {
message.error(msg || $t(RESPONSE_TIPS.error));
return
}
break;
}
case 'online':{
message.loading($t(RESPONSE_TIPS.loading));
const { code, data, msg } = await fetchData<BasicResponse<{ publish: PublishApprovalInfoType }>>(
'service/publish',
{ method: 'GET', eoParams:{ id: (entity as PublishVersionTableListItem)?.flowId,service:serviceId,team:teamId },eoTransformKeys:['version_remark'] }
);
message.destroy();
if (code === STATUS_CODE.SUCCESS) {
setDrawerTitle($t('上线'))
setDrawerType(type)
setDrawerOkTitle($t('上线'))
setDrawerData({...data.publish, flowId:(entity as PublishVersionTableListItem)?.flowId})
} else {
message.error(msg || $t(RESPONSE_TIPS.error));
return
}
break;
}
case 'approval':{
message.loading($t(RESPONSE_TIPS.loading));
const { code, data, msg } = await fetchData<BasicResponse<{ publish: PublishApprovalInfoType }>>(
'service/publish',
{ method: 'GET', eoParams:{ id: (entity as PublishVersionTableListItem)?.flowId,service:serviceId,team:teamId },eoTransformKeys:['version_remark'] }
);
message.destroy();
if (code === STATUS_CODE.SUCCESS) {
setDrawerTitle($t('审批'))
setDrawerType(type)
setDrawerData(data.publish)
setDrawerOkTitle($t('通过'))
} else {
message.error(msg || $t(RESPONSE_TIPS.error));
return
}
break;
}
case 'publish':
case 'add':{
message.loading($t(RESPONSE_TIPS.loading));
const { code, data, msg } = await fetchData<BasicResponse<{ diffs: PublishApprovalInfoType }>>(
'service/publish/check',
{ method: 'GET', eoParams:{service:serviceId,team:teamId, ...(type === 'publish' ?{ release:entity?.id }:{})},eoTransformKeys:['version_remark'] }
);
message.destroy();
if (code === STATUS_CODE.SUCCESS) {
setDrawerTitle($t('申请发布'))
setDrawerType(type)
setDrawerData({...data, ...(type === 'publish'&& {version:entity?.version, id:entity?.id})})
setDrawerOkTitle($t('确认'))
setIsOkToPublish(data.isOk??true)
} else {
message.error(msg || $t(RESPONSE_TIPS.error));
return
}
break;
}
}
setDrawerVisible(true)
}
const openModal = async (type: 'delete' |'stop'|'cancel' | 'rollback', entity?: PublishTableListItem|PublishVersionTableListItem) => {
let title: string = '';
let content: string | React.ReactNode = '';
switch (type) {
case 'delete':
title = $t('删除');
content = $t(DELETE_TIPS.default);
break;
case 'rollback':
title = $t('回滚');
content = $t('请确认是否回滚?');
break;
case 'cancel':
title = $t('撤销申请');
content = $t('请确认是否撤销申请?');
break;
case 'stop':
title = $t('终止发布');
content = $t('请确认是否终止发布?');
break;
}
modal.confirm({
title,
content,
onOk: () => {
switch (type){
case 'rollback':
return handlePublishAction('rollback',entity!).then((res)=>{if(res === true)manualReloadTable()})
case 'delete':
return handlePublishAction('delete',entity!).then((res)=>{if(res === true)manualReloadTable()})
case 'cancel':
case 'stop':
return handlePublishAction('stop',entity!).then((res)=>{if(res === true)manualReloadTable()})
}
},
width: 600,
okText: $t('确认'),
cancelText: $t('取消'),
onCancel:()=>{setExtraModalBtnLoading(false)},
closable: true,
icon: <></>,
okButtonProps:{
disabled: isActionAllowed(type) || false
},
footer: (_, { OkBtn, CancelBtn }) => (
<>
<CancelBtn />
<WithPermission>
<OkBtn />
</WithPermission>
</>
),
});
};
const tableOperation = (entity:PublishTableListItem | PublishVersionTableListItem)=>{
const viewBtn = <TableBtnWithPermission access="team.service.release.view" key="view" btnType="view" onClick={()=>{openDrawer('view',entity)}} btnTitle="查看详情"/>
let btnArr:React.ReactNode[] = []
if(pageType !== 'insidePage' && pageStatus !== 0){
btnArr = [
viewBtn
]
return btnArr
}
if((entity as PublishVersionTableListItem).status === 'accept'){
btnArr = [
<TableBtnWithPermission access="team.service.release.online" key="online" btnType="online" onClick={()=>{openDrawer('online',entity)}} btnTitle="上线"/>,
<Divider type="vertical" className="mx-0" key="div1"/>,
viewBtn,
<Divider type="vertical" className="mx-0" key="div2"/>,
<TableBtnWithPermission access="team.service.release.stop" key="stop" btnType="stop" onClick={()=>{openModal('stop',entity)}} btnTitle="终止发布"/>
]
}
if((entity as PublishVersionTableListItem).status === 'publishing'){
btnArr = [
viewBtn,
<Divider type="vertical" className="mx-0" key="div2"/>,
<TableBtnWithPermission access="team.service.release.stop" key="stop" btnType="stop" onClick={()=>{openModal('stop',entity)}} btnTitle="终止发布"/>
]
}
if((entity as PublishVersionTableListItem).status === 'apply'){
btnArr = [
<TableBtnWithPermission access="team.service.release.approval" key="approval" btnType="approval" onClick={()=>{openDrawer('approval',entity)}} btnTitle="审批"/>,
<Divider type="vertical" className="mx-0" key="div1"/>,
viewBtn,
<Divider type="vertical" className="mx-0" key="div2"/>,
<TableBtnWithPermission access="team.service.release.cancel" key="cancel" btnType="cancel" onClick={()=>{openModal('cancel',entity)}} btnTitle="撤回申请"/>
]
}
// 第一期不做回滚
// if( (entity as PublishVersionTableListItem).status === 'online' && (entity as PublishVersionTableListItem).canRollback){
// btnArr = [...btnArr,
// ...(btnArr.length > 0 ? [<Divider type="vertical" className="mx-0" />]:
// [viewBtn,
// <Divider type="vertical" className="mx-0" />]),
// <WithPermission access="team.service.release.rollback"><Button className="h-[22px] border-none p-0 flex items-center bg-transparent " key="rollback" onClick={()=>openModal('rollback',entity)}>回滚版本</Button></WithPermission>
// ]
// }
if( ['close','refuse','none'].indexOf((entity as PublishVersionTableListItem).status as string) !== -1 || !(entity as PublishVersionTableListItem).flowId){
btnArr = [...btnArr,
...(btnArr.length > 0 ? [<Divider type="vertical" className="mx-0" key="div1" />]:
[viewBtn,
// <Divider type="vertical" className="mx-0" key="div1"/>
]),
// <TableBtnWithPermission access="team.service.release.add" key="publish" onClick={()=>{openDrawer('publish',entity)}} btnTitle="申请发布"/>
]
}
if( ['running','error'].indexOf((entity as PublishVersionTableListItem).status as string) !== -1 && (entity as PublishVersionTableListItem).flowId){
btnArr = [viewBtn]
}
if((entity as PublishVersionTableListItem).canDelete){
btnArr = [...btnArr, btnArr.length > 0 && <Divider type="vertical" className="mx-0" key="div5"/>,<TableBtnWithPermission access="team.service.release.delete" key="delete" btnType="delete" onClick={()=>{openModal('delete',entity)}} btnTitle="删除"/> ]
}
return btnArr
}
const operation:PageProColumns<PublishTableListItem>[] =[
{
title: COLUMNS_TITLE.operate,
key: 'option',
btnNums:pageStatus === 0 ? 2 : 1,
valueType: 'option',
fixed:'right',
render: (_: React.ReactNode, entity: PublishTableListItem|PublishVersionTableListItem) => tableOperation(entity)
}
]
useEffect(() => {
setBreadcrumb([
{
title:<Link to={`/aiservice/list`}>{$t('服务')}</Link>
},
{
title:$t('发布')
}
])
getMemberList()
manualReloadTable()
}, [serviceId]);
const getMemberList = async ()=>{
setMemberValueEnum([])
const {code,data,msg} = await fetchData<BasicResponse<{ members: SimpleMemberItem[] }>>('simple/member',{method:'GET'})
if(code === STATUS_CODE.SUCCESS){
setMemberValueEnum(data.members)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}
const columns = useMemo(()=>{
return ((pageType === 'insidePage' || pageStatus === 0 ) ?
PUBLISH_APPROVAL_VERSION_INNER_TABLE_COLUMN
:PUBLISH_APPROVAL_RECORD_INNER_TABLE_COLUMN)
.map(x=>{
if(x.filters &&(x.dataIndex as string[])?.indexOf('creator') !== -1){
const tmpValueEnum:{[k:string]:{text:string}} = {}
memberValueEnum?.forEach((x:SimpleMemberItem)=>{
tmpValueEnum[x.name] = {text:$t(x.name)}
})
x.valueEnum = tmpValueEnum
}
if(x.dataIndex === 'status'){
x.valueEnum = (pageType === 'insidePage' || pageStatus === 0 ) ? new Map([
['apply',<span className={PublishTableStatusColorClass.apply}>{$t(PublishApplyStatusEnum.apply || '-')}</span>],
['running',<span className={PublishTableStatusColorClass.running}>{$t(PublishApplyStatusEnum.running || '-')}</span>],
['none',<span className={PublishTableStatusColorClass.none}>{$t(PublishApplyStatusEnum.none || '-')}</span>],
['refuse',<span className={PublishTableStatusColorClass.refuse}>{$t(PublishApplyStatusEnum.refuse || '-')}</span>],
['publishing',<span className={PublishTableStatusColorClass.publishing}>{$t(PublishApplyStatusEnum.publishing || '-')}</span>],
['error',<span className={PublishTableStatusColorClass.error}>{$t(PublishApplyStatusEnum.error || '-')}</span>],
]) : new Map([
['apply',<span className={PublishTableStatusColorClass.apply}>{$t(PublishStatusEnum.apply || '-')}</span>],
['accept',<span className={PublishTableStatusColorClass.accept}>{$t(PublishStatusEnum.accept || '-')}</span>],
['done',<span className={PublishTableStatusColorClass.done}>{$t(PublishStatusEnum.done || '-')}</span>],
['stop',<span className={PublishTableStatusColorClass.stop}>{$t(PublishStatusEnum.stop || '-')}</span>],
['close',<span className={PublishTableStatusColorClass.close}>{$t(PublishStatusEnum.close || '-')}</span>],
['refuse',<span className={PublishTableStatusColorClass.refuse}>{$t(PublishStatusEnum.refuse || '-')}</span>],
['publishing',<span className={PublishTableStatusColorClass.publishing}>{$t(PublishStatusEnum.publishing || '-')}</span>],
['error',<span className={PublishTableStatusColorClass.error}>{$t(PublishStatusEnum.error || '-')}</span>],
])
}
return {...x,title:typeof x.title === 'string' ? $t(x.title as string) : x.title}
}
)
},[pageType, pageStatus, memberValueEnum,state.language])
useEffect(() => {
!init && pageListRef.current?.reload()
}, [pageStatus]);
useEffect(() => {
setPageStatus(Number(query.get('status') ||0) as 0|1)
}, [query]);
useEffect(()=>{
setPageType(currLocation.split('/')[0] === 'service' ? 'insidePage' : 'global')
},[currLocation])
const manualReloadTable = () => {
setTableHttpReload(true); // 表格数据需要从后端接口获取
pageListRef.current?.reload()
};
const drawerActions = {
approval: () => drawerRef.current?.save('pass'),
add: () => drawerRef.current?.publish(),
publish: () => drawerRef.current?.publish(true),
online: () => drawerRef.current?.online(),
};
const onSubmit = () => {
const action = drawerActions[drawerType as keyof typeof drawerActions];
if (action) {
return action()?.then((res) => {
if(drawerType === 'add' && res){
handleOnline((res as unknown as Record<string, unknown>)?.data?.publish)
}
if (res === true && (drawerType === 'online' || drawerType === 'add')) {
handleOnline(drawerData)
}else if(res === true){
manualReloadTable();
}
return res;
});
} else {
return Promise.resolve(true);
}
};
return (
<>
<PageList
id="global_system_publish"
ref={pageListRef}
columns = {[...columns,...operation]}
request = {(params: ParamsType & {
pageSize?: number | undefined;
current?: number | undefined;
keyword?: string | undefined;
})=>getAiServicePublishList(params)}
addNewBtnTitle={pageStatus === 0 ? $t("新建版本"):''}
onAddNewBtnClick={()=>{openDrawer('add')}}
addNewBtnAccess="team.service.release.add"
onChange={() => {
setTableHttpReload(false)
}}
onRowClick={(row:PublishTableListItem|PublishVersionTableListItem)=>openDrawer('view',row)}
tableClickAccess="team.service.release.view"
tableClass="pr-PAGE_INSIDE_X"
/>
<DrawerWithFooter
destroyOnClose={true}
title={drawerTitle}
width={'60%'}
onClose={()=>{setDrawerVisible(false)}}
open={drawerVisible}
okBtnTitle={drawerOkTitle}
submitDisabled={drawerType === 'add' ? !isOkToPublish : false}
submitAccess={`team.service.release.${drawerType === 'publish'? 'add' : drawerType}`}
cancelBtnTitle={drawerType === 'online' ? $t('关闭') : undefined}
showOkBtn={drawerType !== 'view'}
onSubmit={onSubmit}
extraBtn={(drawerType === 'approval'||drawerType === 'online') ? <WithPermission access={`team.service.release.${drawerType === 'approval'? 'approval' : 'stop'}`}>
<Button
type={drawerType === 'approval'? "primary" : 'default'}
danger={drawerType === 'approval'}
loading={extraModalBtnLoading}
className={`${drawerType === 'online'? 'text-theme border-theme':''}`}
onClick={() => {
setExtraModalBtnLoading(true);
if(drawerType === 'approval'){
drawerRef.current?.save('refuse').then((res) => {
if (res === true) {
setDrawerVisible(false);manualReloadTable();
}
}).finally(() => {
setExtraModalBtnLoading(false);
});
}else{
handlePublishAction('stop', drawerData!).then((res) => {
if (res === true) {
setDrawerVisible(false);manualReloadTable();
}
}).finally(() => {
setExtraModalBtnLoading(false);
});
}
}}
>
{drawerType === 'approval'? $t("拒绝") : $t('终止发布')}
</Button>
</WithPermission> :undefined}
>
<PublishApprovalModalContent insidePage={true} serviceType='ai' ref={drawerRef}
data={drawerData as PublishVersionTableListItem } type={drawerType} serviceId={serviceId!} teamId={teamId!} />
</DrawerWithFooter>
</>
)
}
export default AiServiceInsidePublicList
@@ -0,0 +1,90 @@
import { App, Table, Tooltip } from "antd";
import { SYSTEM_PUBLISH_ONLINE_COLUMNS } from "../../../const/system/const";
import { useEffect, useMemo, useState } from "react";
import { useFetch } from "@common/hooks/http";
import { BasicResponse, RESPONSE_TIPS, STATUS_CODE, STATUS_COLOR } from "@common/const/const";
import { EntityItem } from "@common/const/type";
import { LoadingOutlined } from "@ant-design/icons";
import { $t } from "@common/locales";
import { useGlobalContext } from "@common/contexts/GlobalStateContext";
type AiServiceInsidePublishOnlineProps = {
serviceId:string
teamId:string
id:string
}
export type AiServiceInsidePublishOnlineItems = {
cluster:EntityItem
status:'done' | 'error' | 'publishing'
error:string
}
export default function AiServiceInsidePublishOnline(props:AiServiceInsidePublishOnlineProps ){
const {serviceId, teamId, id} = props
const {message} = App.useApp()
const [dataSource, setDataSource] = useState<[]>()
const {fetchData} = useFetch()
const [isStopped, setIsStopped] = useState(false);
const { state } = useGlobalContext()
const getOnlineStatus = ()=>{
fetchData<BasicResponse<{publishStatusList:AiServiceInsidePublishOnlineItems[]}>>('service/publish/status',{method:'GET',eoParams:{service:serviceId,team:teamId, id}, eoTransformKeys:['publish_status_list']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setDataSource(data.publishStatusList)
if(data.publishStatusList.filter((x:AiServiceInsidePublishOnlineItems)=>x.status === 'publishing').length === 0){
setIsStopped(true)
}
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo)=> message.error(errorInfo))
}
useEffect(()=>{
getOnlineStatus();
},[])
useEffect(() => {
let intervalId: NodeJS.Timeout;
if (!isStopped) {
intervalId = setInterval(() => {
!isStopped && getOnlineStatus();
}, 5000);
}
return () => {
clearInterval(intervalId);
};
}, [isStopped]);
const translatedPublishColumns = useMemo(()=>SYSTEM_PUBLISH_ONLINE_COLUMNS.map((x)=>{
if(x.dataIndex === 'status'){
return {...x,title:$t(x.title),
render:(_:unknown,entity:AiServiceInsidePublishOnlineItems)=>{
switch(entity.status){
case 'done':
return <span className={STATUS_COLOR[entity.status as keyof typeof STATUS_COLOR]}>{$t('成功')}</span>
case 'error':
return <Tooltip title={entity.error || $t('上线失败')}><span className={`${STATUS_COLOR[entity.status as keyof typeof STATUS_COLOR]} truncate block`}>{$t('失败')} {entity.error}</span></Tooltip>
default:
return <LoadingOutlined className="text-theme" spin />
}
}}
}
}),[state.language])
return (
<Table
className="min-h-[100px] h-full"
bordered={true}
columns={translatedPublishColumns}
size="small"
rowKey="id"
dataSource={dataSource}
pagination={false}
/>
)
}
@@ -0,0 +1,258 @@
import { LoadingOutlined } from "@ant-design/icons";
import InsidePage from "@common/components/aoplatform/InsidePage";
import { BasicResponse, STATUS_CODE, RESPONSE_TIPS } from "@common/const/const";
import { EntityItem } from "@common/const/type";
import { useFetch } from "@common/hooks/http";
import { $t } from "@common/locales";
import { Icon } from "@iconify/react/dist/iconify.js";
import { App, Spin, Card, Tag, Select, Button, Empty } from "antd";
import { useEffect, useMemo, useRef, useState } from "react";
import AiSettingModalContent, { AiSettingModalContentHandle } from "./AiSettingModal";
import WithPermission from "@common/components/aoplatform/WithPermission";
import { useGlobalContext } from "@common/contexts/GlobalStateContext";
import { DefaultOptionType } from "antd/es/select";
import { checkAccess } from "@common/utils/permission";
export type AiSettingListItem = {
name: string
id:string
logo:string
defaultLlm: string
defaultLlmLogo:string
enable:boolean
configured:boolean
}
export type AiProviderLlmsItems = {
id:string
logo:string
scopes:('chat'|'completions')[]
}
export type AiProviderDefaultConfig = {
id:string
name:string
logo:string
defaultLlm:string
scopes:string[]
}
export type AiProviderConfig = {
id:string
name:string
config:string
getApikeyUrl:string
}
const AiSettingList = ()=>{
const { modal,message } = App.useApp()
const {fetchData} = useFetch()
const [aiSettingList, setAiSettingList] = useState<AiSettingListItem[]>([])
const [loading, setLoading] = useState<boolean>(false)
// const [updateLoading, setUpdateLoading] = useState<boolean>(false)
const [loadingDefaultModel, setLoadingDefaultModel] = useState<string>('')
const modalRef = useRef<AiSettingModalContentHandle>()
const {setAiConfigFlushed,accessData} = useGlobalContext()
const [llmMap, setLlmMap] = useState<Map<string, {loading:boolean, list:DefaultOptionType[]}>>(new Map<string, {loading:boolean, list:DefaultOptionType[]}>)
const [currentProvider, setCurrentProvider] = useState<string>()
const getAiSettingList = ()=>{
setLoading(true)
return fetchData<BasicResponse<{providers:Omit<AiSettingListItem,'availableLlms'|'llmListStatus'>[]}>>(`ai/providers`,{method:'GET', eoTransformKeys:['default_llm','default_llm_logo']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setAiSettingList(data.providers?.map((x:AiSettingListItem)=>({...x,name:$t(x.name),llmListStatus:'unload', availableLlms:[]})
))
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}).finally(()=>setLoading(false))
}
const getLlmList = (provider:AiSettingListItem)=>{
setLlmMap(prev=>{
const newMap = new Map(prev);
if(newMap.get(provider.id)){
newMap.get(provider.id)!.loading = true
}else{
newMap.set(provider.id, {loading:true,list:[]})
}
return newMap
})
fetchData<BasicResponse<{llms:AiProviderLlmsItems[]}>>(`ai/provider/llms`,{method:'GET',eoParams:{provider:provider.id}}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setLlmMap(prev=>{
const newMap = new Map(prev);
const llmDetail = newMap.get(provider.id)
llmDetail!.list = data.llms?.map((x:AiProviderLlmsItems)=>({
label:<div className="flex w-full items-center gap-[4px]">
<div className="flex items-center" dangerouslySetInnerHTML={{ __html: x.logo }} />
<span>{x.id}</span></div>,
value:x.id}))
return newMap
})
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}).finally(()=>{
setLlmMap(prev=>{
const newMap = new Map(prev);
const llmDetail = newMap.get(provider.id)
llmDetail!.loading = false
return newMap
})
})
}
// 第一期暂时隐藏
// const updateModalList = ()=>{
// setUpdateLoading(true)
// return fetchData<BasicResponse<{roles:AiSettingListItem[]}>>(`aisetting`,{method:'GET'}).then(response=>{
// const {code,msg} = response
// if(code === STATUS_CODE.SUCCESS){
// getAiSettingList()
// }else{
// message.error(msg || $t(RESPONSE_TIPS.error))
// }
// }).finally(()=>setUpdateLoading(false))
// }
const openModal = async (entity:AiSettingListItem)=>{
message.loading($t(RESPONSE_TIPS.loading))
const {code,data,msg} = await fetchData<BasicResponse<{provider:AiProviderConfig}>>('ai/provider/config',{method:'GET',eoParams:{provider:entity!.id}, eoTransformKeys:['get_apikey_url']})
message.destroy()
if(code !== STATUS_CODE.SUCCESS){
message.error(msg || $t(RESPONSE_TIPS.error))
return
}
modal.confirm({
title:$t('模型配置'),
content:<AiSettingModalContent ref={modalRef} entity={data.provider} readOnly={!checkAccess('system.devops.ai_provider.edit', accessData)}/>,
onOk:()=>{
return modalRef.current?.save().then((res)=>{if(res === true)
setAiConfigFlushed(true)
getAiSettingList()})
},
width:600,
okText:$t('确认'),
footer:(_, { OkBtn, CancelBtn }) =>{
return (
<div className="flex items-center justify-between">
<a target="_blank" rel="noopener noreferrer" href={data.provider.getApikeyUrl} className="flex items-center gap-[8px]">
<span>{$t('从 (0) 获取 API KEY',[data.provider.name])}</span>
<Icon icon="ic:baseline-open-in-new" width={16} height={16} />
</a>
<div>
<CancelBtn/>
<WithPermission access="system.devops.ai_provider.edit" showDisabled={false}>
<OkBtn/>
</WithPermission>
</div>
</div>
);
},
cancelText:$t('取消'),
closable:true,
icon:<></>,
})
}
const changeDefaultModel = (value: string, entity:AiSettingListItem) => {
setLoadingDefaultModel(entity.id)
return fetchData<BasicResponse<null>>(`ai/provider/default-llm`,{method:'PUT', eoBody:{llm:value}, eoParams:{provider:entity.id}}).then(response=>{
const {code,msg} = response
if(code === STATUS_CODE.SUCCESS){
getAiSettingList()
message.success(msg || $t(RESPONSE_TIPS.success))
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}).finally(()=>setLoadingDefaultModel(''))
};
const modelOptions = useMemo(()=>{
return currentProvider ? llmMap?.get(currentProvider)?.list : []
},[currentProvider,llmMap])
useEffect(() => {
getAiSettingList()
}, []);
return (<>
<InsidePage
className="pb-PAGE_INSIDE_B overflow-y-auto"
pageTitle={$t('AI 模型供应商')}
showBorder={false}
scrollPage={false}
// customBtn={
// <WithPermission access="system.devops.ai_provider.edit">
// <Button
// icon={<Icon icon="ic:baseline-refresh" width={20} height={20} />}
// type="text"
// iconPosition={'start'}
// classNames={{icon:'h-[20px]'}}
// loading={updateLoading}
// onClick={updateModalList}>
// {$t('同步最新模型')}
// </Button>
// </WithPermission>
// }
>
<Spin className="h-full" wrapperClassName="h-full pr-PAGE_INSIDE_X" indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} spinning={loading}>
{aiSettingList && aiSettingList.length > 0 ? <div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: '20px',
}}
>
{aiSettingList.map((provider:AiSettingListItem)=>(
<Card title={
<div className="flex justify-between items-center">
<div className="flex items-center" dangerouslySetInnerHTML={{ __html: provider.logo }} />
<Tag bordered={false} color={provider.configured ? 'green' : undefined} className="h-[22px] px-[4px] text-center">
{provider.configured ? $t('已配置') : $t('未配置')}
</Tag>
</div> }
className="shadow-[0_5px_10px_0_rgba(0,0,0,0.05)] rounded-[10px] overflow-visible h-[156px] m-0 flex flex-col "
classNames={{header:'border-b-[0px] p-[20px] px-[24px]', body:"pt-0 flex-1"}}
>
<div className="flex flex-col gap-btnbase h-full justify-between">
<div className="flex items-center w-full h-[32px]">
<label className="text-nowrap">{$t('默认')}</label>
<WithPermission access="system.devops.ai_provider.edit">
<Select
value={provider.defaultLlm}
variant="borderless"
style={{ width: '100%' }}
onChange={(value)=>changeDefaultModel(value, provider)}
labelRender={(props)=>{
return !props.label && !llmMap.get(provider.id)?.list?.length ?
<div className="flex items-center">
<div className="flex items-center" dangerouslySetInnerHTML={{__html:provider.defaultLlmLogo}}></div>
<span>{provider.defaultLlm}</span>
</div>: props.label }}
x options={modelOptions}
onFocus={()=>{if(!llmMap.get(provider.id)?.loading && !llmMap.get(provider.id)?.list?.length ){
getLlmList(provider)
setCurrentProvider(provider.id)
}}}
loading={llmMap.get(provider.id)?.loading || !!(loadingDefaultModel && loadingDefaultModel === provider.id )}
/>
</WithPermission>
</div>
<WithPermission access="system.devops.ai_provider.view">
<Button block icon={<Icon icon="ic:outline-settings" width={18} height={18}/>} onClick={()=>openModal(provider)} classNames={{icon:'h-[18px]'}}>{$t('设置')}</Button>
</WithPermission>
</div>
</Card>
))}
</div>:<Empty image={Empty.PRESENTED_IMAGE_SIMPLE}/>}
</Spin>
</InsidePage>
</>)
}
export default AiSettingList;
@@ -0,0 +1,58 @@
import WithPermission from "@common/components/aoplatform/WithPermission";
import { BasicResponse, STATUS_CODE, RESPONSE_TIPS } from "@common/const/const";
import { useFetch } from "@common/hooks/http";
import { $t } from "@common/locales";
import { App } from "antd";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { AiProviderConfig } from "./AiSettingList";
import { Codebox } from "@common/components/postcat/api/Codebox";
export type AiSettingModalContentProps = {
entity:AiProviderConfig
readOnly:boolean
}
export type AiSettingModalContentHandle = {
save:()=>Promise<boolean|string>
}
const AiSettingModalContent = forwardRef<AiSettingModalContentHandle,AiSettingModalContentProps>((props,ref)=>{
const { message } = App.useApp()
const {entity,readOnly} = props
const {fetchData} = useFetch()
const [code, setCode] = useState<string>()
useEffect(() => {
try{
entity!.config && setCode(JSON.stringify(JSON.parse(entity!.config),null,2))
}catch(e){
setCode('')
}
}, []);
const save: ()=>Promise<boolean | string> = ()=>{
return fetchData<BasicResponse<null>>('ai/provider/config',{method:'PUT',eoParams:{provider:entity?.id}, eoBody:({config:code})}).then(response=>{
const {code,msg} = response
if(code === STATUS_CODE.SUCCESS){
message.success(msg || $t(RESPONSE_TIPS.success))
return Promise.resolve(true)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return Promise.reject(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo)=> Promise.reject(errorInfo))
}
useImperativeHandle(ref, ()=>({
save
})
)
return (
<Codebox editorTheme="vs-dark" readOnly={readOnly}
value={code} onChange={setCode} width="100%" height="300px" language='json' enableToolbar={false} />
)
})
export default AiSettingModalContent
@@ -4,15 +4,26 @@ import { $t } from "@common/locales"
import { Icon } from "@iconify/react/dist/iconify.js"
import { Button, Card, Collapse } from "antd"
import { Dispatch, SetStateAction, useEffect, useState } from "react"
import { useLocation, useNavigate } from "react-router-dom"
export default function Guide(){
const [showGuide, setShowGuide] = useState(localStorage.getItem('showGuide') !== 'false' )
const [, forceUpdate] = useState<unknown>(null);
const {state} = useGlobalContext()
const location = useLocation()
const currentUrl = location.pathname
const navigator = useNavigate()
useEffect(()=>{
localStorage.setItem('showGuide', showGuide.toString())
},[showGuide])
useEffect(()=>{
if(currentUrl === '/guide'){
setTimeout(()=>{
navigator('/guide/page')
},0)
}
},[])
useEffect(()=>{forceUpdate({})},[state.language])
return (
<InsidePage
@@ -67,7 +67,7 @@ const SystemInsideRouterCreate = forwardRef<SystemInsideRouterCreateHandle,Syste
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
const {disable, protocols, path, methods, description, match, proxy} = data.router
form.setFieldsValue({disable, protocols, path, methods, description, match,proxy
form.setFieldsValue({disable, protocols, path:prefixForce && path?.startsWith(apiPrefix + '/')? path.slice((apiPrefix?.length || 0) + 1) : path, methods, description, match,proxy
})
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
@@ -158,7 +158,7 @@ const SystemInsideRouterCreate = forwardRef<SystemInsideRouterCreateHandle,Syste
}]}
className={styles['form-input-group']}
>
<Input prefix={type === 'edit' ? null :(prefixForce ? `${apiPrefix}/` :"/")} className="w-INPUT_NORMAL"
<Input prefix={(prefixForce ? `${apiPrefix}/` :"/")} className="w-INPUT_NORMAL"
placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
@@ -11,7 +11,7 @@ import {RouterParams} from "@core/components/aoplatform/RenderRoutes.tsx";
import SystemInsideRouterCreate from "./SystemInsideRouterCreate.tsx";
import {useSystemContext} from "../../../contexts/SystemContext.tsx";
import { SYSTEM_API_TABLE_COLUMNS } from "../../../const/system/const.tsx";
import {SystemApiTableListItem, SystemInsideRouterCreateHandle, SystemInsideApiDocumentHandle } from "../../../const/system/type.ts";
import {SystemApiTableListItem, SystemInsideRouterCreateHandle } from "../../../const/system/type.ts";
import TableBtnWithPermission from "@common/components/aoplatform/TableBtnWithPermission.tsx";
import { useGlobalContext } from "@common/contexts/GlobalStateContext.tsx";
import { checkAccess } from "@common/utils/permission.ts";
@@ -34,7 +34,7 @@ const SystemInsidePublicList:FC = ()=>{
const drawerRef = useRef<PublishApprovalModalHandle>(null)
const [extraModalBtnLoading,setExtraModalBtnLoading] = useState<boolean>(false)
const [pageStatus,setPageStatus] = useState<0|1>(0 as 0|1)
const [pageType, setPageType] = useState<'insideSystem'|'global'>('insideSystem')
const [pageType, setPageType] = useState<'insidePage'|'global'>('insidePage')
const query =new URLSearchParams(useLocation().search)
const currLocation = useLocation().pathname
const [memberValueEnum, setMemberValueEnum] = useState<SimpleMemberItem[]>([])
@@ -50,7 +50,7 @@ const SystemInsidePublicList:FC = ()=>{
current?: number | undefined;
keyword?: string | undefined;
})=>{
if(!(pageType !== 'insideSystem' && pageStatus !== 0 ) && !tableHttpReload){
if(!(pageType !== 'insidePage' && pageStatus !== 0 ) && !tableHttpReload){
setTableHttpReload(true)
return Promise.resolve({
data: tableListDataSource,
@@ -59,7 +59,7 @@ const SystemInsidePublicList:FC = ()=>{
}
return fetchData<BasicResponse<{releases?:PublishVersionTableListItem[],publishs?:PublishTableListItem[]}>>(
pageStatus === 0 ? 'service/releases':'service/publishs',
{method:'GET',eoParams:(pageType !== 'insideSystem' && pageStatus !== 0 ) ? {service:serviceId,team:teamId,page:params?.current,page_size:params?.pageSize}:{service:serviceId,team:teamId},eoTransformKeys:['pageSize','apply_time','approve_time','release_status','is_valid','fail_msg','create_time','can_rollback','flow_id','can_delete']}).then(response=>{
{method:'GET',eoParams:(pageType !== 'insidePage' && pageStatus !== 0 ) ? {service:serviceId,team:teamId,page:params?.current,page_size:params?.pageSize}:{service:serviceId,team:teamId},eoTransformKeys:['pageSize','apply_time','approve_time','release_status','is_valid','fail_msg','create_time','can_rollback','flow_id','can_delete']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
const finalRes = pageStatus === 0 ? data.releases.map((x:PublishVersionTableListItem)=>{if(!x.status|| x.status === 'close'){x.status = 'none'} return x}):data.publishs
@@ -270,7 +270,7 @@ const SystemInsidePublicList:FC = ()=>{
const tableOperation = (entity:PublishTableListItem | PublishVersionTableListItem)=>{
const viewBtn = <TableBtnWithPermission access="team.service.release.view" key="view" btnType="view" onClick={()=>{openDrawer('view',entity)}} btnTitle="查看详情"/>
let btnArr:React.ReactNode[] = []
if(pageType !== 'insideSystem' && pageStatus !== 0){
if(pageType !== 'insidePage' && pageStatus !== 0){
btnArr = [
viewBtn
]
@@ -374,7 +374,7 @@ const SystemInsidePublicList:FC = ()=>{
}
const columns = useMemo(()=>{
return ((pageType === 'insideSystem' || pageStatus === 0 ) ?
return ((pageType === 'insidePage' || pageStatus === 0 ) ?
PUBLISH_APPROVAL_VERSION_INNER_TABLE_COLUMN
:PUBLISH_APPROVAL_RECORD_INNER_TABLE_COLUMN)
.map(x=>{
@@ -386,7 +386,7 @@ const SystemInsidePublicList:FC = ()=>{
x.valueEnum = tmpValueEnum
}
if(x.dataIndex === 'status'){
x.valueEnum = (pageType === 'insideSystem' || pageStatus === 0 ) ? new Map([
x.valueEnum = (pageType === 'insidePage' || pageStatus === 0 ) ? new Map([
['apply',<span className={PublishTableStatusColorClass.apply}>{$t(PublishApplyStatusEnum.apply || '-')}</span>],
['running',<span className={PublishTableStatusColorClass.running}>{$t(PublishApplyStatusEnum.running || '-')}</span>],
['none',<span className={PublishTableStatusColorClass.none}>{$t(PublishApplyStatusEnum.none || '-')}</span>],
@@ -419,7 +419,7 @@ const SystemInsidePublicList:FC = ()=>{
}, [query]);
useEffect(()=>{
setPageType(currLocation.split('/')[0] === 'service' ? 'insideSystem' : 'global')
setPageType(currLocation.split('/')[0] === 'service' ? 'insidePage' : 'global')
},[currLocation])
const manualReloadTable = () => {
@@ -517,7 +517,7 @@ const SystemInsidePublicList:FC = ()=>{
</Button>
</WithPermission> :undefined}
>
<PublishApprovalModalContent insideSystem ref={drawerRef}
<PublishApprovalModalContent insidePage ref={drawerRef}
data={drawerData as PublishVersionTableListItem } type={drawerType} serviceId={serviceId!} teamId={teamId!} />
</DrawerWithFooter>
</>
@@ -131,10 +131,11 @@ const ServiceHubDetail = ()=>{
className={ `rounded-[12px] border-none rounded-[12px] ${ serviceBasicInfo?.logo ? 'bg-[linear-gradient(135deg,white,#f0f0f0)]' : 'bg-theme'}`}
src={ serviceBasicInfo?.logo ? <img src={serviceBasicInfo?.logo} alt="Logo" style={{ maxWidth: '200px', width:'45px',height:'45px',objectFit:'unset'}}
/> : undefined}
icon={serviceBasicInfo?.logo ? '' :<iconpark-icon name="auto-generate-api"></iconpark-icon>}> </Avatar>
icon={serviceBasicInfo?.logo ? '' :<iconpark-icon name="auto-generate-api"></iconpark-icon>}> </Avatar>
<div className="pl-[20px] w-[calc(100%-50px)]">
<p className="text-[14px] h-[20px] leading-[20px] truncate font-bold">{serviceName}</p>
<p className="text-[14px] h-[20px] leading-[20px] truncate font-bold flex items-center gap-[4px]">{serviceName}
</p>
<div className="mt-[10px] flex flex-col gap-btnrbase font-normal">
{serviceDesc || '-'}
<div>
@@ -179,15 +179,15 @@ const CardTitle = (service:ServiceHubTableListItem)=>{
<div className="flex">
<Avatar shape="square" size={50} className=" border-none bg-[linear-gradient(135deg,white,#f0f0f0)] text-[#333] rounded-[12px]" src={service.logo ? <img src={service.logo} alt="Logo" style={{ maxWidth: '200px', width:'45px',height:'45px',objectFit:'unset'}} /> : undefined}> {service.logo ? '' : service.name.substring(0,1)}</Avatar>
<div className="pl-[20px] w-[calc(100%-50px)]">
<p className="text-[14px] h-[20px] leading-[20px] truncate w-full">{service.name}</p>
<p className="text-[14px] h-[20px] leading-[20px] truncate w-full flex items-center gap-[4px]">{service.name}</p>
<div className="mt-[10px] h-[20px] flex items-center font-normal">
<Tag color="#7371fc1b" className="text-theme font-normal border-0 mr-[12px] max-w-[150px] truncate" key={service.id} bordered={false} title={service.catalogue?.name || '-'}>{service.catalogue?.name || '-'}</Tag>
<Tooltip title={$t('API 数量')}>
<span className="mr-[12px]"><ApiOutlined className="mr-[1px] text-[14px] h-[14px] w-[14px]"/><span className="font-normal text-[14px]">{service.apiNum ?? '-'}</span></span>
<span className="mr-[12px] flex items-center"><ApiOutlined className="mr-[1px] text-[14px] h-[14px] w-[14px]"/><span className="font-normal text-[14px]">{service.apiNum ?? '-'}</span></span>
</Tooltip>
<Tooltip title={$t('接入应用数量')}>
<span className="mr-[12px] flex items-center"><span className="h-[14px] mr-[4px] flex items-center"><iconpark-icon size="14px" name="auto-generate-api"></iconpark-icon></span><span className="font-normal text-[14px]">{service.subscriberNum ?? '-'}</span></span>
<span className="mr-[12px] flex items-center"><span className="h-[14px] mr-[4px] flex items-center "><iconpark-icon size="14px" name="auto-generate-api"></iconpark-icon></span><span className="font-normal text-[14px]">{service.subscriberNum ?? '-'}</span></span>
</Tooltip>
</div>
</div>