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 contentBody = ''
let buffer = '' let buffer = ''
const webAddressList = []
const reader = response.body?.getReader() const reader = response.body?.getReader()
if (!reader) throw new Error('无法获取流读取器') if (!reader) throw new Error('无法获取流读取器')
@@ -248,10 +247,8 @@
?.trim() || '' ?.trim() || ''
if (!hasReportStarted && eventName === 'report') { if (!hasReportStarted && eventName === 'report') {
console.log('开始生成报告--------')
isGeneratingReport.value = true 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 hasReportStarted = true
} }
@@ -298,9 +295,9 @@
params.versionID = dataLines[0] params.versionID = dataLines[0]
projectStore.setProject({ nodeId: dataLines[0] }) projectStore.setProject({ nodeId: dataLines[0] })
} }
if (eventName === 'webAddress') { // if (eventName === 'webAddress') {
console.log('webAddress-----', eventName, dataLines) // console.log('webAddress111111111111111', eventName, dataLines)
} // }
if (eventName === 'tool') { if (eventName === 'tool') {
MyEvent.emit('loading-sketch') MyEvent.emit('loading-sketch')
@@ -313,7 +310,7 @@
const jsonData = JSON.parse(jsonText) const jsonData = JSON.parse(jsonText)
// console.log('jsonData', jsonData) // console.log('jsonData', jsonData)
if (jsonData.webAddress) { if (jsonData.webAddress) {
console.log('webAddress-----', jsonData) aiMessage.webAddress = JSON.parse(jsonData.webAddress)
} }
if (jsonData.title) { if (jsonData.title) {
emits('setTitle', jsonData.title) emits('setTitle', jsonData.title)
@@ -439,7 +436,10 @@
while (i < dialogue.length) { while (i < dialogue.length) {
const item = dialogue[i] const item = dialogue[i]
if (item.webAddress?.length > 0) {
console.log('item.webAddress-----', item.webAddress)
debugger
}
if (item.role === 'user') { if (item.role === 'user') {
// user 角色直接添加 // user 角色直接添加
result.push({ result.push({
@@ -496,7 +496,7 @@
for (let i = 0; i < session.dialogue.length; i++) { for (let i = 0; i < session.dialogue.length; i++) {
if (session.dialogue[i].report) { if (session.dialogue[i].report) {
session.dialogue[i].content = 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 || '') (session.dialogue[i].content || '')
break break
} }
@@ -516,7 +516,7 @@
// 3. 收集 sketchIDAndUrl 到 imgList // 3. 收集 sketchIDAndUrl 到 imgList
if (session.sketchIDAndUrl) { if (session.sketchIDAndUrl) {
imgList.push(...session.sketchIDAndUrl) imgList.push(session.sketchIDAndUrl)
} }
// 4. 处理 dialogue // 4. 处理 dialogue

View File

@@ -40,10 +40,21 @@
<template v-slot:s-card="{ children: children, ...attrs }"> <template v-slot:s-card="{ children: children, ...attrs }">
<Card :title="attrs.title" @click.native="handleClickReport" /> <Card :title="attrs.title" @click.native="handleClickReport" />
</template> </template>
<template v-slot:url-card="{ children: children }">
<Url :list="content.webAddress" @click.native="handleClickUrls" />
</template>
</VueMarkdown> </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>
<div <div
v-show="!content.thinking" v-show="!content.streaming"
class="operate flex" class="operate flex"
:class="{ 'is-user': content.isUser }" :class="{ 'is-user': content.isUser }"
> >
@@ -96,13 +107,13 @@
isLast: Boolean isLast: Boolean
}>() }>()
watch( // watch(
() => props.content, // () => props.content,
(newVal) => { // (newVal) => {
console.log('newVal-----', newVal) // console.log('newVal-----', newVal)
}, // },
{ immediate: true } // { immediate: true }
) // )
const emit = defineEmits(['regenerate']) const emit = defineEmits(['regenerate'])
@@ -215,6 +226,7 @@
// 点击显示报告 // 点击显示报告
} }
const handleClickUrls = (data) => { const handleClickUrls = (data) => {
MyEvent.emit('openUrls', props.content.webAddress)
// 点击显示来源 // 点击显示来源
} }
</script> </script>
@@ -260,6 +272,24 @@
width: fit-content; width: fit-content;
max-width: 82%; 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 { .operate {
margin-top: 1.3rem; margin-top: 1.3rem;

View File

@@ -52,27 +52,34 @@
</template> </template>
<div v-else class="reportBorder"> <div v-else class="reportBorder">
<div class="report"> <div class="report">
<div v-if="false" class="report-content-null"> <!-- <div v-if="false" class="report-content-null">
<img :src="reportNull" alt="" /> <img :src="reportNull" alt="" />
</div> </div> -->
<div v-else class="report-content"> <template v-if="reportType === 'report'">
<div class="downBtnBox"> <div class="report-content">
<div class="downBtn" @click="handleDownloadMd"> <div class="downBtnBox">
<div class="icon"> <div class="downBtn" @click="handleDownloadMd">
<SvgIcon name="reportDown" size="16"></SvgIcon> <div class="icon">
<SvgIcon name="reportDown" size="16"></SvgIcon>
</div>
<span>{{ $t('agent.Download') }}</span>
</div> </div>
<span>{{ $t('agent.Download') }}</span> </div>
<div class="content">
<VueMarkdown
:custom-attrs="customAttrs"
:markdown="markdownContent"
:rehype-plugins="[rehypeRaw]"
>
</VueMarkdown>
</div> </div>
</div> </div>
<div class="content"> </template>
<VueMarkdown <template v-else>
:custom-attrs="customAttrs" <div class="url-list">
:markdown="markdownContent" <div class="url-item" v-for="item in urlList" :key="item"></div>
:rehype-plugins="[rehypeRaw]"
>
</VueMarkdown>
</div> </div>
</div> </template>
</div> </div>
</div> </div>
</div> </div>
@@ -92,6 +99,9 @@
import { VueMarkdown } from '@crazydos/vue-markdown' import { VueMarkdown } from '@crazydos/vue-markdown'
import type { CustomAttrs } from '@crazydos/vue-markdown' import type { CustomAttrs } from '@crazydos/vue-markdown'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
import { useWebsiteTitle } from '@/utils/useWebTitle'
const { titles, loading, fetchAll } = useWebsiteTitle()
const { t } = useI18n() const { t } = useI18n()
const emits = defineEmits(['deleteSketch']) const emits = defineEmits(['deleteSketch'])
@@ -125,17 +135,29 @@
const sessionId = ref('') const sessionId = ref('')
const markdownContent = 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 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( watch(
() => sessionId.value, () => sessionId.value,
(newVal) => { (newVal) => {
if (newVal) { if (newVal) {
markdownContent.value = localStorage.getItem(`reportsContent_${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) => { const handleImageLoad = (index: number) => {
loadedStatus[index] = true loadedStatus[index] = true
if (index === props.sketchList.length - 1) { showLoading.value = false
showLoading.value = false
}
} }
// 获取当前显示的图片源 // 获取当前显示的图片源
@@ -209,6 +229,8 @@ const setSessionId = (id: string) => {
watch( watch(
() => props.sketchList, () => props.sketchList,
(val) => { (val) => {
console.log('sketchList-----', val);
if (val.length > 0) { if (val.length > 0) {
showLoading.value = false showLoading.value = false
} }
@@ -223,7 +245,8 @@ const setSessionId = (id: string) => {
}) })
defineExpose({ defineExpose({
setSessionId setSessionId,
setUrls
}) })
</script> </script>
@@ -289,6 +312,7 @@ const setSessionId = (id: string) => {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
@@ -348,6 +372,7 @@ const setSessionId = (id: string) => {
white-space: pre-wrap; white-space: pre-wrap;
overflow-y: auto; overflow-y: auto;
margin: 2rem; margin: 2rem;
padding: 0 8.8rem 8.8rem;
} }
} }
} }

View File

@@ -1,49 +1,79 @@
<template> <template>
<div class="report-card"> <div class="report-card" :class="{ 'is-url': isUrl }">
<div class="report-card-header"> <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>
<div class="report-card-content"> <div class="report-card-content">
<span>{{ title }}</span> <span v-if="!isUrl">{{ content }}</span>
<span v-else>Destination URL</span>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ // const props = defineProps<{
title: string // title: string
}>() // isUrl?: boolean
// }>()
const props = withDefaults(
defineProps<{
title?: string
content?: string
isUrl?: boolean
}>(),
{
isUrl: false,
title: '',
content: ''
}
)
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.report-card { .report-card {
cursor: pointer; cursor: pointer;
width:100%; width: 100%;
margin: 2.4rem 0; margin: 2.4rem 0;
min-height: 11.2rem; min-height: 11.2rem;
background: url('@/assets/images/report-card.png') no-repeat; background: url('@/assets/images/report-card.png') no-repeat;
background-size: 100% 100%; background-size: 100% 100%;
padding: 2.9rem; padding: 2.9rem;
overflow: hidden; overflow: hidden;
&.is-url {
background: url('@/assets/images/link-card.png') no-repeat;
background-size: 100% 100%;
}
// &:first-of-type{ // &:first-of-type{
// margin-top: 0; // margin-top: 0;
// } // }
&-header { &-header {
font-family: 'Medium'; font-family: 'Medium';
font-size: 1.6rem; font-size: 1.6rem;
margin-bottom: 1.3rem; margin-bottom: 1.3rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
line-clamp: 3; 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> </style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<ReportCard :report="{title: 'WebSources', content: 'Destination URL'}"/> <ReportCard is-url :report="{title: 'WebSources', content: 'Destination URL'}"/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -103,11 +103,19 @@
const proJectId = computed(() => route.params.id) const proJectId = computed(() => route.params.id)
const handleOpenReport = (data) => { const handleOpenReport = (data, isUrls = false) => {
previewRef.value.setSessionId(data) if (isUrls) {
previewRef.value.setUrls(data)
} else {
previewRef.value.setSessionId(data)
}
previewType.value = 'report' previewType.value = 'report'
} }
const handleOpenUrls = (data) => {
handleOpenReport(data, true)
}
watch( watch(
() => proJectId.value, () => proJectId.value,
(newVal, oldVal) => { (newVal, oldVal) => {
@@ -120,6 +128,7 @@ const handleOpenReport = (data) => {
onMounted(() => { onMounted(() => {
MyEvent.add('openReport', handleOpenReport) MyEvent.add('openReport', handleOpenReport)
MyEvent.add('openUrls', handleOpenUrls)
projectStore.clearProject() projectStore.clearProject()
if (proJectId.value) { if (proJectId.value) {
handleGetProjectInfoAndHistory() handleGetProjectInfoAndHistory()
@@ -127,6 +136,7 @@ const handleOpenReport = (data) => {
}) })
onUnmounted(() => { onUnmounted(() => {
MyEvent.remove('openReport', handleOpenReport) MyEvent.remove('openReport', handleOpenReport)
MyEvent.remove('openUrls', handleOpenUrls)
}) })
</script> </script>