Add web search knowledge base, message tagging, and word-memory tip topics
Introduces websearch.py (Tavily) + knowledge.py to ground chat responses in search results, tagging.py to tag user and assistant messages via LLM and pick related topics for the Discord useless-tips job, and word_memory.py to track recent conversation nouns (surfaced in the new WordPool.vue sidebar) used to pick tip topics close to a related tag.
This commit is contained in:
@@ -2,10 +2,20 @@
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
import type { Source } from '../api/client'
|
||||
|
||||
const store = useChatStore()
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function uniqueSources(sources: Source[]): Source[] {
|
||||
const seen = new Set<string>()
|
||||
return sources.filter((s) => {
|
||||
if (seen.has(s.url)) return false
|
||||
seen.add(s.url)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.messages.length,
|
||||
async () => {
|
||||
@@ -23,17 +33,36 @@ watch(
|
||||
<div
|
||||
v-for="(msg, i) in store.messages"
|
||||
:key="i"
|
||||
:class="['message', msg.role, { error: msg.error }]"
|
||||
:class="['message-wrap', msg.role]"
|
||||
>
|
||||
<div class="bubble">
|
||||
<!-- ユーザーメッセージはプレーンテキスト -->
|
||||
<template v-if="msg.role === 'user'">{{ msg.content }}</template>
|
||||
<div :class="['message', msg.role, { error: msg.error }]">
|
||||
<div class="bubble">
|
||||
<!-- ユーザーメッセージはプレーンテキスト -->
|
||||
<template v-if="msg.role === 'user'">{{ msg.content }}</template>
|
||||
|
||||
<!-- アシスタントはMarkdownレンダリング -->
|
||||
<template v-else>
|
||||
<MarkdownContent :content="msg.content" :pending="msg.pending" />
|
||||
<span v-if="msg.pending && !msg.content" class="cursor">▌</span>
|
||||
</template>
|
||||
<!-- アシスタントはMarkdownレンダリング -->
|
||||
<template v-else>
|
||||
<MarkdownContent :content="msg.content" :pending="msg.pending" />
|
||||
<span v-if="msg.pending && !msg.content" class="cursor">▌</span>
|
||||
<button v-if="msg.error" class="retry-btn" :disabled="store.isStreaming" @click="store.retryMessage(i)">
|
||||
🔄 再送信
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="msg.tags && msg.tags.length > 0" class="tags">
|
||||
<span v-for="tag in msg.tags" :key="tag" class="tag">#{{ tag }}</span>
|
||||
</div>
|
||||
<div v-if="msg.sources && msg.sources.length > 0" class="sources">
|
||||
<span class="sources-label">🔍 参照元:</span>
|
||||
<a
|
||||
v-for="src in uniqueSources(msg.sources)"
|
||||
:key="src.url"
|
||||
:href="src.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="source-link"
|
||||
>{{ src.title || src.url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,9 +82,50 @@ watch(
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
.message { display: flex; }
|
||||
.message-wrap { display: flex; flex-direction: column; gap: 4px; }
|
||||
.message-wrap.user { align-items: flex-end; }
|
||||
.message-wrap.assistant { align-items: flex-start; }
|
||||
.message { display: flex; width: 100%; }
|
||||
.message.user { justify-content: flex-end; }
|
||||
.message.assistant { justify-content: flex-start; }
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-width: 72%;
|
||||
}
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
color: #93c5fd;
|
||||
background: #1d3557;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 72%;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sources-label {
|
||||
color: #888;
|
||||
}
|
||||
.source-link {
|
||||
color: #6ee7b7;
|
||||
background: #103027;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
text-decoration: none;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.source-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.bubble {
|
||||
max-width: 72%;
|
||||
padding: 10px 14px;
|
||||
@@ -84,5 +154,23 @@ watch(
|
||||
display: inline-block;
|
||||
animation: blink 0.8s step-end infinite;
|
||||
}
|
||||
.retry-btn {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
background: #2a1414;
|
||||
color: #f87171;
|
||||
border: 1px solid #7f1d1d;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.retry-btn:hover:not(:disabled) {
|
||||
background: #3b1a1a;
|
||||
}
|
||||
.retry-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
</style>
|
||||
|
||||
@@ -74,7 +74,6 @@ onUnmounted(() => {
|
||||
.memory-monitor {
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid #333;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 12px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
|
||||
@@ -44,13 +44,13 @@ onMounted(load)
|
||||
|
||||
<style scoped>
|
||||
.session-list {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
width: 100%;
|
||||
background: #1a1a1a;
|
||||
border-right: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
|
||||
interface WordEntry {
|
||||
word: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const words = ref<WordEntry[]>([])
|
||||
const expanded = ref(false)
|
||||
let intervalId: number | null = null
|
||||
|
||||
const maxCount = computed(() => Math.max(1, ...words.value.map((w) => w.count)))
|
||||
|
||||
const fontSize = (count: number) => {
|
||||
const ratio = count / maxCount.value
|
||||
return `${12 + ratio * 14}px`
|
||||
}
|
||||
|
||||
const opacity = (count: number) => {
|
||||
const ratio = count / maxCount.value
|
||||
return 0.5 + ratio * 0.5
|
||||
}
|
||||
|
||||
const fetchWordPool = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system/word-pool')
|
||||
words.value = await response.json()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch word pool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWordPool()
|
||||
intervalId = window.setInterval(fetchWordPool, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="word-pool">
|
||||
<button class="header" @click="expanded = !expanded">
|
||||
<span>🗣️ ワードプール(どうでもいいTips用)</span>
|
||||
<span class="chevron">{{ expanded ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
<template v-if="expanded">
|
||||
<div v-if="words.length === 0" class="empty">まだ単語が記録されていません</div>
|
||||
<div v-else class="cloud">
|
||||
<span
|
||||
v-for="entry in words"
|
||||
:key="entry.word"
|
||||
class="word"
|
||||
:style="{ fontSize: fontSize(entry.count), opacity: opacity(entry.count) }"
|
||||
:title="`${entry.count}回`"
|
||||
>
|
||||
{{ entry.word }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.word-pool {
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid #333;
|
||||
padding: 12px;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
color: #888;
|
||||
font-family: monospace;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.chevron {
|
||||
color: #666;
|
||||
font-size: 9px;
|
||||
}
|
||||
.empty {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
align-items: baseline;
|
||||
margin-top: 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.word {
|
||||
color: #93c5fd;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user