add base
This commit is contained in:
		
							
								
								
									
										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>
 | 
			
		||||
							
								
								
									
										28
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								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 <xiongxiao@xiongxiao.me>",
 | 
			
		||||
  "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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										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,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
		Reference in New Issue
	
	Block a user