add base
This commit is contained in:
parent
5567c62d53
commit
5f28d4a149
5
.npmrc
Normal file
5
.npmrc
Normal file
@ -0,0 +1,5 @@
|
||||
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
|
||||
@abearxiong:registry=https://npm.pkg.github.com
|
||||
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
||||
@build:registry=https://npm.xiongxiao.me
|
||||
@kevisual:registry=https://npm.xiongxiao.me
|
14
index.html
Normal file
14
index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Browser Apps</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script src="./src/main.tsx" type="module"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
26
package.json
26
package.json
@ -4,13 +4,35 @@
|
||||
"description": "",
|
||||
"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,
|
||||
},
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user