diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..fcd480b
--- /dev/null
+++ b/.npmrc
@@ -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
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..2372e2c
--- /dev/null
+++ b/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Browser Apps
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
index afcc482..350d2af 100644
--- a/package.json
+++ b/package.json
@@ -4,13 +4,35 @@
"description": "",
"main": "index.js",
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
+ "dev": "vite",
+ "build": "vite build"
},
"keywords": [],
"author": "abearxiong ",
"license": "MIT",
"type": "module",
"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"
}
-}
+}
\ No newline at end of file
diff --git a/src/h.ts b/src/h.ts
new file mode 100644
index 0000000..ed18535
--- /dev/null
+++ b/src/h.ts
@@ -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 extends SyntheticEvent {
+ target: EventTarget & (T extends HTMLInputElement ? HTMLInputElement : T);
+ }
+ }
+}
+
+export const useRef = (initialValue: T | null = null): RefObject => {
+ return { current: initialValue };
+};
+
+export const useEffect = (callback: () => void) => {
+ setTimeout(callback, 0);
+};
diff --git a/src/main.css b/src/main.css
new file mode 100644
index 0000000..262c628
--- /dev/null
+++ b/src/main.css
@@ -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 {
+ }
+ }
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..29d31c0
--- /dev/null
+++ b/src/main.tsx
@@ -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);
+});
diff --git a/src/modules/ResponseText.tsx b/src/modules/ResponseText.tsx
new file mode 100644
index 0000000..b0e6621
--- /dev/null
+++ b/src/modules/ResponseText.tsx
@@ -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 ;
+};
+
+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 ;
+};
+
+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 ;
+};
diff --git a/src/page/AiChat.tsx b/src/page/AiChat.tsx
new file mode 100644
index 0000000..c280c7e
--- /dev/null
+++ b/src/page/AiChat.tsx
@@ -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 (
+
+ {props.content}
+
+ );
+};
+export const AssistantMessage = (props: MessageProps) => {
+ if (props.response) {
+ return ;
+ }
+ return ;
+};
+
+export const Message = (props: MessageProps) => {
+ const isUser = props.role === 'user';
+ const WrapperMessage = isUser ? UserMessage : AssistantMessage;
+ return (
+
+
+
+
+
+

+
AI 助手
+
+
+
+
+
+
+
+
+ );
+};
+export const AiChat = () => {
+ const ref = useRef();
+ const aiMessageRef = useRef();
+ const textareaRef = useRef();
+ 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 (
+ {
+ console.log('onLoad======', e, ref.current);
+ }}>
+
+
+ );
+};
diff --git a/src/page/index.css b/src/page/index.css
new file mode 100644
index 0000000..b562b7c
--- /dev/null
+++ b/src/page/index.css
@@ -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;
+}
diff --git a/src/response/ollama/ollama.ts b/src/response/ollama/ollama.ts
new file mode 100644
index 0000000..c3e3c28
--- /dev/null
+++ b/src/response/ollama/ollama.ts
@@ -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 => {
+ 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;
+};
diff --git a/src/response/ollama/route.ts b/src/response/ollama/route.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/response/ollama/stream-chat.ts b/src/response/ollama/stream-chat.ts
new file mode 100644
index 0000000..19c672d
--- /dev/null
+++ b/src/response/ollama/stream-chat.ts
@@ -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);
+};
diff --git a/src/route-page.ts b/src/route-page.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/store/ai-chat-store.ts b/src/store/ai-chat-store.ts
new file mode 100644
index 0000000..de9f619
--- /dev/null
+++ b/src/store/ai-chat-store.ts
@@ -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;
+ end: boolean;
+ setEnd: (end: boolean) => void;
+};
+export const createAiChatStore: StateCreator = (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 = store.createStore(createAiChatStore, 'aiStore');
diff --git a/src/tab.ts b/src/tab.ts
new file mode 100644
index 0000000..c4a0be8
--- /dev/null
+++ b/src/tab.ts
@@ -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();
+});
+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();
diff --git a/src/utils/check-connect.ts b/src/utils/check-connect.ts
new file mode 100644
index 0000000..94a9d49
--- /dev/null
+++ b/src/utils/check-connect.ts
@@ -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);
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..d3aa6b5
--- /dev/null
+++ b/tailwind.config.js
@@ -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) { ... }
+ },
+ },
+};
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..f264f1c
--- /dev/null
+++ b/tsconfig.app.json
@@ -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"
+ ]
+}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..0d3d714
--- /dev/null
+++ b/tsconfig.node.json
@@ -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"]
+}
diff --git a/typing.d.ts b/typing.d.ts
new file mode 100644
index 0000000..0c5d0af
--- /dev/null
+++ b/typing.d.ts
@@ -0,0 +1,8 @@
+// typing.d.ts 或 global.d.ts
+declare global {
+ namespace JSX {
+ type Element = HTMLElement; // 将 JSX.Element 设置为 HTMLElement
+ }
+}
+
+export {};
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..5c6564c
--- /dev/null
+++ b/vite.config.ts
@@ -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,
+ },
+});