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:
2026-06-20 18:06:06 +09:00
parent 4e837d1419
commit 234ebba103
23 changed files with 946 additions and 39 deletions
+3 -1
View File
@@ -8,6 +8,7 @@ import LogViewer from './components/LogViewer.vue'
import ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue'
import Clock from './components/Clock.vue'
import WordPool from './components/WordPool.vue'
const showLogs = ref(false)
</script>
@@ -17,6 +18,7 @@ const showLogs = ref(false)
<div class="sidebar">
<SessionList />
<MemoryMonitor />
<WordPool />
</div>
<div class="main">
<header>
@@ -49,7 +51,7 @@ body { background: #111; color: #eee; font-family: system-ui, sans-serif; }
<style scoped>
.app { display: flex; height: 100vh; }
.sidebar { display: flex; flex-direction: column; border-right: 1px solid #333; overflow: hidden; }
.sidebar { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid #333; overflow: hidden; }
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
header {
display: flex; align-items: center; justify-content: space-between;
+16 -2
View File
@@ -16,9 +16,14 @@ export interface SessionInfo {
updated_at: string
}
export interface Source {
title: string
url: string
}
export interface SSEChunk {
type: 'session_id' | 'chunk' | 'done' | 'error'
value?: string
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources'
value?: string | Source[]
}
const BASE = ''
@@ -45,6 +50,7 @@ export interface MessageRecord {
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
tags: string[]
}
export async function fetchMessages(sessionId: string): Promise<MessageRecord[]> {
@@ -95,6 +101,7 @@ export function streamChat(
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let finished = false
while (true) {
const { done, value } = await reader.read()
@@ -106,6 +113,7 @@ export function streamChat(
if (line.startsWith('data: ')) {
try {
const parsed: SSEChunk = JSON.parse(line.slice(6))
if (parsed.type === 'done' || parsed.type === 'error') finished = true
onChunk(parsed)
} catch (e) {
console.error('[SSE] JSON parse error:', e, '| raw:', line)
@@ -113,6 +121,12 @@ export function streamChat(
}
}
}
// サーバーが done/error を送らずに接続を終了した場合(再起動・切断など)はストリーミング状態が
// 固定されたままになるので、ここで明示的にエラーとして扱い入力をロック解除する
if (!finished) {
onError(new Error('接続が予期せず終了しました。もう一度お試しください。'))
}
})
.catch((err) => {
if (err.name !== 'AbortError') onError(err)
+98 -10
View File
@@ -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;
+3 -3
View File
@@ -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;
+117
View File
@@ -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>
+27 -7
View File
@@ -1,12 +1,14 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { fetchSessions, fetchMessages, deleteSession, streamChat, type SessionInfo } from '../api/client'
import { fetchSessions, fetchMessages, deleteSession, streamChat, type SessionInfo, type Source } from '../api/client'
export interface Message {
role: 'user' | 'assistant'
content: string
pending?: boolean
error?: boolean
tags?: string[]
sources?: Source[]
}
export interface Toast {
@@ -21,7 +23,7 @@ export const useChatStore = defineStore('chat', () => {
const currentSessionId = ref<string | null>(null)
const messages = ref<Message[]>([])
const isStreaming = ref(false)
const selectedModel = ref('gpt-5.4')
const selectedModel = ref('bedrock/jp.anthropic.claude-sonnet-4-6')
const toasts = ref<Toast[]>([])
const currentSession = computed(() =>
@@ -52,7 +54,7 @@ export const useChatStore = defineStore('chat', () => {
const records = await fetchMessages(sessionId)
messages.value = records
.filter((r) => r.role === 'user' || r.role === 'assistant')
.map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content }))
.map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content, tags: r.tags }))
} catch (e) {
pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`)
}
@@ -81,23 +83,27 @@ export const useChatStore = defineStore('chat', () => {
selectedModel.value,
(chunk) => {
if (chunk.type === 'session_id' && chunk.value) {
currentSessionId.value = chunk.value
currentSessionId.value = chunk.value as string
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'chunk' && chunk.value) {
messages.value[msgIdx].content += chunk.value
messages.value[msgIdx].content += chunk.value as string
} else if (chunk.type === 'sources' && chunk.value) {
messages.value[msgIdx].sources = chunk.value as Source[]
} else if (chunk.type === 'done') {
messages.value[msgIdx].pending = false
isStreaming.value = false
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'error') {
messages.value[msgIdx].content = chunk.value ?? 'バックエンドでエラーが発生しました。'
messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。'
messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true
isStreaming.value = false
}
},
(err) => {
messages.value[msgIdx].content = err.message
// 途中まで届いていた内容は残しつつ、エラーメッセージを付記する
const partial = messages.value[msgIdx].content
messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message
messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true
isStreaming.value = false
@@ -105,6 +111,19 @@ export const useChatStore = defineStore('chat', () => {
)
}
/** エラーになったアシスタント応答のひとつ前にあるユーザーメッセージを再送信する。 */
function retryMessage(assistantIdx: number): void {
if (isStreaming.value) return
const userIdx = assistantIdx - 1
const userMsg = messages.value[userIdx]
if (!userMsg || userMsg.role !== 'user') return
const text = userMsg.content
// 失敗したユーザーメッセージとエラー応答を取り除いてから送信し直す
messages.value.splice(userIdx, 2)
sendMessage(text)
}
return {
sessions,
currentSessionId,
@@ -119,5 +138,6 @@ export const useChatStore = defineStore('chat', () => {
selectSession,
removeSession,
sendMessage,
retryMessage,
}
})