Browse Source

feat: Enhance chat functionality and UI components

- Added ChatBox component for improved chat interface.
- Introduced Msg and Node components for rendering messages with markdown support.
- Implemented useScroll composable for automatic scrolling behavior in chat.
- Updated auto-imports to include useScroll globally.
- Added loading spinner in index.html for better user experience during loading.
- Removed deprecated theme-helpers.scss file and refactored global styles.
- Updated package.json to include new dependencies for markdown rendering and DOM manipulation.
mono
谢亚昕 2 months ago
parent
commit
91ca9904f2
  1. BIN
      bun.lockb
  2. 2
      packages/client/auto-imports.d.ts
  3. 3
      packages/client/components.d.ts
  4. 57
      packages/client/index.html
  5. 2
      packages/client/package.json
  6. 184
      packages/client/src/assets/styles/scss/_global.scss
  7. 190
      packages/client/src/assets/styles/scss/_theme-helpers.scss
  8. 1
      packages/client/src/assets/styles/scss/common.scss
  9. 119
      packages/client/src/components/AiDemo/_/sseData.ts
  10. 64
      packages/client/src/components/ChatBox/_/Msg.vue
  11. 61
      packages/client/src/components/ChatBox/_/Node.vue
  12. 102
      packages/client/src/components/ChatBox/_/sseData.ts
  13. 153
      packages/client/src/components/ChatBox/index.vue
  14. 90
      packages/client/src/composables/useScroll/index.ts
  15. 38
      packages/client/src/pages/index.vue

BIN
bun.lockb

Binary file not shown.

2
packages/client/auto-imports.d.ts

@ -91,6 +91,7 @@ declare global {
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useScroll: typeof import('./src/composables/useScroll/index')['useScroll']
const useSeoMeta: typeof import('@unhead/vue')['useSeoMeta']
const useServerHead: typeof import('@unhead/vue')['useServerHead']
const useServerHeadSafe: typeof import('@unhead/vue')['useServerHeadSafe']
@ -206,6 +207,7 @@ declare module 'vue' {
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useScroll: UnwrapRef<typeof import('./src/composables/useScroll/index')['useScroll']>
readonly useSeoMeta: UnwrapRef<typeof import('@unhead/vue')['useSeoMeta']>
readonly useServerHead: UnwrapRef<typeof import('@unhead/vue')['useServerHead']>
readonly useServerHeadSafe: UnwrapRef<typeof import('@unhead/vue')['useServerHeadSafe']>

3
packages/client/components.d.ts

@ -9,10 +9,13 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AiDemo: typeof import('./src/components/AiDemo/index.vue')['default']
ChatBox: typeof import('./src/components/ChatBox/index.vue')['default']
ClientOnly: typeof import('./../../internal/x/components/ClientOnly.vue')['default']
CookieDemo: typeof import('./src/components/CookieDemo.vue')['default']
DataFetch: typeof import('./src/components/DataFetch.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
Msg: typeof import('./src/components/ChatBox/_/Msg.vue')['default']
Node: typeof import('./src/components/ChatBox/_/Node.vue')['default']
QuillEditor: typeof import('./src/components/QuillEditor/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

57
packages/client/index.html

@ -4,6 +4,63 @@
<!--app-head-->
</head>
<body>
<div class="loading-container">
<div class="loading-spinner"></div>
<style>
body {
height: 100vh;
overflow: hidden;
}
.loading-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #fff;
z-index: 1000;
transition: opacity 0.3s ease-in-out;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-spinner {
animation: spin 1s linear infinite;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
}
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
<script>
window.addEventListener("load", () => {
const loadingContainer = document.querySelector(".loading-container");
loadingContainer.style.opacity = "0";
setTimeout(() => {
loadingContainer.remove();
}, 300);
});
</script>
</div>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.ts"></script>
</body>

2
packages/client/package.json

@ -8,6 +8,7 @@
"check": "vue-tsc"
},
"devDependencies": {
"@types/jsdom": "^27.0.0",
"sass-embedded": "^1.93.2",
"unplugin-vue-components": "^29.1.0",
"vue-tsc": "^3.1.0"
@ -20,6 +21,7 @@
"ant-design-x-vue": "^1.3.2",
"dompurify": "^3.2.7",
"htmlparser2": "^10.0.0",
"jsdom": "^27.0.0",
"marked": "^16.3.0",
"maz-ui": "^4.1.6",
"ofetch": "^1.4.1",

184
packages/client/src/assets/styles/scss/_global.scss

@ -1,26 +1,11 @@
/*
theme-helpers.scss
为主题开发提供的 SCSS 帮助函数 / mixin / 示例
说明中文注释:
- 提供把 SCSS map 转换为 CSS 自定义属性variables mixin
- 提供生成主题light/dark选择器的 mixin 示例
- 提供读取 CSS 变量的辅助函数 `css-var()`
- 提供颜色可读性判断函数 `readable-text()`基于简单亮度公式
- 提供颜色微调辅助 `tone()`
使用方式在你的主样式中导入此文件并调用 mixin/map示例在文件底部有注释
*/
// -----------------------------
// CSS 变量相关帮助
// -----------------------------
@use "sass:string";
// 返回一个 var(...) 字符串方便在 SCSS 中使用 CSS 变量
@function css-var($name, $fallback: null) {
@if $fallback == null {
@return unquote("var(--#{$name})");
@return string.unquote("var(--#{$name})");
} @else {
@return unquote("var(--#{$name}, #{$fallback})");
@return string.unquote("var(--#{$name}, #{$fallback})");
}
}
@ -41,166 +26,3 @@
@include declare-theme-variables($map);
}
}
// -----------------------------
// 颜色工具函数
// -----------------------------
// 计算近似亮度0-255用于对比判定基于 ITU BT.601 近似
@function _luma($color) {
// 期望 $color color 类型
$r: red($color);
$g: green($color);
$b: blue($color);
@return ($r * 0.299) + ($g * 0.587) + ($b * 0.114);
}
// 根据背景色返回可读的文字颜色#000 #fff
// 示例 color: readable-text(#0d1117);
@function readable-text($bg, $light: #ffffff, $dark: #000000) {
// 如果传入的不是 color 类型尝试转换如果是变量字符串则无法计算
@if type-of($bg) != 'color' {
// 无法在构建时计算 CSS 变量的对比默认返回白色以便在暗色环境下可读
@return $light;
}
@if _luma($bg) > 186 {
@return $dark;
}
@return $light;
}
// 基于 lighten/darken 的简单色调微调函数正值变亮负值变暗
@function tone($color, $percent) {
@if type-of($color) != 'color' {
@warn "tone(): first argument is not a color; returned value will be unchanged.";
@return $color;
}
@if $percent == 0 {
@return $color;
}
@if $percent > 0 {
@return lighten($color, $percent);
} @else {
@return darken($color, abs($percent));
}
}
// 使颜色变浅的辅助函数
// 用法
// lighten-by(#0d1117, 20) -> 20% 变亮
// lighten-by(#0d1117, 20%) -> 20% 变亮
// lighten-by(#0d1117, 0.2) -> 20% 变亮小数形式
@function lighten-by($color, $amount) {
@if type-of($color) != 'color' {
@warn "lighten-by(): first argument is not a color; returned value will be unchanged.";
@return $color;
}
@if type-of($amount) != 'number' {
@warn "lighten-by(): amount must be a number (e.g. 20, 20% or 0.2). Returning original color.";
@return $color;
}
// 规范化为百分比单位Sass percent 类型
$pct: $amount;
@if unit($amount) != '%' {
// 无单位数字如果在 (0,1] 范围内视为小数比例否则当作百分比数值
@if $amount > 0 and $amount <= 1 {
$pct: $amount * 100%;
} @else {
$pct: $amount * 1%;
}
}
@return lighten($color, $pct);
}
// -----------------------------
// 常用组件/场景 mixin
// -----------------------------
// 简单的背景/文字组合接收背景颜色或变量名
// 用法@include bg-fg('color-canvas-default'); // 传入变量名
// @include bg-fg(#0d1117); // 传入 color 类型
@mixin bg-fg($bg, $fg: null) {
@if type-of($bg) == 'string' {
// 假定传入的是变量名使用 css-var
background: css-var($bg);
@if $fg == null {
// 无法静态计算对比留空或用户自行指定
color: inherit;
} else {
color: css-var($fg);
}
} @else if type-of($bg) == 'color' {
background: $bg;
@if $fg == null {
color: readable-text($bg);
} @else if type-of($fg) == 'color' {
color: $fg;
} @else {
color: css-var($fg);
}
} @else {
@warn "bg-fg(): unsupported bg type";
}
}
// 一个可重用的按钮样式 mixin支持传入变量名或颜色
// 用法
// .btn { @include theme-button('color-accent-emphasis'); }
@mixin theme-button($bg, $fg: null, $radius: 6px, $pad-y: 8px, $pad-x: 12px) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: $pad-y $pad-x;
border-radius: $radius;
border: none;
cursor: pointer;
@if type-of($bg) == 'string' {
background: css-var($bg);
@if $fg == null { color: css-var('color-fg-default'); } @else { color: css-var($fg); }
} @else if type-of($bg) == 'color' {
background: $bg;
@if $fg == null { color: readable-text($bg); } @else if type-of($fg) == 'color' { color: $fg; } @else { color: css-var($fg); }
}
// 微交互
&:hover { filter: brightness(0.95); }
&:active { transform: translateY(1px); }
}
// -----------------------------
// 示例注释掉直接拷贝到你的样式里使用
// -----------------------------
// 示例主题 maps键名与全局 CSS 变量中的命名保持一致但不包含前缀 --
// $theme-light: (
// 'color-fg-default': #24292f,
// 'color-fg-muted': #57606a,
// 'color-canvas-default': #ffffff,
// 'color-border-default': #d0d7de,
// 'color-accent-fg': #0969da,
// );
//
// $theme-dark: (
// 'color-fg-default': #c9d1d9,
// 'color-fg-muted': #8b949e,
// 'color-canvas-default': #0d1117,
// 'color-border-default': #30363d,
// 'color-accent-fg': #58a6ff,
// );
//
// 生成到 :root 和手动切换器
// @include generate-theme(':root', $theme-light);
// @include generate-theme(':root[data-theme="dark"]', $theme-dark);
//
// 使用 CSS 变量
// .markdown-body { color: css-var('color-fg-default'); background: css-var('color-canvas-default'); }
//
// 使用 mixin 快速为按钮应用主题颜色
// .btn { @include theme-button('color-accent-emphasis'); }
// -----------------------------
// 结束
// -----------------------------

190
packages/client/src/assets/styles/scss/_theme-helpers.scss

@ -1,190 +0,0 @@
// 返回一个 var(...) 字符串方便在 SCSS 中使用 CSS 变量
@function css-var($name, $fallback: null) {
@if $fallback == null {
@return unquote("var(--#{$name})");
} @else {
@return unquote("var(--#{$name}, #{$fallback})");
}
}
// 将一个 map 转换为 CSS 变量声明需在选择器块内使用
// 用法
// :root { @include declare-theme-variables($my-theme-map); }
@mixin declare-theme-variables($map) {
@each $token, $val in $map {
// 允许传入颜色字符串或数字
--#{$token}: #{$val};
}
}
// 生成主题选择器selector 可以是 ":root"":root[data-theme=\"dark\"]" ".theme-dark"
// 用法@include generate-theme(':root', $theme-light);
@mixin generate-theme($selector, $map) {
#{$selector} {
@include declare-theme-variables($map);
}
}
// -----------------------------
// 颜色工具函数
// -----------------------------
// 计算近似亮度0-255用于对比判定基于 ITU BT.601 近似
@function _luma($color) {
// 期望 $color color 类型
$r: red($color);
$g: green($color);
$b: blue($color);
@return ($r * 0.299) + ($g * 0.587) + ($b * 0.114);
}
// 根据背景色返回可读的文字颜色#000 #fff
// 示例 color: readable-text(#0d1117);
@function readable-text($bg, $light: #ffffff, $dark: #000000) {
// 如果传入的不是 color 类型尝试转换如果是变量字符串则无法计算
@if type-of($bg) != 'color' {
// 无法在构建时计算 CSS 变量的对比默认返回白色以便在暗色环境下可读
@return $light;
}
@if _luma($bg) > 186 {
@return $dark;
}
@return $light;
}
// 基于 lighten/darken 的简单色调微调函数正值变亮负值变暗
@function tone($color, $percent) {
@if type-of($color) != 'color' {
@warn "tone(): first argument is not a color; returned value will be unchanged.";
@return $color;
}
@if $percent == 0 {
@return $color;
}
@if $percent > 0 {
@return lighten($color, $percent);
} @else {
@return darken($color, abs($percent));
}
}
// 使颜色变浅的辅助函数
// 用法
// lighten-by(#0d1117, 20) -> 20% 变亮
// lighten-by(#0d1117, 20%) -> 20% 变亮
// lighten-by(#0d1117, 0.2) -> 20% 变亮小数形式
@function lighten-by($color, $amount) {
@if type-of($color) != 'color' {
@warn "lighten-by(): first argument is not a color; returned value will be unchanged.";
@return $color;
}
@if type-of($amount) != 'number' {
@warn "lighten-by(): amount must be a number (e.g. 20, 20% or 0.2). Returning original color.";
@return $color;
}
// 规范化为百分比单位Sass percent 类型
$pct: $amount;
@if unit($amount) != '%' {
// 无单位数字如果在 (0,1] 范围内视为小数比例否则当作百分比数值
@if $amount > 0 and $amount <= 1 {
$pct: $amount * 100%;
} @else {
$pct: $amount * 1%;
}
}
@return lighten($color, $pct);
}
// -----------------------------
// 常用组件/场景 mixin
// -----------------------------
// 简单的背景/文字组合接收背景颜色或变量名
// 用法@include bg-fg('color-canvas-default'); // 传入变量名
// @include bg-fg(#0d1117); // 传入 color 类型
@mixin bg-fg($bg, $fg: null) {
@if type-of($bg) == 'string' {
// 假定传入的是变量名使用 css-var
background: css-var($bg);
@if $fg == null {
// 无法静态计算对比留空或用户自行指定
color: inherit;
} else {
color: css-var($fg);
}
} @else if type-of($bg) == 'color' {
background: $bg;
@if $fg == null {
color: readable-text($bg);
} @else if type-of($fg) == 'color' {
color: $fg;
} @else {
color: css-var($fg);
}
} @else {
@warn "bg-fg(): unsupported bg type";
}
}
// 一个可重用的按钮样式 mixin支持传入变量名或颜色
// 用法
// .btn { @include theme-button('color-accent-emphasis'); }
@mixin theme-button($bg, $fg: null, $radius: 6px, $pad-y: 8px, $pad-x: 12px) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: $pad-y $pad-x;
border-radius: $radius;
border: none;
cursor: pointer;
@if type-of($bg) == 'string' {
background: css-var($bg);
@if $fg == null { color: css-var('color-fg-default'); } @else { color: css-var($fg); }
} @else if type-of($bg) == 'color' {
background: $bg;
@if $fg == null { color: readable-text($bg); } @else if type-of($fg) == 'color' { color: $fg; } @else { color: css-var($fg); }
}
// 微交互
&:hover { filter: brightness(0.95); }
&:active { transform: translateY(1px); }
}
// -----------------------------
// 示例注释掉直接拷贝到你的样式里使用
// -----------------------------
// 示例主题 maps键名与全局 CSS 变量中的命名保持一致但不包含前缀 --
// $theme-light: (
// 'color-fg-default': #24292f,
// 'color-fg-muted': #57606a,
// 'color-canvas-default': #ffffff,
// 'color-border-default': #d0d7de,
// 'color-accent-fg': #0969da,
// );
//
// $theme-dark: (
// 'color-fg-default': #c9d1d9,
// 'color-fg-muted': #8b949e,
// 'color-canvas-default': #0d1117,
// 'color-border-default': #30363d,
// 'color-accent-fg': #58a6ff,
// );
//
// 生成到 :root 和手动切换器
// @include generate-theme(':root', $theme-light);
// @include generate-theme(':root[data-theme="dark"]', $theme-dark);
//
// 使用 CSS 变量
// .markdown-body { color: css-var('color-fg-default'); background: css-var('color-canvas-default'); }
//
// 使用 mixin 快速为按钮应用主题颜色
// .btn { @include theme-button('color-accent-emphasis'); }
// -----------------------------
// 结束
// -----------------------------

1
packages/client/src/assets/styles/scss/common.scss

@ -50,6 +50,7 @@ $theme-dark: (
// 手动主题切换支持data-theme class
@include generate-theme(':root[data-theme="dark"]', $theme-dark);
@include generate-theme(".theme-light", $theme-light);
@include generate-theme(".theme-dark", $theme-dark);
#app {

119
packages/client/src/components/AiDemo/_/sseData.ts

@ -1,31 +1,102 @@
export default [
{ event: "message", answer: "## asdas\n" },
{
event: "message",
answer:
"## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n",
},
{ event: "message", answer: "**asasa**\n" },
{ event: "message", answer: "![啊啊啊](https://ts1.tc.mm.bing.net/th/id/R-C.823270fc68b9c58f0d9b3feb92b7b172?rik=aubbEBMSC86e%2bw&riu=http%3a%2f%2fimg95.699pic.com%2fphoto%2f50038%2f1181.jpg_wh860.jpg&ehk=iQboj4JMLLfDitOL7VJtSktED0AE%2f7Fyxfik0GTJkyQ%3d&risl=&pid=ImgRaw&r=0)asd\n" },
{
event: "message",
answer:
"![啊啊啊](https://ts1.tc.mm.bing.net/th/id/R-C.823270fc68b9c58f0d9b3feb92b7b172?rik=aubbEBMSC86e%2bw&riu=http%3a%2f%2fimg95.699pic.com%2fphoto%2f50038%2f1181.jpg_wh860.jpg&ehk=iQboj4JMLLfDitOL7VJtSktED0AE%2f7Fyxfik0GTJkyQ%3d&risl=&pid=ImgRaw&r=0)asd\n",
},
{ event: "message", answer: "```\nasdaaaasasaasaas\n" },
{ event: "message", answer: "asdsaa\n" },
{ event: "message", answer: "console.log(as)\n" },
{ event: "message", answer: "asa\n```\n\n" },
{ event: "message", answer: "<input type=\"text\">\n\n" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: "## asdas" },
{ event: "message", answer: '<input type="text">\n\n' },
{ event: "message", answer: "qweqen" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message_end", answer: "## asdas" },
]
];

64
packages/client/src/components/ChatBox/_/Msg.vue

@ -0,0 +1,64 @@
<script setup lang="ts">
import { marked } from "marked";
import { parseDocument } from "htmlparser2";
import { default as DOMPurify } from "dompurify";
import Node from "./Node.vue";
const props = defineProps({
msg: {
type: String,
required: true,
},
});
// marked
marked.setOptions({
breaks: true, // <br>
gfm: true, // GitHub markdown
// smartypants: true, // 使
});
const renderedContent = ref([]);
// markdown
const renderMarkdown = async (content: string) => {
try {
if (import.meta.env.SSR) {
// SSR 使 jsdom DOM
const { default: jsdom } = await import("jsdom");
const { window } = new jsdom.JSDOM('<!DOCTYPE html>');
// window/documentDOMPurify
const purifiedDOMPurify = DOMPurify(window);
return parseDocument(
purifiedDOMPurify.sanitize(marked.parse(content) as string)
).children;
} else {
// 使 DOMPurify
return parseDocument(
DOMPurify.sanitize(marked.parse(content) as string)
).children;
}
} catch (error) {
console.error('Markdown 渲染错误:', error);
//
return [{ type: 'text', data: content }];
}
};
// props.msg
watch(() => props.msg, async (newMsg) => {
if (newMsg) {
renderedContent.value = await renderMarkdown(newMsg);
}
}, { immediate: true }); // immediate: true
</script>
<template>
<div class="msg-container">
<Node v-for="(node, index) in renderedContent" :key="index" :node="node"></Node>
</div>
</template>
<style lang="scss" scoped>
</style>

61
packages/client/src/components/ChatBox/_/Node.vue

@ -0,0 +1,61 @@
<template>
<!-- 文本节点直接渲染内容 -->
<template v-if="node.type === 'text'">
{{ node.data }}
</template>
<!-- 图片节点使用 el-image 组件 -->
<!-- <template v-else-if="isImageNode">
<el-image
:src="node.attribs.src + '?x-oss-process=image/resize,w_400/format,jpg'"
:alt="node.attribs.alt || '图片'"
:preview-src-list="[node.attribs.src]"
preview-teleported
class="w-30% block!"
/>
<input />
</template> -->
<!-- 其他非文本节点渲染对应标签 + 递归子节点 -->
<template v-else>
<component :is="node.tagName" v-bind="node.attribs">
<Node v-for="(child, index) in node.children" :key="index" :node="child" />
</component>
</template>
</template>
<style lang="scss" scoped>
img {
max-width: 50%;
}
* {
word-break: break-all;
}
</style>
<script setup>
import { computed } from 'vue'
// import { ElImage } from 'element-plus'
//
const props = defineProps({
node: {
type: Object,
required: true
}
})
//
const isImageNode = computed(() => {
return props.node.tagName?.toLowerCase() === 'img'
})
//
const imageStyle = computed(() => {
const style = {}
// img
if (props.node.attribs.width) style.width = props.node.attribs.width
if (props.node.attribs.height) style.height = props.node.attribs.height
return style
})
</script>

102
packages/client/src/components/ChatBox/_/sseData.ts

@ -0,0 +1,102 @@
export default [
{
event: "message",
answer:
"## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n## asdas\n",
},
{ event: "message", answer: "**asasa**\n" },
{
event: "message",
answer:
"![啊啊啊](https://ts1.tc.mm.bing.net/th/id/R-C.823270fc68b9c58f0d9b3feb92b7b172?rik=aubbEBMSC86e%2bw&riu=http%3a%2f%2fimg95.699pic.com%2fphoto%2f50038%2f1181.jpg_wh860.jpg&ehk=iQboj4JMLLfDitOL7VJtSktED0AE%2f7Fyxfik0GTJkyQ%3d&risl=&pid=ImgRaw&r=0)asd\n",
},
{ event: "message", answer: "```\nasdaaaasasaasaas\n" },
{ event: "message", answer: "asdsaa\n" },
{ event: "message", answer: "console.log(as)\n" },
{ event: "message", answer: "asa\n```\n\n" },
{ event: "message", answer: '<input type="text">\n\n' },
{ event: "message", answer: "qweqen" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "qweqeq" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message", answer: "## asdas\n" },
{ event: "message_end", answer: "## asdas" },
];

153
packages/client/src/components/ChatBox/index.vue

@ -0,0 +1,153 @@
<script setup lang="ts">
import Msg from "./_/Msg.vue";
const chatboxContainerEl = useTemplateRef<HTMLDivElement>("chatboxContainer");
const chatboxContentEl = useTemplateRef<HTMLDivElement>("chatboxContent");
useScroll({
containerEl: chatboxContainerEl,
contentEl: chatboxContentEl,
firstToBottom: true,
});
import sseDataModule from "./_/sseData.ts";
const cursor = ref(0);
const msg = ref("");
onMounted(() => {
const interval = setInterval(() => {
msg.value += sseDataModule[cursor.value].answer;
cursor.value++;
if (cursor.value >= sseDataModule.length) {
clearInterval(interval);
}
}, 300);
});
//https://www.codecopy.cn/post/t3clc5
// https://zhuanlan.zhihu.com/p/1948421667379483653#:~:text=Cursor%20%E6%9C%89%E5%BE%88%E5%A4%9A%E5%A5%97%E6%8F%90%E7%A4%BA%E8%AF%8D%EF%BC%8C%E6%AF%8F%E5%A5%97%E6%8F%90%E7%A4%BA%E8%AF%8D%E9%80%82%E7%94%A8%E4%BA%8E%E4%B8%8D%E5%90%8C%E7%9A%84%E5%9C%BA%E6%99%AF%E3%80%82%20%E6%AF%94%E5%A6%82%EF%BC%9A%20Agent%20%E6%A8%A1%E5%BC%8F%E6%8F%90%E7%A4%BA%E8%AF%8D%EF%BC%9A%E8%AE%A9%20AI%20%E8%83%BD%E5%A4%9F%E8%87%AA%E4%B8%BB%E5%9C%B0%E5%88%86%E6%9E%90%E3%80%81%E8%A7%84%E5%88%92%E5%B9%B6%E6%89%A7%E8%A1%8C%E7%BC%96%E7%A0%81%E4%BB%BB%E5%8A%A1%EF%BC%8C%E7%9B%B4%E5%88%B0%E9%97%AE%E9%A2%98%E8%A2%AB%E5%BD%BB%E5%BA%95%E8%A7%A3%E5%86%B3%E3%80%82,Chat%20%E5%AF%B9%E8%AF%9D%E6%8F%90%E7%A4%BA%E8%AF%8D%EF%BC%9A%E9%80%82%E7%94%A8%E4%BA%8E%E4%BB%A5%E5%AF%B9%E8%AF%9D%E9%97%AE%E7%AD%94%E4%B8%BA%E4%B8%BB%E7%9A%84%E5%9C%BA%E6%99%AF%EF%BC%8C%E8%83%BD%E5%BF%AB%E9%80%9F%E5%93%8D%E5%BA%94%E7%94%A8%E6%88%B7%E7%9A%84%E9%97%AE%E9%A2%98%E3%80%82%20Memory%20%E5%AF%B9%E8%AF%9D%E8%AE%B0%E5%BF%86%E6%8F%90%E7%A4%BA%E8%AF%8D%EF%BC%9A%E8%AF%84%E4%BC%B0%20AI%20%E7%9A%84%E9%95%BF%E6%9C%9F%E8%AE%B0%E5%BF%86%EF%BC%8C%E4%BF%9D%E8%AF%81%20AI%20%E8%83%BD%E5%A4%9F%E4%BB%8E%E5%8E%86%E5%8F%B2%E4%BA%A4%E4%BA%92%E4%B8%AD%E5%AD%A6%E4%B9%A0%E5%B9%B6%E6%B2%89%E6%B7%80%E9%AB%98%E8%B4%A8%E9%87%8F%E7%9A%84%E9%80%9A%E7%94%A8%E5%81%8F%E5%A5%BD%E8%AE%B0%E5%BF%86%E3%80%82
// https://mcpcn.com/docs/tutorials/building-a-client-node/#%e4%ba%a4%e4%ba%92%e5%bc%8f%e8%81%8a%e5%a4%a9%e7%95%8c%e9%9d%a2
</script>
<template>
<div class="chat-wrapper">
<div class="chatbox-container" ref="chatboxContainer">
<div class="chatbox-content" ref="chatboxContent">
<div class="system-msg">
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
你是一个美丽的女人
</div>
<div class="chat-item right">
rightrightrightright
rightrightrightright
rightrightrightright
rightrightrightright
rightrightrightright
rightrightrightright
<!-- <Msg :msg="msg"></Msg> -->
</div>
<div class="chat-item left">
<!-- <Msg :msg="msg"></Msg> -->
</div>
</div>
</div>
<div class="chat-input">
<input type="text" placeholder="请输入内容" class="chat-input-input">
</div>
</div>
</template>
<style lang="scss" scoped>
.chat-wrapper {
padding: 20px 0;
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.chat-input {
.chat-input-input {
width: 100%;
height: 100%;
box-sizing: border-box;
border: none;
background: css-var("color-canvas-subtle");
color: css-var("color-fg-default");
line-height: 1.5;
word-break: break-all;
border-radius: 10px;
padding: 10px;
box-sizing: border-box;
outline: none;
}
}
.chatbox-container {
background: css-var("color-canvas-default");
color: css-var("color-fg-default");
height: 0;
flex: 1;
overflow: auto;
padding: 10px;
box-sizing: border-box;
border-radius: 10px;
background: css-var("color-canvas-subtle");
color: css-var("color-fg-default");
line-height: 1.2;
.chatbox-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.system-msg {
align-self: center;
font-size: 12px;
color: css-var("color-fg-muted");
line-height: 1.5;
word-break: break-all;
padding: 10px;
width: 50%;
box-sizing: border-box;
border-radius: 10px;
background: css-var("color-canvas-subtle");
color: css-var("color-fg-default");
word-break: break-all;
}
.chat-item {
max-width: 100%;
padding: 10px;
box-sizing: border-box;
border-radius: 10px;
background: css-var("color-canvas-subtle");
color: css-var("color-fg-default");
line-height: 1.5;
word-break: break-all;
&.left {
align-self: flex-start;
}
&.right {
border: css-var("color-border-default") 1px solid;
align-self: flex-end;
margin-left: 10%;
}
}
}
</style>

90
packages/client/src/composables/useScroll/index.ts

@ -0,0 +1,90 @@
interface IOption {
containerEl: Readonly<globalThis.ShallowRef<HTMLDivElement | null>>;
contentEl: Readonly<globalThis.ShallowRef<HTMLDivElement | null>>;
// 是否首次滚动到底部
firstToBottom?: boolean;
}
const defaultOption: Partial<IOption> = {
firstToBottom: true,
};
export function useScroll(option: IOption) {
if (import.meta.env.SSR) {
return;
}
const { containerEl, contentEl, firstToBottom } = {
...defaultOption,
...option,
};
const isAtBottom = ref(false);
const isAtTop = ref(false);
let _firstToBottom = firstToBottom;
let resizeObserver: ResizeObserver | null = null;
nextTick(() => {
const targetElement = containerEl.value!;
const talkContent = contentEl.value!;
function handleScroll() {
if (targetElement.scrollHeight === targetElement.clientHeight) {
isAtBottom.value = false;
isAtTop.value = false;
return;
}
if (
Math.abs(
targetElement.scrollHeight -
targetElement.clientHeight -
targetElement.scrollTop
) <= 50
) {
isAtBottom.value = true;
} else {
isAtBottom.value = false;
}
if (targetElement.scrollTop <= 50) {
isAtTop.value = true;
} else {
isAtTop.value = false;
}
}
targetElement.onscroll = handleScroll;
resizeObserver = new ResizeObserver(() => {
// 当内容高度发生变化时,如果当前不在底部,则滚动到底部
if (targetElement.scrollHeight === targetElement.clientHeight) {
return;
}
if (isAtBottom.value || _firstToBottom) {
_firstToBottom = false;
scrollToBottom();
}
});
resizeObserver.observe(talkContent);
handleScroll();
});
onScopeDispose(clear);
function scrollToBottom() {
const container = containerEl.value!;
const scrollTop = container.scrollHeight - container.clientHeight;
container.scrollTop = scrollTop;
// container.scrollTo({ top: scrollTop, behavior: "smooth" });
}
function scrollToTop() {
containerEl.value!.scrollTop = 0;
}
function clear() {
if (containerEl.value) {
containerEl.value.onscroll = null;
}
if (resizeObserver) {
contentEl.value && resizeObserver.unobserve(contentEl.value);
resizeObserver.disconnect();
}
}
return {
isAtBottom,
scrollToTop,
scrollToBottom,
clear,
};
}

38
packages/client/src/pages/index.vue

@ -9,41 +9,23 @@ definePage({
defineOptions({
name: "home",
});
// import { useModal } from "vue-final-modal";
// import ModalConfirmPlainCss from "./_M.vue";
// const { open, close } = useModal({
// component: ModalConfirmPlainCss,
// attrs: {
// title: "Hello World!",
// onConfirm() {
// close();
// },
// },
// slots: {
// default: "<p>The content of the modal</p>",
// },
// });
</script>
<template>
<div>
<div>safda</div>
<div>safda</div>
<div>safda</div>
<div>safda</div>
<div class="card">sada</div>
<div class="btn">asd</div>
<div class="home">
<div class="chatbox-placeholder">
<ChatBox></ChatBox>
</div>
</div>
</template>
<style lang="scss" scoped>
.card {
background: lighten-by(css-var("color-canvas-default"), 6%);
color: lighten-by(#0d1117, 80%);
.home {
height: 100%;
}
.btn {
// @include theme-button("color-accent-emphasis");
.chatbox-placeholder {
width: 1000px;
margin: 0 auto;
height: 100%;
}
</style>

Loading…
Cancel
Save