From 5f28d4a149a163c3e6b858d353ae5049f5da65df Mon Sep 17 00:00:00 2001 From: xion Date: Sun, 1 Dec 2024 23:58:21 +0800 Subject: [PATCH] add base --- .npmrc | 5 + index.html | 14 + package.json | 28 +- src/h.ts | 91 ++++++ src/main.css | 21 ++ src/main.tsx | 32 +++ src/modules/ResponseText.tsx | 109 +++++++ src/page/AiChat.tsx | 164 +++++++++++ src/page/index.css | 25 ++ src/response/ollama/ollama.ts | 81 ++++++ src/response/ollama/route.ts | 0 src/response/ollama/stream-chat.ts | 106 +++++++ src/route-page.ts | 0 src/store/ai-chat-store.ts | 75 +++++ src/tab.ts | 446 +++++++++++++++++++++++++++++ src/utils/check-connect.ts | 48 ++++ tailwind.config.js | 47 +++ tsconfig.app.json | 42 +++ tsconfig.json | 7 + tsconfig.node.json | 22 ++ typing.d.ts | 8 + vite.config.ts | 46 +++ 22 files changed, 1414 insertions(+), 3 deletions(-) create mode 100644 .npmrc create mode 100644 index.html create mode 100644 src/h.ts create mode 100644 src/main.css create mode 100644 src/main.tsx create mode 100644 src/modules/ResponseText.tsx create mode 100644 src/page/AiChat.tsx create mode 100644 src/page/index.css create mode 100644 src/response/ollama/ollama.ts create mode 100644 src/response/ollama/route.ts create mode 100644 src/response/ollama/stream-chat.ts create mode 100644 src/route-page.ts create mode 100644 src/store/ai-chat-store.ts create mode 100644 src/tab.ts create mode 100644 src/utils/check-connect.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 typing.d.ts create mode 100644 vite.config.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..fcd480b --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN} +@abearxiong:registry=https://npm.pkg.github.com +//registry.npmjs.org/:_authToken=${NPM_TOKEN} +@build:registry=https://npm.xiongxiao.me +@kevisual:registry=https://npm.xiongxiao.me \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..2372e2c --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + Browser Apps + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index afcc482..350d2af 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,35 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "dev": "vite", + "build": "vite build" }, "keywords": [], "author": "abearxiong ", "license": "MIT", "type": "module", "dependencies": { - "vite": "^6.0.1" + "@kevisual/router": "workspace:*", + "@kevisual/store": "workspace:*", + "@vitejs/plugin-react": "^4.3.4", + "clsx": "^2.1.1", + "eventemitter3": "^5.0.1", + "highlight.js": "^11.10.0", + "immer": "^10.1.1", + "lodash-es": "^4.17.21", + "marked": "^15.0.3", + "marked-highlight": "^2.2.1", + "nanoid": "^5.0.9", + "react": "^18.3.1", + "vite": "^6.0.1", + "zustand": "^5.0.1" + }, + "devDependencies": { + "@build/tailwind": "1.0.2-alpha-2", + "@types/lodash-es": "^4.17.12", + "@types/react": "^18.3.12", + "@types/umami": "^2.10.0", + "autoprefixer": "^10.4.20", + "tailwindcss": "^3.4.15" } -} +} \ No newline at end of file diff --git a/src/h.ts b/src/h.ts new file mode 100644 index 0000000..ed18535 --- /dev/null +++ b/src/h.ts @@ -0,0 +1,91 @@ +import { nanoid } from 'nanoid'; +import { RefObject, SyntheticEvent } from 'react'; +const loadChidren = (element: any, children: any[]) => { + children.forEach((child) => { + if (typeof child === 'boolean') { + return; + } + if (typeof child === 'function') { + console.log('child', child); + return; + } + if (typeof child === 'undefined') { + return; + } + console.log('child', child); + element.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); + }); +}; +// 在项目中定义 h 函数 +export function h(type: string | Function, props: any, ...children: any[]): HTMLElement { + if (typeof type === 'function') { + const element = type(props); + loadChidren(element, children); + return element; + } + const element = document.createElement(type); + const filterKeys = ['onLoad', 'onUnload', 'key']; + const key = props?.key || nanoid(); + Object.entries(props || {}).forEach(([key, value]) => { + if (filterKeys.includes(key)) { + return; + } + if (key === 'className') { + element.setAttribute('class', value as string); + return; + } + if (key.startsWith('on')) { + element.addEventListener(key.slice(2).toLowerCase(), value as EventListener); + return; + } + if (key === 'ref' && value) { + (value as any).current = element; + return; + } + if (typeof value === 'object') { + console.log('error', element, type, value); + } else { + element.setAttribute(key, value as string); + } + }); + const onLoad = props?.onLoad; + const checkConnect = () => { + if (element.isConnected) { + onLoad?.({ el: element, key, _props: props }); + console.log('onLoad', element, key); + return true; + } + return false; + }; + setTimeout(() => { + const res = checkConnect(); + if (!res) { + setTimeout(() => {}, 1000); + } + }, 20); + + loadChidren(element, children); + return element; +} + +declare global { + namespace JSX { + // type Element = HTMLElement; // 将 JSX.Element 设置为 HTMLElement + interface Element extends HTMLElement { + class?: string; + } + } + namespace React { + interface FormEvent extends SyntheticEvent { + target: EventTarget & (T extends HTMLInputElement ? HTMLInputElement : T); + } + } +} + +export const useRef = (initialValue: T | null = null): RefObject => { + return { current: initialValue }; +}; + +export const useEffect = (callback: () => void) => { + setTimeout(callback, 0); +}; diff --git a/src/main.css b/src/main.css new file mode 100644 index 0000000..262c628 --- /dev/null +++ b/src/main.css @@ -0,0 +1,21 @@ +@tailwind components; + +@layer components { + .chat-message { + @apply flex flex-col gap-2; + .message-content { + @apply px-8 mt-2; + } + .message-user { + @apply flex-row-reverse; + .message-content-wrapper { + @apply max-w-[66%]; + } + .message-content { + @apply bg-gray-200 px-4 py-2 rounded-lg; + } + } + .message-assistant { + } + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..29d31c0 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,32 @@ +import { h } from '@/h'; +import '@build/tailwind/main.css'; +// tab的app-routes模块 +import './tab'; +import './page/index.css'; +import './main.css'; + +import { Page } from '@kevisual/store/page'; +import { AiChat } from './page/AiChat'; + +const div = document.createElement('div'); + +div.className = 'text-4xl text-center p-4 bg-blue-200'; +div.innerHTML = 'Browser Apps'; + +const page = new Page({ + path: '', + key: '', + basename: '', +}); +page.addPage('/home', 'home'); +page.addPage('/ai', 'ai'); + +page.subscribe('home', (page) => { + document.body.appendChild(div); +}); + +page.subscribe('ai', (page) => { + const aiChat = AiChat(); + console.log(aiChat); + document.body.appendChild(aiChat); +}); diff --git a/src/modules/ResponseText.tsx b/src/modules/ResponseText.tsx new file mode 100644 index 0000000..b0e6621 --- /dev/null +++ b/src/modules/ResponseText.tsx @@ -0,0 +1,109 @@ +import { throttle } from 'lodash-es'; +import { Marked } from 'marked'; +import 'highlight.js/styles/github.css'; // 你可以选择其他样式 + +import { markedHighlight } from 'marked-highlight'; +import hljs from 'highlight.js'; +import clsx from 'clsx'; + +const marked = new Marked( + markedHighlight({ + emptyLangClass: 'hljs', + langPrefix: 'hljs language-', + highlight(code, lang, info) { + const language = hljs.getLanguage(lang) ? lang : 'plaintext'; + return hljs.highlight(code, { language }).value; + }, + }), +); +import { h, useRef, useEffect } from '@/h'; + +type ResponseTextProps = { + response: Response; + onFinish?: (text: string) => void; + onChange?: (text: string) => void; + className?: string; + id?: string; +}; +export const ResponseText = (props: ResponseTextProps) => { + const ref = useRef(); + const render = async () => { + const response = props.response; + if (!response) return; + const msg = ref.current!; + if (!msg) { + console.log('msg is null'); + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + const reader = response.body?.getReader(); + const decoder = new TextDecoder('utf-8'); + let done = false; + + while (!done) { + const { value, done: streamDone } = await reader!.read(); + done = streamDone; + + if (value) { + const chunk = decoder.decode(value, { stream: true }); + // 更新状态,实时刷新 UI + msg.innerHTML += chunk; + } + } + if (done) { + props.onFinish && props.onFinish(msg.innerHTML); + } + }; + useEffect(render); + return
; +}; + +export const ResponseMarkdown = (props: ResponseTextProps) => { + const ref = useRef(); + let content = ''; + const render = async () => { + const response = props.response; + if (!response) return; + const msg = ref.current!; + if (!msg) { + console.log('msg is null'); + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + const reader = response.body?.getReader(); + const decoder = new TextDecoder('utf-8'); + let done = false; + + while (!done) { + const { value, done: streamDone } = await reader!.read(); + done = streamDone; + + if (value) { + const chunk = decoder.decode(value, { stream: true }); + content = content + chunk; + renderThrottle(content); + } + } + if (done) { + props.onFinish && props.onFinish(msg.innerHTML); + } + }; + const renderThrottle = throttle(async (markdown: string) => { + const msg = ref.current!; + msg.innerHTML = await marked.parse(markdown); + props.onChange?.(msg.innerHTML); + }, 100); + useEffect(render); + return
; +}; + +export const Markdown = (props: { className?: string; markdown: string; id?: string }) => { + const ref = useRef(); + const parse = async () => { + const md = await marked.parse(props.markdown); + const msg = ref.current!; + msg.innerHTML = md; + }; + useEffect(parse); + return
; +}; diff --git a/src/page/AiChat.tsx b/src/page/AiChat.tsx new file mode 100644 index 0000000..c280c7e --- /dev/null +++ b/src/page/AiChat.tsx @@ -0,0 +1,164 @@ +import { h, useRef } from '@/h'; +import { ResponseText, ResponseMarkdown, Markdown } from '@/modules/ResponseText'; +import { aiChatStore, store } from '@/store/ai-chat-store'; +import clsx from 'clsx'; +export type MessageData = { + content: string; + role: 'user' | 'assistant' | 'system'; + response?: Response | null; + className?: string; + onFinish?: (text: string) => void; + // onFinish之前,每200ms调用一次 + onChange?: (text: string) => void; + id?: string; +}; +type MessageProps = {} & MessageData; +const UserMessage = (props: MessageProps) => { + return ( +
+ {props.content} +
+ ); +}; +export const AssistantMessage = (props: MessageProps) => { + if (props.response) { + return ; + } + return ; +}; + +export const Message = (props: MessageProps) => { + const isUser = props.role === 'user'; + const WrapperMessage = isUser ? UserMessage : AssistantMessage; + return ( +
+
+
+
+
+ +
AI 助手
+
+
+ +
+
+
+
+
+ ); +}; +export const AiChat = () => { + const ref = useRef(); + const aiMessageRef = useRef(); + const textareaRef = useRef(); + const scrollContainer = useRef(); + store.subscribe( + (state, prevState) => { + const messages = state.messages; + console.log('store', state, prevState); + if (aiMessageRef.current) { + messages.forEach((message) => { + const id = message.id; + if (document.getElementById('msg-' + id)) { + return; + } + + // let msg: JSX.Element; + // if (message.role === 'ai' && message.response) { + // msg = ResponseMarkdown({ response: message.response, className: 'message-item ai', onFinish: () => {} }); + // } else if (message.role === 'ai' && message.content) { + // msg = AssistantMessage({ ...message, className: 'message-item ai' }); + // } else { + // msg = UserMessage({ ...message, id }); + // } + const onChange = (text: string) => { + // const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight; + const scrollContainerEl = scrollContainer.current; + const bottom = document.querySelector('.ai-message-bottom'); + if (scrollContainerEl) { + // 如果滚动条距离底部小于100px,自动滚动到底部 + if (scrollContainerEl.scrollHeight - scrollContainerEl.scrollTop - scrollContainerEl.clientHeight < 400) { + } + console.log('scrollContainerEl', scrollContainerEl.scrollHeight, scrollContainerEl.scrollTop, scrollContainerEl.clientHeight); + console.log('scrollContainerEl<400', scrollContainerEl.scrollHeight - scrollContainerEl.scrollTop - scrollContainerEl.clientHeight < 400); + bottom?.scrollIntoView({ behavior: 'smooth' }); + } + }; + const onFinish = (text: string) => { + aiChatStore.getState().setEnd(true); + }; + const msg = Message({ ...message, id, onChange, onFinish }); + aiMessageRef.current!.appendChild(msg); + }); + } + }, + { key: 'aiStore', path: 'messages' }, + ); + const onSend = () => { + const input = textareaRef.current; + const state = aiChatStore.getState(); + if (state.loading || !state.end) { + console.log('loading'); + return; + } + if (input && input.value.trim() !== '') { + aiChatStore.getState().sendMessage(input.value); + input.value = ''; + } else { + console.log('输入为空'); + } + }; + return ( +
{ + console.log('onLoad======', e, ref.current); + }}> +
+

AI聊天

+
+
+
+
+
+ +