Browse Source
- 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
15 changed files with 643 additions and 423 deletions
Binary file not shown.
@ -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,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: "asd\n" }, |
|||
{ |
|||
event: "message", |
|||
answer: |
|||
"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" }, |
|||
] |
|||
]; |
|||
|
|||
@ -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/document注入DOMPurify |
|||
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> |
|||
@ -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> |
|||
@ -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: |
|||
"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" }, |
|||
]; |
|||
@ -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> |
|||
@ -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, |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue