Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08b1785a4 | ||
|
|
ef588d99d8 | ||
|
|
1ad8b130c7 | ||
|
|
002282a8b7 | ||
|
|
20cb449f76 | ||
|
|
db98677838 | ||
|
|
d207537b53 |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "frontend-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "backend-dev",
|
||||
"runtimeExecutable": "backend\\run_dev.cmd",
|
||||
"runtimeArgs": [],
|
||||
"port": 8000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -59,8 +59,8 @@ Copy-Item .env.example .env
|
||||
frontend (Vue3 + Pinia, :5173)
|
||||
↕ REST + SSE (Vite proxy)
|
||||
backend (FastAPI, :8000)
|
||||
├── app/api/ チャット・メモリ・ログ のルーター
|
||||
├── app/core/ LLM ラッパー・メモリロジック・設定
|
||||
├── app/api/ チャット・メモリ・ノート・ログ のルーター
|
||||
├── app/core/ LLM ラッパー・メモリロジック・共有ノート・設定
|
||||
└── app/db/ SQLAlchemy モデル・DB 接続
|
||||
↕
|
||||
SQLite (playchat.db) + LiteLLM → 任意の LLM
|
||||
@@ -71,9 +71,19 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
|
||||
- **短期**: セッション内の全メッセージをコンテキストに含める
|
||||
- **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると古い半分を LLM で要約 → `memories` テーブルに保存 → 次回以降のシステムプロンプトに注入
|
||||
|
||||
### 共有ノート
|
||||
|
||||
ユーザーと AI が共同編集するノート。`note_pages` テーブルに 1 ページ 1 行で保存し、UI ではタブとして切り替える。
|
||||
|
||||
- **モデルから見える範囲**: システムプロンプトに入るのは**ページのタイトル一覧**(と本文の先頭 40 文字程度のプレビュー)だけ。本文全体は含めない
|
||||
- **モデルによる読み書き**: `read_note` / `write_note` ツール(`app/core/notes.py`)で行う。`write_note` は存在しないタイトルを指定するとページを新規作成し、`mode` が `append`(既定)なら追記、`replace` なら全置換
|
||||
- **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される
|
||||
- **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる
|
||||
- **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない
|
||||
|
||||
### SSE ストリーミング
|
||||
|
||||
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `done`(完了)/ `error`(エラー)。
|
||||
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
|
||||
フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。
|
||||
|
||||
### Markdown レンダリング
|
||||
@@ -107,3 +117,6 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
|
||||
| `OPENAI_API_KEY` | — | OpenAI API キー |
|
||||
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
|
||||
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
|
||||
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
|
||||
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
|
||||
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
|
||||
|
||||
@@ -16,6 +16,22 @@ AWS_BEARER_TOKEN_BEDROCK=
|
||||
# デバッグ (true にすると LiteLLM の詳細ログが出力される)
|
||||
DEBUG=false
|
||||
|
||||
# GUIの「設定」画面(APIキー等の変更)を保護する簡易パスワード。
|
||||
# 未設定だと設定画面は常に403で拒否される(=GUIからのAPIキー変更機能を使わないなら空でよい)。
|
||||
# HTTPS化していない環境ではBasic認証の資格情報が平文で流れるため、公開環境ではHTTPS化とセットで使うこと。
|
||||
SETTINGS_PASSWORD=
|
||||
|
||||
# サイト全体(チャット含む全機能)を保護する簡易パスワード。SETTINGS_PASSWORDとは別物。
|
||||
# 未設定だとこれまで通りサイト全体が無認証で使える。外部公開する場合は必ず設定すること。
|
||||
SITE_PASSWORD=
|
||||
# セッションCookieの署名鍵。未設定でも動くが、その場合バックエンド再起動のたびに全員ログインし直しになる。
|
||||
# 公開環境では `openssl rand -hex 32` などで生成した値を設定しておくこと。
|
||||
SESSION_SECRET_KEY=
|
||||
# ローカルのhttp開発環境(HTTPS化していない)でログインが効かない場合のみ false にする。公開環境では変更しないこと。
|
||||
SESSION_COOKIE_SECURE=true
|
||||
# ログインセッションの有効日数
|
||||
SESSION_MAX_AGE_DAYS=30
|
||||
|
||||
# Tavily Web検索(未設定だとweb_searchツールは何も検索せず空振りする)
|
||||
TAVILY_API_KEY=
|
||||
# 蓄積知識をシステムプロンプトに注入する際の最大件数
|
||||
|
||||
+4
-2
@@ -4,8 +4,10 @@ RUN pip install --no-cache-dir uv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
# UV_COMPILE_BYTECODE は無効のまま(janomeの辞書データファイルが巨大で、
|
||||
# 事前バイトコードコンパイルが小規模インスタンスで60秒タイムアウトすることがあるため)。
|
||||
# 初回import時に遅延コンパイルされるだけで動作に影響はない。
|
||||
ENV UV_LINK_MODE=copy \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# 依存関係だけ先にインストールしてレイヤーキャッシュを効かせる
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import SESSION_COOKIE_SECURE, SESSION_MAX_AGE_DAYS, SITE_PASSWORD
|
||||
from app.core.site_auth import COOKIE_NAME, create_session_token, verify_session_token
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class AuthStatus(BaseModel):
|
||||
required: bool
|
||||
authenticated: bool
|
||||
|
||||
|
||||
@router.get("/status", response_model=AuthStatus)
|
||||
def status(request: Request):
|
||||
if not SITE_PASSWORD:
|
||||
return AuthStatus(required=False, authenticated=True)
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
return AuthStatus(required=True, authenticated=bool(token and verify_session_token(token)))
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthStatus)
|
||||
def login(req: LoginRequest, response: Response):
|
||||
if not SITE_PASSWORD:
|
||||
raise HTTPException(status_code=403, detail="SITE_PASSWORD is not configured on the server")
|
||||
if not secrets.compare_digest(req.password, SITE_PASSWORD):
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
create_session_token(),
|
||||
max_age=SESSION_MAX_AGE_DAYS * 86400,
|
||||
httponly=True,
|
||||
secure=SESSION_COOKIE_SECURE,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
return AuthStatus(required=True, authenticated=True)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=AuthStatus)
|
||||
def logout(response: Response):
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
return AuthStatus(required=bool(SITE_PASSWORD), authenticated=False)
|
||||
+97
-6
@@ -2,16 +2,17 @@ import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Session, Message
|
||||
from app.db.models import Session, Message, MessageTag
|
||||
from app.core.memory_kb import memory as mem
|
||||
from app.core import llm
|
||||
from app.core.memory_kb import knowledge
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.core.notes import NOTE_TOOLS
|
||||
from app.core.search.websearch import WEB_SEARCH_TOOL
|
||||
from app.core.config import DEFAULT_MODEL
|
||||
from app.core.memory_kb.tagging import tag_message_by_id
|
||||
@@ -47,6 +48,25 @@ class MessageResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ImportMessage(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime | None = None
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
class ImportSessionMeta(BaseModel):
|
||||
title: str | None = None
|
||||
model: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
session: ImportSessionMeta = ImportSessionMeta()
|
||||
messages: list[ImportMessage]
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionResponse])
|
||||
def list_sessions(db: DBSession = Depends(get_db)):
|
||||
return db.query(Session).order_by(Session.updated_at.desc()).all()
|
||||
@@ -69,6 +89,73 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)):
|
||||
]
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/export")
|
||||
def export_session(session_id: str, db: DBSession = Depends(get_db)):
|
||||
"""セッションのメタデータと全メッセージをJSONファイルとしてダウンロードする。"""
|
||||
session = db.get(Session, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
data = {
|
||||
"session": {
|
||||
"id": session.id,
|
||||
"title": session.title,
|
||||
"model": session.model,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"updated_at": session.updated_at.isoformat(),
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"tags": [t.tag for t in m.tags],
|
||||
}
|
||||
for m in session.messages
|
||||
],
|
||||
}
|
||||
return JSONResponse(
|
||||
content=data,
|
||||
headers={"Content-Disposition": f'attachment; filename="session_{session.id}.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/import", response_model=SessionResponse, status_code=201)
|
||||
def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
|
||||
"""export で出力したJSON(またはその形式に沿ったJSON)から新規セッションを作成する。"""
|
||||
if not req.messages:
|
||||
raise HTTPException(status_code=400, detail="messages is empty")
|
||||
for m in req.messages:
|
||||
if m.role not in ("user", "assistant", "system"):
|
||||
raise HTTPException(status_code=400, detail=f"invalid role: {m.role}")
|
||||
|
||||
title = req.session.title or req.messages[0].content[:40]
|
||||
session = Session(
|
||||
id=str(uuid.uuid4()),
|
||||
title=title,
|
||||
model=req.session.model or DEFAULT_MODEL,
|
||||
)
|
||||
if req.session.created_at:
|
||||
session.created_at = req.session.created_at
|
||||
session.updated_at = req.session.updated_at or datetime.now(timezone.utc)
|
||||
db.add(session)
|
||||
db.flush()
|
||||
|
||||
for m in req.messages:
|
||||
msg = Message(session_id=session.id, role=m.role, content=m.content)
|
||||
if m.created_at:
|
||||
msg.created_at = m.created_at
|
||||
db.add(msg)
|
||||
db.flush()
|
||||
for tag in m.tags:
|
||||
db.add(MessageTag(message_id=msg.id, tag=tag))
|
||||
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", status_code=204)
|
||||
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
|
||||
session = db.get(Session, session_id)
|
||||
@@ -124,9 +211,12 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
used_model = req.model or session.model
|
||||
full_response = []
|
||||
try:
|
||||
resolved_context, searches = await llm.resolve_web_search(
|
||||
context, model=used_model, session_id=session.id,
|
||||
resolved_context, searches, notes_changed = await llm.resolve_tool_calls(
|
||||
context, model=used_model, session_id=session.id, db=db,
|
||||
)
|
||||
if notes_changed:
|
||||
# AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる
|
||||
yield sse({"type": "notes_updated"})
|
||||
if searches:
|
||||
sources = [
|
||||
{"title": r["title"], "url": r["url"]}
|
||||
@@ -139,9 +229,10 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
knowledge.save_knowledge(db, s["query"], summary, s["results"])
|
||||
local_knowledge.save_search_result(s["query"], summary, s["results"])
|
||||
|
||||
search_tools = [WEB_SEARCH_TOOL] if len(resolved_context) > len(context) else None
|
||||
# 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
|
||||
used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] 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,
|
||||
resolved_context, model=used_model, session_id=session.id, tools=used_tools,
|
||||
):
|
||||
full_response.append(chunk)
|
||||
yield sse({"type": "chunk", "value": chunk})
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.auth import require_settings_password
|
||||
from app.db.database import get_db
|
||||
from app.db.models import ModelPreset
|
||||
|
||||
router = APIRouter(prefix="/api/models", tags=["models"])
|
||||
|
||||
|
||||
class ModelPresetResponse(BaseModel):
|
||||
id: int
|
||||
label: str
|
||||
value: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ModelPresetRequest(BaseModel):
|
||||
label: str
|
||||
value: str
|
||||
|
||||
|
||||
@router.get("", response_model=list[ModelPresetResponse])
|
||||
def list_model_presets(db: DBSession = Depends(get_db)):
|
||||
"""モデル選択プルダウンの表示に使うため認証不要(誰でも読める)。"""
|
||||
return db.query(ModelPreset).order_by(ModelPreset.id).all()
|
||||
|
||||
|
||||
@router.post("", response_model=ModelPresetResponse, dependencies=[Depends(require_settings_password)])
|
||||
def create_model_preset(req: ModelPresetRequest, db: DBSession = Depends(get_db)):
|
||||
preset = ModelPreset(label=req.label, value=req.value)
|
||||
db.add(preset)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=f"モデル文字列が重複しています: {req.value}") from e
|
||||
db.refresh(preset)
|
||||
return preset
|
||||
|
||||
|
||||
@router.put("/{preset_id}", response_model=ModelPresetResponse, dependencies=[Depends(require_settings_password)])
|
||||
def update_model_preset(preset_id: int, req: ModelPresetRequest, db: DBSession = Depends(get_db)):
|
||||
preset = db.get(ModelPreset, preset_id)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="Model preset not found")
|
||||
preset.label = req.label
|
||||
preset.value = req.value
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=f"モデル文字列が重複しています: {req.value}") from e
|
||||
db.refresh(preset)
|
||||
return preset
|
||||
|
||||
|
||||
@router.delete("/{preset_id}", status_code=204, dependencies=[Depends(require_settings_password)])
|
||||
def delete_model_preset(preset_id: int, db: DBSession = Depends(get_db)):
|
||||
preset = db.get(ModelPreset, preset_id)
|
||||
if preset:
|
||||
db.delete(preset)
|
||||
db.commit()
|
||||
@@ -0,0 +1,73 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.notes import next_position
|
||||
from app.db.database import get_db
|
||||
from app.db.models import NotePage
|
||||
|
||||
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
||||
|
||||
|
||||
class NotePageResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
position: int
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotePageCreate(BaseModel):
|
||||
title: str = "新しいページ"
|
||||
content: str = ""
|
||||
|
||||
|
||||
class NotePageUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
content: str | None = None
|
||||
|
||||
|
||||
@router.get("", response_model=list[NotePageResponse])
|
||||
def list_pages(db: DBSession = Depends(get_db)):
|
||||
return db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||
|
||||
|
||||
@router.post("", response_model=NotePageResponse, status_code=201)
|
||||
def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
|
||||
title = req.title.strip() or "新しいページ"
|
||||
page = NotePage(title=title, content=req.content, position=next_position(db))
|
||||
db.add(page)
|
||||
db.commit()
|
||||
db.refresh(page)
|
||||
return page
|
||||
|
||||
|
||||
@router.patch("/{page_id}", response_model=NotePageResponse)
|
||||
def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_db)):
|
||||
page = db.get(NotePage, page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Note page not found")
|
||||
if req.title is not None:
|
||||
title = req.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is empty")
|
||||
page.title = title
|
||||
if req.content is not None:
|
||||
page.content = req.content
|
||||
db.commit()
|
||||
db.refresh(page)
|
||||
return page
|
||||
|
||||
|
||||
@router.delete("/{page_id}", status_code=204)
|
||||
def delete_page(page_id: int, db: DBSession = Depends(get_db)):
|
||||
page = db.get(NotePage, page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Note page not found")
|
||||
db.delete(page)
|
||||
db.commit()
|
||||
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core import settings_store
|
||||
from app.core.auth import require_settings_password
|
||||
from app.db.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_settings_password)])
|
||||
|
||||
|
||||
class SettingEntry(BaseModel):
|
||||
key: str
|
||||
is_set: bool
|
||||
masked: str | None
|
||||
overridden: bool
|
||||
|
||||
|
||||
class SaveSettingsRequest(BaseModel):
|
||||
values: dict[str, str]
|
||||
|
||||
|
||||
@router.get("", response_model=list[SettingEntry])
|
||||
def list_settings(db: DBSession = Depends(get_db)):
|
||||
return settings_store.list_masked(db)
|
||||
|
||||
|
||||
@router.put("", response_model=list[SettingEntry])
|
||||
def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
|
||||
try:
|
||||
for key, value in req.values.items():
|
||||
if not value:
|
||||
continue # 空欄は「変更なし」として無視する
|
||||
settings_store.save(db, key, value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return settings_store.list_masked(db)
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=list[SettingEntry])
|
||||
def clear_setting(key: str, db: DBSession = Depends(get_db)):
|
||||
try:
|
||||
settings_store.clear(db, key)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return settings_store.list_masked(db)
|
||||
@@ -0,0 +1,23 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
|
||||
from app.core.config import SETTINGS_PASSWORD
|
||||
|
||||
_security = HTTPBasic()
|
||||
|
||||
|
||||
def require_settings_password(credentials: HTTPBasicCredentials = Depends(_security)) -> None:
|
||||
"""設定画面(/api/settings)専用の簡易パスワード認証。ユーザー名は見ない。
|
||||
|
||||
SETTINGS_PASSWORD が未設定の場合は、空パスワードでの突破を防ぐため常に拒否する。
|
||||
"""
|
||||
if not SETTINGS_PASSWORD:
|
||||
raise HTTPException(status_code=403, detail="SETTINGS_PASSWORD is not configured on the server")
|
||||
if not secrets.compare_digest(credentials.password, SETTINGS_PASSWORD):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid password",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import secrets
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -9,6 +10,17 @@ 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")
|
||||
|
||||
# GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
|
||||
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD")
|
||||
|
||||
# サイト全体(設定画面以外の全API)を保護する簡易パスワード。未設定の場合はこれまで通り無認証で動く。
|
||||
SITE_PASSWORD: str | None = os.getenv("SITE_PASSWORD")
|
||||
# セッションCookieの署名に使う秘密鍵。未設定なら起動毎に使い捨てキーを生成する(バックエンド再起動でログインし直しになる)。
|
||||
SESSION_SECRET_KEY: str = os.getenv("SESSION_SECRET_KEY") or secrets.token_hex(32)
|
||||
# ローカルのhttp開発環境ではSecure Cookieが送信されないため、その場合のみfalseにする。公開環境では必ずtrueのままにすること。
|
||||
SESSION_COOKIE_SECURE: bool = os.getenv("SESSION_COOKIE_SECURE", "true").lower() in ("1", "true", "yes")
|
||||
SESSION_MAX_AGE_DAYS: int = int(os.getenv("SESSION_MAX_AGE_DAYS", "30"))
|
||||
|
||||
# メッセージへの自動タグ付けに使うモデル(未設定ならDEFAULT_MODELを使用)
|
||||
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
|
||||
|
||||
@@ -25,6 +37,10 @@ LOCAL_KB_EMBEDDING_MODEL: str = os.getenv("LOCAL_KB_EMBEDDING_MODEL", "text-embe
|
||||
LOCAL_KB_TOP_K: int = int(os.getenv("LOCAL_KB_TOP_K", "3"))
|
||||
LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75"))
|
||||
|
||||
# 共有ノート(ユーザーとAIが共同編集するノート)
|
||||
# read_note ツールが1回に返す本文の最大文字数
|
||||
NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000"))
|
||||
|
||||
# Amazon Bedrock
|
||||
# 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。
|
||||
# IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。
|
||||
|
||||
+32
-16
@@ -2,13 +2,17 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from typing import AsyncIterator, AsyncGenerator
|
||||
from typing import TYPE_CHECKING, AsyncIterator, AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import litellm
|
||||
from app.core.config import DEFAULT_MODEL, DEBUG
|
||||
from app.core.notes import NOTE_TOOLS, execute_note_tool
|
||||
from app.core.search.websearch import WEB_SEARCH_TOOL, search_web
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
litellm.drop_params = True
|
||||
if DEBUG:
|
||||
litellm._turn_on_debug()
|
||||
@@ -103,32 +107,38 @@ async def embed(texts: list[str], model: str) -> list[list[float]]:
|
||||
return [item["embedding"] for item in response.data]
|
||||
|
||||
|
||||
async def resolve_web_search(
|
||||
async def resolve_tool_calls(
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
session_id: str | None = None,
|
||||
db: "DBSession | None" = None,
|
||||
max_rounds: int = 2,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""必要に応じてLLMにweb_searchツールを呼ばせ、検索結果を会話に組み込んだ上でメッセージ列を返す。
|
||||
) -> tuple[list[dict], list[dict], bool]:
|
||||
"""必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。
|
||||
|
||||
モデルがtool callingに対応していない場合は何もせず元のmessagesをそのまま返す。
|
||||
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト)
|
||||
扱うツールは web_search と共有ノート(read_note / write_note)。ノート系は db が
|
||||
渡されたときだけ提示する。モデルがtool callingに対応していない場合は何もせず
|
||||
元のmessagesをそのまま返す。
|
||||
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
|
||||
"""
|
||||
used_model = model or DEFAULT_MODEL
|
||||
working_messages = list(messages)
|
||||
searches: list[dict] = []
|
||||
notes_changed = False
|
||||
|
||||
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS if db is not None else [])
|
||||
|
||||
for _ in range(max_rounds):
|
||||
try:
|
||||
response = await litellm.acompletion(
|
||||
model=used_model,
|
||||
messages=working_messages,
|
||||
tools=[WEB_SEARCH_TOOL],
|
||||
tools=tools,
|
||||
tool_choice="auto",
|
||||
)
|
||||
except Exception:
|
||||
logger.info("Model %s does not support tool calling; skipping web search", used_model)
|
||||
return messages, searches
|
||||
logger.info("Model %s does not support tool calling; skipping tools", used_model)
|
||||
return messages, searches, notes_changed
|
||||
|
||||
choice_message = response.choices[0].message
|
||||
tool_calls = getattr(choice_message, "tool_calls", None)
|
||||
@@ -153,13 +163,19 @@ async def resolve_web_search(
|
||||
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})
|
||||
|
||||
name = tc.function.name
|
||||
if name in ("read_note", "write_note") and db is not None:
|
||||
tool_text, changed = execute_note_tool(db, name, args)
|
||||
notes_changed = notes_changed or changed
|
||||
else:
|
||||
tool_text = "検索結果が見つかりませんでした。"
|
||||
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",
|
||||
@@ -167,4 +183,4 @@ async def resolve_web_search(
|
||||
"content": tool_text,
|
||||
})
|
||||
|
||||
return working_messages, searches
|
||||
return working_messages, searches, notes_changed
|
||||
|
||||
@@ -7,12 +7,13 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core import llm
|
||||
from app.core.config import LOCAL_KB_DIR, LOCAL_KB_EMBEDDING_MODEL, LOCAL_KB_MIN_SCORE, LOCAL_KB_TOP_K
|
||||
from app.core.config import LOCAL_KB_DIR, LOCAL_KB_MIN_SCORE, LOCAL_KB_TOP_K
|
||||
from app.db.models import KnowledgeEntry, LocalKnowledgeDoc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,6 +23,11 @@ def _kb_dir() -> Path:
|
||||
return Path(LOCAL_KB_DIR)
|
||||
|
||||
|
||||
def _embedding_model() -> str:
|
||||
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。"""
|
||||
return os.environ.get("LOCAL_KB_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
|
||||
|
||||
def _hash(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -104,7 +110,7 @@ async def sync_local_kb(db: DBSession) -> None:
|
||||
continue
|
||||
|
||||
try:
|
||||
[embedding] = await llm.embed([content], model=LOCAL_KB_EMBEDDING_MODEL)
|
||||
[embedding] = await llm.embed([content], model=_embedding_model())
|
||||
except Exception:
|
||||
logger.exception("Failed to embed local knowledge file: %s", rel_path)
|
||||
continue
|
||||
@@ -142,7 +148,7 @@ async def get_local_kb_context(db: DBSession, query_text: str) -> str | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
[query_embedding] = await llm.embed([query_text], model=LOCAL_KB_EMBEDDING_MODEL)
|
||||
[query_embedding] = await llm.embed([query_text], model=_embedding_model())
|
||||
except Exception:
|
||||
logger.exception("Failed to embed query for local knowledge search")
|
||||
return None
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.core import llm
|
||||
from app.core.memory_kb import knowledge
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.core.memory_kb.tagging import tag_memory
|
||||
from app.core import notes
|
||||
|
||||
|
||||
async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
||||
@@ -18,6 +19,11 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
||||
if knowledge_context:
|
||||
context.append({"role": "system", "content": knowledge_context})
|
||||
|
||||
# 共有ノートは「タイトル一覧」だけを常時見せ、本文は read_note ツールで読ませる
|
||||
notes_context = notes.get_notes_context(db)
|
||||
if notes_context:
|
||||
context.append({"role": "system", "content": notes_context})
|
||||
|
||||
if messages:
|
||||
latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""ユーザーとAIが共同で編集する共有ノート。
|
||||
|
||||
システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが
|
||||
read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.config import NOTES_TOOL_CONTENT_LIMIT
|
||||
from app.db.models import NotePage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
READ_NOTE_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_note",
|
||||
"description": "共有ノートの特定ページの本文を読み出す。システムプロンプトに提示されたタイトル一覧の中から、"
|
||||
"今の会話に関係しそうなページがあるときに使う。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "読み出すページのタイトル(タイトル一覧に載っているもの)"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
WRITE_NOTE_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_note",
|
||||
"description": "共有ノートのページに書き込む。ユーザーからノートへの記録・追記・整理を頼まれたときに使う。"
|
||||
"存在しないタイトルを指定した場合は新しいページを作成する。"
|
||||
"既存ページを replace で上書きすると元の内容は失われるので、追記でよい場合は必ず append を使うこと。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "書き込む対象のページタイトル"},
|
||||
"content": {"type": "string", "description": "書き込む本文(Markdown可)"},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["append", "replace"],
|
||||
"description": "append=末尾に追記(既定) / replace=ページ全体を置き換え",
|
||||
},
|
||||
},
|
||||
"required": ["title", "content"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
NOTE_TOOLS = [READ_NOTE_TOOL, WRITE_NOTE_TOOL]
|
||||
|
||||
|
||||
def _find_page(db: DBSession, title: str) -> NotePage | None:
|
||||
"""タイトルでページを探す。完全一致 → 大文字小文字・前後空白を無視した一致 の順に試す。"""
|
||||
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||
for page in pages:
|
||||
if page.title == title:
|
||||
return page
|
||||
normalized = title.strip().casefold()
|
||||
for page in pages:
|
||||
if page.title.strip().casefold() == normalized:
|
||||
return page
|
||||
return None
|
||||
|
||||
|
||||
def next_position(db: DBSession) -> int:
|
||||
"""新規ページを末尾に置くための position を返す。"""
|
||||
last = db.query(NotePage).order_by(NotePage.position.desc()).first()
|
||||
return (last.position + 1) if last else 0
|
||||
|
||||
|
||||
def get_notes_context(db: DBSession) -> str | None:
|
||||
"""共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。"""
|
||||
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||
|
||||
lines = [
|
||||
"【共有ノート】",
|
||||
"ユーザーとあなたが共同で編集できるノートです。以下のページがあります。",
|
||||
"",
|
||||
]
|
||||
if pages:
|
||||
for page in pages:
|
||||
preview = " ".join(page.content.split())[:40]
|
||||
suffix = f" — {preview}…" if preview else " — (空)"
|
||||
lines.append(f"- {page.title}{suffix}")
|
||||
else:
|
||||
lines.append("- (まだページがありません)")
|
||||
lines += [
|
||||
"",
|
||||
"本文はここには含まれていません。内容が必要なときは read_note ツールで読み出してください。",
|
||||
"ノートへの記録・追記を頼まれたときは write_note ツールで書き込んでください"
|
||||
"(新しいタイトルを指定すればページが新規作成されます)。",
|
||||
"ユーザーが明示的に頼んでいないのに勝手に書き込まないこと。",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def read_note(db: DBSession, title: str) -> str:
|
||||
"""ページ本文をツール結果用のテキストとして返す。"""
|
||||
page = _find_page(db, title)
|
||||
if not page:
|
||||
available = ", ".join(p.title for p in db.query(NotePage).order_by(NotePage.position, NotePage.id).all())
|
||||
return f"「{title}」というページはありません。現在のページ: {available or '(なし)'}"
|
||||
if not page.content.strip():
|
||||
return f"ページ「{page.title}」は空です。"
|
||||
content = page.content
|
||||
if len(content) > NOTES_TOOL_CONTENT_LIMIT:
|
||||
content = content[:NOTES_TOOL_CONTENT_LIMIT] + "\n…(以下省略)"
|
||||
return f"ページ「{page.title}」の本文:\n\n{content}"
|
||||
|
||||
|
||||
_LIST_ITEM = re.compile(r"^\s*([-*+]|\d+[.)])\s")
|
||||
|
||||
|
||||
def _append(existing: str, addition: str) -> str:
|
||||
"""既存本文に追記する。箇条書き同士をつなぐ場合だけ空行を挟まない。"""
|
||||
if not existing.strip():
|
||||
return addition
|
||||
body = existing.rstrip()
|
||||
last_line = body.rsplit("\n", 1)[-1]
|
||||
first_line = addition.lstrip("\n").split("\n", 1)[0]
|
||||
separator = "\n" if _LIST_ITEM.match(last_line) and _LIST_ITEM.match(first_line) else "\n\n"
|
||||
return f"{body}{separator}{addition}"
|
||||
|
||||
|
||||
def write_note(db: DBSession, title: str, content: str, mode: str = "append") -> str:
|
||||
"""ページに書き込む。存在しなければ新規作成する。"""
|
||||
if not title.strip():
|
||||
return "タイトルが空のため書き込めませんでした。"
|
||||
|
||||
page = _find_page(db, title)
|
||||
if page is None:
|
||||
page = NotePage(title=title.strip(), content=content, position=next_position(db))
|
||||
db.add(page)
|
||||
db.commit()
|
||||
return f"新しいページ「{page.title}」を作成し、本文を書き込みました。"
|
||||
|
||||
if mode == "replace":
|
||||
page.content = content
|
||||
result = f"ページ「{page.title}」の内容を置き換えました。"
|
||||
else:
|
||||
page.content = _append(page.content, content)
|
||||
result = f"ページ「{page.title}」に追記しました。"
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
def execute_note_tool(db: DBSession, name: str, args: dict) -> tuple[str, bool]:
|
||||
"""ノート系ツールを実行する。戻り値: (ツール結果テキスト, ノートを変更したか)。"""
|
||||
try:
|
||||
if name == "read_note":
|
||||
return read_note(db, args.get("title", "")), False
|
||||
if name == "write_note":
|
||||
result = write_note(
|
||||
db,
|
||||
args.get("title", ""),
|
||||
args.get("content", ""),
|
||||
args.get("mode", "append"),
|
||||
)
|
||||
return result, True
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("Note tool %s failed", name)
|
||||
return f"ノート操作に失敗しました: {e}", False
|
||||
return f"未知のツールです: {name}", False
|
||||
@@ -0,0 +1,89 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.db.models import AppSetting
|
||||
|
||||
# GUIから編集可能な環境変数の許可リスト。ここに無いキーは save()/clear() で拒否する。
|
||||
CONFIGURABLE_KEYS = [
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AWS_BEARER_TOKEN_BEDROCK",
|
||||
"AWS_REGION_NAME",
|
||||
"OLLAMA_API_BASE",
|
||||
"TAVILY_API_KEY",
|
||||
"LOCAL_KB_EMBEDDING_MODEL",
|
||||
]
|
||||
|
||||
# .env由来の起動時の値のスナップショット。clear() で戻す先として使う。
|
||||
_env_defaults: dict[str, str | None] = {}
|
||||
_snapshot_taken = False
|
||||
|
||||
|
||||
def _ensure_snapshot() -> None:
|
||||
global _snapshot_taken
|
||||
if _snapshot_taken:
|
||||
return
|
||||
for key in CONFIGURABLE_KEYS:
|
||||
_env_defaults[key] = os.environ.get(key)
|
||||
_snapshot_taken = True
|
||||
|
||||
|
||||
def _mask(value: str) -> str:
|
||||
if len(value) <= 4:
|
||||
return "••••"
|
||||
return f"••••{value[-4:]}"
|
||||
|
||||
|
||||
def load_into_env(db: DBSession) -> None:
|
||||
"""DBに保存済みの値をos.environに適用する。起動時に一度呼ぶ。"""
|
||||
_ensure_snapshot()
|
||||
for row in db.query(AppSetting).all():
|
||||
if row.key in CONFIGURABLE_KEYS:
|
||||
os.environ[row.key] = row.value
|
||||
|
||||
|
||||
def list_masked(db: DBSession) -> list[dict]:
|
||||
_ensure_snapshot()
|
||||
saved = {row.key: row.value for row in db.query(AppSetting).all()}
|
||||
result = []
|
||||
for key in CONFIGURABLE_KEYS:
|
||||
current = os.environ.get(key)
|
||||
result.append({
|
||||
"key": key,
|
||||
"is_set": bool(current),
|
||||
"masked": _mask(current) if current else None,
|
||||
"overridden": key in saved,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def save(db: DBSession, key: str, value: str) -> None:
|
||||
if key not in CONFIGURABLE_KEYS:
|
||||
raise ValueError(f"Unknown setting key: {key}")
|
||||
_ensure_snapshot()
|
||||
row = db.get(AppSetting, key)
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
db.add(AppSetting(key=key, value=value))
|
||||
db.commit()
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def clear(db: DBSession, key: str) -> None:
|
||||
if key not in CONFIGURABLE_KEYS:
|
||||
raise ValueError(f"Unknown setting key: {key}")
|
||||
_ensure_snapshot()
|
||||
row = db.get(AppSetting, key)
|
||||
if row:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
original = _env_defaults.get(key)
|
||||
if original is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = original
|
||||
@@ -0,0 +1,40 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.core.config import SESSION_MAX_AGE_DAYS, SESSION_SECRET_KEY, SITE_PASSWORD
|
||||
|
||||
COOKIE_NAME = "playchat_session"
|
||||
|
||||
|
||||
def _sign(payload: str) -> str:
|
||||
return hmac.new(SESSION_SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def create_session_token() -> str:
|
||||
issued_at = str(int(time.time()))
|
||||
return f"{issued_at}.{_sign(issued_at)}"
|
||||
|
||||
|
||||
def verify_session_token(token: str) -> bool:
|
||||
issued_at, _, signature = token.partition(".")
|
||||
if not issued_at or not signature:
|
||||
return False
|
||||
if not hmac.compare_digest(_sign(issued_at), signature):
|
||||
return False
|
||||
age_seconds = time.time() - int(issued_at)
|
||||
return 0 <= age_seconds <= SESSION_MAX_AGE_DAYS * 86400
|
||||
|
||||
|
||||
def require_site_session(request: Request) -> None:
|
||||
"""サイト全体(設定画面以外の全API)を保護するセッションCookie認証。
|
||||
|
||||
SITE_PASSWORD が未設定の場合は既存のローカル開発体験を壊さないよう常に通す。
|
||||
"""
|
||||
if not SITE_PASSWORD:
|
||||
return
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token or not verify_session_token(token):
|
||||
raise HTTPException(status_code=401, detail="Login required")
|
||||
@@ -104,6 +104,41 @@ class LocalKnowledgeDoc(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class NotePage(Base):
|
||||
"""ユーザーとAIが共同で編集する共有ノートの1ページ。UIではタブとして表示する。
|
||||
|
||||
ページのタイトル一覧は毎回システムプロンプトに注入され、本文はモデルが
|
||||
read_note ツールで読み出す(app.core.notes を参照)。
|
||||
"""
|
||||
__tablename__ = "note_pages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""GUIの設定画面から編集する接続情報(APIキー等)。app.core.settings_store が読み書きする。"""
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ModelPreset(Base):
|
||||
"""モデル選択プルダウン(ModelSelector.vue)に表示するプリセット。GUIの設定画面から編集する。"""
|
||||
__tablename__ = "model_presets"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
label: Mapped[str] = mapped_column(String)
|
||||
value: Mapped[str] = mapped_column(String, unique=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ApiLog(Base):
|
||||
__tablename__ = "api_logs"
|
||||
|
||||
|
||||
+53
-7
@@ -1,16 +1,36 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import auth as auth_api
|
||||
from app.api import chat, logs, memory, notes, system
|
||||
from app.api import models as models_api
|
||||
from app.api import settings as settings_api
|
||||
from app.core import settings_store
|
||||
from app.core.config import CORS_ORIGINS
|
||||
from app.core.site_auth import require_site_session
|
||||
from app.core.discord.discord_bot import start_discord_bot
|
||||
from app.core.discord.scheduler import start_scheduler
|
||||
from app.core.memory_kb.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
|
||||
from app.db.models import Message, ModelPreset
|
||||
|
||||
# ModelSelector.vue に元々ハードコードされていたプリセット。初回起動時のみDBへシードする。
|
||||
_DEFAULT_MODEL_PRESETS = [
|
||||
("GPT-5.5", "gpt-5.5"),
|
||||
("GPT-5.4", "gpt-5.4"),
|
||||
("GPT-5.4 mini", "gpt-5.4-mini"),
|
||||
("Claude Sonnet 4.6", "claude-sonnet-4-6"),
|
||||
("Claude Haiku 4.5", "claude-haiku-4-5-20251001"),
|
||||
("Ollama llama3", "ollama/llama3"),
|
||||
("Ollama Gemma4", "ollama/gemma4"),
|
||||
("Gemma 4 12B (llama-server)", "gpt-3.5-turbo"),
|
||||
("Claude Sonnet 4.6 (Bedrock)", "bedrock/jp.anthropic.claude-sonnet-4-6"),
|
||||
("Llama 3 70B (Bedrock)", "bedrock/meta.llama3-70b-instruct-v1:0"),
|
||||
("Nova 2 Lite (Bedrock)", "bedrock/jp.amazon.nova-2-lite-v1:0"),
|
||||
]
|
||||
|
||||
|
||||
def _seed_word_memory_from_db() -> None:
|
||||
@@ -28,9 +48,31 @@ def _seed_word_memory_from_db() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def _load_settings_into_env() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
settings_store.load_into_env(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _seed_default_models() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if db.query(ModelPreset).first() is not None:
|
||||
return
|
||||
for label, value in _DEFAULT_MODEL_PRESETS:
|
||||
db.add(ModelPreset(label=label, value=value))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
_load_settings_into_env()
|
||||
_seed_default_models()
|
||||
_seed_word_memory_from_db()
|
||||
start_scheduler()
|
||||
asyncio.create_task(start_discord_bot())
|
||||
@@ -48,10 +90,14 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
app.include_router(chat.router)
|
||||
app.include_router(memory.router)
|
||||
app.include_router(logs.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(auth_api.router)
|
||||
app.include_router(chat.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(memory.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(notes.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(logs.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(system.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(settings_api.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(models_api.router, dependencies=[Depends(require_site_session)])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
rem SSLKEYLOGFILE がこのマシンのTLS監視プロキシの名前付きパイプを指していると
|
||||
rem litellm(OpenSSLを直接ロード)が OPENSSL_Uplink エラーで起動時にクラッシュするため、
|
||||
rem このコマンドの実行中だけ unset する(CLAUDE.md の「ローカル開発環境メモ」参照)。
|
||||
set SSLKEYLOGFILE=
|
||||
uv run --directory backend uvicorn app.main:app --reload --port 8000
|
||||
@@ -20,9 +20,24 @@ services:
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
caddy:
|
||||
image: caddy:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
backend_knowledge_base:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
+51
@@ -24,6 +24,7 @@ SSE でストリーミング応答を返す。
|
||||
|---|---|---|
|
||||
| `session_id` | セッション UUID | 新規セッション作成直後 |
|
||||
| `chunk` | テキスト断片 | LLM トークン生成ごと |
|
||||
| `notes_updated` | — | AI が共有ノートを書き換えた直後 |
|
||||
| `done` | — | 正常完了 |
|
||||
| `error` | エラーメッセージ | 例外発生時 |
|
||||
|
||||
@@ -88,6 +89,56 @@ SSE でストリーミング応答を返す。
|
||||
|
||||
---
|
||||
|
||||
## 共有ノート
|
||||
|
||||
ユーザーと AI が共同編集するノート。ページのタイトル一覧は毎回システムプロンプトに注入され、
|
||||
本文は AI が `read_note` / `write_note` ツールで読み書きする。
|
||||
|
||||
### GET /api/notes
|
||||
全ページを `position` 昇順で返す。
|
||||
|
||||
**レスポンス**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "買い物リスト",
|
||||
"content": "- 牛乳
|
||||
- 卵",
|
||||
"position": 0,
|
||||
"updated_at": "2026-08-19T12:00:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/notes
|
||||
ページを新規作成し、`201 Created` で作成したページを返す。
|
||||
|
||||
**リクエスト**
|
||||
```json
|
||||
{ "title": "新しいページ", "content": "" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/notes/{page_id}
|
||||
ページのタイトル・本文を更新する。指定したフィールドだけが変更される。
|
||||
タイトルを空文字にしようとした場合は `400`、ページが存在しない場合は `404` を返す。
|
||||
|
||||
**リクエスト**
|
||||
```json
|
||||
{ "content": "更新後の本文" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/notes/{page_id}
|
||||
ページを削除。`204 No Content` を返す。
|
||||
|
||||
---
|
||||
|
||||
## ログ
|
||||
|
||||
### GET /logs
|
||||
|
||||
@@ -43,6 +43,7 @@ backend/
|
||||
| `sessions` | id (UUID), title, model, created_at, updated_at | チャットセッション |
|
||||
| `messages` | id, session_id, role, content, created_at | 会話メッセージ(短期メモリ) |
|
||||
| `memories` | id, session_id, summary, embedding_json, created_at | 要約による長期メモリ |
|
||||
| `note_pages` | id, title, content, position, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ |
|
||||
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
|
||||
|
||||
### メモリ機構(`core/memory.py`)
|
||||
@@ -57,6 +58,15 @@ backend/
|
||||
- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録
|
||||
- `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される
|
||||
|
||||
### 共有ノート(`core/notes.py` / `api/notes.py`)
|
||||
|
||||
ユーザーと AI が共同編集するノート。`note_pages` の 1 行が UI の 1 タブに対応する。
|
||||
|
||||
- `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない)
|
||||
- 本文の読み書きはツール経由: `read_note`(本文取得)/ `write_note`(追記・置換・新規作成)
|
||||
- `llm.resolve_tool_calls()` が `web_search` と同じループでこれらを解決する。ノート系ツールは `db` を渡したときのみモデルに提示される
|
||||
- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除
|
||||
|
||||
### SSE ストリーミング(`api/chat.py`)
|
||||
|
||||
`POST /chat/stream` は `StreamingResponse` で以下のイベントを順番に送出する:
|
||||
@@ -64,6 +74,7 @@ backend/
|
||||
```
|
||||
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
|
||||
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
|
||||
data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ
|
||||
data: {"type":"done"} # 正常完了
|
||||
data: {"type":"error","value":"エラー内容"} # 例外発生時
|
||||
```
|
||||
|
||||
+90
-17
@@ -10,6 +10,9 @@ Lightsail Containers(マネージドコンテナサービス)よりも、状
|
||||
Internet
|
||||
│ :80 / :443
|
||||
▼
|
||||
[caddy コンテナ] Let's Encrypt証明書の自動取得・更新、HTTP→HTTPSリダイレクト
|
||||
│ :80(コンテナ間通信のみ、ホストには公開しない)
|
||||
▼
|
||||
[frontend コンテナ] nginx
|
||||
├─ 静的ファイル (Vue ビルド成果物) を配信
|
||||
└─ /chat, /api, /memory, /logs, /health を backend にリバースプロキシ
|
||||
@@ -26,7 +29,7 @@ Internet
|
||||
|
||||
- [backend/Dockerfile](../backend/Dockerfile)
|
||||
- [frontend/Dockerfile](../frontend/Dockerfile) / [frontend/nginx.conf](../frontend/nginx.conf)
|
||||
- [docker-compose.yml](../docker-compose.yml)
|
||||
- [docker-compose.yml](../docker-compose.yml) / [Caddyfile](../Caddyfile)
|
||||
|
||||
## 1. Lightsail インスタンスの作成
|
||||
|
||||
@@ -39,7 +42,7 @@ Internet
|
||||
作成後、インスタンス詳細ページの「ネットワーキング」タブで:
|
||||
|
||||
- **静的 IP をアタッチ**(再起動してもIPが変わらないようにする。ドメインを使う場合は必須)
|
||||
- ファイアウォールで **80 (HTTP)** と、HTTPS を使うなら **443** を許可(22/SSH はデフォルトで開いている)
|
||||
- ファイアウォールで **80 (HTTP)** と **443 (HTTPS)** を許可(Caddyの証明書取得とHTTPS配信に両方必要。22/SSH はデフォルトで開いている)
|
||||
|
||||
## 2. SSH 接続 & Docker のインストール
|
||||
|
||||
@@ -59,10 +62,40 @@ docker compose version
|
||||
|
||||
## 3. リポジトリの取得と設定
|
||||
|
||||
```bash
|
||||
git clone <このリポジトリのURL> playchat
|
||||
cd playchat
|
||||
リポジトリは自前の Git サーバー(`colc-git.duckdns.org`、Gitea/Forgejo系)でホストしている。
|
||||
DuckDNS 経由の公開IPで、HTTPS(443)・SSH(22) とも外部から到達可能なので、Lightsail インスタンスからも
|
||||
VPN 等は不要で直接クローンできる。認証方法は以下のどちらか。
|
||||
|
||||
### 方法A: SSH デプロイキー(推奨・pull専用なら安全)
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "lightsail-playchat" -f ~/.ssh/id_ed25519 -N ""
|
||||
cat ~/.ssh/id_ed25519.pub
|
||||
```
|
||||
|
||||
表示された公開鍵を colc-git.duckdns.org 側のリポジトリ設定の **Deploy Keys** に **Read only** で登録してから:
|
||||
|
||||
```bash
|
||||
git clone git@colc-git.duckdns.org:colcaroli/playchat.git
|
||||
cd playchat
|
||||
```
|
||||
|
||||
### 方法B: HTTPS + アクセストークン
|
||||
|
||||
colc-git.duckdns.org 側で個人アクセストークン(PAT)を発行し:
|
||||
|
||||
```bash
|
||||
git clone https://colc-git.duckdns.org/colcaroli/playchat.git
|
||||
cd playchat
|
||||
# Username / Password 入力を求められたら、Password 欄に発行したトークンを入力
|
||||
```
|
||||
|
||||
トークンをキャッシュしたい場合は `git config credential.helper cache`(メモリに一時保存。
|
||||
`store` は平文保存になるため非推奨)。
|
||||
|
||||
### .env の設定
|
||||
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
nano backend/.env # APIキーやモデル名など本番用の値を設定
|
||||
```
|
||||
@@ -76,9 +109,19 @@ nano backend/.env # APIキーやモデル名など本番用の値を設定
|
||||
| `CORS_ORIGINS` | `http://<静的IP>` またはドメイン(nginx が同一オリジンで配信するので基本は素通りするが念のため合わせておく) |
|
||||
| `DATABASE_URL` | **編集不要**(`docker-compose.yml` が `/app/data/playchat.db` に上書きし、Docker Volume に永続化する) |
|
||||
| `DISCORD_BOT_TOKEN` 等 | Discord Bot / 通知機能を使うなら設定。不要なら空のままでよい(未設定時は該当機能が無効) |
|
||||
| `SETTINGS_PASSWORD` | GUIの「設定」画面(後述)を使うなら必ず設定する。未設定だと設定画面は403で拒否される |
|
||||
|
||||
`backend/.env` はコンテナに `env_file` として渡され、Git 管理対象外(`.gitignore` 済み)なので直接編集してよい。
|
||||
|
||||
### GUIからのAPIキー管理
|
||||
|
||||
初回起動後は、`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS認証情報などをSSHで`.env`を編集し直さなくても、
|
||||
アプリ右上の「設定」ボタン(`SETTINGS_PASSWORD` によるBasic認証で保護)から変更できる。GUIで保存した値は
|
||||
SQLite(`AppSetting`テーブル、`backend_data`ボリュームで永続化)に保存され、`.env`の値より優先される。
|
||||
|
||||
**注意:** このパスワード保護は簡易的なものであり、HTTP平文通信の場合はBasic認証の資格情報がネットワーク上に
|
||||
そのまま流れてしまう。この機能を使うなら[6. HTTPS化](#6-https-化caddy)と合わせて運用すること。
|
||||
|
||||
## 4. ビルド & 起動
|
||||
|
||||
```bash
|
||||
@@ -87,7 +130,9 @@ docker compose up -d
|
||||
docker compose logs -f backend # 起動ログ確認(Ctrl+C で抜ける。プロセスは動き続ける)
|
||||
```
|
||||
|
||||
ブラウザで `http://<静的IP>/` にアクセスして動作確認する。
|
||||
`docker-compose.yml`にはCaddyが含まれており、80/443番ポートはCaddyが受け持つ(`frontend`は直接公開しない)。
|
||||
Caddyfileにはドメイン名を設定する前提のため、動作確認は静的IPへの直接アクセスではなく
|
||||
[6. HTTPS化](#6-https-化caddy)の手順でドメイン経由で行うこと。
|
||||
|
||||
停止・再起動・更新の運用コマンド:
|
||||
|
||||
@@ -113,21 +158,49 @@ docker cp $(docker compose ps -q backend):/app/data/backup.db ./playchat-backup-
|
||||
|
||||
インスタンスごとのバックアップとしては、Lightsail の「スナップショット」機能(自動スナップショット設定可)も併用すると安心。
|
||||
|
||||
## 6. HTTPS 化(ドメインがある場合)
|
||||
## 6. HTTPS 化(Caddy)
|
||||
|
||||
ドメインの A レコードを Lightsail の静的 IP に向けた上で、ホスト側で certbot を使い証明書を取得し、
|
||||
`frontend` コンテナへ読み取り専用でマウントする。
|
||||
[Caddy](https://caddyserver.com/) をリバースプロキシとして`frontend`の手前に置き、証明書の取得・更新を
|
||||
自動化する(Let's Encrypt、ACME HTTP-01 チャレンジ)。手動でのcertbot操作は不要。
|
||||
|
||||
```bash
|
||||
sudo apt install -y certbot
|
||||
docker compose stop frontend # 80番ポートを一時的に空ける
|
||||
sudo certbot certonly --standalone -d your-domain.example.com
|
||||
docker compose start frontend
|
||||
構成:
|
||||
|
||||
```
|
||||
Internet
|
||||
│ :80 / :443
|
||||
▼
|
||||
[caddy コンテナ] ← Let's Encrypt証明書の自動取得・更新、HTTP→HTTPSリダイレクト
|
||||
│ :80 (コンテナ間通信、ホストには公開しない)
|
||||
▼
|
||||
[frontend コンテナ] nginx(静的配信 + backendへのAPIプロキシ)
|
||||
▼
|
||||
[backend コンテナ]
|
||||
```
|
||||
|
||||
`frontend/nginx.conf` に 443 用のサーバーブロックを追記し、`docker-compose.yml` の `frontend` サービスに
|
||||
`443:443` のポートマッピングと `/etc/letsencrypt` のボリュームマウントを追加する(証明書更新時は `docker compose restart frontend` が必要。
|
||||
`certbot renew` の `--deploy-hook` に登録しておくと自動化できる)。この構成に変更したい場合は改めて対応する。
|
||||
`frontend`はもう80番ポートをホストに公開せず(`expose`のみ)、代わりに`caddy`が80/443を受けて
|
||||
`frontend`へ内部転送する。ルートの [Caddyfile](../Caddyfile) にドメインを設定する:
|
||||
|
||||
```
|
||||
your-domain.example.com {
|
||||
reverse_proxy frontend:80
|
||||
}
|
||||
```
|
||||
|
||||
前提条件:
|
||||
|
||||
- ドメインの A レコード(IPv4)を Lightsail の静的 IP に向けておく(DuckDNS等の無料動的DNSでも可)
|
||||
- Lightsail ファイアウォールで **443 (HTTPS)** を許可([1. Lightsail インスタンスの作成](#1-lightsail-インスタンスの作成)で追加済みのはず)
|
||||
|
||||
起動すると、Caddyがドメイン名を見て自動的にACMEチャレンジ(ポート80経由)で証明書を取得し、
|
||||
以後は自動更新される。証明書は`caddy_data`ボリュームに永続化されるので、`docker compose down`
|
||||
(`-v`無し)では消えない。
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f caddy # 証明書取得ログを確認("certificate obtained successfully" 等)
|
||||
```
|
||||
|
||||
`https://your-domain.example.com/` にアクセスして確認する。
|
||||
|
||||
## 7. 常駐タスクについて
|
||||
|
||||
|
||||
+68
-26
@@ -1,46 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import SessionList from './components/SessionList.vue'
|
||||
import ChatWindow from './components/ChatWindow.vue'
|
||||
import MessageInput from './components/MessageInput.vue'
|
||||
import ModelSelector from './components/ModelSelector.vue'
|
||||
import LogViewer from './components/LogViewer.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import NotePanel from './components/NotePanel.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'
|
||||
import LoginGate from './components/LoginGate.vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
const showLogs = ref(false)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
type SidePanel = 'none' | 'logs' | 'settings' | 'notes'
|
||||
const activePanel = ref<SidePanel>('none')
|
||||
|
||||
function togglePanel(panel: SidePanel) {
|
||||
activePanel.value = activePanel.value === panel ? 'none' : panel
|
||||
}
|
||||
|
||||
function handleUnauthorized() {
|
||||
authStore.markUnauthenticated()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
authStore.checkStatus()
|
||||
window.addEventListener('playchat:unauthorized', handleUnauthorized)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('playchat:unauthorized', handleUnauthorized)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<SessionList />
|
||||
<MemoryMonitor />
|
||||
<WordPool />
|
||||
</div>
|
||||
<div class="main">
|
||||
<header>
|
||||
<span class="title">PlayChat</span>
|
||||
<ModelSelector />
|
||||
<Clock />
|
||||
<button class="log-toggle" :class="{ active: showLogs }" @click="showLogs = !showLogs">
|
||||
ログ
|
||||
</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="chat-pane">
|
||||
<ChatWindow />
|
||||
<MessageInput />
|
||||
</div>
|
||||
<div v-if="showLogs" class="log-pane">
|
||||
<LogViewer />
|
||||
<template v-if="authStore.loading" />
|
||||
<LoginGate v-else-if="authStore.required && !authStore.authenticated" />
|
||||
<template v-else>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<SessionList />
|
||||
<MemoryMonitor />
|
||||
<WordPool />
|
||||
</div>
|
||||
<div class="main">
|
||||
<header>
|
||||
<span class="title">PlayChat</span>
|
||||
<ModelSelector />
|
||||
<Clock />
|
||||
<button class="log-toggle" :class="{ active: activePanel === 'notes' }" @click="togglePanel('notes')">
|
||||
ノート
|
||||
</button>
|
||||
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
|
||||
設定
|
||||
</button>
|
||||
<button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')">
|
||||
ログ
|
||||
</button>
|
||||
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
|
||||
ログアウト
|
||||
</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="chat-pane">
|
||||
<ChatWindow />
|
||||
<MessageInput />
|
||||
</div>
|
||||
<div v-if="activePanel === 'logs'" class="log-pane">
|
||||
<LogViewer />
|
||||
</div>
|
||||
<div v-else-if="activePanel === 'settings'" class="log-pane">
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
<div v-else-if="activePanel === 'notes'" class="log-pane">
|
||||
<NotePanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ToastList />
|
||||
<ToastList />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
+162
-1
@@ -22,7 +22,7 @@ export interface Source {
|
||||
}
|
||||
|
||||
export interface SSEChunk {
|
||||
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources'
|
||||
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated'
|
||||
value?: string | Source[]
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ async function parseJson<T>(res: Response, label: string): Promise<T> {
|
||||
throw new Error(`${label}: バックエンドに接続できません (${res.status} ${res.statusText})`)
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) window.dispatchEvent(new Event('playchat:unauthorized'))
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(`${label}: ${body.detail ?? res.statusText}`)
|
||||
}
|
||||
@@ -62,6 +63,39 @@ export async function deleteSession(sessionId: string): Promise<void> {
|
||||
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function exportSession(sessionId: string): void {
|
||||
const a = document.createElement('a')
|
||||
a.href = `${BASE}/chat/sessions/${sessionId}/export`
|
||||
a.download = `session_${sessionId}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
|
||||
export interface ImportPayload {
|
||||
session?: {
|
||||
title?: string | null
|
||||
model?: string | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
messages: {
|
||||
role: string
|
||||
content: string
|
||||
created_at?: string | null
|
||||
tags?: string[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export async function importSession(payload: ImportPayload): Promise<SessionInfo> {
|
||||
const res = await fetch(`${BASE}/chat/sessions/import`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return parseJson<SessionInfo>(res, 'インポート')
|
||||
}
|
||||
|
||||
export async function fetchLogs(params: {
|
||||
session_id?: string
|
||||
status?: 'ok' | 'error'
|
||||
@@ -81,6 +115,95 @@ export async function deleteLog(logId: number): Promise<void> {
|
||||
await fetch(`${BASE}/logs/${logId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
required: boolean
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
const res = await fetch(`${BASE}/api/auth/status`)
|
||||
return parseJson<AuthStatus>(res, '認証状態取得')
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<AuthStatus> {
|
||||
const res = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
return parseJson<AuthStatus>(res, 'ログイン')
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export interface SettingEntry {
|
||||
key: string
|
||||
is_set: boolean
|
||||
masked: string | null
|
||||
overridden: boolean
|
||||
}
|
||||
|
||||
function authHeader(password: string): HeadersInit {
|
||||
return { Authorization: `Basic ${btoa(`x:${password}`)}` }
|
||||
}
|
||||
|
||||
export async function fetchSettings(password: string): Promise<SettingEntry[]> {
|
||||
const res = await fetch(`${BASE}/api/settings`, { headers: authHeader(password) })
|
||||
return parseJson<SettingEntry[]>(res, '設定取得')
|
||||
}
|
||||
|
||||
export async function saveSettings(values: Record<string, string>, password: string): Promise<SettingEntry[]> {
|
||||
const res = await fetch(`${BASE}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { ...authHeader(password), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ values }),
|
||||
})
|
||||
return parseJson<SettingEntry[]>(res, '設定保存')
|
||||
}
|
||||
|
||||
export async function clearSetting(key: string, password: string): Promise<SettingEntry[]> {
|
||||
const res = await fetch(`${BASE}/api/settings/${key}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeader(password),
|
||||
})
|
||||
return parseJson<SettingEntry[]>(res, '設定クリア')
|
||||
}
|
||||
|
||||
export interface ModelPreset {
|
||||
id: number
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export async function fetchModelPresets(): Promise<ModelPreset[]> {
|
||||
const res = await fetch(`${BASE}/api/models`)
|
||||
return parseJson<ModelPreset[]>(res, 'モデル一覧取得')
|
||||
}
|
||||
|
||||
export async function createModelPreset(label: string, value: string, password: string): Promise<ModelPreset> {
|
||||
const res = await fetch(`${BASE}/api/models`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeader(password), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label, value }),
|
||||
})
|
||||
return parseJson<ModelPreset>(res, 'モデル追加')
|
||||
}
|
||||
|
||||
export async function updateModelPreset(id: number, label: string, value: string, password: string): Promise<ModelPreset> {
|
||||
const res = await fetch(`${BASE}/api/models/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...authHeader(password), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label, value }),
|
||||
})
|
||||
return parseJson<ModelPreset>(res, 'モデル更新')
|
||||
}
|
||||
|
||||
export async function deleteModelPreset(id: number, password: string): Promise<void> {
|
||||
await fetch(`${BASE}/api/models/${id}`, { method: 'DELETE', headers: authHeader(password) })
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
message: string,
|
||||
sessionId: string | null,
|
||||
@@ -134,3 +257,41 @@ export function streamChat(
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
export interface NotePage {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
position: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export async function fetchNotePages(): Promise<NotePage[]> {
|
||||
const res = await fetch(`${BASE}/api/notes`)
|
||||
return parseJson<NotePage[]>(res, 'ノート取得')
|
||||
}
|
||||
|
||||
export async function createNotePage(title: string): Promise<NotePage> {
|
||||
const res = await fetch(`${BASE}/api/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
})
|
||||
return parseJson<NotePage>(res, 'ページ作成')
|
||||
}
|
||||
|
||||
export async function updateNotePage(
|
||||
pageId: number,
|
||||
patch: { title?: string; content?: string },
|
||||
): Promise<NotePage> {
|
||||
const res = await fetch(`${BASE}/api/notes/${pageId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
return parseJson<NotePage>(res, 'ページ更新')
|
||||
}
|
||||
|
||||
export async function deleteNotePage(pageId: number): Promise<void> {
|
||||
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const store = useAuthStore()
|
||||
const passwordInput = ref('')
|
||||
|
||||
async function submit() {
|
||||
const ok = await store.login(passwordInput.value)
|
||||
if (ok) passwordInput.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-gate">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<span class="title">PlayChat</span>
|
||||
<p class="hint">サイトを利用するにはパスワードが必要です。</p>
|
||||
<input
|
||||
v-model="passwordInput"
|
||||
type="password"
|
||||
placeholder="パスワード"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
/>
|
||||
<button type="submit" :disabled="store.loading || !passwordInput">
|
||||
{{ store.loading ? '確認中...' : 'ログイン' }}
|
||||
</button>
|
||||
<p v-if="store.error" class="error">{{ store.error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-gate {
|
||||
height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: #111;
|
||||
}
|
||||
.card {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
width: 280px; padding: 24px; background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a; border-radius: 10px;
|
||||
}
|
||||
.title { font-size: 18px; font-weight: 600; color: #fff; }
|
||||
.hint { font-size: 12px; color: #888; }
|
||||
input {
|
||||
background: #2a2a2a; color: #eee; border: 1px solid #444;
|
||||
border-radius: 6px; padding: 8px 10px; font-size: 13px; width: 100%;
|
||||
}
|
||||
button {
|
||||
background: #1d3557; color: #93c5fd; border: 1px solid #2563eb;
|
||||
border-radius: 6px; padding: 8px 10px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
.error { font-size: 12px; color: #f87171; }
|
||||
</style>
|
||||
@@ -60,7 +60,7 @@ onUnmounted(() => {
|
||||
<span class="value">{{ stats.memory_percent.toFixed(1) }}% ({{ formatMemory(stats.memory_used_mb) }} / {{ formatMemory(stats.memory_total_mb) }})</span>
|
||||
</div>
|
||||
|
||||
<div class="stat" v-if="stats.gpu_memory_percent !== undefined">
|
||||
<div class="stat" v-if="stats.gpu_memory_percent != null">
|
||||
<span class="label">GPU</span>
|
||||
<div class="bar">
|
||||
<div class="fill gpu" :style="{ width: stats.gpu_memory_percent + '%' }"></div>
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
|
||||
const store = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const presets = [
|
||||
{ label: 'GPT-5.5', value: 'gpt-5.5' },
|
||||
{ label: 'GPT-5.4', value: 'gpt-5.4' },
|
||||
{ label: 'GPT-5.4 mini', value: 'gpt-5.4-mini' },
|
||||
{ label: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' },
|
||||
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
|
||||
{ label: 'Ollama llama3', value: 'ollama/llama3' },
|
||||
{ label: 'Ollama Gemma4', value: 'ollama/gemma4' },
|
||||
{ label: 'Gemma 4 12B (llama-server)', value: 'gpt-3.5-turbo' },
|
||||
{ label: 'Claude Sonnet 4.6 (Bedrock)', value: 'bedrock/jp.anthropic.claude-sonnet-4-6' },
|
||||
{ label: 'Llama 3 70B (Bedrock)', value: 'bedrock/meta.llama3-70b-instruct-v1:0' },
|
||||
{ label: 'Nova 2 Lite (Bedrock)', value: 'bedrock/jp.amazon.nova-2-lite-v1:0' },
|
||||
]
|
||||
// 選択中のモデルがプリセット一覧に無い場合(削除された/カスタム値)も表示が壊れないよう、
|
||||
// その場で追加のoptionとして補う。
|
||||
const options = computed(() => {
|
||||
const presets = settingsStore.modelPresets
|
||||
if (presets.some((p) => p.value === store.selectedModel)) return presets
|
||||
return [...presets, { id: -1, label: store.selectedModel, value: store.selectedModel }]
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
settingsStore.loadModelPresets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-selector">
|
||||
<label>モデル</label>
|
||||
<select v-model="store.selectedModel">
|
||||
<option v-for="p in presets" :key="p.value" :value="p.value">{{ p.label }}</option>
|
||||
<option v-for="p in options" :key="p.value" :value="p.value">{{ p.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
|
||||
import { useNotesStore } from '../stores/notes'
|
||||
|
||||
const store = useNotesStore()
|
||||
const renamingId = ref<number | null>(null)
|
||||
const renameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function startRename(id: number) {
|
||||
renamingId.value = id
|
||||
await nextTick()
|
||||
renameInput.value?.select()
|
||||
}
|
||||
|
||||
function onTitleInput(id: number, e: Event) {
|
||||
store.edit(id, { title: (e.target as HTMLInputElement).value })
|
||||
}
|
||||
|
||||
function finishRename() {
|
||||
renamingId.value = null
|
||||
store.flush()
|
||||
}
|
||||
|
||||
function onContentInput(e: Event) {
|
||||
if (store.activeId === null) return
|
||||
store.edit(store.activeId, { content: (e.target as HTMLTextAreaElement).value })
|
||||
}
|
||||
|
||||
async function confirmRemove(id: number, title: string) {
|
||||
if (!window.confirm(`ページ「${title}」を削除しますか?`)) return
|
||||
await store.removePage(id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.load()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
store.flush()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="note-panel">
|
||||
<div class="tabs">
|
||||
<div
|
||||
v-for="page in store.pages"
|
||||
:key="page.id"
|
||||
class="tab"
|
||||
:class="{ active: page.id === store.activeId }"
|
||||
@click="store.selectPage(page.id)"
|
||||
@dblclick="startRename(page.id)"
|
||||
>
|
||||
<input
|
||||
v-if="renamingId === page.id"
|
||||
ref="renameInput"
|
||||
class="tab-rename"
|
||||
:value="page.title"
|
||||
@input="onTitleInput(page.id, $event)"
|
||||
@blur="finishRename"
|
||||
@keydown.enter="finishRename"
|
||||
@keydown.esc="finishRename"
|
||||
@click.stop
|
||||
>
|
||||
<template v-else>
|
||||
<span class="tab-title" :title="page.title">{{ page.title }}</span>
|
||||
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
|
||||
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
|
||||
</template>
|
||||
</div>
|
||||
<button class="tab-add" title="ページを追加" @click="store.addPage()">+</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
|
||||
|
||||
<textarea
|
||||
v-if="store.activePage"
|
||||
class="editor"
|
||||
:value="store.activePage.content"
|
||||
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。"
|
||||
spellcheck="false"
|
||||
@input="onContentInput"
|
||||
@blur="store.flush()"
|
||||
/>
|
||||
<div v-else class="empty">
|
||||
<p>ページがありません。</p>
|
||||
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.note-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #161616;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
padding: 6px 6px 0;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 8px;
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #333;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
}
|
||||
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
|
||||
.tab-title { overflow: hidden; text-overflow: ellipsis; }
|
||||
.dot { color: #eab308; font-size: 8px; }
|
||||
.tab-del, .tab-add {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.tab-del:hover, .tab-add:hover { color: #f87171; }
|
||||
.tab-add { color: #888; padding: 0 8px; }
|
||||
.tab-add:hover { color: #93c5fd; }
|
||||
.tab-rename {
|
||||
background: #111;
|
||||
border: 1px solid #2563eb;
|
||||
border-radius: 3px;
|
||||
color: #eee;
|
||||
font-size: 12px;
|
||||
padding: 1px 4px;
|
||||
width: 110px;
|
||||
}
|
||||
.error {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
color: #fca5a5;
|
||||
background: #2a1414;
|
||||
border-bottom: 1px solid #7f1d1d;
|
||||
}
|
||||
.editor {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
background: #161616;
|
||||
color: #ddd;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.editor::placeholder { color: #555; }
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
.create-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hint {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import { useChatStore } from '../stores/chat'
|
||||
|
||||
const store = useChatStore()
|
||||
const error = ref<string | null>(null)
|
||||
const importInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function load() {
|
||||
error.value = null
|
||||
@@ -14,6 +15,18 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function triggerImport() {
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
async function onImportFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
await store.importSessionFromFile(file)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -21,7 +34,17 @@ onMounted(load)
|
||||
<aside class="session-list">
|
||||
<div class="header">
|
||||
<span>チャット履歴</span>
|
||||
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
|
||||
<div class="header-actions">
|
||||
<button class="import-btn" title="JSONからインポート" @click="triggerImport">⭱</button>
|
||||
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
|
||||
</div>
|
||||
<input
|
||||
ref="importInput"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
class="hidden-file-input"
|
||||
@change="onImportFileChange"
|
||||
>
|
||||
</div>
|
||||
<div v-if="error" class="api-error" :title="error">⚠ 接続エラー</div>
|
||||
<ul>
|
||||
@@ -32,6 +55,11 @@ onMounted(load)
|
||||
@click="store.selectSession(s.id)"
|
||||
>
|
||||
<span class="title">{{ s.title }}</span>
|
||||
<button
|
||||
class="export-btn"
|
||||
@click.stop="store.downloadSessionLog(s.id)"
|
||||
title="JSONでエクスポート"
|
||||
>⭳</button>
|
||||
<button
|
||||
class="del-btn"
|
||||
@click.stop="store.removeSession(s.id)"
|
||||
@@ -69,7 +97,13 @@ onMounted(load)
|
||||
border-bottom: 1px solid #7f1d1d;
|
||||
cursor: default;
|
||||
}
|
||||
.new-btn {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.new-btn,
|
||||
.import-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
@@ -78,6 +112,9 @@ onMounted(load)
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -104,6 +141,7 @@ li.active { background: #333; }
|
||||
font-size: 13px;
|
||||
color: #ddd;
|
||||
}
|
||||
.export-btn,
|
||||
.del-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -114,5 +152,7 @@ li.active { background: #333; }
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.export-btn { font-size: 13px; }
|
||||
li:hover .export-btn,
|
||||
li:hover .del-btn { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
|
||||
const store = useSettingsStore()
|
||||
|
||||
const passwordInput = ref('')
|
||||
const drafts = reactive<Record<string, string>>({})
|
||||
|
||||
const presetDrafts = reactive<Record<number, { label: string; value: string }>>({})
|
||||
const newLabel = ref('')
|
||||
const newValue = ref('')
|
||||
|
||||
watch(
|
||||
() => store.modelPresets,
|
||||
(presets) => {
|
||||
for (const key of Object.keys(presetDrafts)) delete presetDrafts[Number(key)]
|
||||
for (const p of presets) presetDrafts[p.id] = { label: p.label, value: p.value }
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function saveModelPreset(id: number) {
|
||||
const draft = presetDrafts[id]
|
||||
if (!draft || !draft.label || !draft.value) return
|
||||
await store.editModelPreset(id, draft.label, draft.value)
|
||||
}
|
||||
|
||||
async function addModelPreset() {
|
||||
if (!newLabel.value || !newValue.value) return
|
||||
const ok = await store.addModelPreset(newLabel.value, newValue.value)
|
||||
if (ok) {
|
||||
newLabel.value = ''
|
||||
newValue.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
OPENAI_API_KEY: 'OpenAI APIキー',
|
||||
ANTHROPIC_API_KEY: 'Anthropic APIキー',
|
||||
AWS_ACCESS_KEY_ID: 'AWS Access Key ID',
|
||||
AWS_SECRET_ACCESS_KEY: 'AWS Secret Access Key',
|
||||
AWS_SESSION_TOKEN: 'AWS Session Token',
|
||||
AWS_BEARER_TOKEN_BEDROCK: 'AWS Bedrock Bearer Token',
|
||||
AWS_REGION_NAME: 'AWS Region',
|
||||
OLLAMA_API_BASE: 'Ollama API Base URL',
|
||||
TAVILY_API_KEY: 'Tavily APIキー',
|
||||
LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)',
|
||||
}
|
||||
|
||||
// 秘匿情報ではないため、マスクせず通常のテキスト入力として表示するキー
|
||||
const PLAIN_TEXT_KEYS = new Set(['LOCAL_KB_EMBEDDING_MODEL', 'OLLAMA_API_BASE', 'AWS_REGION_NAME'])
|
||||
|
||||
async function submitPassword() {
|
||||
const ok = await store.unlock(passwordInput.value)
|
||||
if (ok) passwordInput.value = ''
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
const values: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(drafts)) {
|
||||
if (value) values[key] = value
|
||||
}
|
||||
if (Object.keys(values).length === 0) return
|
||||
const ok = await store.save(values)
|
||||
if (ok) {
|
||||
for (const key of Object.keys(drafts)) drafts[key] = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function clearOne(key: string) {
|
||||
await store.clear(key)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.tryAutoUnlock()
|
||||
store.loadModelPresets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-panel">
|
||||
<div class="toolbar">
|
||||
<span class="title">API接続設定</span>
|
||||
<button v-if="store.unlocked" class="lock-btn" @click="store.lock()">ロック</button>
|
||||
</div>
|
||||
|
||||
<form v-if="!store.unlocked" class="login" @submit.prevent="submitPassword">
|
||||
<p class="hint">APIキーを表示・変更するにはパスワードが必要です。</p>
|
||||
<input
|
||||
v-model="passwordInput"
|
||||
type="password"
|
||||
placeholder="パスワード"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button type="submit" :disabled="store.loading || !passwordInput">
|
||||
{{ store.loading ? '確認中...' : 'ロック解除' }}
|
||||
</button>
|
||||
<p v-if="store.error" class="error">{{ store.error }}</p>
|
||||
</form>
|
||||
|
||||
<div v-else class="entries">
|
||||
<p v-if="store.error" class="error">{{ store.error }}</p>
|
||||
<div v-for="entry in store.entries" :key="entry.key" class="entry">
|
||||
<label>{{ KEY_LABELS[entry.key] ?? entry.key }}</label>
|
||||
<div class="row">
|
||||
<input
|
||||
v-model="drafts[entry.key]"
|
||||
:type="PLAIN_TEXT_KEYS.has(entry.key) ? 'text' : 'password'"
|
||||
:placeholder="entry.is_set ? `設定済み ${entry.masked}` : '未設定'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
v-if="entry.overridden"
|
||||
class="clear-btn"
|
||||
title="GUIで設定した値をクリアして.envの値に戻す"
|
||||
@click="clearOne(entry.key)"
|
||||
>
|
||||
クリア
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="save-btn" :disabled="store.loading" @click="saveAll">
|
||||
{{ store.loading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<h3 class="section-title">モデル管理</h3>
|
||||
<div v-for="p in store.modelPresets" :key="p.id" class="preset-row">
|
||||
<input v-model="presetDrafts[p.id].label" placeholder="表示名" />
|
||||
<input v-model="presetDrafts[p.id].value" placeholder="モデル文字列" />
|
||||
<button class="mini-btn" @click="saveModelPreset(p.id)">保存</button>
|
||||
<button class="mini-btn danger" @click="store.removeModelPreset(p.id)">削除</button>
|
||||
</div>
|
||||
|
||||
<div class="preset-row new-preset">
|
||||
<input v-model="newLabel" placeholder="表示名(例: GPT-5.5)" />
|
||||
<input v-model="newValue" placeholder="モデル文字列(例: gpt-5.5)" />
|
||||
<button class="mini-btn" @click="addModelPreset">追加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #111;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 12px; border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
|
||||
}
|
||||
.title { font-size: 13px; font-weight: 600; color: #aaa; }
|
||||
.lock-btn {
|
||||
background: none; border: 1px solid #444; color: #aaa;
|
||||
border-radius: 5px; padding: 2px 10px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.login { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.hint { font-size: 12px; color: #888; }
|
||||
.error { font-size: 12px; color: #f87171; }
|
||||
input {
|
||||
background: #2a2a2a; color: #eee; border: 1px solid #444;
|
||||
border-radius: 6px; padding: 6px 10px; font-size: 13px; width: 100%;
|
||||
}
|
||||
button {
|
||||
background: #1d3557; color: #93c5fd; border: 1px solid #2563eb;
|
||||
border-radius: 6px; padding: 6px 10px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
.entries { padding: 12px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.entry { display: flex; flex-direction: column; gap: 4px; }
|
||||
.entry label { font-size: 12px; color: #888; }
|
||||
.row { display: flex; gap: 6px; }
|
||||
.row input { flex: 1; }
|
||||
.clear-btn {
|
||||
background: none; border: 1px solid #444; color: #aaa;
|
||||
font-size: 12px; padding: 4px 8px; white-space: nowrap;
|
||||
}
|
||||
.save-btn { align-self: flex-start; padding: 6px 18px; }
|
||||
hr { border: none; border-top: 1px solid #2a2a2a; margin: 4px 0; }
|
||||
.section-title { font-size: 12px; color: #888; text-transform: uppercase; }
|
||||
.preset-row { display: flex; gap: 6px; align-items: center; }
|
||||
.preset-row input { flex: 1; min-width: 0; }
|
||||
.mini-btn {
|
||||
background: none; border: 1px solid #444; color: #aaa;
|
||||
font-size: 12px; padding: 6px 10px; white-space: nowrap;
|
||||
}
|
||||
.mini-btn.danger { border-color: #7f1d1d; color: #f87171; }
|
||||
.new-preset { padding-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { fetchAuthStatus, login as loginApi, logout as logoutApi } from '../api/client'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const required = ref(false)
|
||||
const authenticated = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function checkStatus(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const status = await fetchAuthStatus()
|
||||
required.value = status.required
|
||||
authenticated.value = status.authenticated
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function login(password: string): Promise<boolean> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const status = await loginApi(password)
|
||||
required.value = status.required
|
||||
authenticated.value = status.authenticated
|
||||
return true
|
||||
} catch (e) {
|
||||
authenticated.value = false
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await logoutApi().catch(() => {})
|
||||
authenticated.value = false
|
||||
}
|
||||
|
||||
function markUnauthenticated(): void {
|
||||
authenticated.value = false
|
||||
}
|
||||
|
||||
return { required, authenticated, loading, error, checkStatus, login, logout, markUnauthenticated }
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { useNotesStore } from './notes'
|
||||
import { ref, computed } from 'vue'
|
||||
import { fetchSessions, fetchMessages, deleteSession, streamChat, type SessionInfo, type Source } from '../api/client'
|
||||
import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client'
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -69,6 +70,32 @@ export const useChatStore = defineStore('chat', () => {
|
||||
await loadSessions()
|
||||
}
|
||||
|
||||
function downloadSessionLog(sessionId: string): void {
|
||||
exportSession(sessionId)
|
||||
}
|
||||
|
||||
async function importSessionFromFile(file: File): Promise<void> {
|
||||
let payload: ImportPayload
|
||||
try {
|
||||
const parsed = JSON.parse(await file.text())
|
||||
if (!Array.isArray(parsed?.messages)) {
|
||||
throw new Error('messages 配列が見つかりません')
|
||||
}
|
||||
payload = parsed as ImportPayload
|
||||
} catch (e) {
|
||||
pushToast(`インポート失敗: JSONの読み込みに失敗しました (${e instanceof Error ? e.message : e})`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const session = await importSession(payload)
|
||||
await loadSessions()
|
||||
await selectSession(session.id)
|
||||
pushToast(`「${session.title}」をインポートしました`)
|
||||
} catch (e) {
|
||||
pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
|
||||
function sendMessage(text: string): void {
|
||||
if (isStreaming.value || !text.trim()) return
|
||||
|
||||
@@ -89,6 +116,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
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 === 'notes_updated') {
|
||||
// AIが共有ノートを書き換えたので、開いているノートパネルに反映する
|
||||
useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`))
|
||||
} else if (chunk.type === 'done') {
|
||||
messages.value[msgIdx].pending = false
|
||||
isStreaming.value = false
|
||||
@@ -137,6 +167,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
loadSessions,
|
||||
selectSession,
|
||||
removeSession,
|
||||
downloadSessionLog,
|
||||
importSessionFromFile,
|
||||
sendMessage,
|
||||
retryMessage,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
fetchNotePages,
|
||||
createNotePage,
|
||||
updateNotePage,
|
||||
deleteNotePage,
|
||||
type NotePage,
|
||||
} from '../api/client'
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 800
|
||||
|
||||
export const useNotesStore = defineStore('notes', () => {
|
||||
const pages = ref<NotePage[]>([])
|
||||
const activeId = ref<number | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
/** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */
|
||||
const dirtyIds = ref<Set<number>>(new Set())
|
||||
|
||||
let saveTimer: number | null = null
|
||||
|
||||
const activePage = computed(() => pages.value.find((p) => p.id === activeId.value) ?? null)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
const fetched = await fetchNotePages()
|
||||
// 編集中のページはローカルの内容を優先し、サーバーの値で潰さない
|
||||
pages.value = fetched.map((page) => {
|
||||
if (!dirtyIds.value.has(page.id)) return page
|
||||
const local = pages.value.find((p) => p.id === page.id)
|
||||
return local ? { ...page, title: local.title, content: local.content } : page
|
||||
})
|
||||
if (activeId.value === null || !pages.value.some((p) => p.id === activeId.value)) {
|
||||
activeId.value = pages.value[0]?.id ?? null
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ノートの取得に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
function selectPage(id: number): void {
|
||||
flush()
|
||||
activeId.value = id
|
||||
}
|
||||
|
||||
async function addPage(title = '新しいページ'): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
const page = await createNotePage(title)
|
||||
pages.value.push(page)
|
||||
activeId.value = page.id
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ページの作成に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
async function removePage(id: number): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
await deleteNotePage(id)
|
||||
dirtyIds.value.delete(id)
|
||||
pages.value = pages.value.filter((p) => p.id !== id)
|
||||
if (activeId.value === id) activeId.value = pages.value[0]?.id ?? null
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ページの削除に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
/** ローカルの内容を即座に更新し、少し待ってからサーバーへ保存する。 */
|
||||
function edit(id: number, patch: { title?: string; content?: string }): void {
|
||||
const page = pages.value.find((p) => p.id === id)
|
||||
if (!page) return
|
||||
Object.assign(page, patch)
|
||||
dirtyIds.value.add(id)
|
||||
|
||||
if (saveTimer !== null) clearTimeout(saveTimer)
|
||||
saveTimer = window.setTimeout(() => {
|
||||
saveTimer = null
|
||||
void save(id)
|
||||
}, SAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
async function save(id: number): Promise<void> {
|
||||
const page = pages.value.find((p) => p.id === id)
|
||||
if (!page || !dirtyIds.value.has(id)) return
|
||||
const { title, content } = page
|
||||
try {
|
||||
const saved = await updateNotePage(id, { title, content })
|
||||
// 保存の往復中にさらに編集されていた場合は dirty のままにしておく
|
||||
if (page.title === title && page.content === content) {
|
||||
dirtyIds.value.delete(id)
|
||||
page.updated_at = saved.updated_at
|
||||
}
|
||||
error.value = null
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ノートの保存に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */
|
||||
function flush(): void {
|
||||
if (saveTimer !== null) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
}
|
||||
for (const id of [...dirtyIds.value]) void save(id)
|
||||
}
|
||||
|
||||
return {
|
||||
pages,
|
||||
activeId,
|
||||
activePage,
|
||||
error,
|
||||
dirtyIds,
|
||||
load,
|
||||
selectPage,
|
||||
addPage,
|
||||
removePage,
|
||||
edit,
|
||||
flush,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
clearSetting,
|
||||
fetchModelPresets,
|
||||
createModelPreset,
|
||||
updateModelPreset,
|
||||
deleteModelPreset,
|
||||
type SettingEntry,
|
||||
type ModelPreset,
|
||||
} from '../api/client'
|
||||
|
||||
const STORAGE_KEY = 'playchat_settings_password'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const password = ref(sessionStorage.getItem(STORAGE_KEY) ?? '')
|
||||
const unlocked = ref(false)
|
||||
const entries = ref<SettingEntry[]>([])
|
||||
const modelPresets = ref<ModelPreset[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function unlock(candidatePassword: string): Promise<boolean> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
entries.value = await fetchSettings(candidatePassword)
|
||||
password.value = candidatePassword
|
||||
sessionStorage.setItem(STORAGE_KEY, candidatePassword)
|
||||
unlocked.value = true
|
||||
return true
|
||||
} catch (e) {
|
||||
unlocked.value = false
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** ページ再読み込み時などに、保存済みパスワードで自動的に再認証を試みる。 */
|
||||
async function tryAutoUnlock(): Promise<void> {
|
||||
if (!password.value || unlocked.value) return
|
||||
await unlock(password.value)
|
||||
}
|
||||
|
||||
async function save(values: Record<string, string>): Promise<boolean> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
entries.value = await saveSettings(values, password.value)
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clear(key: string): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
entries.value = await clearSetting(key, password.value)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
}
|
||||
}
|
||||
|
||||
function lock() {
|
||||
unlocked.value = false
|
||||
password.value = ''
|
||||
sessionStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
/** モデル一覧は認証不要で誰でも取得できる(ModelSelectorから直接呼ばれる想定)。 */
|
||||
async function loadModelPresets(): Promise<void> {
|
||||
try {
|
||||
modelPresets.value = await fetchModelPresets()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
}
|
||||
}
|
||||
|
||||
async function addModelPreset(label: string, value: string): Promise<boolean> {
|
||||
error.value = null
|
||||
try {
|
||||
const created = await createModelPreset(label, value, password.value)
|
||||
modelPresets.value = [...modelPresets.value, created]
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function editModelPreset(id: number, label: string, value: string): Promise<boolean> {
|
||||
error.value = null
|
||||
try {
|
||||
const updated = await updateModelPreset(id, label, value, password.value)
|
||||
modelPresets.value = modelPresets.value.map((p) => (p.id === id ? updated : p))
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeModelPreset(id: number): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
await deleteModelPreset(id, password.value)
|
||||
modelPresets.value = modelPresets.value.filter((p) => p.id !== id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
password,
|
||||
unlocked,
|
||||
entries,
|
||||
modelPresets,
|
||||
loading,
|
||||
error,
|
||||
unlock,
|
||||
tryAutoUnlock,
|
||||
save,
|
||||
clear,
|
||||
lock,
|
||||
loadModelPresets,
|
||||
addModelPreset,
|
||||
editModelPreset,
|
||||
removeModelPreset,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user