feat: 碎片信息记录网站初始版本
功能: - 实时保存记录到本地 - 大模型自动生成标题 - 左侧列表显示所有记录 - 搜索功能 - 新建/编辑/删除记录
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/
|
||||||
|
.idea/
|
||||||
236
app.py
Normal file
236
app.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""
|
||||||
|
碎片信息记录网站
|
||||||
|
- 实时保存到本地
|
||||||
|
- 大模型生成标题
|
||||||
|
- 搜索功能
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, render_template, jsonify, request
|
||||||
|
from flask_cors import CORS
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
import threading
|
||||||
|
|
||||||
|
app = Flask(__name__, static_folder='static', static_url_path='/static')
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
|
# 数据目录
|
||||||
|
DATA_DIR = Path(__file__).parent / 'data'
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
NOTES_FILE = DATA_DIR / 'notes.json'
|
||||||
|
|
||||||
|
# 大模型配置
|
||||||
|
LLM_CONFIG = {
|
||||||
|
'base_url': 'http://192.168.2.5:1234/v1',
|
||||||
|
'api_key': 'sk-lm-fuP5tGU8:Hi7YU87jHyDP6Ay8Tl2j',
|
||||||
|
'model': 'qwen3.5-4b',
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_notes():
|
||||||
|
"""加载所有笔记"""
|
||||||
|
if NOTES_FILE.exists():
|
||||||
|
return json.loads(NOTES_FILE.read_text(encoding='utf-8'))
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_notes(notes):
|
||||||
|
"""保存笔记"""
|
||||||
|
NOTES_FILE.write_text(json.dumps(notes, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
def generate_title(content):
|
||||||
|
"""用大模型生成标题"""
|
||||||
|
if not content or len(content.strip()) < 10:
|
||||||
|
return content[:20] if content else "新记录"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{LLM_CONFIG['base_url']}/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {LLM_CONFIG['api_key']}"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": LLM_CONFIG['model'],
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "你是一个标题生成助手。请根据用户的内容,生成一个简洁的标题(不超过15个字)。只返回标题,不要其他内容。"},
|
||||||
|
{"role": "user", "content": content[:500]}
|
||||||
|
],
|
||||||
|
"max_tokens": 50,
|
||||||
|
"temperature": 0.3
|
||||||
|
},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
title = data['choices'][0]['message']['content'].strip()
|
||||||
|
# 清理可能的引号或多余字符
|
||||||
|
title = title.replace('"', '').replace("'", '').strip()
|
||||||
|
if len(title) > 20:
|
||||||
|
title = title[:20]
|
||||||
|
return title
|
||||||
|
except Exception as e:
|
||||||
|
print(f"生成标题失败: {e}")
|
||||||
|
|
||||||
|
# 降级:取前20个字
|
||||||
|
return content[:20].strip()
|
||||||
|
|
||||||
|
def generate_title_async(note_id, content):
|
||||||
|
"""异步生成标题"""
|
||||||
|
title = generate_title(content)
|
||||||
|
|
||||||
|
# 更新笔记标题
|
||||||
|
notes = load_notes()
|
||||||
|
for note in notes:
|
||||||
|
if note['id'] == note_id:
|
||||||
|
note['title'] = title
|
||||||
|
break
|
||||||
|
save_notes(notes)
|
||||||
|
|
||||||
|
# ============ 页面路由 ============
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
# ============ API路由 ============
|
||||||
|
|
||||||
|
@app.route('/api/notes')
|
||||||
|
def api_notes():
|
||||||
|
"""获取笔记列表"""
|
||||||
|
keyword = request.args.get('q', '').strip().lower()
|
||||||
|
notes = load_notes()
|
||||||
|
|
||||||
|
# 搜索过滤
|
||||||
|
if keyword:
|
||||||
|
notes = [n for n in notes if keyword in n.get('title', '').lower() or keyword in n.get('content', '').lower()]
|
||||||
|
|
||||||
|
# 按更新时间排序(最近在前)
|
||||||
|
notes = sorted(notes, key=lambda x: x.get('updated_at', ''), reverse=True)
|
||||||
|
|
||||||
|
# 返回列表信息
|
||||||
|
return jsonify([{
|
||||||
|
'id': n['id'],
|
||||||
|
'title': n['title'],
|
||||||
|
'updated_at': n['updated_at'],
|
||||||
|
'created_at': n['created_at'],
|
||||||
|
'preview': n['content'][:50] if n['content'] else '',
|
||||||
|
} for n in notes])
|
||||||
|
|
||||||
|
@app.route('/api/notes/<note_id>')
|
||||||
|
def api_note_detail(note_id):
|
||||||
|
"""获取单个笔记详情"""
|
||||||
|
notes = load_notes()
|
||||||
|
note = next((n for n in notes if n['id'] == note_id), None)
|
||||||
|
|
||||||
|
if not note:
|
||||||
|
return jsonify({'error': 'Note not found'}), 404
|
||||||
|
|
||||||
|
return jsonify(note)
|
||||||
|
|
||||||
|
@app.route('/api/notes', methods=['POST'])
|
||||||
|
def api_create_note():
|
||||||
|
"""创建新笔记"""
|
||||||
|
notes = load_notes()
|
||||||
|
|
||||||
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
new_note = {
|
||||||
|
'id': uuid.uuid4().hex[:12],
|
||||||
|
'title': '新记录',
|
||||||
|
'content': '',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.append(new_note)
|
||||||
|
save_notes(notes)
|
||||||
|
|
||||||
|
return jsonify(new_note)
|
||||||
|
|
||||||
|
@app.route('/api/notes/<note_id>', methods=['PUT'])
|
||||||
|
def api_update_note(note_id):
|
||||||
|
"""更新笔记内容"""
|
||||||
|
data = request.get_json()
|
||||||
|
content = data.get('content', '')
|
||||||
|
|
||||||
|
notes = load_notes()
|
||||||
|
note = next((n for n in notes if n['id'] == note_id), None)
|
||||||
|
|
||||||
|
if not note:
|
||||||
|
return jsonify({'error': 'Note not found'}), 404
|
||||||
|
|
||||||
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# 判断是否需要生成新标题
|
||||||
|
old_content = note.get('content', '')
|
||||||
|
need_new_title = (not note['title'] or note['title'] == '新记录' or
|
||||||
|
len(old_content) < 50 and len(content) >= 50)
|
||||||
|
|
||||||
|
note['content'] = content
|
||||||
|
note['updated_at'] = now
|
||||||
|
|
||||||
|
# 如果内容变化较大,异步生成新标题
|
||||||
|
if need_new_title and len(content) >= 20:
|
||||||
|
threading.Thread(target=generate_title_async, args=(note_id, content)).start()
|
||||||
|
|
||||||
|
save_notes(notes)
|
||||||
|
|
||||||
|
return jsonify(note)
|
||||||
|
|
||||||
|
@app.route('/api/notes/<note_id>/title', methods=['POST'])
|
||||||
|
def api_regenerate_title(note_id):
|
||||||
|
"""手动重新生成标题"""
|
||||||
|
notes = load_notes()
|
||||||
|
note = next((n for n in notes if n['id'] == note_id), None)
|
||||||
|
|
||||||
|
if not note:
|
||||||
|
return jsonify({'error': 'Note not found'}), 404
|
||||||
|
|
||||||
|
title = generate_title(note['content'])
|
||||||
|
note['title'] = title
|
||||||
|
note['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
save_notes(notes)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'title': title})
|
||||||
|
|
||||||
|
@app.route('/api/notes/<note_id>', methods=['DELETE'])
|
||||||
|
def api_delete_note(note_id):
|
||||||
|
"""删除笔记"""
|
||||||
|
notes = load_notes()
|
||||||
|
notes = [n for n in notes if n['id'] != note_id]
|
||||||
|
save_notes(notes)
|
||||||
|
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
@app.route('/api/search')
|
||||||
|
def api_search():
|
||||||
|
"""搜索笔记"""
|
||||||
|
keyword = request.args.get('q', '').strip().lower()
|
||||||
|
|
||||||
|
if not keyword:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
notes = load_notes()
|
||||||
|
results = [n for n in notes if keyword in n.get('title', '').lower() or keyword in n.get('content', '').lower()]
|
||||||
|
|
||||||
|
results = sorted(results, key=lambda x: x.get('updated_at', ''), reverse=True)
|
||||||
|
|
||||||
|
return jsonify([{
|
||||||
|
'id': n['id'],
|
||||||
|
'title': n['title'],
|
||||||
|
'updated_at': n['updated_at'],
|
||||||
|
'preview': n['content'][:100] if n['content'] else '',
|
||||||
|
} for n in results])
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("=" * 50)
|
||||||
|
print("碎片信息记录网站")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"访问地址: http://localhost:19009")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
app.run(host='0.0.0.0', port=19009, debug=True)
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
flask
|
||||||
|
flask-cors
|
||||||
|
requests
|
||||||
224
templates/index.html
Normal file
224
templates/index.html
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>碎片记录</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.note-item:hover { background: #f3f4f6; }
|
||||||
|
.note-item.active { background: #e0e7ff; border-left: 3px solid #6366f1; }
|
||||||
|
.editor-area:focus { outline: none; }
|
||||||
|
.fade-in { animation: fadeIn 0.3s ease; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
.gradient-bg { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 h-screen overflow-hidden">
|
||||||
|
<div class="flex h-full">
|
||||||
|
<!-- 左侧:笔记列表 -->
|
||||||
|
<aside class="w-72 bg-white border-r flex flex-col">
|
||||||
|
<!-- 顶部:搜索和新建 -->
|
||||||
|
<div class="p-4 border-b">
|
||||||
|
<div class="relative mb-3">
|
||||||
|
<i class="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||||
|
<input type="text" id="searchInput" placeholder="搜索..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:border-purple-400"
|
||||||
|
oninput="searchNotes()">
|
||||||
|
</div>
|
||||||
|
<button onclick="createNote()" class="w-full py-2 gradient-bg text-white rounded-lg hover:opacity-90 transition">
|
||||||
|
<i class="ri-add-line mr-1"></i> 新建记录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 笔记列表 -->
|
||||||
|
<div id="noteList" class="flex-1 overflow-auto">
|
||||||
|
<div class="p-4 text-center text-gray-400">
|
||||||
|
<i class="ri-file-text-line text-4xl mb-2"></i>
|
||||||
|
<p>暂无记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右侧:编辑区域 -->
|
||||||
|
<main class="flex-1 flex flex-col bg-gray-50">
|
||||||
|
<!-- 顶部工具栏 -->
|
||||||
|
<div id="toolbar" class="hidden p-4 bg-white border-b flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 id="currentTitle" class="text-lg font-semibold text-gray-800"></h2>
|
||||||
|
<p id="currentTime" class="text-sm text-gray-500"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="regenerateTitle()" class="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg">
|
||||||
|
<i class="ri-magic-line mr-1"></i> 重新生成标题
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteCurrentNote()" class="px-3 py-1 text-sm text-red-500 hover:bg-red-50 rounded-lg">
|
||||||
|
<i class="ri-delete-bin-line mr-1"></i> 删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑区域 -->
|
||||||
|
<div id="editorContainer" class="hidden flex-1 p-6 fade-in">
|
||||||
|
<textarea id="editor"
|
||||||
|
class="w-full h-full p-4 bg-white rounded-xl border border-gray-200 resize-none editor-area text-gray-700 leading-relaxed"
|
||||||
|
placeholder="在这里记录你的想法..."
|
||||||
|
oninput="saveContent()"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div id="emptyState" class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="text-center text-gray-400">
|
||||||
|
<i class="ri-quill-pen-line text-6xl mb-4"></i>
|
||||||
|
<p class="text-lg">选择一个记录,或新建一个</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentNoteId = null;
|
||||||
|
let saveTimer = null;
|
||||||
|
let notes = [];
|
||||||
|
|
||||||
|
// 加载笔记列表
|
||||||
|
async function loadNotes() {
|
||||||
|
const keyword = document.getElementById('searchInput').value.trim();
|
||||||
|
const url = keyword ? `/api/search?q=${encodeURIComponent(keyword)}` : '/api/notes';
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
notes = await res.json();
|
||||||
|
|
||||||
|
const container = document.getElementById('noteList');
|
||||||
|
|
||||||
|
if (notes.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="p-4 text-center text-gray-400">
|
||||||
|
<i class="ri-file-text-line text-4xl mb-2"></i>
|
||||||
|
<p>${keyword ? '未找到相关记录' : '暂无记录'}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = notes.map(n => `
|
||||||
|
<div class="note-item p-4 cursor-pointer border-b ${currentNoteId === n.id ? 'active' : ''}"
|
||||||
|
onclick="selectNote('${n.id}')">
|
||||||
|
<h3 class="font-medium text-gray-800 truncate">${n.title || '新记录'}</h3>
|
||||||
|
<p class="text-sm text-gray-500 truncate mt-1">${n.preview || '空白记录'}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">${n.updated_at}</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新笔记
|
||||||
|
async function createNote() {
|
||||||
|
const res = await fetch('/api/notes', { method: 'POST' });
|
||||||
|
const note = await res.json();
|
||||||
|
|
||||||
|
currentNoteId = note.id;
|
||||||
|
loadNotes();
|
||||||
|
showEditor(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择笔记
|
||||||
|
async function selectNote(id) {
|
||||||
|
currentNoteId = id;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/notes/${id}`);
|
||||||
|
const note = await res.json();
|
||||||
|
|
||||||
|
showEditor(note);
|
||||||
|
loadNotes(); // 更新高亮状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示编辑器
|
||||||
|
function showEditor(note) {
|
||||||
|
document.getElementById('toolbar').classList.remove('hidden');
|
||||||
|
document.getElementById('editorContainer').classList.remove('hidden');
|
||||||
|
document.getElementById('emptyState').classList.add('hidden');
|
||||||
|
|
||||||
|
document.getElementById('currentTitle').textContent = note.title || '新记录';
|
||||||
|
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
|
||||||
|
document.getElementById('editor').value = note.content || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存内容(延迟保存)
|
||||||
|
function saveContent() {
|
||||||
|
if (!currentNoteId) return;
|
||||||
|
|
||||||
|
// 延迟保存,避免频繁请求
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
|
||||||
|
saveTimer = setTimeout(async () => {
|
||||||
|
const content = document.getElementById('editor').value;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/notes/${currentNoteId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content })
|
||||||
|
});
|
||||||
|
|
||||||
|
const note = await res.json();
|
||||||
|
|
||||||
|
// 更新显示
|
||||||
|
document.getElementById('currentTitle').textContent = note.title;
|
||||||
|
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
|
||||||
|
|
||||||
|
// 更新列表
|
||||||
|
loadNotes();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索笔记
|
||||||
|
function searchNotes() {
|
||||||
|
loadNotes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新生成标题
|
||||||
|
async function regenerateTitle() {
|
||||||
|
if (!currentNoteId) return;
|
||||||
|
|
||||||
|
const btn = event.target.closest('button');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="ri-loader-4-line animate-spin mr-1"></i> 生成中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${currentNoteId}/title`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('currentTitle').textContent = data.title;
|
||||||
|
loadNotes();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="ri-magic-line mr-1"></i> 重新生成标题';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除笔记
|
||||||
|
async function deleteCurrentNote() {
|
||||||
|
if (!currentNoteId) return;
|
||||||
|
|
||||||
|
if (!confirm('确定删除这条记录?')) return;
|
||||||
|
|
||||||
|
await fetch(`/api/notes/${currentNoteId}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
currentNoteId = null;
|
||||||
|
|
||||||
|
document.getElementById('toolbar').classList.add('hidden');
|
||||||
|
document.getElementById('editorContainer').classList.add('hidden');
|
||||||
|
document.getElementById('emptyState').classList.remove('hidden');
|
||||||
|
|
||||||
|
loadNotes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
loadNotes();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user