feat: url link
This commit is contained in:
BIN
src/assets/images/link.png
Normal file
BIN
src/assets/images/link.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 426 B |
BIN
src/assets/images/search.png
Normal file
BIN
src/assets/images/search.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 332 B |
BIN
src/assets/images/web-card.png
Normal file
BIN
src/assets/images/web-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
92
src/utils/useWebTitle.ts
Normal file
92
src/utils/useWebTitle.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useWebsiteTitle = () => {
|
||||
const titles = ref<Map<string, string>>(new Map())
|
||||
const loading = ref(new Set<string>())
|
||||
const errors = ref(new Map<string, string>())
|
||||
|
||||
// 新增:重试配置
|
||||
const MAX_RETRY = 3
|
||||
const BASE_DELAY = 800 // 毫秒
|
||||
|
||||
const getCache = (url: string) => {
|
||||
const cached = sessionStorage.getItem(`title_cache_${url}`)
|
||||
if (!cached) return null
|
||||
const { title, expire } = JSON.parse(cached)
|
||||
return Date.now() < expire ? title : null
|
||||
}
|
||||
|
||||
const setCache = (url: string, title: string) => {
|
||||
const data = { title, expire: Date.now() + 7 * 24 * 60 * 60 * 1000 }
|
||||
sessionStorage.setItem(`title_cache_${url}`, JSON.stringify(data))
|
||||
}
|
||||
|
||||
const fetchWithRetry = async (url: string, retryCount = 0): Promise<string> => {
|
||||
const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`
|
||||
|
||||
try {
|
||||
const res = await fetch(proxyUrl)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const html = await res.text()
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const title =
|
||||
doc.querySelector('title')?.textContent?.trim() || new URL(url).hostname || '无标题'
|
||||
|
||||
return title
|
||||
} catch (err) {
|
||||
if (retryCount >= MAX_RETRY) {
|
||||
throw err // 达到最大重试次数,抛出错误
|
||||
}
|
||||
|
||||
// 第1次等800ms,第2次等1600ms,第3次等3200ms
|
||||
const delay = BASE_DELAY * Math.pow(2, retryCount)
|
||||
console.warn(
|
||||
`获取标题失败,${retryCount + 1}/${MAX_RETRY} 次重试,等待 ${delay}ms`,
|
||||
url
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
return fetchWithRetry(url, retryCount + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTitle = async (url: string): Promise<string> => {
|
||||
if (titles.value.has(url)) return titles.value.get(url)!
|
||||
|
||||
const cached = getCache(url)
|
||||
if (cached) {
|
||||
titles.value.set(url, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
if (loading.value.has(url)) return ''
|
||||
|
||||
loading.value.add(url)
|
||||
errors.value.delete(url)
|
||||
|
||||
try {
|
||||
const title = await fetchWithRetry(url)
|
||||
titles.value.set(url, title)
|
||||
setCache(url, title)
|
||||
return title
|
||||
} catch (err) {
|
||||
const msg = `获取标题失败(已重试 ${MAX_RETRY} 次)`
|
||||
errors.value.set(url, msg)
|
||||
console.error(err)
|
||||
return msg
|
||||
} finally {
|
||||
loading.value.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAll = async (urls: string[]) => {
|
||||
await Promise.allSettled(urls.map(fetchTitle))
|
||||
}
|
||||
|
||||
return { titles, loading, errors, fetchTitle, fetchAll }
|
||||
}
|
||||
@@ -210,7 +210,6 @@
|
||||
// 流式响应处理
|
||||
let contentBody = ''
|
||||
let buffer = ''
|
||||
const webAddressList = []
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('无法获取流读取器')
|
||||
|
||||
@@ -248,10 +247,8 @@
|
||||
?.trim() || ''
|
||||
|
||||
if (!hasReportStarted && eventName === 'report') {
|
||||
console.log('开始生成报告--------')
|
||||
|
||||
isGeneratingReport.value = true
|
||||
contentBody += `<slot slot-name="card" title="123">123</slot>`
|
||||
contentBody += `<slot slot-name="card" title="123" content="123">123</slot>`
|
||||
hasReportStarted = true
|
||||
}
|
||||
|
||||
@@ -298,9 +295,9 @@
|
||||
params.versionID = dataLines[0]
|
||||
projectStore.setProject({ nodeId: dataLines[0] })
|
||||
}
|
||||
if (eventName === 'webAddress') {
|
||||
console.log('webAddress-----', eventName, dataLines)
|
||||
}
|
||||
// if (eventName === 'webAddress') {
|
||||
// console.log('webAddress111111111111111', eventName, dataLines)
|
||||
// }
|
||||
|
||||
if (eventName === 'tool') {
|
||||
MyEvent.emit('loading-sketch')
|
||||
@@ -313,7 +310,7 @@
|
||||
const jsonData = JSON.parse(jsonText)
|
||||
// console.log('jsonData', jsonData)
|
||||
if (jsonData.webAddress) {
|
||||
console.log('webAddress-----', jsonData)
|
||||
aiMessage.webAddress = JSON.parse(jsonData.webAddress)
|
||||
}
|
||||
if (jsonData.title) {
|
||||
emits('setTitle', jsonData.title)
|
||||
@@ -439,7 +436,10 @@
|
||||
|
||||
while (i < dialogue.length) {
|
||||
const item = dialogue[i]
|
||||
|
||||
if (item.webAddress?.length > 0) {
|
||||
console.log('item.webAddress-----', item.webAddress)
|
||||
debugger
|
||||
}
|
||||
if (item.role === 'user') {
|
||||
// user 角色直接添加
|
||||
result.push({
|
||||
@@ -496,7 +496,7 @@
|
||||
for (let i = 0; i < session.dialogue.length; i++) {
|
||||
if (session.dialogue[i].report) {
|
||||
session.dialogue[i].content =
|
||||
`<slot slot-name="card" title="123">123</slot>` +
|
||||
`<slot slot-name="card" title="123" content="123">123</slot>` +
|
||||
(session.dialogue[i].content || '')
|
||||
break
|
||||
}
|
||||
@@ -516,7 +516,7 @@
|
||||
|
||||
// 3. 收集 sketchIDAndUrl 到 imgList
|
||||
if (session.sketchIDAndUrl) {
|
||||
imgList.push(...session.sketchIDAndUrl)
|
||||
imgList.push(session.sketchIDAndUrl)
|
||||
}
|
||||
|
||||
// 4. 处理 dialogue
|
||||
|
||||
@@ -40,10 +40,21 @@
|
||||
<template v-slot:s-card="{ children: children, ...attrs }">
|
||||
<Card :title="attrs.title" @click.native="handleClickReport" />
|
||||
</template>
|
||||
<template v-slot:url-card="{ children: children }">
|
||||
<Url :list="content.webAddress" @click.native="handleClickUrls" />
|
||||
</template>
|
||||
</VueMarkdown>
|
||||
<!-- <Url @click.native="handleClickUrls" /> -->
|
||||
<div
|
||||
class="web-address flex align-center"
|
||||
v-show="content.webAddress?.length > 0"
|
||||
>
|
||||
<img src="@/assets/images/search.png" class="search-icon" />
|
||||
<span>{{ content.webAddress?.length }} web pages have been retrieved.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="!content.thinking"
|
||||
v-show="!content.streaming"
|
||||
class="operate flex"
|
||||
:class="{ 'is-user': content.isUser }"
|
||||
>
|
||||
@@ -96,13 +107,13 @@
|
||||
isLast: Boolean
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
(newVal) => {
|
||||
console.log('newVal-----', newVal)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
// watch(
|
||||
// () => props.content,
|
||||
// (newVal) => {
|
||||
// console.log('newVal-----', newVal)
|
||||
// },
|
||||
// { immediate: true }
|
||||
// )
|
||||
|
||||
const emit = defineEmits(['regenerate'])
|
||||
|
||||
@@ -215,6 +226,7 @@
|
||||
// 点击显示报告
|
||||
}
|
||||
const handleClickUrls = (data) => {
|
||||
MyEvent.emit('openUrls', props.content.webAddress)
|
||||
// 点击显示来源
|
||||
}
|
||||
</script>
|
||||
@@ -260,6 +272,24 @@
|
||||
width: fit-content;
|
||||
max-width: 82%;
|
||||
}
|
||||
.web-address {
|
||||
width: fit-content;
|
||||
min-width: 22.5rem;
|
||||
line-height: 2.6rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: 1.5rem;
|
||||
color: #000000a6;
|
||||
border: 0.1rem solid #0000001a;
|
||||
font-family: 'Regular';
|
||||
font-weight: 400;
|
||||
font-size: 1.2rem;
|
||||
margin-top: 1rem;
|
||||
column-gap: 0.8rem;
|
||||
.search-icon {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.operate {
|
||||
margin-top: 1.3rem;
|
||||
|
||||
@@ -52,27 +52,34 @@
|
||||
</template>
|
||||
<div v-else class="reportBorder">
|
||||
<div class="report">
|
||||
<div v-if="false" class="report-content-null">
|
||||
<!-- <div v-if="false" class="report-content-null">
|
||||
<img :src="reportNull" alt="" />
|
||||
</div>
|
||||
<div v-else class="report-content">
|
||||
<div class="downBtnBox">
|
||||
<div class="downBtn" @click="handleDownloadMd">
|
||||
<div class="icon">
|
||||
<SvgIcon name="reportDown" size="16"></SvgIcon>
|
||||
</div> -->
|
||||
<template v-if="reportType === 'report'">
|
||||
<div class="report-content">
|
||||
<div class="downBtnBox">
|
||||
<div class="downBtn" @click="handleDownloadMd">
|
||||
<div class="icon">
|
||||
<SvgIcon name="reportDown" size="16"></SvgIcon>
|
||||
</div>
|
||||
<span>{{ $t('agent.Download') }}</span>
|
||||
</div>
|
||||
<span>{{ $t('agent.Download') }}</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<VueMarkdown
|
||||
:custom-attrs="customAttrs"
|
||||
:markdown="markdownContent"
|
||||
:rehype-plugins="[rehypeRaw]"
|
||||
>
|
||||
</VueMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<VueMarkdown
|
||||
:custom-attrs="customAttrs"
|
||||
:markdown="markdownContent"
|
||||
:rehype-plugins="[rehypeRaw]"
|
||||
>
|
||||
</VueMarkdown>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="url-list">
|
||||
<div class="url-item" v-for="item in urlList" :key="item"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +99,9 @@
|
||||
import { VueMarkdown } from '@crazydos/vue-markdown'
|
||||
import type { CustomAttrs } from '@crazydos/vue-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import { useWebsiteTitle } from '@/utils/useWebTitle'
|
||||
|
||||
const { titles, loading, fetchAll } = useWebsiteTitle()
|
||||
|
||||
const { t } = useI18n()
|
||||
const emits = defineEmits(['deleteSketch'])
|
||||
@@ -125,17 +135,29 @@
|
||||
|
||||
const sessionId = ref('')
|
||||
const markdownContent = ref('')
|
||||
const setSessionId = (id: string) => {
|
||||
|
||||
const urlList = ref([])
|
||||
const reportType = ref<'report' | 'urls'>('report')
|
||||
const setSessionId = (id: string) => {
|
||||
console.log('setSessionId-----', id)
|
||||
reportType.value = 'report'
|
||||
sessionId.value = id
|
||||
}
|
||||
const setUrls = (list: string[]) => {
|
||||
console.log('setUrls-----', list)
|
||||
reportType.value = 'urls'
|
||||
urlList.value = [
|
||||
'https://furnitureindustrynews.substack.com/p/what-2026-really-looks-like-for-furniture',
|
||||
'https://furnitureindustrynews.substack.com/p/what-2026-really-looks-like-for-furniture',
|
||||
'https://furnitureindustrynews.substack.com/p/what-2026-really-looks-like-for-furniture'
|
||||
]
|
||||
fetchAll(list)
|
||||
}
|
||||
watch(
|
||||
() => sessionId.value,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
markdownContent.value = localStorage.getItem(`reportsContent_${newVal}`)
|
||||
console.log('markdownContent-----', markdownContent.value);
|
||||
|
||||
console.log(`报告key值:reportsContent_${newVal}`, markdownContent.value)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -143,9 +165,7 @@ const setSessionId = (id: string) => {
|
||||
// 图片加载完成时触发
|
||||
const handleImageLoad = (index: number) => {
|
||||
loadedStatus[index] = true
|
||||
if (index === props.sketchList.length - 1) {
|
||||
showLoading.value = false
|
||||
}
|
||||
showLoading.value = false
|
||||
}
|
||||
|
||||
// 获取当前显示的图片源
|
||||
@@ -209,6 +229,8 @@ const setSessionId = (id: string) => {
|
||||
watch(
|
||||
() => props.sketchList,
|
||||
(val) => {
|
||||
console.log('sketchList-----', val);
|
||||
|
||||
if (val.length > 0) {
|
||||
showLoading.value = false
|
||||
}
|
||||
@@ -223,7 +245,8 @@ const setSessionId = (id: string) => {
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
setSessionId
|
||||
setSessionId,
|
||||
setUrls
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -289,6 +312,7 @@ const setSessionId = (id: string) => {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -348,6 +372,7 @@ const setSessionId = (id: string) => {
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
margin: 2rem;
|
||||
padding: 0 8.8rem 8.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,79 @@
|
||||
<template>
|
||||
<div class="report-card">
|
||||
<div class="report-card" :class="{ 'is-url': isUrl }">
|
||||
<div class="report-card-header">
|
||||
<span>{{ title }}</span>
|
||||
<span v-if="!isUrl">{{ title }}</span>
|
||||
<div v-else class="web-sources flex align-center">
|
||||
<span>Web Sources</span>
|
||||
<img src="@/assets/images/link.png" class="link-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-card-content">
|
||||
<span>{{ title }}</span>
|
||||
<span v-if="!isUrl">{{ content }}</span>
|
||||
<span v-else>Destination URL</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
}>()
|
||||
// const props = defineProps<{
|
||||
// title: string
|
||||
// isUrl?: boolean
|
||||
// }>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
content?: string
|
||||
isUrl?: boolean
|
||||
}>(),
|
||||
{
|
||||
isUrl: false,
|
||||
title: '',
|
||||
content: ''
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.report-card {
|
||||
cursor: pointer;
|
||||
width:100%;
|
||||
width: 100%;
|
||||
margin: 2.4rem 0;
|
||||
min-height: 11.2rem;
|
||||
background: url('@/assets/images/report-card.png') no-repeat;
|
||||
background-size: 100% 100%;
|
||||
padding: 2.9rem;
|
||||
padding: 2.9rem;
|
||||
overflow: hidden;
|
||||
&.is-url {
|
||||
background: url('@/assets/images/link-card.png') no-repeat;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
// &:first-of-type{
|
||||
// margin-top: 0;
|
||||
// }
|
||||
&-header {
|
||||
font-family: 'Medium';
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 1.3rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
line-clamp: 3;
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 1.3rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
line-clamp: 3;
|
||||
.web-sources {
|
||||
column-gap: 0.8rem;
|
||||
.link-icon {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-content {
|
||||
font-family: 'Regular';
|
||||
font-weight: 300;
|
||||
font-size: 1.6rem;
|
||||
color: #7c7c7c;
|
||||
}
|
||||
&-content{
|
||||
font-family: 'Regular';
|
||||
font-weight: 300;
|
||||
font-size: 1.6rem;
|
||||
color: #7c7c7c;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ReportCard :report="{title: 'WebSources', content: 'Destination URL'}"/>
|
||||
<ReportCard is-url :report="{title: 'WebSources', content: 'Destination URL'}"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -103,11 +103,19 @@
|
||||
|
||||
const proJectId = computed(() => route.params.id)
|
||||
|
||||
const handleOpenReport = (data) => {
|
||||
previewRef.value.setSessionId(data)
|
||||
const handleOpenReport = (data, isUrls = false) => {
|
||||
if (isUrls) {
|
||||
previewRef.value.setUrls(data)
|
||||
} else {
|
||||
previewRef.value.setSessionId(data)
|
||||
}
|
||||
previewType.value = 'report'
|
||||
}
|
||||
|
||||
const handleOpenUrls = (data) => {
|
||||
handleOpenReport(data, true)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => proJectId.value,
|
||||
(newVal, oldVal) => {
|
||||
@@ -120,6 +128,7 @@ const handleOpenReport = (data) => {
|
||||
|
||||
onMounted(() => {
|
||||
MyEvent.add('openReport', handleOpenReport)
|
||||
MyEvent.add('openUrls', handleOpenUrls)
|
||||
projectStore.clearProject()
|
||||
if (proJectId.value) {
|
||||
handleGetProjectInfoAndHistory()
|
||||
@@ -127,6 +136,7 @@ const handleOpenReport = (data) => {
|
||||
})
|
||||
onUnmounted(() => {
|
||||
MyEvent.remove('openReport', handleOpenReport)
|
||||
MyEvent.remove('openUrls', handleOpenUrls)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user