This commit is contained in:
2026-01-03 02:44:21 +08:00
parent 2aa086c5d0
commit d1439ed33f
12 changed files with 384 additions and 40 deletions

View File

@@ -25,7 +25,7 @@
"@astrojs/vue": "^5.1.3",
"@kevisual/cache": "^0.0.5",
"@kevisual/context": "^0.0.4",
"@kevisual/query": "^0.0.33",
"@kevisual/query": "^0.0.34",
"@kevisual/query-login": "^0.0.7",
"@kevisual/registry": "^0.0.1",
"@kevisual/router": "^0.0.52",
@@ -34,6 +34,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-table": "^8.21.3",
"@uiw/react-md-editor": "^4.0.11",
"antd": "^6.1.3",
"astro": "^5.16.6",
@@ -53,7 +54,7 @@
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.69.0",
"react-resizable-panels": "^4.1.0",
"react-resizable-panels": "^4.2.0",
"react-toastify": "^11.0.5",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.26",
@@ -63,10 +64,11 @@
"access": "public"
},
"devDependencies": {
"@kevisual/api": "^0.0.14",
"@kevisual/api": "^0.0.16",
"@kevisual/types": "^0.0.10",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.11",
"dotenv": "^17.2.3",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"

71
web/pnpm-lock.yaml generated
View File

@@ -27,11 +27,11 @@ importers:
specifier: ^0.0.4
version: 0.0.4
'@kevisual/query':
specifier: ^0.0.33
version: 0.0.33
specifier: ^0.0.34
version: 0.0.34
'@kevisual/query-login':
specifier: ^0.0.7
version: 0.0.7(@kevisual/query@0.0.33)
version: 0.0.7(@kevisual/query@0.0.34)
'@kevisual/registry':
specifier: ^0.0.1
version: 0.0.1(typescript@5.9.3)
@@ -53,6 +53,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@6.4.1(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.2))
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@uiw/react-md-editor':
specifier: ^4.0.11
version: 4.0.11(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -111,8 +114,8 @@ importers:
specifier: ^7.69.0
version: 7.69.0(react@19.2.3)
react-resizable-panels:
specifier: ^4.1.0
version: 4.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
specifier: ^4.2.0
version: 4.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-toastify:
specifier: ^11.0.5
version: 11.0.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -127,8 +130,8 @@ importers:
version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
devDependencies:
'@kevisual/api':
specifier: ^0.0.14
version: 0.0.14
specifier: ^0.0.16
version: 0.0.16
'@kevisual/types':
specifier: ^0.0.10
version: 0.0.10
@@ -138,6 +141,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.7)
baseline-browser-mapping:
specifier: ^2.9.11
version: 2.9.11
dotenv:
specifier: ^17.2.3
version: 17.2.3
@@ -723,8 +729,8 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kevisual/api@0.0.14':
resolution: {integrity: sha512-GOs61Jvjxs+7PB8+iSPko9/RGeWENxltHueV75M6W0psRsnx/J+06I48/cO413FwCoqSOqpOoivdRgSENdHM9g==}
'@kevisual/api@0.0.16':
resolution: {integrity: sha512-JInnqWHjUxos1oWHe8dmwxWOMCRgv5nI/7HbSrzvHDQxHE6Egc3xA5iALUcRDdkNOnPz98ErZnLmSgHHJDOwYQ==}
'@kevisual/cache@0.0.3':
resolution: {integrity: sha512-BWEck69KYL96/ywjYVkML974RHjDJTj2ITQND1zFPR+hlBV1H1p55QZgSYRJCObg3EAV1S9Zic/fR2T4pfe8yg==}
@@ -746,8 +752,8 @@ packages:
peerDependencies:
'@kevisual/query': ^0
'@kevisual/query@0.0.33':
resolution: {integrity: sha512-3w74bcLpwV3z483eg8n0DgkftfjWC6iLONXBvfyjW6IZf6jMOuouFaM4Rk+uEsTgElU6XGMKseNTp6dlQdWYkg==}
'@kevisual/query@0.0.34':
resolution: {integrity: sha512-UHA0qEJYzU76pffUx0OhcOL5zKuxR/Kg269OHjrFm7+7RO85Qzv4ON1vUJDFp61hRuRVwiwOEKucQLHLE6UpMg==}
'@kevisual/registry@0.0.1':
resolution: {integrity: sha512-//OHu9m4JDrMjgP8o8dcjZd3D3IAUkRVlkTSviouZEH7r5m7mccA3Hvzw0XJ/lelx6exC6LWsyv6c4uV0Dp+gw==}
@@ -1667,6 +1673,17 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1925,8 +1942,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.8.16:
resolution: {integrity: sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==}
baseline-browser-mapping@2.9.11:
resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
hasBin: true
bcp-47-match@2.0.3:
@@ -3101,8 +3118,8 @@ packages:
'@types/react':
optional: true
react-resizable-panels@4.1.0:
resolution: {integrity: sha512-8ZpOwdKQz6bCs2LGnfS6HuBITxkOLelSMzBX4DrWsgHaU3ukTPxmBNAeK8Bsp3LAEdtXeG6ll6UPN7OJNua4sw==}
react-resizable-panels@4.2.0:
resolution: {integrity: sha512-X/WbnyT/bgx09KEGvtJvaTr3axRrcBGcJdELIoGXZipCxc2hPwFsH/pfpVgwNVq5LpQxF/E5pPXGTQdjBnidPw==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
@@ -4404,7 +4421,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kevisual/api@0.0.14':
'@kevisual/api@0.0.16':
dependencies:
'@kevisual/js-filter': 0.0.3
'@kevisual/load': 0.0.6
@@ -4430,13 +4447,15 @@ snapshots:
dependencies:
eventemitter3: 5.0.1
'@kevisual/query-login@0.0.7(@kevisual/query@0.0.33)':
'@kevisual/query-login@0.0.7(@kevisual/query@0.0.34)':
dependencies:
'@kevisual/cache': 0.0.3
'@kevisual/query': 0.0.33
'@kevisual/query': 0.0.34
dotenv: 17.2.3
'@kevisual/query@0.0.33': {}
'@kevisual/query@0.0.34':
dependencies:
tslib: 2.8.1
'@kevisual/registry@0.0.1(typescript@5.9.3)':
dependencies:
@@ -5434,6 +5453,14 @@ snapshots:
tailwindcss: 4.1.18
vite: 6.4.1(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.2)
'@tanstack/react-table@8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@tanstack/table-core': 8.21.3
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@tanstack/table-core@8.21.3': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.5
@@ -5930,7 +5957,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.8.16: {}
baseline-browser-mapping@2.9.11: {}
bcp-47-match@2.0.3: {}
@@ -5957,7 +5984,7 @@ snapshots:
browserslist@4.26.3:
dependencies:
baseline-browser-mapping: 2.8.16
baseline-browser-mapping: 2.9.11
caniuse-lite: 1.0.30001750
electron-to-chromium: 1.5.235
node-releases: 2.0.23
@@ -7424,7 +7451,7 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
react-resizable-panels@4.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
react-resizable-panels@4.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)

103
web/src/apps/chat/index.tsx Normal file
View File

@@ -0,0 +1,103 @@
import { app } from '@/index.ts'
import { useStudioStore } from '../studio/store';
import { useShallow } from 'zustand/shallow';
import { useState } from 'react';
import { query } from '@/modules/query.ts'
export const Chat = () => {
const studioStore = useStudioStore(useShallow((state) => ({
routes: state.routes,
})));
const [text, setText] = useState('');
const onSend = async () => {
const { routes } = studioStore;
let callPrompts = '';
const toolsList = routes.map((r, index) =>
`${index + 1}. 工具名称: ${r.id}\n 描述: ${r.description}`
).join('\n\n');
callPrompts = `你是一个 AI 助手,你可以使用以下工具来帮助用户完成任务:
${toolsList}
## 回复规则
1. 如果用户的请求可以使用上述工具完成,请返回 JSON 格式数据
2. 如果没有合适的工具,请直接分析并回答用户问题
## JSON 数据格式
\`\`\`json
{
"id": "工具的id",
"payload": {
// 工具所需的参数(如果需要)
// 例如: "id": "xxx", "name": "xxx"
}
}
\`\`\`
注意:
- payload 中包含工具执行所需的所有参数
- 如果工具不需要参数payload 可以为空对象 {}
- 确保返回的 id 与上述工具列表中的工具名称完全匹配`
const res = await query.post({
path: 'ai',
payload: {
messages: [
{
role: 'system',
content: callPrompts
},
{
role: 'user',
content: text
}
],
isJson: true
}
})
console.log('发送消息', text, res);
if (res.code === 200) {
// 处理返回结果
const payload = res.data?.action;
if (payload) {
const route = routes.find(r => r.id === payload.id);
console.log('找到工具', route);
const { path, key } = route || {};
const { id, ...otherParams } = payload.payload || {};
if (route) {
const r = await app.run({ path, key, ...otherParams });
console.log('工具调用结果', r);
} else {
console.error('未找到对应工具', payload.id);
}
}
}
}
return <div className="h-full flex flex-col border-l border-gray-300 bg-white">
<div className="h-12 flex items-center justify-between px-4 border-b border-gray-300 bg-white">
<div className="text-lg font-medium text-black"></div>
</div>
<div style={{ height: '3rem' }} className="flex items-center justify-between px-4 border-b border-gray-300 bg-gray-50">
<div className="text-sm text-gray-600">使</div>
</div>
<div style={{ height: 'calc(100% - 6rem)' }} className="overflow-auto">
{/* 聊天内容区域 */}
</div>
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-300 bg-white">
<input
type="text"
placeholder="输入消息..."
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSend()}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md bg-white text-black placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-800 transition-all"
/>
<button
onClick={onSend}
className="px-4 py-2 bg-black hover:bg-gray-900 text-white font-medium rounded-md transition-colors duration-200 flex-shrink-0"
>
</button>
</div>
</div>
}

View File

@@ -0,0 +1,46 @@
type MessageData = {
id: string;
type: 'md' | 'api';
api?: {
url?: string;
/**
* 默认不存在,动态生成
*/
query?: any;
};
question?: string;
action?: any;
/**
* 默认不存在,动态生成
*/
response?: any;
}
type MessageProps = {
data: MessageData;
}
export const Message = (props: MessageProps) => {
const { data } = props;
if (data.type === 'md') {
return <div className="prose prose-sm max-w-full">
{/* Markdown 渲染组件 */}
Markdown
</div>
}
if (data.type === 'api') {
return <div>
{/* 查询结果渲染组件 */}
</div>
}
return <div></div>
}
export const Messages = (props: { items: MessageData[] }) => {
const items = props.items || [];
return <div className="p-4 space-y-4">
{items.map((item) => (
<Message key={item.id} data={item} />
))}
</div>
}

View File

View File

@@ -0,0 +1,101 @@
import { QueryProxy } from '@kevisual/api/proxy'
import { app } from '@/index.ts'
import { useEffect, useState } from 'react'
import { flexRender, useReactTable, getCoreRowModel } from '@tanstack/react-table';
type Props = {
data: any
type: 'component' | 'page'
}
export const QueryView = (props: Props) => {
return <div>API </div>
}
const queryProxy = new QueryProxy({
router: app as any
});
export const App = () => {
const [data, setData] = useState<any[]>([])
const [columns, setColumns] = useState<any[]>([])
const table = useReactTable({
data,
columns: columns,
getCoreRowModel: getCoreRowModel(),
})
const main = async () => {
const res = await queryProxy.runByRouteView({
id: 'getData',
description: '获取数据',
title: '获取数据',
type: 'api',
api: {
url: "/api/router",
},
action: {
path: 'router',
key: 'list'
}
})
const response = res.response;
setData(response.data.list)
console.log('res', res);
const [firstItem] = response.data.list || []
if (firstItem) {
const cols = Object.keys(firstItem).map(key => ({
accessorKey: key,
header: key.toUpperCase(),
}))
setColumns(cols)
}
}
useEffect(() => {
main()
}, [])
return <div id='route-view' className='w-full h-full overflow-auto p-4'>
<table className='w-full border-collapse border border-gray-300 rounded-lg overflow-hidden shadow-md'>
<thead className='bg-gray-100 border-b-2 border-gray-300'>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
className='px-4 py-3 text-left text-sm font-semibold text-gray-700 whitespace-nowrap'
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, idx) => (
<tr
key={row.id}
className={`border-b border-gray-200 transition-colors duration-200 ${
idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
} hover:bg-blue-50`}
>
{row.getVisibleCells().map(cell => (
<td
key={cell.id}
className='px-4 py-3 text-sm text-gray-600'
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
}
export const AppProvider = (props: { children?: React.ReactNode }) => {
return <main className='w-full h-screen flex flex-col overflow-auto'>
<App />
</main>
}

View File

@@ -5,7 +5,7 @@ import { MonitorPlay, Play, PanelLeft, PanelLeftClose } from 'lucide-react';
import { Panel, Group } from 'react-resizable-panels'
import { ViewList } from '../view/list.tsx';
import { useShallow } from 'zustand/shallow';
import { Chat } from '../chat/index.tsx';
export const AppProvider = () => {
const { showLeftPanel } = useStudioStore(useShallow((state) => ({
showLeftPanel: state.showLeftPanel,
@@ -17,9 +17,17 @@ export const AppProvider = () => {
</Panel>}
<Panel>
<WrapperHeader>
<App />
<Group className="h-full overflow-hidden">
<Panel >
<App />
</Panel>
<Panel defaultSize={500} minSize={300} maxSize={600} className="border-l border-gray-300 overflow-auto">
<Chat />
</Panel>
</Group>
</WrapperHeader>
</Panel>
</Group>
<ToastContainer
position="top-right"
@@ -59,12 +67,12 @@ interface RouteItem {
}
export const App = () => {
const { routes, getRouteList, run, loading } = useStudioStore();
const { routes, queryRouteList, run, loading } = useStudioStore();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [visibleIds, setVisibleIds] = useState<Set<string>>(new Set());
useEffect(() => {
getRouteList();
queryRouteList();
}, []);
const toggleDescription = (id: string) => {
@@ -143,8 +151,7 @@ export const App = () => {
className="cursor-pointer group"
>
<div
className={`text-gray-700 transition-colors duration-200 cursor-pointer overflow-hidden ${isExpanded ? 'text-gray-900 animate-expand-in' : 'group-hover:text-gray-900 max-h-0 opacity-0'
}`}
className={`text-gray-700 transition-colors duration-200 cursor-pointer overflow-hidden`}
>
{isExpanded ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap">

View File

@@ -5,7 +5,7 @@ import { toast } from 'react-toastify';
import { use } from '@kevisual/context'
import { MyCache } from '@kevisual/cache'
import { persist } from 'zustand/middleware';
import { app } from '@/index.ts'
const historyReplace = (url: string) => {
if (window.history.replaceState) {
window.history.replaceState(null, '', url);
@@ -21,16 +21,17 @@ type RouteItem = {
type RouteViewList = Array<RouterViewData>;
interface StudioState {
loading: boolean;
setLoading: (loading: boolean) => void;
routes: Array<RouteItem>;
getRouteList: (viewId?: string) => Promise<void>;
run: (route: RouteItem) => Promise<void>;
queryProxy?: QueryProxy;
init: (force?: boolean) => Promise<{ queryProxy: QueryProxy }>;
routeViewList: RouteViewList;
getViewList: () => Promise<void>;
queryRouteList: () => Promise<void>;
getCurrentView: () => Promise<void>;
updateRouteView: (view: RouterViewData) => Promise<void>;
deleteRouteView: (id: string) => Promise<void>;
@@ -38,6 +39,8 @@ interface StudioState {
setCurrentView: (view?: RouterViewData) => Promise<void>;
showLeftPanel: boolean;
setShowLeftPanel: (show: boolean) => void;
showRightPanel: boolean;
setShowRightPanel: (show: boolean) => void;
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -47,7 +50,7 @@ export const useStudioStore = create<StudioState>()(
loading: false,
setLoading: (loading: boolean) => set({ loading }),
routes: [],
getRouteList: async () => {
queryRouteList: async () => {
await get().getCurrentView();
const state = await get().init();
let currentView: RouterViewData | undefined = get().currentView;
@@ -67,8 +70,9 @@ export const useStudioStore = create<StudioState>()(
url.searchParams.delete('viewId');
}
historyReplace(url.toString());
console.log('视图切换', beforeView, view);
await get().init(beforeView?.id !== view?.id);
await get().getRouteList();
await get().queryRouteList();
},
getViewList: async () => {
const res = await query.post({ path: 'views', key: 'list' });
@@ -154,26 +158,30 @@ export const useStudioStore = create<StudioState>()(
},
viewId: '',
}
console.log('初始化 QueryProxy', routerViewData);
queryProxy = new QueryProxy({
routerViewData
routerViewData,
router: app as any,
});
set({ loading: true });
await sleep(1000); // 保证 loading 状态更新
await queryProxy.init();
await sleep(500);
set({ loading: false });
set({ queryProxy });
return { queryProxy }
},
showLeftPanel: false,
setShowLeftPanel: (show: boolean) => set({ showLeftPanel: show }),
showRightPanel: false,
setShowRightPanel: (show: boolean) => set({ showRightPanel: show }),
}),
{
name: 'studio-storage',
partialize: (state) => ({ showLeftPanel: state.showLeftPanel }),
partialize: (state) => ({ showLeftPanel: state.showLeftPanel, showRightPanel: state.showRightPanel }),
}
)
);
use('studioStore', () => {
return useStudioStore.getState();
return useStudioStore;
});

View File

@@ -63,11 +63,26 @@ export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps
}
const handleSave = () => {
const pickData = dataItems.map(item => {
if (item.type === 'api') {
delete item.api.query
}
if (item.type === 'worker') {
delete item.worker.worker
}
if (item.type === 'context') {
delete item.context.router
}
if (item.type === 'page') {
}
return item
})
const viewData = {
id: data?.id,
title,
data: {
items: dataItems
items: pickData
},
views
}

5
web/src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import { app } from '@/app.ts';
import './routes/left-panel.ts';
export { app };

View File

@@ -0,0 +1,8 @@
---
import Html from '@/components/html.astro';
import { AppProvider } from '@/apps/query-view/index.tsx';
---
<Html title='Router Studio'>
<AppProvider client:only />
</Html>

View File

@@ -0,0 +1,22 @@
import { app } from '@/app.ts';
import { use } from '@kevisual/context'
app.route({
path: 'web',
key: 'togglePanel',
description: '当前的网页页面功能,切换左侧面板显示与隐藏',
metadata: {
tags: ['web', 'studio', 'page'],
}
}).define(async (ctx) => {
const store = use('studioStore');
try {
const state = store.getState();
state.setShowLeftPanel(!state.showLeftPanel);
ctx.body = { success: true };
} catch (error) {
ctx.body = { success: false, message: (error as Error).message };
}
}).addTo(app)