This commit is contained in:
xion 2024-12-01 23:58:21 +08:00
parent 5567c62d53
commit 5f28d4a149
22 changed files with 1414 additions and 3 deletions

5
.npmrc Normal file
View 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
View 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>

View File

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

View 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
View 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
View 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;
}

View 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;
};

View File

View 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
View File

View 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
View 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 信息比如tabIdopenTimetabConfig',
})
.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();

View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View 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
View 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
View 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,
},
});