sync: update from private repo (3a0870b)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-10 04:16:39 +00:00
parent 9f8958c4a2
commit dfc5950117
9 changed files with 414 additions and 378 deletions
@@ -1,34 +1,15 @@
/**
* MemoryLearningForm.tsx — "Memory & Learning" settings section
* MemoryLearningForm.tsx — "Reflection 履歴" settings section
*
* Two stacked panels:
* 1. MemoryEntriesPanel — list / inline-edit / delete user memory entries
* 2. ReflectionTimelinePanel — paged snapshot history + revert + 30-day metrics
* ReflectionTimelinePanel — paged snapshot history + revert + 30-day metrics.
* (Memory entry editing moved to User Folder → memory/ — MemoryPanel.tsx.)
*/
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
import { HelpText } from './HelpText';
// ── API types ─────────────────────────────────────────────────────────────────
type MemoryType = 'user' | 'feedback' | 'project' | 'reference';
// Mirrors the server's flat shape from `listMemoryEntries` in
// src/user-folder/memory.ts and `GET /api/local/memory/entries` in
// src/bridge/memory-api.ts. If you change this shape, update both.
interface MemoryEntry {
name: string;
description: string;
type: MemoryType;
body: string;
}
interface MemoryListResponse {
entries: MemoryEntry[];
index: string;
}
interface SnapshotIndexEntry {
ts: string;
snapshotId: string;
@@ -80,35 +61,6 @@ interface ReflectionMetrics {
// ── API helpers ───────────────────────────────────────────────────────────────
async function fetchMemoryEntries(): Promise<MemoryListResponse> {
const res = await fetch('/api/local/memory/entries');
if (!res.ok) throw new Error(`メモリエントリの読み込みに失敗しました (${res.status})`);
return res.json();
}
async function upsertMemoryEntry(
name: string,
payload: { description: string; type: MemoryType; body: string },
): Promise<void> {
const res = await fetch(`/api/local/memory/entries/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({ error: res.statusText }));
if (!res.ok) throw new Error(data.error ?? res.statusText);
}
async function deleteMemoryEntry(name: string): Promise<void> {
const res = await fetch(`/api/local/memory/entries/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(data.error ?? res.statusText);
}
}
async function fetchHistoryPage(cursor?: string): Promise<HistoryPage> {
const params = new URLSearchParams({ limit: '20' });
if (cursor) params.set('before', cursor);
@@ -171,313 +123,6 @@ function formatTs(ts: string): string {
}
}
// ── Validator rejection code messages ─────────────────────────────────────────
const REJECTION_MESSAGES: Record<string, string> = {
rejected_bad_name: '名前が無効です(英数字・ハイフン・アンダースコア、1〜64文字)',
rejected_bad_description: '概要は必須で、1行以内で入力してください',
rejected_unknown_type: 'タイプは user / feedback / project / reference のいずれかを指定してください',
rejected_bad_body: '本文は文字列で指定してください',
rejected_body_too_large: '本文が許容サイズを超えています',
rejected_bad_request: 'リクエストの形式が正しくありません',
};
function rejectionMessage(code: string): string {
return REJECTION_MESSAGES[code] ?? code;
}
// ── MemoryEntryModal ──────────────────────────────────────────────────────────
interface EntryFormState {
name: string;
description: string;
type: MemoryType;
body: string;
}
const MEMORY_TYPES: MemoryType[] = ['user', 'feedback', 'project', 'reference'];
function MemoryEntryModal({
initial,
isNew,
onClose,
onSaved,
}: {
initial: EntryFormState;
isNew: boolean;
onClose: () => void;
onSaved: () => void;
}) {
const [form, setForm] = useState<EntryFormState>(initial);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const set = <K extends keyof EntryFormState>(k: K, v: EntryFormState[K]) =>
setForm(prev => ({ ...prev, [k]: v }));
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await upsertMemoryEntry(form.name, {
description: form.description,
type: form.type,
body: form.body,
});
onSaved();
onClose();
} catch (e: any) {
setError(rejectionMessage(e.message));
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-surface rounded-lg shadow-xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
<div className="flex items-center justify-between px-4 py-3 border-b border-hairline">
<h3 className="text-sm font-semibold text-slate-800">
{isNew ? '新しいメモリエントリ' : `編集 — ${initial.name}`}
</h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-700 text-lg leading-none"
aria-label="閉じる"
>
×
</button>
</div>
<div className="overflow-y-auto p-4 space-y-3 flex-1">
{/* Name — only editable when creating */}
<div>
<label className="block text-2xs font-medium text-slate-600 mb-1">
{isNew && <span className="text-slate-400 ml-1"></span>}
</label>
<input
type="text"
value={form.name}
onChange={e => set('name', e.target.value)}
disabled={!isNew}
placeholder="my-fact"
className={`w-full h-8 px-2.5 text-[13px] font-mono border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${
!isNew ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : 'bg-canvas'
}`}
/>
</div>
<div>
<label className="block text-2xs font-medium text-slate-600 mb-1"></label>
<input
type="text"
value={form.description}
onChange={e => set('description', e.target.value)}
placeholder="メモリ一覧に表示される1行の説明"
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
/>
</div>
<div>
<label className="block text-2xs font-medium text-slate-600 mb-1"></label>
<select
value={form.type}
onChange={e => set('type', e.target.value as MemoryType)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring outline-none bg-canvas"
>
{MEMORY_TYPES.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
<HelpText>
user: あなた固有の好み feedback: 過去のフィードバック project: プロジェクト別の文脈 reference: 参照資料
</HelpText>
</div>
<div>
<label className="block text-2xs font-medium text-slate-600 mb-1"></label>
<textarea
value={form.body}
onChange={e => set('body', e.target.value)}
rows={8}
className="w-full px-2.5 py-2 text-xs font-mono border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none resize-y"
placeholder="Markdown またはプレーンテキスト…"
/>
</div>
{error && (
<div className="text-xs text-red-600 dark:text-red-300 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 px-3 py-2 rounded-md">
{error}
</div>
)}
</div>
<div className="flex justify-end gap-2 px-4 py-3 border-t border-hairline">
<button
onClick={onClose}
className="px-3 h-8 text-xs text-slate-700 border border-hairline bg-canvas rounded-md hover:bg-surface transition-colors"
>
</button>
<button
onClick={() => void handleSave()}
disabled={saving || !form.name.trim() || !form.description.trim()}
className="px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50 transition-colors"
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
</div>
);
}
// ── MemoryEntriesPanel ────────────────────────────────────────────────────────
function MemoryEntriesPanel() {
const qc = useQueryClient();
const { data, isLoading, error } = useQuery<MemoryListResponse>({
queryKey: ['memory-entries'],
queryFn: fetchMemoryEntries,
});
const [modal, setModal] = useState<{ entry: EntryFormState; isNew: boolean } | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleNew = () => {
setModal({
isNew: true,
entry: { name: '', description: '', type: 'user', body: '' },
});
};
const handleEdit = (e: MemoryEntry) => {
setModal({
isNew: false,
entry: {
name: e.name,
description: e.description,
type: e.type,
body: e.body,
},
});
};
const handleDelete = async (name: string) => {
if (!confirm(`メモリエントリ「${name}」を削除しますか?`)) return;
setDeleting(name);
setDeleteError(null);
try {
await deleteMemoryEntry(name);
await qc.invalidateQueries({ queryKey: ['memory-entries'] });
} catch (e: any) {
setDeleteError(`${name}」の削除に失敗しました: ${e.message}`);
} finally {
setDeleting(null);
}
};
const handleSaved = () => {
void qc.invalidateQueries({ queryKey: ['memory-entries'] });
};
return (
<div className="rounded-lg border border-hairline">
<div className="flex items-center justify-between px-4 py-3 border-b border-hairline bg-surface rounded-t-lg">
<div>
<h3 className="text-sm font-semibold text-slate-800"></h3>
<p className="text-2xs text-slate-500 mt-0.5">
</p>
</div>
<button
onClick={handleNew}
className="px-2.5 h-7 text-2xs font-medium bg-accent text-accent-fg rounded-md hover:bg-accent-deep transition-colors"
>
+
</button>
</div>
{isLoading && (
<div className="px-4 py-6 text-xs text-slate-400 text-center"></div>
)}
{error && (
<div className="px-4 py-3 text-xs text-red-600">
: {String(error)}
</div>
)}
{deleteError && (
<div className="px-4 py-2 text-xs text-red-600 dark:text-red-300 bg-red-50 dark:bg-red-500/15">
{deleteError}
</div>
)}
{data && data.entries.length === 0 && (
<div className="px-4 py-8 text-center">
<p className="text-xs text-slate-400"></p>
<p className="text-2xs text-slate-400 mt-1">
reflection
</p>
</div>
)}
{data && data.entries.length > 0 && (
<ul className="divide-y divide-hairline">
{data.entries.map(entry => (
<li key={entry.name} className="flex items-start gap-3 px-4 py-3 hover:bg-surface/60 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono font-medium text-slate-800 truncate">
{entry.name}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-500 flex-shrink-0">
{entry.type}
</span>
</div>
<p className="text-2xs text-slate-500 mt-0.5 truncate">{entry.description}</p>
{entry.body && (
<p className="text-2xs text-slate-400 mt-0.5 line-clamp-2 font-mono whitespace-pre-wrap break-words">
{entry.body.slice(0, 200)}{entry.body.length > 200 ? '…' : ''}
</p>
)}
</div>
<div className="flex gap-1.5 flex-shrink-0 mt-0.5">
<button
onClick={() => handleEdit(entry)}
className="px-2 h-6 text-2xs text-slate-600 border border-hairline bg-canvas hover:bg-surface rounded transition-colors"
>
</button>
<button
onClick={() => void handleDelete(entry.name)}
disabled={deleting === entry.name}
className="px-2 h-6 text-2xs text-red-700 dark:text-red-300 border border-red-200 bg-canvas hover:bg-red-50 dark:hover:bg-red-500/15 rounded transition-colors disabled:opacity-50"
>
{deleting === entry.name ? '…' : '削除'}
</button>
</div>
</li>
))}
</ul>
)}
{modal && (
<MemoryEntryModal
initial={modal.entry}
isNew={modal.isNew}
onClose={() => setModal(null)}
onSaved={handleSaved}
/>
)}
</div>
);
}
// ── SnapshotCard ──────────────────────────────────────────────────────────────
function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onReverted: () => void }) {
@@ -971,12 +616,12 @@ function ReflectionTimelinePanel() {
export function MemoryLearningForm() {
return (
<div className="space-y-6">
<h2 className="text-base font-semibold text-slate-800">Memory &amp; Learning</h2>
<h2 className="text-base font-semibold text-slate-800">Reflection </h2>
<p className="text-xs text-slate-500 -mt-4">
reflection
reflectionrevert
memory/
</p>
<MemoryEntriesPanel />
<ReflectionTimelinePanel />
</div>
);
@@ -24,7 +24,7 @@ const CONFIG_GROUPS = [
sections: [
{ id: 'preferences', label: 'Preferences' },
{ id: 'notifications', label: '🔔 Notifications' },
{ id: 'memory-learning', label: '🧠 Memory & Learning' },
{ id: 'memory-learning', label: '🧠 Reflection 履歴' },
],
},
{