add base
This commit is contained in:
parent
5567c62d53
commit
5f28d4a149
5
.npmrc
Normal file
5
.npmrc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
|
||||||
|
@abearxiong:registry=https://npm.pkg.github.com
|
||||||
|
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
||||||
|
@build:registry=https://npm.xiongxiao.me
|
||||||
|
@kevisual:registry=https://npm.xiongxiao.me
|
14
index.html
Normal file
14
index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Browser Apps</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<script src="./src/main.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
26
package.json
26
package.json
@ -4,13 +4,35 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"dev": "vite",
|
||||||
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vite": "^6.0.1"
|
"@kevisual/router": "workspace:*",
|
||||||
|
"@kevisual/store": "workspace:*",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"highlight.js": "^11.10.0",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"marked": "^15.0.3",
|
||||||
|
"marked-highlight": "^2.2.1",
|
||||||
|
"nanoid": "^5.0.9",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"vite": "^6.0.1",
|
||||||
|
"zustand": "^5.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@build/tailwind": "1.0.2-alpha-2",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/umami": "^2.10.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"tailwindcss": "^3.4.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
91
src/h.ts
Normal file
91
src/h.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { RefObject, SyntheticEvent } from 'react';
|
||||||
|
const loadChidren = (element: any, children: any[]) => {
|
||||||
|
children.forEach((child) => {
|
||||||
|
if (typeof child === 'boolean') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof child === 'function') {
|
||||||
|
console.log('child', child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof child === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('child', child);
|
||||||
|
element.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// 在项目中定义 h 函数
|
||||||
|
export function h(type: string | Function, props: any, ...children: any[]): HTMLElement {
|
||||||
|
if (typeof type === 'function') {
|
||||||
|
const element = type(props);
|
||||||
|
loadChidren(element, children);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
const element = document.createElement(type);
|
||||||
|
const filterKeys = ['onLoad', 'onUnload', 'key'];
|
||||||
|
const key = props?.key || nanoid();
|
||||||
|
Object.entries(props || {}).forEach(([key, value]) => {
|
||||||
|
if (filterKeys.includes(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'className') {
|
||||||
|
element.setAttribute('class', value as string);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.startsWith('on')) {
|
||||||
|
element.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'ref' && value) {
|
||||||
|
(value as any).current = element;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
console.log('error', element, type, value);
|
||||||
|
} else {
|
||||||
|
element.setAttribute(key, value as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const onLoad = props?.onLoad;
|
||||||
|
const checkConnect = () => {
|
||||||
|
if (element.isConnected) {
|
||||||
|
onLoad?.({ el: element, key, _props: props });
|
||||||
|
console.log('onLoad', element, key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
const res = checkConnect();
|
||||||
|
if (!res) {
|
||||||
|
setTimeout(() => {}, 1000);
|
||||||
|
}
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
loadChidren(element, children);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
// type Element = HTMLElement; // 将 JSX.Element 设置为 HTMLElement
|
||||||
|
interface Element extends HTMLElement {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namespace React {
|
||||||
|
interface FormEvent<T = Element> extends SyntheticEvent<T> {
|
||||||
|
target: EventTarget & (T extends HTMLInputElement ? HTMLInputElement : T);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRef = <T = HTMLDivElement>(initialValue: T | null = null): RefObject<T> => {
|
||||||
|
return { current: initialValue };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEffect = (callback: () => void) => {
|
||||||
|
setTimeout(callback, 0);
|
||||||
|
};
|
21
src/main.css
Normal file
21
src/main.css
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
@tailwind components;
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.chat-message {
|
||||||
|
@apply flex flex-col gap-2;
|
||||||
|
.message-content {
|
||||||
|
@apply px-8 mt-2;
|
||||||
|
}
|
||||||
|
.message-user {
|
||||||
|
@apply flex-row-reverse;
|
||||||
|
.message-content-wrapper {
|
||||||
|
@apply max-w-[66%];
|
||||||
|
}
|
||||||
|
.message-content {
|
||||||
|
@apply bg-gray-200 px-4 py-2 rounded-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.message-assistant {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
src/main.tsx
Normal file
32
src/main.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { h } from '@/h';
|
||||||
|
import '@build/tailwind/main.css';
|
||||||
|
// tab的app-routes模块
|
||||||
|
import './tab';
|
||||||
|
import './page/index.css';
|
||||||
|
import './main.css';
|
||||||
|
|
||||||
|
import { Page } from '@kevisual/store/page';
|
||||||
|
import { AiChat } from './page/AiChat';
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
|
||||||
|
div.className = 'text-4xl text-center p-4 bg-blue-200';
|
||||||
|
div.innerHTML = 'Browser Apps';
|
||||||
|
|
||||||
|
const page = new Page({
|
||||||
|
path: '',
|
||||||
|
key: '',
|
||||||
|
basename: '',
|
||||||
|
});
|
||||||
|
page.addPage('/home', 'home');
|
||||||
|
page.addPage('/ai', 'ai');
|
||||||
|
|
||||||
|
page.subscribe('home', (page) => {
|
||||||
|
document.body.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
page.subscribe('ai', (page) => {
|
||||||
|
const aiChat = AiChat();
|
||||||
|
console.log(aiChat);
|
||||||
|
document.body.appendChild(aiChat);
|
||||||
|
});
|
109
src/modules/ResponseText.tsx
Normal file
109
src/modules/ResponseText.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { throttle } from 'lodash-es';
|
||||||
|
import { Marked } from 'marked';
|
||||||
|
import 'highlight.js/styles/github.css'; // 你可以选择其他样式
|
||||||
|
|
||||||
|
import { markedHighlight } from 'marked-highlight';
|
||||||
|
import hljs from 'highlight.js';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const marked = new Marked(
|
||||||
|
markedHighlight({
|
||||||
|
emptyLangClass: 'hljs',
|
||||||
|
langPrefix: 'hljs language-',
|
||||||
|
highlight(code, lang, info) {
|
||||||
|
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
|
||||||
|
return hljs.highlight(code, { language }).value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
import { h, useRef, useEffect } from '@/h';
|
||||||
|
|
||||||
|
type ResponseTextProps = {
|
||||||
|
response: Response;
|
||||||
|
onFinish?: (text: string) => void;
|
||||||
|
onChange?: (text: string) => void;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
export const ResponseText = (props: ResponseTextProps) => {
|
||||||
|
const ref = useRef();
|
||||||
|
const render = async () => {
|
||||||
|
const response = props.response;
|
||||||
|
if (!response) return;
|
||||||
|
const msg = ref.current!;
|
||||||
|
if (!msg) {
|
||||||
|
console.log('msg is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
const { value, done: streamDone } = await reader!.read();
|
||||||
|
done = streamDone;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
// 更新状态,实时刷新 UI
|
||||||
|
msg.innerHTML += chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
props.onFinish && props.onFinish(msg.innerHTML);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(render);
|
||||||
|
return <div id={props.id} className={clsx('response markdown-body', props.className)} ref={ref}></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResponseMarkdown = (props: ResponseTextProps) => {
|
||||||
|
const ref = useRef();
|
||||||
|
let content = '';
|
||||||
|
const render = async () => {
|
||||||
|
const response = props.response;
|
||||||
|
if (!response) return;
|
||||||
|
const msg = ref.current!;
|
||||||
|
if (!msg) {
|
||||||
|
console.log('msg is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
const { value, done: streamDone } = await reader!.read();
|
||||||
|
done = streamDone;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
content = content + chunk;
|
||||||
|
renderThrottle(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
props.onFinish && props.onFinish(msg.innerHTML);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const renderThrottle = throttle(async (markdown: string) => {
|
||||||
|
const msg = ref.current!;
|
||||||
|
msg.innerHTML = await marked.parse(markdown);
|
||||||
|
props.onChange?.(msg.innerHTML);
|
||||||
|
}, 100);
|
||||||
|
useEffect(render);
|
||||||
|
return <div id={props.id} className={clsx('response markdown-body', props.className)} ref={ref}></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Markdown = (props: { className?: string; markdown: string; id?: string }) => {
|
||||||
|
const ref = useRef();
|
||||||
|
const parse = async () => {
|
||||||
|
const md = await marked.parse(props.markdown);
|
||||||
|
const msg = ref.current!;
|
||||||
|
msg.innerHTML = md;
|
||||||
|
};
|
||||||
|
useEffect(parse);
|
||||||
|
return <div id={props.id} ref={ref} className={clsx('markdown-body', props.className)}></div>;
|
||||||
|
};
|
164
src/page/AiChat.tsx
Normal file
164
src/page/AiChat.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { h, useRef } from '@/h';
|
||||||
|
import { ResponseText, ResponseMarkdown, Markdown } from '@/modules/ResponseText';
|
||||||
|
import { aiChatStore, store } from '@/store/ai-chat-store';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
export type MessageData = {
|
||||||
|
content: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
response?: Response | null;
|
||||||
|
className?: string;
|
||||||
|
onFinish?: (text: string) => void;
|
||||||
|
// onFinish之前,每200ms调用一次
|
||||||
|
onChange?: (text: string) => void;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
type MessageProps = {} & MessageData;
|
||||||
|
const UserMessage = (props: MessageProps) => {
|
||||||
|
return (
|
||||||
|
<div className={`whitespace-pre message-item ${props.role}`} id={props.id}>
|
||||||
|
{props.content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const AssistantMessage = (props: MessageProps) => {
|
||||||
|
if (props.response) {
|
||||||
|
return <ResponseMarkdown className={clsx(props.className)} onChange={props.onChange} response={props.response} onFinish={props.onFinish} />;
|
||||||
|
}
|
||||||
|
return <Markdown className={clsx(props.className)} markdown={props.content} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Message = (props: MessageProps) => {
|
||||||
|
const isUser = props.role === 'user';
|
||||||
|
const WrapperMessage = isUser ? UserMessage : AssistantMessage;
|
||||||
|
return (
|
||||||
|
<div className={clsx('pt-6 w-[80%] mx-auto chat-message-wrapper', props.className)}>
|
||||||
|
<div className={clsx('chat-message')}>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
{
|
||||||
|
'message-user': isUser,
|
||||||
|
'message-assistant': !isUser,
|
||||||
|
},
|
||||||
|
'flex flex-col',
|
||||||
|
)}>
|
||||||
|
<div className='message-content-wrapper'>
|
||||||
|
<div className={clsx('message-avatar flex gap-3 items-center', isUser && 'hidden')}>
|
||||||
|
<img src='https://minio.xiongxiao.me/resources/root/avatar.png' alt='' className='w-6 h-6 rounded-full' />
|
||||||
|
<div>AI 助手</div>
|
||||||
|
</div>
|
||||||
|
<div className='message-content scrollbar overflow-x-hidden'>
|
||||||
|
<WrapperMessage {...props} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const AiChat = () => {
|
||||||
|
const ref = useRef();
|
||||||
|
const aiMessageRef = useRef();
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>();
|
||||||
|
const scrollContainer = useRef();
|
||||||
|
store.subscribe(
|
||||||
|
(state, prevState) => {
|
||||||
|
const messages = state.messages;
|
||||||
|
console.log('store', state, prevState);
|
||||||
|
if (aiMessageRef.current) {
|
||||||
|
messages.forEach((message) => {
|
||||||
|
const id = message.id;
|
||||||
|
if (document.getElementById('msg-' + id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// let msg: JSX.Element;
|
||||||
|
// if (message.role === 'ai' && message.response) {
|
||||||
|
// msg = ResponseMarkdown({ response: message.response, className: 'message-item ai', onFinish: () => {} });
|
||||||
|
// } else if (message.role === 'ai' && message.content) {
|
||||||
|
// msg = AssistantMessage({ ...message, className: 'message-item ai' });
|
||||||
|
// } else {
|
||||||
|
// msg = UserMessage({ ...message, id });
|
||||||
|
// }
|
||||||
|
const onChange = (text: string) => {
|
||||||
|
// const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
const scrollContainerEl = scrollContainer.current;
|
||||||
|
const bottom = document.querySelector('.ai-message-bottom');
|
||||||
|
if (scrollContainerEl) {
|
||||||
|
// 如果滚动条距离底部小于100px,自动滚动到底部
|
||||||
|
if (scrollContainerEl.scrollHeight - scrollContainerEl.scrollTop - scrollContainerEl.clientHeight < 400) {
|
||||||
|
}
|
||||||
|
console.log('scrollContainerEl', scrollContainerEl.scrollHeight, scrollContainerEl.scrollTop, scrollContainerEl.clientHeight);
|
||||||
|
console.log('scrollContainerEl<400', scrollContainerEl.scrollHeight - scrollContainerEl.scrollTop - scrollContainerEl.clientHeight < 400);
|
||||||
|
bottom?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onFinish = (text: string) => {
|
||||||
|
aiChatStore.getState().setEnd(true);
|
||||||
|
};
|
||||||
|
const msg = Message({ ...message, id, onChange, onFinish });
|
||||||
|
aiMessageRef.current!.appendChild(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ key: 'aiStore', path: 'messages' },
|
||||||
|
);
|
||||||
|
const onSend = () => {
|
||||||
|
const input = textareaRef.current;
|
||||||
|
const state = aiChatStore.getState();
|
||||||
|
if (state.loading || !state.end) {
|
||||||
|
console.log('loading');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input && input.value.trim() !== '') {
|
||||||
|
aiChatStore.getState().sendMessage(input.value);
|
||||||
|
input.value = '';
|
||||||
|
} else {
|
||||||
|
console.log('输入为空');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className='w-full h-full bg-blue-400 flex justify-center'
|
||||||
|
onLoad={(e) => {
|
||||||
|
console.log('onLoad======', e, ref.current);
|
||||||
|
}}>
|
||||||
|
<div className='w-[80%] border p-2 shadow bg-white mt-2 mb-2 rounded-lg flex flex-col'>
|
||||||
|
<h1 className='text-2xl font-bold text-gray-800 text-center'>AI聊天</h1>
|
||||||
|
<div className='relative flex-grow overflow-auto scrollbar' ref={scrollContainer}>
|
||||||
|
<div className='message mt-2 mb-5 m-h-[40px]' id='ai-message-content' ref={aiMessageRef}></div>
|
||||||
|
<div className='ai-message-bottom mb-4'></div>
|
||||||
|
<div className='flex w-full sticky justify-center bottom-0'>
|
||||||
|
<div className='w-[80%] flex flex-col gap-2 '>
|
||||||
|
<label htmlFor='inputField' className='text-lg font-medium text-gray-700'>
|
||||||
|
输入框
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id='inputField'
|
||||||
|
ref={textareaRef}
|
||||||
|
onInput={(e) => {
|
||||||
|
// console.log(e.target?.value); // 控制台输出输入的值
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault(); // 防止换行
|
||||||
|
onSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200'
|
||||||
|
placeholder='请输入内容'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className='bg-blue-500 text-white p-2 rounded disabled:bg-gray-400'
|
||||||
|
onClick={() => {
|
||||||
|
onSend();
|
||||||
|
}}>
|
||||||
|
提交
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
25
src/page/index.css
Normal file
25
src/page/index.css
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
.markdown-body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h1,
|
||||||
|
.markdown-body h2,
|
||||||
|
.markdown-body h3 {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body a {
|
||||||
|
color: #facc15;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body code {
|
||||||
|
background: #f6f8fa;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
81
src/response/ollama/ollama.ts
Normal file
81
src/response/ollama/ollama.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { createOllamaChatStream } from './stream-chat';
|
||||||
|
|
||||||
|
const ollamaUrl = 'http://192.168.31.220:11434';
|
||||||
|
|
||||||
|
export const createResponse = (response: Response) => {
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let done = false;
|
||||||
|
let result = '';
|
||||||
|
reader.read().then(function processText({ done, value }) {
|
||||||
|
if (done) {
|
||||||
|
console.log('Stream complete');
|
||||||
|
done = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result += decoder.decode(value, { stream: true });
|
||||||
|
return reader.read().then(processText);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
type OllamaResponse = {
|
||||||
|
context: number[];
|
||||||
|
created_at: string;
|
||||||
|
done: boolean;
|
||||||
|
done_reason: string;
|
||||||
|
eval_count: number;
|
||||||
|
eval_duration: number;
|
||||||
|
model: string;
|
||||||
|
prompt_eval_count: number;
|
||||||
|
prompt_eval_duration: number;
|
||||||
|
response: string;
|
||||||
|
total_duration: number;
|
||||||
|
};
|
||||||
|
export const postOllama = async (prompt: string): Promise<OllamaResponse> => {
|
||||||
|
const response = await fetch(`${ollamaUrl}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'qwen2.5:14b',
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
export const previewResponse = async (response: Response) => {
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
const { value, done: streamDone } = await reader.read();
|
||||||
|
done = streamDone;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
console.log(chunk);
|
||||||
|
// 在页面实时显示流返回的内容
|
||||||
|
document.getElementById('output')!.innerText += chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const postOllamaStrem = async (prompt: string) => {
|
||||||
|
const res = await fetch(`${ollamaUrl}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'qwen2.5:14b',
|
||||||
|
prompt: prompt,
|
||||||
|
stream: true, // 启用流式返回
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const readSteam = await createOllamaChatStream(res);
|
||||||
|
// previewResponse(readSteam)
|
||||||
|
return readSteam;
|
||||||
|
};
|
0
src/response/ollama/route.ts
Normal file
0
src/response/ollama/route.ts
Normal file
106
src/response/ollama/stream-chat.ts
Normal file
106
src/response/ollama/stream-chat.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
export const StreamChat = async (response: Response) => {
|
||||||
|
// const response = await fetch(request);
|
||||||
|
const reader = response?.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const readableStream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
function push() {
|
||||||
|
reader
|
||||||
|
.read()
|
||||||
|
.then(({ done, value }) => {
|
||||||
|
if (done) {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch (e) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const messageList = chunk.split('\n\n').filter((message) => message);
|
||||||
|
messageList.forEach((message) => {
|
||||||
|
// const message = chunk.replace('data: ', '');
|
||||||
|
message = message.replace('data: ', '');
|
||||||
|
console.log(chunk);
|
||||||
|
console.log(message);
|
||||||
|
if (message === '[DONE]') {
|
||||||
|
// controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(message);
|
||||||
|
controller.enqueue(encoder.encode(parsed.choices[0].delta.content));
|
||||||
|
});
|
||||||
|
push();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('读取流中的数据时发生错误', err);
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
push();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(readableStream);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOllamaChatStream = async (response: Response) => {
|
||||||
|
// const response = await fetch(request);
|
||||||
|
const reader = response?.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const readableStream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
function push() {
|
||||||
|
reader
|
||||||
|
.read()
|
||||||
|
.then(({ done, value }) => {
|
||||||
|
if (done) {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch (e) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
// try {
|
||||||
|
// const parsed = JSON.parse(chunk)
|
||||||
|
// console.log(parsed)
|
||||||
|
// controller.enqueue(encoder.encode(parsed.response))
|
||||||
|
// push()
|
||||||
|
// } catch (e) {
|
||||||
|
// console.error('解析流中的数据时发生错误', e)
|
||||||
|
// console.error('解析的流', chunk)
|
||||||
|
// // controller.error(e)
|
||||||
|
// }
|
||||||
|
// console.log('parsex chunk', chunk)
|
||||||
|
const messageList = chunk.split('\n').filter((message) => message);
|
||||||
|
messageList.forEach((message) => {
|
||||||
|
// console.log('message', message, '\nchunk:', chunk)
|
||||||
|
if (message === '[DONE]') {
|
||||||
|
// controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message);
|
||||||
|
controller.enqueue(encoder.encode(parsed.response));
|
||||||
|
push();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析流中的数据时发生错误', e);
|
||||||
|
console.error('解析的流', message);
|
||||||
|
// controller.error(e)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('读取流中的数据时发生错误', err);
|
||||||
|
controller.error(err);
|
||||||
|
push();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
push();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(readableStream);
|
||||||
|
};
|
0
src/route-page.ts
Normal file
0
src/route-page.ts
Normal file
75
src/store/ai-chat-store.ts
Normal file
75
src/store/ai-chat-store.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { StoreManager } from '@kevisual/store';
|
||||||
|
import type { StateCreator, StoreApi } from 'zustand';
|
||||||
|
import { useContextKey } from '@kevisual/store/context';
|
||||||
|
import { postOllama, postOllamaStrem } from '@/response/ollama/ollama';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export const store = useContextKey('storeManager', () => {
|
||||||
|
return new StoreManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
type AiChatMessage = {
|
||||||
|
content?: string;
|
||||||
|
role?: 'user' | 'ai';
|
||||||
|
id?: string;
|
||||||
|
response?: Response;
|
||||||
|
};
|
||||||
|
type AiChatStore = {
|
||||||
|
messages: AiChatMessage[];
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
sendMessage: (text: string) => Promise<any>;
|
||||||
|
end: boolean;
|
||||||
|
setEnd: (end: boolean) => void;
|
||||||
|
};
|
||||||
|
export const createAiChatStore: StateCreator<AiChatStore, [], [], AiChatStore> = (set, get) => {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
loading: false,
|
||||||
|
end: true,
|
||||||
|
setEnd: (end) => {
|
||||||
|
set({ end });
|
||||||
|
},
|
||||||
|
setLoading: (loading) => {
|
||||||
|
set({ loading });
|
||||||
|
},
|
||||||
|
sendMessage: async (text) => {
|
||||||
|
// const res = await postOllama(text);
|
||||||
|
const id = nanoid(8);
|
||||||
|
// const getDataFromOllamaJson = (value: typeof res) => {
|
||||||
|
// const id = nanoid(8);
|
||||||
|
// return {
|
||||||
|
// content: value.response as string,
|
||||||
|
// role: 'ai' as const,
|
||||||
|
// id: id,
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
// const data = getDataFromOllamaJson(res);
|
||||||
|
set({ loading: true });
|
||||||
|
const newMessage = [
|
||||||
|
...get().messages,
|
||||||
|
{ content: text, role: 'user' as const, id: id }, //
|
||||||
|
];
|
||||||
|
// set({
|
||||||
|
// messages: newMessage,
|
||||||
|
// });
|
||||||
|
const resStream = await postOllamaStrem(text);
|
||||||
|
set({ loading: false });
|
||||||
|
const getDataFromOllamaStream = (value: Response) => {
|
||||||
|
const id = nanoid(8);
|
||||||
|
return {
|
||||||
|
role: 'ai' as const,
|
||||||
|
id: id,
|
||||||
|
response: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const dataStream = getDataFromOllamaStream(resStream);
|
||||||
|
|
||||||
|
set({
|
||||||
|
messages: [...newMessage, dataStream],
|
||||||
|
end: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export const aiChatStore: StoreApi<AiChatStore> = store.createStore(createAiChatStore, 'aiStore');
|
446
src/tab.ts
Normal file
446
src/tab.ts
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { QueryRouterServer } from '@kevisual/router/browser';
|
||||||
|
import { useContextKey } from '@kevisual/store/context';
|
||||||
|
import { useConfigKey } from '@kevisual/store/config';
|
||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
// const errorCdoe = {
|
||||||
|
// 840: 'not me', // 不是我
|
||||||
|
// 999: 'find max', // 找到最大的
|
||||||
|
// };
|
||||||
|
export const emitter = useContextKey('emitter', () => {
|
||||||
|
return new EventEmitter();
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tabId = useConfigKey('tabId', () => {
|
||||||
|
return nanoid(8);
|
||||||
|
});
|
||||||
|
export const openTabs = useContextKey('openTabs', () => {
|
||||||
|
return new Set<string>();
|
||||||
|
});
|
||||||
|
export const tabConfig = useConfigKey('tabConfig', () => {
|
||||||
|
const random = Math.random().toString(36).substring(7);
|
||||||
|
return {
|
||||||
|
title: 'tab random' + random,
|
||||||
|
description: 'tab config',
|
||||||
|
tabId: useConfigKey('tabId', () => {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const app = useContextKey('app', () => {
|
||||||
|
return new QueryRouterServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
export const umami = useContextKey('umami', () => {
|
||||||
|
if (!window.umami) {
|
||||||
|
(window as any).umami = {
|
||||||
|
track: (event: string, data: any) => {
|
||||||
|
console.log('umami event not found', event, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return window.umami;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// 控制 tab 启动
|
||||||
|
let load = false;
|
||||||
|
let openTime = Date.now();
|
||||||
|
let meIsMax = '1'; // 0: 未知, 1: 对比来源,我更大, 2: 不是
|
||||||
|
|
||||||
|
export type Msg = {
|
||||||
|
requestId?: string;
|
||||||
|
path: string;
|
||||||
|
key: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'tab',
|
||||||
|
key: 'introduce',
|
||||||
|
description: '当多个tab打开的时候,找打哦最后打开的,确信那些tabs是有效和打开的',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const source = ctx.query?.source;
|
||||||
|
const sourceOpenTime = ctx.query?.openTime;
|
||||||
|
const update = ctx.query?.update;
|
||||||
|
if (!source) {
|
||||||
|
ctx.throw?.(400, 'tabId is required');
|
||||||
|
}
|
||||||
|
openTabs.add(source);
|
||||||
|
if (update) {
|
||||||
|
localStorage.setItem('tab-' + tabId, source);
|
||||||
|
}
|
||||||
|
if (!load) {
|
||||||
|
// console.log('soucre time', sourceOpenTime, openTime, sourceOpenTime > openTime);
|
||||||
|
if (openTime < sourceOpenTime) {
|
||||||
|
meIsMax = '2'; // 来源的时间比我大
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.code = 999;
|
||||||
|
ctx.body = 'ok';
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'tab',
|
||||||
|
key: 'close',
|
||||||
|
description: '当触发关闭事件的时候,清除tab',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const source = ctx.query?.source;
|
||||||
|
if (!source) {
|
||||||
|
ctx.throw?.(400, 'tabId is required');
|
||||||
|
}
|
||||||
|
openTabs.delete(source);
|
||||||
|
localStorage.removeItem('tab-' + source);
|
||||||
|
ctx.body = 'ok';
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'tab',
|
||||||
|
key: 'setTabs',
|
||||||
|
description: '已经知道最新的tabs了,通知所有的channel,设置打开的 tab',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const tabs = ctx.query?.tabs || [];
|
||||||
|
openTabs.clear();
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
openTabs.add(tab);
|
||||||
|
});
|
||||||
|
console.log(`[${tabId}]Set open tabs: ${tabs.length}`, openTabs.keys());
|
||||||
|
load = true;
|
||||||
|
ctx.body = 'ok';
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'tab',
|
||||||
|
key: 'getTabs',
|
||||||
|
description: '获取所有的打开的 tab',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
ctx.body = Array.from(openTabs);
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
// 操作私有 tab
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'tab',
|
||||||
|
key: 'getMe',
|
||||||
|
description: '获取自己的 tab 信息,比如tabId,openTime,tabConfig',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
ctx.body = { tabId, openTime, tabConfig };
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'tab',
|
||||||
|
key: 'me',
|
||||||
|
id: 'me',
|
||||||
|
description: '验证是否是自己的标签页面,如果是自己则继续,否则返回错误',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
// check me
|
||||||
|
const to = ctx.query?.to;
|
||||||
|
if (to !== tabId) {
|
||||||
|
// not me
|
||||||
|
ctx.throw?.(840, 'not me');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.body = 'ok';
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'channel',
|
||||||
|
key: 'postAllTabs',
|
||||||
|
description: '向所有的 tab 发送消息,如果payload有hasResponse,则等待所有的 tab 返回消息',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const data = ctx.query?.data;
|
||||||
|
const hasResponse = ctx.query?.hasResponse;
|
||||||
|
if (!data?.path) {
|
||||||
|
ctx.throw?.(400, 'path is required');
|
||||||
|
}
|
||||||
|
const res = await new Promise((resolve) => {
|
||||||
|
if (hasResponse) {
|
||||||
|
postAllTabs(data, (res) => {
|
||||||
|
resolve(res);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
postAllTabs(data);
|
||||||
|
resolve('ok');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.body = res || 'ok';
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'channel',
|
||||||
|
key: 'postTab',
|
||||||
|
description: '向指定的 tab 发送消息,如果payload有hasResponse,则等待指定的 tab 返回消息',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const data = ctx.query?.data;
|
||||||
|
const hasResponse = ctx.query?.hasResponse;
|
||||||
|
if (!data?.path) {
|
||||||
|
ctx.throw?.(400, 'path is required');
|
||||||
|
}
|
||||||
|
const res = await new Promise((resolve) => {
|
||||||
|
if (hasResponse) {
|
||||||
|
postTab(data, (res) => {
|
||||||
|
resolve(res);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
postTab(data);
|
||||||
|
resolve('ok');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.body = res || 'ok';
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'tab',
|
||||||
|
key: 'setConfig',
|
||||||
|
description: '设置 tab 的配置信息',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const config = ctx.query?.config;
|
||||||
|
if (config) {
|
||||||
|
Object.assign(tabConfig, config);
|
||||||
|
}
|
||||||
|
ctx.body = tabConfig;
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'tab',
|
||||||
|
key: 'getConfig',
|
||||||
|
description: '获取 tab 的配置信息',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
ctx.body = tabConfig;
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
const channel = useContextKey('channel', () => {
|
||||||
|
return new BroadcastChannel('tab-channel');
|
||||||
|
});
|
||||||
|
|
||||||
|
channel.addEventListener('message', (event) => {
|
||||||
|
const { requestId, to, source, responseId } = event.data;
|
||||||
|
if (responseId && to === tabId) {
|
||||||
|
// 有 id 的消息, 这是这个模块请求返回回来的消息,不被其他模块处理。
|
||||||
|
emitter.emit(responseId, event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log('channel message', event.data);
|
||||||
|
if (to === 'all' || to === tabId) {
|
||||||
|
const { path, key, payload, ...rest } = event.data;
|
||||||
|
if (!path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app
|
||||||
|
.run({
|
||||||
|
path,
|
||||||
|
key,
|
||||||
|
payload: {
|
||||||
|
...rest,
|
||||||
|
...payload,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
const { data, message, code } = res;
|
||||||
|
if (requestId) {
|
||||||
|
if (code !== 999) {
|
||||||
|
console.log('channel response', requestId, res);
|
||||||
|
}
|
||||||
|
const msg = {
|
||||||
|
// 返回数据一般没有 requestId,如果有,在res中自己放置
|
||||||
|
responseId: requestId,
|
||||||
|
to: source,
|
||||||
|
source: tabId,
|
||||||
|
data: data,
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
};
|
||||||
|
if (data?.requestId) {
|
||||||
|
msg['requestId'] = data.requestId;
|
||||||
|
}
|
||||||
|
channel.postMessage(msg);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(tabId, requestId, err);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const postAllTabs = (msg: Msg, callback?: (data: any[], error?: any[]) => any, timeout = 3 * 60 * 1000) => {
|
||||||
|
const action = {
|
||||||
|
...msg,
|
||||||
|
to: 'all',
|
||||||
|
source: tabId,
|
||||||
|
};
|
||||||
|
if (callback) {
|
||||||
|
const requestId = nanoid(8);
|
||||||
|
action.requestId = requestId;
|
||||||
|
const res: any = [];
|
||||||
|
const error: any = [];
|
||||||
|
emitter.on(requestId, (data) => {
|
||||||
|
res.push(data);
|
||||||
|
// console.log('postAllTabs callback', data, res.length, openTabs.size);
|
||||||
|
if (res.length >= openTabs.size - 1) {
|
||||||
|
callback(res);
|
||||||
|
emitter.off(requestId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
const tabs = [...openTabs];
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
if (tab === tabId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.find((r) => r.source === tab)) {
|
||||||
|
error.push({ code: 500, message: 'timeout', tab, source: tab, to: tabId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
callback(res, error);
|
||||||
|
|
||||||
|
emitter.off(requestId);
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
channel.postMessage(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
const postTab = (msg: Msg, callback?: (data: any) => any, timeout = 3 * 60 * 1000) => {
|
||||||
|
const action = {
|
||||||
|
...msg,
|
||||||
|
to: msg.to,
|
||||||
|
source: tabId,
|
||||||
|
};
|
||||||
|
if (callback) {
|
||||||
|
const requestId = nanoid(8);
|
||||||
|
action.requestId = requestId;
|
||||||
|
emitter.on(requestId, (data) => {
|
||||||
|
callback(data);
|
||||||
|
emitter.off(requestId);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
callback({ code: 500, message: 'timeout' });
|
||||||
|
emitter.off(requestId);
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
channel.postMessage(action);
|
||||||
|
};
|
||||||
|
const channelFn = {
|
||||||
|
postAllTabs,
|
||||||
|
postTab,
|
||||||
|
};
|
||||||
|
|
||||||
|
useConfigKey('channelMsg', () => {
|
||||||
|
return channelFn;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTabs = (filter = true) => {
|
||||||
|
const allTabs = Object.keys(localStorage).filter((key) => key.startsWith('tab-'));
|
||||||
|
const tabs = allTabs.filter((key) => {
|
||||||
|
if (!filter) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const tab = localStorage.getItem(key);
|
||||||
|
if (tab === tabId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return tabs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOtherTabs = async () => {
|
||||||
|
const res = await app.run({
|
||||||
|
path: 'channel',
|
||||||
|
key: 'postAllTabs',
|
||||||
|
payload: {
|
||||||
|
data: {
|
||||||
|
path: 'tab',
|
||||||
|
key: 'getConfig',
|
||||||
|
},
|
||||||
|
hasResponse: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('getOtherTabs', res);
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
window.getOtherTabs = getOtherTabs;
|
||||||
|
const checkTabs = async () => {
|
||||||
|
postAllTabs({ path: 'tab', key: 'introduce', payload: { openTime: openTime, update: true } });
|
||||||
|
await sleep(1000);
|
||||||
|
const tabs = getTabs();
|
||||||
|
postAllTabs({ path: 'tab', key: 'setTabs', payload: { tabs: tabs.map((tab) => tab.replace('tab-', '')) } });
|
||||||
|
openTabs.clear();
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
openTabs.add(tab.replace('tab-', ''));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 初始化 tab
|
||||||
|
*/
|
||||||
|
const init = async () => {
|
||||||
|
localStorage.setItem('tab-' + tabId, tabId);
|
||||||
|
// 向其他 tab 发送消息, 页面已经打开
|
||||||
|
postAllTabs({ path: 'tab', key: 'introduce', payload: { openTime: openTime } });
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
// 向其他 tab 发送消息, 页面即将关闭
|
||||||
|
localStorage.removeItem('tab-' + tabId);
|
||||||
|
console.log('close tab', tabId);
|
||||||
|
postAllTabs({ path: 'tab', key: 'close' });
|
||||||
|
});
|
||||||
|
introduceMe();
|
||||||
|
};
|
||||||
|
const introduceMe = () => {
|
||||||
|
if (meIsMax === '2') {
|
||||||
|
// 我知道我不是最大的了,我自己就不再发送消息了
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postAllTabs({ path: 'tab', key: 'introduce', payload: { openTime: openTime } });
|
||||||
|
if (!load) {
|
||||||
|
const time = Date.now() - openTime;
|
||||||
|
if (time > 2100) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
introduceMe();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// umami.track('tab', { tabId: tabId });
|
||||||
|
|
||||||
|
window.onload = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (meIsMax === '1') {
|
||||||
|
console.log('I am max', openTime);
|
||||||
|
checkTabs();
|
||||||
|
load = true;
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
init();
|
48
src/utils/check-connect.ts
Normal file
48
src/utils/check-connect.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
type TaskData = {
|
||||||
|
element?: HTMLElement;
|
||||||
|
onUnload?: () => void;
|
||||||
|
onLoad?: () => void;
|
||||||
|
};
|
||||||
|
// 定义任务队列
|
||||||
|
const loadTasks: TaskData[] = [];
|
||||||
|
const unloadTasks: TaskData[] = [];
|
||||||
|
let isSchedulerLoadRunning = false; // 调度器状态,加载
|
||||||
|
let isSchedulerUnLoadRunning = false; // 调度器状态
|
||||||
|
|
||||||
|
export const addTask = (task: TaskData) => {
|
||||||
|
if (task.onLoad || task.onUnload) {
|
||||||
|
loadTasks.push(task);
|
||||||
|
}
|
||||||
|
if (!isSchedulerLoadRunning) {
|
||||||
|
isSchedulerLoadRunning = true;
|
||||||
|
requestIdleCallback(processTasks);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const addUnloadTask = (task: TaskData) => {
|
||||||
|
if (task.onUnload) {
|
||||||
|
unloadTasks.push(task);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 调度函数
|
||||||
|
const processTasks: IdleRequestCallback = (deadline) => {
|
||||||
|
// 优先处理高优先级任务
|
||||||
|
while (loadTasks.length && deadline.timeRemaining() > 0) {
|
||||||
|
const task = loadTasks.shift();
|
||||||
|
if (task?.element?.isConnected) {
|
||||||
|
task.onLoad?.();
|
||||||
|
if (task.onUnload) {
|
||||||
|
addUnloadTask(task);
|
||||||
|
}
|
||||||
|
} else if (task) {
|
||||||
|
addTask(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 检查是否还有任务
|
||||||
|
if (loadTasks.length) {
|
||||||
|
requestIdleCallback(processTasks);
|
||||||
|
} else {
|
||||||
|
// 没有任务,停止调度器
|
||||||
|
isSchedulerLoadRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestIdleCallback(processTasks);
|
47
tailwind.config.js
Normal file
47
tailwind.config.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const root = path.resolve(process.cwd());
|
||||||
|
const contents = ['./src/**/*.{ts,tsx,html}', './src/**/*.css'];
|
||||||
|
const content = contents.map((item) => path.join(root, item));
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
// darkMode: ['class'],
|
||||||
|
content: contents,
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/aspect-ratio'), //
|
||||||
|
require('@tailwindcss/typography'),
|
||||||
|
require('tailwindcss-animate'),
|
||||||
|
require('@build/tailwind'),
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
mon: ['Montserrat', 'sans-serif'], // 定义自定义字体族
|
||||||
|
rob: ['Roboto', 'sans-serif'],
|
||||||
|
int: ['Inter', 'sans-serif'],
|
||||||
|
orb: ['Orbitron', 'sans-serif'],
|
||||||
|
din: ['DIN', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
screen: {
|
||||||
|
sm: '640px',
|
||||||
|
// => @media (min-width: 640px) { ... }
|
||||||
|
|
||||||
|
md: '768px',
|
||||||
|
// => @media (min-width: 768px) { ... }
|
||||||
|
|
||||||
|
lg: '1024px',
|
||||||
|
// => @media (min-width: 1024px) { ... }
|
||||||
|
|
||||||
|
xl: '1280px',
|
||||||
|
// => @media (min-width: 1280px) { ... }
|
||||||
|
|
||||||
|
'2xl': '1536px',
|
||||||
|
// => @media (min-width: 1536px) { ... }
|
||||||
|
'3xl': '1920px',
|
||||||
|
// => @media (min-width: 1920) { ... }
|
||||||
|
'4xl': '2560px',
|
||||||
|
// => @media (min-width: 2560) { ... }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
42
tsconfig.app.json
Normal file
42
tsconfig.app.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsxFragmentFactory": "Fragment",
|
||||||
|
"jsxFactory": "h",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"typeRoots": [
|
||||||
|
"./node_modules/@types",
|
||||||
|
"./node_modules/@kevisual/types",
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
"typings.d.ts"
|
||||||
|
]
|
||||||
|
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
8
typing.d.ts
vendored
Normal file
8
typing.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// typing.d.ts 或 global.d.ts
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
type Element = HTMLElement; // 将 JSX.Element 设置为 HTMLElement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
46
vite.config.ts
Normal file
46
vite.config.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import path from 'path';
|
||||||
|
// import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from 'tailwindcss';
|
||||||
|
import autoprefixer from 'autoprefixer';
|
||||||
|
import nesting from 'tailwindcss/nesting';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: '.',
|
||||||
|
// plugins: [react()],
|
||||||
|
css: {
|
||||||
|
postcss: {
|
||||||
|
// @ts-ignore
|
||||||
|
plugins: [nesting, tailwindcss, autoprefixer],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
// react: './utils/h.ts', // 将 react 替换为自定义的 JSX 库
|
||||||
|
// 'react-dom': './utils/h.ts', // 将 react-dom 替换为自定义的 JSX 库
|
||||||
|
// 上到下,上面的先被匹配
|
||||||
|
// '@kevisual/store/config': 'https://kevisual.xiongxiao.me/system/lib/web-config.js', // 将本地路径替换为远程 URL
|
||||||
|
// '@kevisual/store/context': 'https://kevisual.xiongxiao.me/system/lib/web-context.js', // 将本地路径替换为远程 URL
|
||||||
|
// '@kevisual/store': 'https://kevisual.xiongxiao.me/system/lib/store.js', // 将本地路径替换为远程 URL
|
||||||
|
// '@kevisual/router/browser': 'https://kevisual.xiongxiao.me/system/lib/router-browser.js', // 将本地路径替换为远程 URL
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
minify: false,
|
||||||
|
outDir: './dist',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['react', 'react-dom'], // 排除 react 和 react-dom 以避免打包
|
||||||
|
},
|
||||||
|
esbuild: {
|
||||||
|
jsxFactory: 'h',
|
||||||
|
jsxFragment: 'Fragment',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5002,
|
||||||
|
},
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user