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:
@@ -16,6 +16,11 @@ AWS_BEARER_TOKEN_BEDROCK=
|
||||
# デバッグ (true にすると LiteLLM の詳細ログが出力される)
|
||||
DEBUG=false
|
||||
|
||||
# Tavily Web検索(未設定だとweb_searchツールは何も検索せず空振りする)
|
||||
TAVILY_API_KEY=
|
||||
# 蓄積知識をシステムプロンプトに注入する際の最大件数
|
||||
KNOWLEDGE_CONTEXT_LIMIT=15
|
||||
|
||||
# 天気予報 → Discord 通知
|
||||
DISCORD_WEBHOOK_URL=
|
||||
WEATHER_AREA_CODE=120000
|
||||
|
||||
+45
-3
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -9,7 +10,11 @@ from app.db.database import get_db
|
||||
from app.db.models import Session, Message
|
||||
from app.core import memory as mem
|
||||
from app.core import llm
|
||||
from app.core import knowledge
|
||||
from app.core.websearch import WEB_SEARCH_TOOL
|
||||
from app.core.config import DEFAULT_MODEL
|
||||
from app.core.tagging import tag_message_by_id
|
||||
from app.core.word_memory import record_message
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
@@ -35,6 +40,7 @@ class MessageResponse(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
tags: list[str] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -50,7 +56,16 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)):
|
||||
session = db.get(Session, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return session.messages
|
||||
return [
|
||||
MessageResponse(
|
||||
id=m.id,
|
||||
role=m.role,
|
||||
content=m.content,
|
||||
created_at=m.created_at,
|
||||
tags=[t.tag for t in m.tags],
|
||||
)
|
||||
for m in session.messages
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", status_code=204)
|
||||
@@ -82,6 +97,10 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
user_msg = Message(session_id=session.id, role="user", content=req.message)
|
||||
db.add(user_msg)
|
||||
db.commit()
|
||||
db.refresh(user_msg)
|
||||
|
||||
record_message(req.message)
|
||||
asyncio.create_task(tag_message_by_id(user_msg.id))
|
||||
|
||||
context = mem.get_context_messages(db, session.id)
|
||||
|
||||
@@ -94,17 +113,40 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
# セッションIDをまず返す(新規セッション時にフロントへ通知)
|
||||
yield sse({"type": "session_id", "value": session.id})
|
||||
|
||||
used_model = req.model or session.model
|
||||
full_response = []
|
||||
try:
|
||||
async for chunk in llm.stream_chat(context, model=req.model or session.model, session_id=session.id):
|
||||
resolved_context, searches = await llm.resolve_web_search(
|
||||
context, model=used_model, session_id=session.id,
|
||||
)
|
||||
if searches:
|
||||
sources = [
|
||||
{"title": r["title"], "url": r["url"]}
|
||||
for s in searches for r in s["results"]
|
||||
]
|
||||
yield sse({"type": "sources", "value": sources})
|
||||
for s in searches:
|
||||
summary = "\n".join(r["content"][:300] for r in s["results"] if r["content"])
|
||||
if summary:
|
||||
knowledge.save_knowledge(db, s["query"], summary, s["results"])
|
||||
|
||||
search_tools = [WEB_SEARCH_TOOL] if len(resolved_context) > len(context) else None
|
||||
async for chunk in llm.stream_chat(
|
||||
resolved_context, model=used_model, session_id=session.id, tools=search_tools,
|
||||
):
|
||||
full_response.append(chunk)
|
||||
yield sse({"type": "chunk", "value": chunk})
|
||||
|
||||
# アシスタントメッセージを保存
|
||||
assistant_content = "".join(full_response)
|
||||
db.add(Message(session_id=session.id, role="assistant", content=assistant_content))
|
||||
assistant_msg = Message(session_id=session.id, role="assistant", content=assistant_content)
|
||||
db.add(assistant_msg)
|
||||
session.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(assistant_msg)
|
||||
|
||||
record_message(assistant_content)
|
||||
asyncio.create_task(tag_message_by_id(assistant_msg.id))
|
||||
|
||||
# メモリ要約チェック
|
||||
await mem.maybe_summarize(db, session)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
import psutil
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.scheduler import send_useless_tip, send_weather_laundry_notification
|
||||
from app.core.tagging import backfill_tags
|
||||
from app.core.weather_cache import get_latest as get_latest_weather
|
||||
from app.core.word_memory import get_word_pool_with_counts
|
||||
from app.db.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
@@ -68,3 +72,16 @@ async def test_useless_tip():
|
||||
async def get_latest_weather_data():
|
||||
"""直近で取得した明日の天気データを返す(未取得ならnull)"""
|
||||
return get_latest_weather()
|
||||
|
||||
|
||||
@router.get("/word-pool")
|
||||
async def get_word_pool():
|
||||
"""どうでもいいtips生成に使われているワードプールを出現回数順で返す"""
|
||||
return get_word_pool_with_counts()
|
||||
|
||||
|
||||
@router.post("/tags/backfill")
|
||||
async def run_tags_backfill(limit: int | None = None, db: DBSession = Depends(get_db)):
|
||||
"""過去のユーザーメッセージのうち、タグが未生成のものにまとめてタグを付ける"""
|
||||
count = await backfill_tags(db, limit=limit)
|
||||
return {"tagged_count": count}
|
||||
|
||||
@@ -9,6 +9,14 @@ MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "2
|
||||
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
# メッセージへの自動タグ付けに使うモデル(未設定ならDEFAULT_MODELを使用)
|
||||
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
|
||||
|
||||
# Tavily Web検索
|
||||
TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY")
|
||||
# 蓄積した知識をシステムプロンプトに注入する際の最大件数
|
||||
KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15"))
|
||||
|
||||
# Amazon Bedrock
|
||||
# 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。
|
||||
# IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。
|
||||
|
||||
@@ -12,8 +12,10 @@ from app.core.config import (
|
||||
)
|
||||
from app.core.llm import complete
|
||||
from app.core.scheduler import send_useless_tip
|
||||
from app.core.tagging import find_related_tag, generate_tags
|
||||
from app.core.weather import fetch_tomorrow_weather, judge_laundry
|
||||
from app.core.weather_cache import set_latest
|
||||
from app.core.word_memory import record_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,12 +63,18 @@ async def on_message(message: discord.Message):
|
||||
if not content:
|
||||
content = "こんにちは"
|
||||
|
||||
record_message(content)
|
||||
|
||||
async with message.channel.typing():
|
||||
try:
|
||||
if any(keyword in content for keyword in USELESS_TALK_KEYWORDS):
|
||||
redirect = await _generate_useless_talk_redirect(content)
|
||||
await message.reply(redirect)
|
||||
await send_useless_tip()
|
||||
|
||||
# 振られた話題のタグを生成し、それに近しい話題からTipsのネタを作る
|
||||
seed_tags = await generate_tags(content)
|
||||
related_tag = find_related_tag(seed_tags)
|
||||
await send_useless_tip(topic_override=related_tag)
|
||||
return
|
||||
|
||||
messages = [{"role": "system", "content": DISCORD_BOT_SYSTEM_PROMPT}]
|
||||
@@ -78,6 +86,8 @@ async def on_message(message: discord.Message):
|
||||
messages.append({"role": "user", "content": content})
|
||||
|
||||
reply = await complete(messages, model=WEATHER_NOTIFY_MODEL)
|
||||
if reply:
|
||||
record_message(reply)
|
||||
await message.reply(reply or "(応答が空でした)")
|
||||
except Exception:
|
||||
logger.exception("Failed to generate reply for Discord mention")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.config import KNOWLEDGE_CONTEXT_LIMIT
|
||||
from app.db.models import KnowledgeEntry, KnowledgeSource
|
||||
|
||||
|
||||
def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict]) -> KnowledgeEntry:
|
||||
"""Web検索で得た知見をDBに保存する。"""
|
||||
entry = KnowledgeEntry(query=query, summary=summary)
|
||||
db.add(entry)
|
||||
db.flush()
|
||||
for src in sources:
|
||||
if not src.get("url"):
|
||||
continue
|
||||
db.add(KnowledgeSource(entry_id=entry.id, title=src.get("title", ""), url=src["url"]))
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def get_knowledge_context(db: DBSession) -> str | None:
|
||||
"""蓄積された知識を、システムプロンプトに注入できる形のテキストにして返す。"""
|
||||
entries = (
|
||||
db.query(KnowledgeEntry)
|
||||
.order_by(KnowledgeEntry.created_at.desc())
|
||||
.limit(KNOWLEDGE_CONTEXT_LIMIT)
|
||||
.all()
|
||||
)
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
blocks = []
|
||||
for entry in reversed(entries):
|
||||
urls = ", ".join(s.url for s in entry.sources) or "出典不明"
|
||||
blocks.append(f"- 「{entry.query}」について: {entry.summary}\n (出典: {urls})")
|
||||
|
||||
return "以下はこれまでのWeb検索で得た知識です。関連する話題が出たら活用してください:\n\n" + "\n".join(blocks)
|
||||
@@ -7,6 +7,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
import litellm
|
||||
from app.core.config import DEFAULT_MODEL, DEBUG
|
||||
from app.core.websearch import WEB_SEARCH_TOOL, search_web
|
||||
|
||||
litellm.drop_params = True
|
||||
if DEBUG:
|
||||
@@ -58,6 +59,7 @@ async def stream_chat(
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
session_id: str | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
used_model = model or DEFAULT_MODEL
|
||||
async with _log_context(used_model, messages, session_id) as log:
|
||||
@@ -65,6 +67,7 @@ async def stream_chat(
|
||||
model=used_model,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
**({"tools": tools} if tools else {}),
|
||||
)
|
||||
chunks: list[str] = []
|
||||
async for chunk in response:
|
||||
@@ -92,3 +95,70 @@ async def complete(
|
||||
log["response_text"] = result
|
||||
_save_log(log)
|
||||
return result
|
||||
|
||||
|
||||
async def resolve_web_search(
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
session_id: str | None = None,
|
||||
max_rounds: int = 2,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""必要に応じてLLMにweb_searchツールを呼ばせ、検索結果を会話に組み込んだ上でメッセージ列を返す。
|
||||
|
||||
モデルがtool callingに対応していない場合は何もせず元のmessagesをそのまま返す。
|
||||
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト)
|
||||
"""
|
||||
used_model = model or DEFAULT_MODEL
|
||||
working_messages = list(messages)
|
||||
searches: list[dict] = []
|
||||
|
||||
for _ in range(max_rounds):
|
||||
try:
|
||||
response = await litellm.acompletion(
|
||||
model=used_model,
|
||||
messages=working_messages,
|
||||
tools=[WEB_SEARCH_TOOL],
|
||||
tool_choice="auto",
|
||||
)
|
||||
except Exception:
|
||||
logger.info("Model %s does not support tool calling; skipping web search", used_model)
|
||||
return messages, searches
|
||||
|
||||
choice_message = response.choices[0].message
|
||||
tool_calls = getattr(choice_message, "tool_calls", None)
|
||||
if not tool_calls:
|
||||
break
|
||||
|
||||
working_messages.append({
|
||||
"role": "assistant",
|
||||
"content": choice_message.content or "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
|
||||
}
|
||||
for tc in tool_calls
|
||||
],
|
||||
})
|
||||
|
||||
for tc in tool_calls:
|
||||
try:
|
||||
args = json.loads(tc.function.arguments or "{}")
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
query = args.get("query", "")
|
||||
results = await search_web(query)
|
||||
if results:
|
||||
tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results)
|
||||
searches.append({"query": query, "results": results})
|
||||
else:
|
||||
tool_text = "検索結果が見つかりませんでした。"
|
||||
|
||||
working_messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": tool_text,
|
||||
})
|
||||
|
||||
return working_messages, searches
|
||||
|
||||
@@ -2,15 +2,21 @@ from sqlalchemy.orm import Session as DBSession
|
||||
from app.db.models import Memory, Message, Session
|
||||
from app.core.config import MEMORY_SUMMARIZE_THRESHOLD
|
||||
from app.core import llm
|
||||
from app.core import knowledge
|
||||
from app.core.tagging import tag_memory
|
||||
|
||||
|
||||
def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
||||
"""短期メモリ(最近のメッセージ)と長期メモリ(要約)を結合してコンテキストを返す。"""
|
||||
"""蓄積知識・長期メモリ(要約)・短期メモリ(最近のメッセージ)を結合してコンテキストを返す。"""
|
||||
memories = db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all()
|
||||
messages = db.query(Message).filter(Message.session_id == session_id).order_by(Message.created_at).all()
|
||||
|
||||
context: list[dict] = []
|
||||
|
||||
knowledge_context = knowledge.get_knowledge_context(db)
|
||||
if knowledge_context:
|
||||
context.append({"role": "system", "content": knowledge_context})
|
||||
|
||||
if memories:
|
||||
combined_summary = "\n\n".join(m.summary for m in memories)
|
||||
context.append({
|
||||
@@ -36,9 +42,13 @@ async def maybe_summarize(db: DBSession, session: Session) -> None:
|
||||
{"role": "system", "content": "以下の会話を簡潔に要約してください。重要な事実・決定・文脈を保持してください。"},
|
||||
*[{"role": m.role, "content": m.content} for m in to_summarize],
|
||||
]
|
||||
summary_text = await llm.complete(summary_prompt, session_id=session.id)
|
||||
summary_text = await llm.complete(summary_prompt, model=session.model, session_id=session.id)
|
||||
|
||||
db.add(Memory(session_id=session.id, summary=summary_text))
|
||||
memory = Memory(session_id=session.id, summary=summary_text)
|
||||
db.add(memory)
|
||||
for msg in to_summarize:
|
||||
db.delete(msg)
|
||||
db.commit()
|
||||
db.refresh(memory)
|
||||
|
||||
await tag_memory(db, memory)
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.core.llm import complete
|
||||
from app.core.notify import send_discord_message
|
||||
from app.core.weather import fetch_tomorrow_weather, judge_laundry
|
||||
from app.core.weather_cache import set_latest
|
||||
from app.core.word_memory import pick_random_word
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,7 +78,12 @@ async def _generate_message(weather: str, pop_text: str, judgement: str, reason:
|
||||
return fallback
|
||||
|
||||
|
||||
async def send_useless_tip() -> None:
|
||||
async def send_useless_tip(topic_override: str | None = None) -> None:
|
||||
"""どうでもいいtipsをDiscordへ送信する。
|
||||
|
||||
topic_overrideを渡すと、ワードプールのランダム単語より優先してそのトピックでネタを作る。
|
||||
雑談Botがタグベースで『近しい話題』を渡したいときに使う。
|
||||
"""
|
||||
if not DISCORD_WEBHOOK_URL_TIPS:
|
||||
logger.warning("DISCORD_WEBHOOK_URL_TIPS is not set; skipping useless tip")
|
||||
return
|
||||
@@ -86,19 +92,31 @@ async def send_useless_tip() -> None:
|
||||
logger.info("Skipping useless tip during 3am quiet hour")
|
||||
return
|
||||
|
||||
topic_word = topic_override or pick_random_word()
|
||||
|
||||
prompt = (
|
||||
"誰の役にも立たない、死ぬほどどうでもいい雑学・豆知識を一つ教えてください。\n"
|
||||
"- 1〜3文程度で簡潔に\n"
|
||||
"- 「どうでもいい」感が伝わるようなノリで\n"
|
||||
"あなたは『どうでもいいTips』という名前のDiscord Botです。"
|
||||
"あなた自身は、これから話す雑学が誰かの役に立つ、有益な豆知識だと心の底から信じ切っています。"
|
||||
"本人にその自覚は一切なく、本気の善意と謎の使命感で「これは知っておいた方がいい」と思って共有しています。\n\n"
|
||||
"実際の内容は、誰の役にも立たない、死ぬほどどうでもいい雑学・豆知識にしてください。\n"
|
||||
"- 全体で60文字以内(絵文字含む)に必ず収める。1文の短いつぶやきでよい\n"
|
||||
"- 「これは役に立ちますよ」「知っておくと得しますよ」というような、善意・自信たっぷりの前置きや一言を添える\n"
|
||||
"- ただし中身は本当にどうでもいい内容にする(このギャップが面白さのポイント)\n"
|
||||
"- 「どや顔」「これ豆な」的な、ちょっと得意げなノリにする\n"
|
||||
"- 絵文字を1つくらい添えてもよい\n"
|
||||
"- 出力は本文のみ(前置き・説明不要)\n"
|
||||
"- 出力は本文のみ(メタな説明やこの指示の繰り返しは不要)\n"
|
||||
"- 過去に何度も言われていそうな定番ネタは避け、できるだけ意外性のある内容にする"
|
||||
)
|
||||
if topic_word:
|
||||
prompt += (
|
||||
f"\n- 雑談Botでの会話に出てきた「{topic_word}」という話題に、こじつけでもいいので"
|
||||
"何らかの形でネタを絡めてください(無関係でも強引に繋げてOK)"
|
||||
)
|
||||
|
||||
try:
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
tip = await complete(messages, model=USELESS_TIPS_MODEL)
|
||||
content = f"🤷 **どうでもいいtips**\n{tip.strip()}"
|
||||
content = f"💡 **今日のお役立ち情報**\n{tip.strip()}"
|
||||
await send_discord_message(DISCORD_WEBHOOK_URL_TIPS, content)
|
||||
except Exception:
|
||||
logger.exception("Failed to send useless tip")
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""ユーザーメッセージにタグを生成してDBに記録する機能。
|
||||
|
||||
単体でも呼び出せるよう、LLM呼び出しとDB保存ロジックを分離している。
|
||||
- generate_tags(): LLMでタグを3つ生成するだけ(DB非依存)
|
||||
- tag_message(): 1メッセージ分のタグを生成してDBに保存
|
||||
- backfill_tags(): 未タグ付けのメッセージをまとめて処理する(過去履歴向け)
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core import word_memory
|
||||
from app.core.config import TAGGING_MODEL
|
||||
from app.core.llm import complete
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import Memory, MemoryTag, Message, MessageTag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TAGGING_PROMPT = (
|
||||
"以下のメッセージの内容を表す短いタグを3つ生成してください。\n"
|
||||
"- 各タグは1〜6文字程度の日本語の単語・短いフレーズ\n"
|
||||
"- メッセージの話題・意図・感情などを要約するようなタグにする\n"
|
||||
"- 出力は以下のJSON形式の配列のみ。説明文や前置きは一切不要\n"
|
||||
'例: ["旅行", "質問", "予算相談"]\n\n'
|
||||
"メッセージ:\n{content}"
|
||||
)
|
||||
|
||||
|
||||
async def generate_tags(content: str, model: str | None = None) -> list[str]:
|
||||
"""メッセージ本文からタグを3つ生成する。失敗時は空リストを返す。"""
|
||||
if not content.strip():
|
||||
return []
|
||||
|
||||
prompt = _TAGGING_PROMPT.format(content=content)
|
||||
|
||||
try:
|
||||
result = await complete([{"role": "user", "content": prompt}], model=model or TAGGING_MODEL)
|
||||
tags = _parse_tags(result)
|
||||
return tags[:3]
|
||||
except Exception:
|
||||
logger.exception("Failed to generate tags for message")
|
||||
return []
|
||||
|
||||
|
||||
def _parse_tags(raw: str) -> list[str]:
|
||||
"""LLMの出力からタグのリストを取り出す。多少のフォーマット崩れは吸収する。"""
|
||||
text = raw.strip()
|
||||
# ```json ... ``` のようなコードフェンスを剥がす
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
if text.lower().startswith("json"):
|
||||
text = text[4:]
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, list):
|
||||
return [str(tag).strip() for tag in parsed if str(tag).strip()]
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# JSON解析に失敗した場合、カンマ・改行区切りとして雑にフォールバック
|
||||
fallback = [t.strip(" -・\"'[]") for t in text.replace("\n", ",").split(",")]
|
||||
return [t for t in fallback if t]
|
||||
|
||||
|
||||
async def tag_message(db: DBSession, message: Message, model: str | None = None) -> list[str]:
|
||||
"""指定したメッセージに対してタグを生成し、DBに保存する。"""
|
||||
tags = await generate_tags(message.content, model=model)
|
||||
for tag in tags:
|
||||
db.add(MessageTag(message_id=message.id, tag=tag))
|
||||
db.commit()
|
||||
return tags
|
||||
|
||||
|
||||
async def tag_memory(db: DBSession, memory: Memory, model: str | None = None) -> list[str]:
|
||||
"""要約(Memory)に対してタグを生成し、DBに保存する。
|
||||
|
||||
会話履歴がコンテキストサイズの閾値を超えて要約されたタイミングで呼ばれる。
|
||||
"""
|
||||
tags = await generate_tags(memory.summary, model=model)
|
||||
for tag in tags:
|
||||
db.add(MemoryTag(memory_id=memory.id, tag=tag))
|
||||
db.commit()
|
||||
return tags
|
||||
|
||||
|
||||
async def tag_message_by_id(message_id: int, model: str | None = None) -> None:
|
||||
"""メッセージIDだけを受け取り、専用のDBセッションでタグ付けする。
|
||||
|
||||
リクエストを処理中のDBセッションとは別に動かす必要がある場合(バックグラウンドタスクなど)に使う。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
message = db.get(Message, message_id)
|
||||
if message is None:
|
||||
logger.warning("tag_message_by_id: message %s not found", message_id)
|
||||
return
|
||||
await tag_message(db, message, model=model)
|
||||
except Exception:
|
||||
logger.exception("Failed to tag message %s in background", message_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _pick_word_for_tag(db: DBSession, tag: str) -> str | None:
|
||||
"""与えられたタグが付いたメッセージ/要約の本文から単語を抽出し、
|
||||
ワードプール(word_memory)と重なる『近しい単語』を1つ選ぶ。
|
||||
|
||||
重なりが無ければ、抽出した単語からランダムに1つ選ぶ。それも無ければNone。
|
||||
"""
|
||||
tagged_message_ids = {
|
||||
mid for (mid,) in db.query(MessageTag.message_id).filter(MessageTag.tag == tag).all()
|
||||
}
|
||||
tagged_memory_ids = {
|
||||
mid for (mid,) in db.query(MemoryTag.memory_id).filter(MemoryTag.tag == tag).all()
|
||||
}
|
||||
|
||||
texts: list[str] = []
|
||||
if tagged_message_ids:
|
||||
texts += [
|
||||
content for (content,) in db.query(Message.content).filter(Message.id.in_(tagged_message_ids)).all()
|
||||
]
|
||||
if tagged_memory_ids:
|
||||
texts += [
|
||||
summary for (summary,) in db.query(Memory.summary).filter(Memory.id.in_(tagged_memory_ids)).all()
|
||||
]
|
||||
|
||||
words: list[str] = []
|
||||
for text in texts:
|
||||
words += word_memory.extract_words(text)
|
||||
if not words:
|
||||
return None
|
||||
|
||||
return word_memory.pick_word_near(words) or random.choice(words)
|
||||
|
||||
|
||||
def find_related_tag(seed_tags: list[str]) -> str | None:
|
||||
"""与えられたタグ群から、同じメッセージ/要約に付いていた『近しい話題』を探し、
|
||||
その話題に紐づく本文からword_memoryに近しい単語を1つ選ぶ。
|
||||
|
||||
関連タグが見つからない場合はseed_tagsから選んだタグで同様に単語を探す。
|
||||
タグに紐づく単語も見つからなければ、タグ自体(文字列)をそのまま返す。
|
||||
雑談Botが話を振られた際、関連知識を生成するためのトピック選びに使う。
|
||||
"""
|
||||
if not seed_tags:
|
||||
return None
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
message_ids = {
|
||||
mid for (mid,) in db.query(MessageTag.message_id).filter(MessageTag.tag.in_(seed_tags)).all()
|
||||
}
|
||||
memory_ids = {
|
||||
mid for (mid,) in db.query(MemoryTag.memory_id).filter(MemoryTag.tag.in_(seed_tags)).all()
|
||||
}
|
||||
|
||||
candidates: list[str] = []
|
||||
if message_ids:
|
||||
candidates += [
|
||||
tag for (tag,) in db.query(MessageTag.tag).filter(MessageTag.message_id.in_(message_ids)).all()
|
||||
]
|
||||
if memory_ids:
|
||||
candidates += [
|
||||
tag for (tag,) in db.query(MemoryTag.tag).filter(MemoryTag.memory_id.in_(memory_ids)).all()
|
||||
]
|
||||
|
||||
related = [tag for tag in candidates if tag not in seed_tags]
|
||||
chosen_tag = random.choice(related) if related else random.choice(seed_tags)
|
||||
|
||||
return _pick_word_for_tag(db, chosen_tag) or chosen_tag
|
||||
except Exception:
|
||||
logger.exception("Failed to find related tag")
|
||||
return random.choice(seed_tags)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def backfill_tags(db: DBSession, model: str | None = None, limit: int | None = None) -> int:
|
||||
"""まだタグが付いていないユーザー/アシスタントメッセージにまとめてタグを付ける。
|
||||
|
||||
単体スクリプト・APIエンドポイントの両方から呼べるようにここに切り出している。
|
||||
戻り値: タグ付けしたメッセージ数
|
||||
"""
|
||||
query = (
|
||||
db.query(Message)
|
||||
.outerjoin(MessageTag, MessageTag.message_id == Message.id)
|
||||
.filter(Message.role.in_(["user", "assistant"]), MessageTag.id.is_(None))
|
||||
.order_by(Message.created_at.asc())
|
||||
)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
messages = query.all()
|
||||
count = 0
|
||||
for message in messages:
|
||||
await tag_message(db, message, model=model)
|
||||
count += 1
|
||||
|
||||
logger.info("Backfilled tags for %d message(s)", count)
|
||||
return count
|
||||
@@ -0,0 +1,56 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import TAVILY_API_KEY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TAVILY_SEARCH_URL = "https://api.tavily.com/search"
|
||||
|
||||
WEB_SEARCH_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "最新情報・事実確認・知らない固有名詞など、自分の知識だけでは答えられない可能性があるときにWebを検索する。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "検索したい内容を表す検索クエリ"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def search_web(query: str, max_results: int = 5) -> list[dict]:
|
||||
"""Tavily APIでWeb検索する。結果は [{title, url, content}, ...]。失敗時は空リスト。"""
|
||||
if not TAVILY_API_KEY:
|
||||
logger.warning("TAVILY_API_KEY is not set; skipping web search")
|
||||
return []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
TAVILY_SEARCH_URL,
|
||||
json={
|
||||
"api_key": TAVILY_API_KEY,
|
||||
"query": query,
|
||||
"max_results": max_results,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Tavily search failed for query=%r", query)
|
||||
return []
|
||||
|
||||
results = []
|
||||
for item in data.get("results", []):
|
||||
results.append({
|
||||
"title": item.get("title") or "",
|
||||
"url": item.get("url") or "",
|
||||
"content": item.get("content") or "",
|
||||
})
|
||||
return results
|
||||
@@ -0,0 +1,113 @@
|
||||
"""雑談Botに送られたメッセージから単語を記録し、あとでランダムに参照できるようにする。
|
||||
|
||||
形態素解析(Janome)で名詞・固有名詞・カタカナ語などの「ネタになりそうな単語」だけを抽出する。
|
||||
"""
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from collections import deque
|
||||
|
||||
from janome.tokenizer import Tokenizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# コードブロック・インラインコードはまるごと除去(コード片を単語として拾わないため)
|
||||
_CODE_BLOCK_RE = re.compile(r"```.*?```", re.DOTALL)
|
||||
_INLINE_CODE_RE = re.compile(r"`[^`]*`")
|
||||
# 見出し・引用・リスト記号の行頭マーカー
|
||||
_LINE_MARKER_RE = re.compile(r"^[ \t]*(#{1,6}|[-*+]|\d+\.|>)[ \t]+", re.MULTILINE)
|
||||
# 強調・打ち消し線などのマーカー(中身のテキストは残す)
|
||||
_EMPHASIS_RE = re.compile(r"(\*\*\*|\*\*|\*|___|__|_|~~)")
|
||||
# [テキスト](URL) → テキストのみ残す
|
||||
_LINK_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
||||
# 残った記法記号(テーブル区切り・水平線など)
|
||||
_LEFTOVER_SYMBOLS_RE = re.compile(r"[`#>|]|^[ \t]*[-=]{3,}[ \t]*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _strip_markdown(content: str) -> str:
|
||||
"""Markdown記法の記号を取り除き、プレーンテキストに近い形にする。"""
|
||||
text = _CODE_BLOCK_RE.sub(" ", content)
|
||||
text = _INLINE_CODE_RE.sub(" ", text)
|
||||
text = _LINE_MARKER_RE.sub("", text)
|
||||
text = _LINK_RE.sub(r"\1", text)
|
||||
text = _EMPHASIS_RE.sub("", text)
|
||||
text = _LEFTOVER_SYMBOLS_RE.sub(" ", text)
|
||||
return text
|
||||
|
||||
_MAX_WORDS = 200
|
||||
_recent_words: deque[str] = deque(maxlen=_MAX_WORDS)
|
||||
|
||||
_tokenizer = Tokenizer()
|
||||
|
||||
# 一般的すぎて雑学のネタとして面白くない名詞を除外する
|
||||
_STOPWORDS = {
|
||||
"こと", "もの", "ところ", "とき", "ため", "よう", "これ", "それ", "あれ", "どこ",
|
||||
"ここ", "そこ", "あなた", "わたし", "私", "僕", "自分", "今日", "明日", "昨日",
|
||||
"何", "誰", "皆", "みんな", "一つ", "感じ",
|
||||
}
|
||||
|
||||
# 名詞のうち、ネタとして拾いたいサブカテゴリ(数詞・代名詞・非自立語などは除く)
|
||||
_TARGET_NOUN_SUBTYPES = {"一般", "固有名詞", "サ変接続"}
|
||||
|
||||
|
||||
def extract_words(content: str) -> list[str]:
|
||||
"""テキストを形態素解析し、ネタになりそうな名詞のリストを返す(ワードプールへの記録はしない)。"""
|
||||
words: list[str] = []
|
||||
try:
|
||||
plain_text = _strip_markdown(content)
|
||||
for token in _tokenizer.tokenize(plain_text):
|
||||
features = token.part_of_speech.split(",")
|
||||
pos, subtype = features[0], features[1] if len(features) > 1 else ""
|
||||
|
||||
if pos != "名詞" or subtype not in _TARGET_NOUN_SUBTYPES:
|
||||
continue
|
||||
|
||||
word = token.base_form
|
||||
if len(word) < 2 or word in _STOPWORDS:
|
||||
continue
|
||||
|
||||
words.append(word)
|
||||
except Exception:
|
||||
logger.exception("Failed to tokenize text for word extraction: %r", content)
|
||||
return words
|
||||
|
||||
|
||||
def record_message(content: str) -> None:
|
||||
"""メッセージ本文を形態素解析し、名詞をワードプールに記録する。"""
|
||||
_recent_words.extend(extract_words(content))
|
||||
|
||||
|
||||
def pick_random_word() -> str | None:
|
||||
if not _recent_words:
|
||||
return None
|
||||
return random.choice(list(_recent_words))
|
||||
|
||||
|
||||
def pick_word_near(words: list[str]) -> str | None:
|
||||
"""指定した単語群(タグに紐づくメッセージ等から抽出した語)と、現在のワードプールの両方に
|
||||
含まれる単語からランダムに1つ選ぶ。共通する単語が無ければNoneを返す。
|
||||
"""
|
||||
if not words:
|
||||
return None
|
||||
pool = set(_recent_words)
|
||||
candidates = [w for w in words if w in pool]
|
||||
if not candidates:
|
||||
return None
|
||||
return random.choice(candidates)
|
||||
|
||||
|
||||
def seed_from_history(messages: list[str]) -> None:
|
||||
"""起動時にDBの既存メッセージから単語を読み込んでおく。"""
|
||||
for content in messages:
|
||||
record_message(content)
|
||||
|
||||
|
||||
def get_word_pool_with_counts() -> list[dict]:
|
||||
"""現在のワードプールを出現回数の多い順で返す。"""
|
||||
counts: dict[str, int] = {}
|
||||
for word in _recent_words:
|
||||
counts[word] = counts.get(word, 0) + 1
|
||||
return [
|
||||
{"word": word, "count": count}
|
||||
for word, count in sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
|
||||
]
|
||||
@@ -27,6 +27,18 @@ class Message(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="messages")
|
||||
tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MessageTag(Base):
|
||||
__tablename__ = "message_tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"))
|
||||
tag: Mapped[str] = mapped_column(String)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
message: Mapped["Message"] = relationship(back_populates="tags")
|
||||
|
||||
|
||||
class Memory(Base):
|
||||
@@ -39,6 +51,41 @@ class Memory(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="memories")
|
||||
tags: Mapped[list["MemoryTag"]] = relationship(back_populates="memory", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MemoryTag(Base):
|
||||
__tablename__ = "memory_tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
memory_id: Mapped[int] = mapped_column(ForeignKey("memories.id", ondelete="CASCADE"))
|
||||
tag: Mapped[str] = mapped_column(String)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
memory: Mapped["Memory"] = relationship(back_populates="tags")
|
||||
|
||||
|
||||
class KnowledgeEntry(Base):
|
||||
"""Web検索で得た知識。セッションを横断して蓄積し、以後の会話のシステムプロンプトに注入する。"""
|
||||
__tablename__ = "knowledge_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
query: Mapped[str] = mapped_column(String)
|
||||
summary: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
sources: Mapped[list["KnowledgeSource"]] = relationship(back_populates="entry", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class KnowledgeSource(Base):
|
||||
__tablename__ = "knowledge_sources"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
entry_id: Mapped[int] = mapped_column(ForeignKey("knowledge_entries.id", ondelete="CASCADE"))
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
url: Mapped[str] = mapped_column(String)
|
||||
|
||||
entry: Mapped["KnowledgeEntry"] = relationship(back_populates="sources")
|
||||
|
||||
|
||||
class ApiLog(Base):
|
||||
|
||||
+19
-1
@@ -7,13 +7,31 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import CORS_ORIGINS
|
||||
from app.core.discord_bot import start_discord_bot
|
||||
from app.core.scheduler import start_scheduler
|
||||
from app.db.database import init_db
|
||||
from app.core.word_memory import seed_from_history
|
||||
from app.db.database import SessionLocal, init_db
|
||||
from app.db.models import Message
|
||||
from app.api import chat, memory, logs, system
|
||||
|
||||
|
||||
def _seed_word_memory_from_db() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = (
|
||||
db.query(Message.content)
|
||||
.filter(Message.role.in_(["user", "assistant"]))
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
seed_from_history([content for (content,) in rows])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
_seed_word_memory_from_db()
|
||||
start_scheduler()
|
||||
asyncio.create_task(start_discord_bot())
|
||||
yield
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"apscheduler>=3.11.2",
|
||||
"httpx>=0.28.1",
|
||||
"discord-py>=2.7.1",
|
||||
"janome>=0.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Generated
+11
@@ -727,6 +727,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "janome"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/4e/dc2b1a89a4ffafbf9bf49c8e11f69e28ba8b3ef81d11afe6e9f96caee6cc/Janome-0.5.0.tar.gz", hash = "sha256:ce4a3ed7a4635c2f80139639327d5b1e0381858ad74a3c4a61e8cc83f820400e", size = 18829020, upload-time = "2023-07-01T10:53:09.161Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/70f4069f4bbf0fca023e82a1fbbade6f5216365d4fe259fee1950723eca5/Janome-0.5.0-py2.py3-none-any.whl", hash = "sha256:d098670394a77881ce2f6b7d696c0ea5ff74c0c8cf74a8a882159ec82c0e6dc7", size = 19654103, upload-time = "2023-07-01T10:52:58.572Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
@@ -1114,6 +1123,7 @@ dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "janome" },
|
||||
{ name = "litellm" },
|
||||
{ name = "nvidia-ml-py" },
|
||||
{ name = "psutil" },
|
||||
@@ -1140,6 +1150,7 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
|
||||
{ name = "huggingface-hub", specifier = ">=1.17.0" },
|
||||
{ name = "janome", specifier = ">=0.5.0" },
|
||||
{ name = "litellm", specifier = ">=1.40.0" },
|
||||
{ name = "nvidia-ml-py", specifier = ">=13.610.43" },
|
||||
{ name = "psutil", specifier = ">=7.2.2" },
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user