feat: url link

This commit is contained in:
2026-03-16 11:43:19 +08:00
parent 3400dcf9af
commit 5b7ee903e8
10 changed files with 255 additions and 68 deletions

BIN
src/assets/images/link.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

92
src/utils/useWebTitle.ts Normal file
View 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 }
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>