dreamloop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dreamloop/__init__.py +3 -0
- dreamloop/analysis.py +406 -0
- dreamloop/cli.py +130 -0
- dreamloop/core.py +601 -0
- dreamloop/static/style.css +1503 -0
- dreamloop/templates/detail.html +181 -0
- dreamloop/templates/index.html +509 -0
- dreamloop/web.py +766 -0
- dreamloop-0.1.0.dist-info/METADATA +252 -0
- dreamloop-0.1.0.dist-info/RECORD +13 -0
- dreamloop-0.1.0.dist-info/WHEEL +4 -0
- dreamloop-0.1.0.dist-info/entry_points.txt +2 -0
- dreamloop-0.1.0.dist-info/licenses/LICENSE +21 -0
dreamloop/web.py
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, Form, HTTPException, Request, status
|
|
10
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
from fastapi.templating import Jinja2Templates
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from .analysis import Analyzer, ai_status, build_analyzer, clean_reflections, load_ai_config, normalize_analysis, save_ai_config, save_secret
|
|
16
|
+
from .core import DreamLoop, call_analyzer
|
|
17
|
+
|
|
18
|
+
PACKAGE_DIR = Path(__file__).parent
|
|
19
|
+
templates = Jinja2Templates(directory=str(PACKAGE_DIR / "templates"))
|
|
20
|
+
REFLECTION_FIELD_KEYS = [
|
|
21
|
+
"strongest_emotion",
|
|
22
|
+
"waking_feeling",
|
|
23
|
+
"important_elements",
|
|
24
|
+
"real_life_context",
|
|
25
|
+
"personal_association",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
29
|
+
"en": {
|
|
30
|
+
"nav_dashboard": "Dashboard",
|
|
31
|
+
"nav_log": "Log",
|
|
32
|
+
"nav_patterns": "Patterns",
|
|
33
|
+
"nav_gallery": "Gallery",
|
|
34
|
+
"nav_settings": "Settings",
|
|
35
|
+
"sidebar_count": "dreams stored in your local workspace",
|
|
36
|
+
"dashboard_eyebrow": "Local-first dream intelligence",
|
|
37
|
+
"dashboard_title": "DreamLoop Dashboard",
|
|
38
|
+
"dashboard_tagline": "Your dreams have patterns. DreamLoop finds them locally.",
|
|
39
|
+
"dashboard_lede": "A six-page loop for private capture, structured AI analysis, pattern discovery, visual memory, and trust settings.",
|
|
40
|
+
"dashboard_cta": "Log a Dream",
|
|
41
|
+
"quick_loop": "Dashboard -> Log -> Detail -> Patterns -> Gallery -> Settings",
|
|
42
|
+
"log_eyebrow": "High-frequency capture",
|
|
43
|
+
"log_title": "Log Dream",
|
|
44
|
+
"log_lede": "Write the dream first. Ask AI to analyze it. Save only after you like the draft result.",
|
|
45
|
+
"local_write": "analysis-first workflow",
|
|
46
|
+
"content_placeholder": "Record a dream before it fades...",
|
|
47
|
+
"reflection_prompt": "Optional prompts",
|
|
48
|
+
"strongest_emotion": "Strongest emotion in the dream",
|
|
49
|
+
"strongest_emotion_placeholder": "e.g. anxious, calm, curious, trapped",
|
|
50
|
+
"waking_feeling": "Feeling after waking",
|
|
51
|
+
"waking_feeling_placeholder": "What stayed with you after waking?",
|
|
52
|
+
"important_elements": "Most important people / objects / scenes",
|
|
53
|
+
"important_elements_placeholder": "Who or what mattered most in the dream?",
|
|
54
|
+
"real_life_context": "Recent real-life situations that may be related",
|
|
55
|
+
"real_life_context_placeholder": "Work, relationships, decisions, stress, conversations...",
|
|
56
|
+
"personal_association": "What this dream makes me think of",
|
|
57
|
+
"personal_association_placeholder": "Any memory, image, phrase, or current concern it brings up",
|
|
58
|
+
"analyze_dream": "AI Analysis",
|
|
59
|
+
"save_without_ai": "Save without AI",
|
|
60
|
+
"draft_analysis": "Draft analysis",
|
|
61
|
+
"draft_not_saved": "Not saved yet",
|
|
62
|
+
"save_analysis": "Save locally",
|
|
63
|
+
"discard": "Discard",
|
|
64
|
+
"generate_chinese_analysis": "Generate Chinese analysis",
|
|
65
|
+
"generate_english_analysis": "Analyze now",
|
|
66
|
+
"generate_dream_image": "Generate dream image",
|
|
67
|
+
"visual_memory_note": "Opt-in visual memory. v0.1 keeps this as a local placeholder and never calls an image API by default.",
|
|
68
|
+
"ai_analysis": "AI Analysis",
|
|
69
|
+
"ai_insight": "AI Insight",
|
|
70
|
+
"no_dream": "Record a dream to unlock AI analysis.",
|
|
71
|
+
"latest_dream": "Latest dream",
|
|
72
|
+
"pending_analysis": "Pending analysis",
|
|
73
|
+
"missing_analysis": "Missing analysis",
|
|
74
|
+
"analysis_ready": "Structured analysis",
|
|
75
|
+
"analysis_unavailable": "AI analysis is optional. Configure Ollama, DeepSeek, OpenAI, or a custom endpoint when you want model output.",
|
|
76
|
+
"analysis_unavailable_before_save": "AI is not ready. You can still save the dream locally without analysis.",
|
|
77
|
+
"analysis_failed": "Analysis failed. Nothing was saved yet.",
|
|
78
|
+
"emotional_tone": "Emotional tone",
|
|
79
|
+
"symbols": "Symbols",
|
|
80
|
+
"themes": "Themes",
|
|
81
|
+
"summary": "Summary",
|
|
82
|
+
"confidence": "Confidence",
|
|
83
|
+
"raw_json": "Raw JSON",
|
|
84
|
+
"dream_details": "Dream details",
|
|
85
|
+
"core_emotion": "Core emotion",
|
|
86
|
+
"important_context": "Your added context",
|
|
87
|
+
"real_life_links": "Possible real-life links",
|
|
88
|
+
"possible_interpretations": "Possible interpretations",
|
|
89
|
+
"real_life_questions": "What I can notice in real life",
|
|
90
|
+
"verification_prompts": "Questions to verify for yourself",
|
|
91
|
+
"local_dreams": "Local dreams",
|
|
92
|
+
"analyzed": "Analyzed",
|
|
93
|
+
"ai_provider": "AI provider",
|
|
94
|
+
"analysis_queue": "Analysis queue",
|
|
95
|
+
"privacy_mode": "Privacy mode",
|
|
96
|
+
"sqlite_journal": "SQLite journal",
|
|
97
|
+
"pending_entries": "pending entries",
|
|
98
|
+
"data_never_leaves": "data never leaves this machine",
|
|
99
|
+
"pattern_map": "Pattern map",
|
|
100
|
+
"dream_calendar": "Dream calendar",
|
|
101
|
+
"open_dream_day": "Click a date to open that day in the log.",
|
|
102
|
+
"no_heatmap": "No dreams yet.",
|
|
103
|
+
"signals": "Signals",
|
|
104
|
+
"mood_spectrum": "Mood spectrum",
|
|
105
|
+
"no_moods": "Moods appear here after saved analysis or manual capture.",
|
|
106
|
+
"structured_output": "Structured output",
|
|
107
|
+
"most_recurring": "Most recurring symbol across analyzed dreams.",
|
|
108
|
+
"no_symbols": "No recurring symbols yet",
|
|
109
|
+
"pattern_summary": "Pattern summary",
|
|
110
|
+
"theme_trends": "Theme trends",
|
|
111
|
+
"runtime": "Runtime",
|
|
112
|
+
"local_runtime": "Local runtime",
|
|
113
|
+
"data_dir": "Data dir",
|
|
114
|
+
"provider_configured": "Provider configured. Secrets are stored locally and never rendered.",
|
|
115
|
+
"local_logbook": "Local logbook",
|
|
116
|
+
"dreamscape_log": "Dreamscape log",
|
|
117
|
+
"first_dream_waiting": "Your first dream is waiting.",
|
|
118
|
+
"no_mood": "no mood",
|
|
119
|
+
"no_tags": "no tags",
|
|
120
|
+
"filter": "Filter",
|
|
121
|
+
"clear_filter": "Clear filter",
|
|
122
|
+
"recent_dreams": "Recent dreams",
|
|
123
|
+
"back_dashboard": "Back to dashboard",
|
|
124
|
+
"day_context": "Day context",
|
|
125
|
+
"calendar_weather": "Calendar / Weather",
|
|
126
|
+
"calendar": "Calendar",
|
|
127
|
+
"weather": "Weather",
|
|
128
|
+
"no_calendar": "No imported calendar events.",
|
|
129
|
+
"no_weather": "No weather synced for this day.",
|
|
130
|
+
"gallery_title": "Dream Gallery",
|
|
131
|
+
"gallery_eyebrow": "Visual memory",
|
|
132
|
+
"gallery_empty": "Generate or save a visual memory from a dream detail page first.",
|
|
133
|
+
"gallery_note": "v0.1 shows local visual cards derived from saved dreams. Full image generation remains opt-in.",
|
|
134
|
+
"settings_title": "AI Provider",
|
|
135
|
+
"settings_eyebrow": "Local model settings",
|
|
136
|
+
"settings_copy": "Choose Ollama for local zero-cost analysis, use DeepSeek/OpenAI, or connect any OpenAI-compatible endpoint. Secrets stay in .dreamloop/secrets.env and are never rendered back.",
|
|
137
|
+
"provider": "Provider",
|
|
138
|
+
"model": "Model",
|
|
139
|
+
"base_url": "Base URL",
|
|
140
|
+
"api_key": "API Key",
|
|
141
|
+
"api_key_placeholder": "Paste a key only when you want to replace it",
|
|
142
|
+
"save_settings": "Save settings",
|
|
143
|
+
"settings_saved": "Settings saved locally.",
|
|
144
|
+
"settings_secret_note": "Existing keys are hidden. Leave API Key blank to keep the current secret.",
|
|
145
|
+
"provider_status": "Provider status",
|
|
146
|
+
"developer_note": "Developer note",
|
|
147
|
+
"cli_note": "Start DreamLoop with dreamloop web or scripts/start-dreamloop.cmd. A native desktop shell is on the roadmap, not part of v0.1.",
|
|
148
|
+
},
|
|
149
|
+
"zh": {
|
|
150
|
+
"nav_dashboard": "总览",
|
|
151
|
+
"nav_log": "录入",
|
|
152
|
+
"nav_patterns": "规律",
|
|
153
|
+
"nav_gallery": "记忆",
|
|
154
|
+
"nav_settings": "设置",
|
|
155
|
+
"sidebar_count": "条梦境保存在本地工作区",
|
|
156
|
+
"dashboard_eyebrow": "本地优先的梦境智能",
|
|
157
|
+
"dashboard_title": "DreamLoop 总览",
|
|
158
|
+
"dashboard_tagline": "梦里反复出现的线索,DreamLoop 在本地帮你看见。",
|
|
159
|
+
"dashboard_lede": "从记录、分析到规律和视觉记忆,六页完成一个私密闭环;设置页负责把信任说清楚。",
|
|
160
|
+
"dashboard_cta": "记录梦境",
|
|
161
|
+
"quick_loop": "总览 -> 录入 -> 详情 -> 规律 -> 记忆 -> 设置",
|
|
162
|
+
"log_eyebrow": "高频录入",
|
|
163
|
+
"log_title": "记录梦境",
|
|
164
|
+
"log_lede": "先写下梦境,再让 AI 生成分析草稿。确认有用后,再保存到本地。",
|
|
165
|
+
"local_write": "先分析,再保存",
|
|
166
|
+
"content_placeholder": "趁梦还没散,先记下来...",
|
|
167
|
+
"reflection_prompt": "可选补充",
|
|
168
|
+
"strongest_emotion": "梦里最强的情绪",
|
|
169
|
+
"strongest_emotion_placeholder": "比如:焦虑、平静、好奇、被困住",
|
|
170
|
+
"waking_feeling": "醒来后的感觉",
|
|
171
|
+
"waking_feeling_placeholder": "醒来后最残留的感受是什么?",
|
|
172
|
+
"important_elements": "梦里最重要的人 / 物 / 场景",
|
|
173
|
+
"important_elements_placeholder": "谁或什么最重要?哪个场景最挥之不去?",
|
|
174
|
+
"real_life_context": "最近现实中可能相关的事",
|
|
175
|
+
"real_life_context_placeholder": "工作、关系、决定、压力、对话都可以写",
|
|
176
|
+
"personal_association": "这个梦让我想到什么",
|
|
177
|
+
"personal_association_placeholder": "它让你想起的记忆、画面、词语或现实烦恼",
|
|
178
|
+
"analyze_dream": "AI 分析",
|
|
179
|
+
"save_without_ai": "不分析,直接保存",
|
|
180
|
+
"draft_analysis": "草稿分析",
|
|
181
|
+
"draft_not_saved": "尚未保存到本地",
|
|
182
|
+
"save_analysis": "保存到本地",
|
|
183
|
+
"discard": "放弃",
|
|
184
|
+
"generate_chinese_analysis": "生成中文分析",
|
|
185
|
+
"generate_english_analysis": "生成英文分析",
|
|
186
|
+
"generate_dream_image": "生成梦境画面",
|
|
187
|
+
"visual_memory_note": "可选视觉记忆。v0.1 只保留本地占位入口,默认不会调用图像 API。",
|
|
188
|
+
"ai_analysis": "AI 分析",
|
|
189
|
+
"ai_insight": "AI 洞察",
|
|
190
|
+
"no_dream": "先记录一条梦境,AI 分析会出现在这里。",
|
|
191
|
+
"latest_dream": "最新梦境",
|
|
192
|
+
"pending_analysis": "等待分析",
|
|
193
|
+
"missing_analysis": "缺少该语言分析",
|
|
194
|
+
"analysis_ready": "结构化分析",
|
|
195
|
+
"analysis_unavailable": "AI 是可选项。想要模型分析时,再配置 Ollama、DeepSeek、OpenAI 或自定义端点。",
|
|
196
|
+
"analysis_unavailable_before_save": "AI 暂不可用;你也可以先把梦境直接保存到本地。",
|
|
197
|
+
"analysis_failed": "分析失败。当前内容尚未保存。",
|
|
198
|
+
"emotional_tone": "情绪基调",
|
|
199
|
+
"symbols": "符号",
|
|
200
|
+
"themes": "主题",
|
|
201
|
+
"summary": "摘要",
|
|
202
|
+
"confidence": "置信度",
|
|
203
|
+
"raw_json": "原始 JSON",
|
|
204
|
+
"dream_details": "梦里的具体细节",
|
|
205
|
+
"core_emotion": "核心情绪",
|
|
206
|
+
"important_context": "你补充的线索",
|
|
207
|
+
"real_life_links": "可能关联的现实处境",
|
|
208
|
+
"possible_interpretations": "可能解释",
|
|
209
|
+
"real_life_questions": "我可以从中看到的现实问题",
|
|
210
|
+
"verification_prompts": "可以自我验证的问题",
|
|
211
|
+
"local_dreams": "本地梦境",
|
|
212
|
+
"analyzed": "已分析",
|
|
213
|
+
"ai_provider": "AI 提供方",
|
|
214
|
+
"analysis_queue": "分析队列",
|
|
215
|
+
"privacy_mode": "隐私模式",
|
|
216
|
+
"sqlite_journal": "SQLite 日志",
|
|
217
|
+
"pending_entries": "条待分析",
|
|
218
|
+
"data_never_leaves": "默认不离开本机",
|
|
219
|
+
"pattern_map": "模式地图",
|
|
220
|
+
"dream_calendar": "梦境日历",
|
|
221
|
+
"open_dream_day": "点击日期,查看当天梦境记录。",
|
|
222
|
+
"no_heatmap": "还没有梦境。",
|
|
223
|
+
"signals": "信号",
|
|
224
|
+
"mood_spectrum": "情绪光谱",
|
|
225
|
+
"no_moods": "保存分析或手动记录后,情绪会出现在这里。",
|
|
226
|
+
"structured_output": "结构化输出",
|
|
227
|
+
"most_recurring": "分析结果里最常出现的符号。",
|
|
228
|
+
"no_symbols": "还没有反复出现的符号",
|
|
229
|
+
"pattern_summary": "模式摘要",
|
|
230
|
+
"theme_trends": "主题趋势",
|
|
231
|
+
"runtime": "运行状态",
|
|
232
|
+
"local_runtime": "本地运行",
|
|
233
|
+
"data_dir": "数据目录",
|
|
234
|
+
"provider_configured": "模型已配置;密钥只保存在本地,不会显示在页面里。",
|
|
235
|
+
"local_logbook": "本地日志",
|
|
236
|
+
"dreamscape_log": "梦境记录",
|
|
237
|
+
"first_dream_waiting": "第一条梦境正在等你。",
|
|
238
|
+
"no_mood": "无情绪",
|
|
239
|
+
"no_tags": "无标签",
|
|
240
|
+
"filter": "筛选",
|
|
241
|
+
"clear_filter": "清除筛选",
|
|
242
|
+
"recent_dreams": "最近梦境",
|
|
243
|
+
"back_dashboard": "返回总览",
|
|
244
|
+
"day_context": "当天上下文",
|
|
245
|
+
"calendar_weather": "日历 / 天气",
|
|
246
|
+
"calendar": "日历",
|
|
247
|
+
"weather": "天气",
|
|
248
|
+
"no_calendar": "没有导入的日历事件。",
|
|
249
|
+
"no_weather": "这一天还没有同步天气。",
|
|
250
|
+
"gallery_title": "梦境画廊",
|
|
251
|
+
"gallery_eyebrow": "视觉记忆",
|
|
252
|
+
"gallery_empty": "先在梦境详情页生成或保存视觉记忆。",
|
|
253
|
+
"gallery_note": "v0.1 展示由已保存梦境生成的本地视觉卡片;完整图像生成仍然是可选路线。",
|
|
254
|
+
"settings_title": "AI 提供方",
|
|
255
|
+
"settings_eyebrow": "本地模型设置",
|
|
256
|
+
"settings_copy": "Ollama 适合零成本本地分析;DeepSeek、OpenAI 和自定义端点适合需要云模型或自建网关的场景。密钥只写入 .dreamloop/secrets.env,页面不会回显。",
|
|
257
|
+
"provider": "提供方",
|
|
258
|
+
"model": "模型",
|
|
259
|
+
"base_url": "Base URL",
|
|
260
|
+
"api_key": "API Key",
|
|
261
|
+
"api_key_placeholder": "需要更换密钥时再粘贴",
|
|
262
|
+
"save_settings": "保存设置",
|
|
263
|
+
"settings_saved": "设置已保存到本地。",
|
|
264
|
+
"settings_secret_note": "已有密钥会隐藏。API Key 留空表示保留当前密钥。",
|
|
265
|
+
"provider_status": "模型状态",
|
|
266
|
+
"developer_note": "开发者说明",
|
|
267
|
+
"cli_note": "当前可用 dreamloop web 或 scripts/start-dreamloop.cmd 启动;原生桌面壳在路线图里,不属于 v0.1。",
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _mood_spectrum(dreams: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
273
|
+
counts: dict[str, int] = {}
|
|
274
|
+
for dream in dreams:
|
|
275
|
+
analysis = dream.get("analysis") or {}
|
|
276
|
+
mood = (dream.get("manual_mood") or analysis.get("emotional_tone") or "").strip()
|
|
277
|
+
if mood:
|
|
278
|
+
counts[mood] = counts.get(mood, 0) + 1
|
|
279
|
+
|
|
280
|
+
total = sum(counts.values())
|
|
281
|
+
if not total:
|
|
282
|
+
return []
|
|
283
|
+
return [
|
|
284
|
+
{"name": name, "count": count, "percent": max(12, round(count / total * 100))}
|
|
285
|
+
for name, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _lang(value: str | None) -> str:
|
|
290
|
+
return value if value in TRANSLATIONS else "en"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _collect_reflections(
|
|
294
|
+
strongest_emotion: str = "",
|
|
295
|
+
waking_feeling: str = "",
|
|
296
|
+
important_elements: str = "",
|
|
297
|
+
real_life_context: str = "",
|
|
298
|
+
personal_association: str = "",
|
|
299
|
+
) -> dict[str, str]:
|
|
300
|
+
return clean_reflections(
|
|
301
|
+
{
|
|
302
|
+
"strongest_emotion": strongest_emotion,
|
|
303
|
+
"waking_feeling": waking_feeling,
|
|
304
|
+
"important_elements": important_elements,
|
|
305
|
+
"real_life_context": real_life_context,
|
|
306
|
+
"personal_association": personal_association,
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _reflection_fields(lang: str, values: dict[str, str] | None = None) -> list[dict[str, str]]:
|
|
312
|
+
t = TRANSLATIONS[_lang(lang)]
|
|
313
|
+
values = values or {}
|
|
314
|
+
return [
|
|
315
|
+
{
|
|
316
|
+
"name": key,
|
|
317
|
+
"label": t[key],
|
|
318
|
+
"placeholder": t[f"{key}_placeholder"],
|
|
319
|
+
"value": values.get(key, ""),
|
|
320
|
+
}
|
|
321
|
+
for key in REFLECTION_FIELD_KEYS
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _page_url(page: str, lang: str, **params: str) -> str:
|
|
326
|
+
path = "/" if page in {"", "dashboard"} else f"/{page}"
|
|
327
|
+
query = {"lang": _lang(lang), **{key: val for key, val in params.items() if val}}
|
|
328
|
+
return path + "?" + urlencode(query)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _dream_url(dream_id: int, lang: str) -> str:
|
|
332
|
+
return f"/dreams/{dream_id}?" + urlencode({"lang": _lang(lang)})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _analyzer_override(app: FastAPI) -> Analyzer | None:
|
|
336
|
+
analyzer = getattr(app.state, "analyzer", None)
|
|
337
|
+
return analyzer if analyzer is not None else None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _analysis_terms(dream: dict[str, Any], key: str) -> set[str]:
|
|
341
|
+
analysis = dream.get("analysis") or {}
|
|
342
|
+
return {str(item).lower() for item in analysis.get(key, [])}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _matches_log_filter(
|
|
346
|
+
dream: dict[str, Any],
|
|
347
|
+
*,
|
|
348
|
+
date_filter: str = "",
|
|
349
|
+
symbol_filter: str = "",
|
|
350
|
+
theme_filter: str = "",
|
|
351
|
+
) -> bool:
|
|
352
|
+
if date_filter and dream["dreamed_on"] != date_filter:
|
|
353
|
+
return False
|
|
354
|
+
if symbol_filter:
|
|
355
|
+
symbol = symbol_filter.lower()
|
|
356
|
+
tag_terms = {str(tag).lower() for tag in dream.get("tags", [])}
|
|
357
|
+
if symbol not in _analysis_terms(dream, "symbols") and symbol not in tag_terms:
|
|
358
|
+
return False
|
|
359
|
+
if theme_filter and theme_filter.lower() not in _analysis_terms(dream, "themes"):
|
|
360
|
+
return False
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _dashboard_insight(dreams: list[dict[str, Any]], trends: dict[str, list[dict[str, Any]]], lang: str) -> dict[str, str]:
|
|
365
|
+
t = TRANSLATIONS[lang]
|
|
366
|
+
if dreams and dreams[0]["analysis"] is None:
|
|
367
|
+
body = (
|
|
368
|
+
"The latest saved dream has no analysis in this language yet. Open Detail to generate it."
|
|
369
|
+
if lang == "en"
|
|
370
|
+
else "最新保存的梦境还没有当前语言分析。打开详情页即可生成。"
|
|
371
|
+
)
|
|
372
|
+
return {"title": t["missing_analysis"], "body": body}
|
|
373
|
+
if trends["symbols"]:
|
|
374
|
+
symbol = trends["symbols"][0]
|
|
375
|
+
body = (
|
|
376
|
+
f"{symbol['name']} appears {symbol['count']} time(s) across analyzed dreams."
|
|
377
|
+
if lang == "en"
|
|
378
|
+
else f"{symbol['name']} 在已分析梦境中出现了 {symbol['count']} 次。"
|
|
379
|
+
)
|
|
380
|
+
return {"title": str(symbol["name"]), "body": body}
|
|
381
|
+
if dreams:
|
|
382
|
+
body = (
|
|
383
|
+
f"{len(dreams)} local dream(s) are stored. More analysis will make recurring patterns visible."
|
|
384
|
+
if lang == "en"
|
|
385
|
+
else f"本地已保存 {len(dreams)} 条梦境。分析越多,反复出现的模式会越清楚。"
|
|
386
|
+
)
|
|
387
|
+
return {"title": t["local_dreams"], "body": body}
|
|
388
|
+
body = (
|
|
389
|
+
"Start with one dream. DreamLoop will keep the text local and build insight from there."
|
|
390
|
+
if lang == "en"
|
|
391
|
+
else "先记录一条梦境。DreamLoop 会把原文留在本地,再从那里生长出洞察。"
|
|
392
|
+
)
|
|
393
|
+
return {"title": t["ai_insight"], "body": body}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _dashboard_stats(dreams: list[dict[str, Any]], trends: dict[str, list[dict[str, Any]]], ai: Any, lang: str) -> list[dict[str, str]]:
|
|
397
|
+
t = TRANSLATIONS[lang]
|
|
398
|
+
analyzed = sum(1 for dream in dreams if dream["analysis"])
|
|
399
|
+
top_symbol = trends["symbols"][0]["name"] if trends["symbols"] else "-"
|
|
400
|
+
return [
|
|
401
|
+
{"label": t["local_dreams"], "value": str(len(dreams)), "detail": t["sqlite_journal"]},
|
|
402
|
+
{"label": t["analyzed"], "value": str(analyzed), "detail": t["structured_output"]},
|
|
403
|
+
{"label": t["ai_provider"], "value": str(ai.provider), "detail": ai.model or "capture only"},
|
|
404
|
+
{"label": t["privacy_mode"], "value": "opt-in", "detail": f"{t['data_never_leaves']} / {top_symbol}"},
|
|
405
|
+
]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class DreamCreate(BaseModel):
|
|
409
|
+
content: str = Field(min_length=1)
|
|
410
|
+
tags: list[str] = Field(default_factory=list)
|
|
411
|
+
manual_mood: str | None = None
|
|
412
|
+
dreamed_on: date | None = None
|
|
413
|
+
reflections: dict[str, str] = Field(default_factory=dict)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class WeatherSync(BaseModel):
|
|
417
|
+
lat: float
|
|
418
|
+
lon: float
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def create_app(root: str | Path | None = None) -> FastAPI:
|
|
422
|
+
app = FastAPI(title="DreamLoop", version="0.1.0")
|
|
423
|
+
loop = DreamLoop(root)
|
|
424
|
+
loop.init()
|
|
425
|
+
app.state.loop = loop
|
|
426
|
+
app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static")
|
|
427
|
+
|
|
428
|
+
def render_home(
|
|
429
|
+
request: Request,
|
|
430
|
+
lang: str = "en",
|
|
431
|
+
*,
|
|
432
|
+
page: str = "dashboard",
|
|
433
|
+
analysis_error: bool = False,
|
|
434
|
+
draft: dict[str, Any] | None = None,
|
|
435
|
+
draft_content: str = "",
|
|
436
|
+
draft_reflections: dict[str, str] | None = None,
|
|
437
|
+
settings_saved: bool = False,
|
|
438
|
+
date_filter: str = "",
|
|
439
|
+
symbol_filter: str = "",
|
|
440
|
+
theme_filter: str = "",
|
|
441
|
+
) -> Any:
|
|
442
|
+
lang = _lang(lang)
|
|
443
|
+
raw_dreams = loop.list_dreams()
|
|
444
|
+
localized_dreams = [loop.get_dream(dream["id"], language=lang) for dream in raw_dreams]
|
|
445
|
+
trends = loop.trends(language=lang)
|
|
446
|
+
ai_payload = ai_status(loop.root)
|
|
447
|
+
log_dreams = [
|
|
448
|
+
dream
|
|
449
|
+
for dream in localized_dreams
|
|
450
|
+
if _matches_log_filter(
|
|
451
|
+
dream,
|
|
452
|
+
date_filter=date_filter,
|
|
453
|
+
symbol_filter=symbol_filter,
|
|
454
|
+
theme_filter=theme_filter,
|
|
455
|
+
)
|
|
456
|
+
]
|
|
457
|
+
filtered_log = bool(date_filter or symbol_filter or theme_filter)
|
|
458
|
+
if page == "log" and filtered_log:
|
|
459
|
+
latest_dream = log_dreams[0] if log_dreams else None
|
|
460
|
+
else:
|
|
461
|
+
latest_dream = localized_dreams[0] if localized_dreams else None
|
|
462
|
+
return templates.TemplateResponse(
|
|
463
|
+
request,
|
|
464
|
+
"index.html",
|
|
465
|
+
{
|
|
466
|
+
"dreams": localized_dreams,
|
|
467
|
+
"log_dreams": log_dreams,
|
|
468
|
+
"recent_dreams": localized_dreams[:3],
|
|
469
|
+
"gallery_cards": localized_dreams,
|
|
470
|
+
"latest_dream": latest_dream,
|
|
471
|
+
"heatmap": loop.heatmap(),
|
|
472
|
+
"ai": ai_payload,
|
|
473
|
+
"ai_config": load_ai_config(loop.root),
|
|
474
|
+
"trends": trends,
|
|
475
|
+
"dashboard_insight": _dashboard_insight(localized_dreams, trends, lang),
|
|
476
|
+
"dashboard_stats": _dashboard_stats(localized_dreams, trends, ai_payload, lang),
|
|
477
|
+
"data_dir": loop.data_dir,
|
|
478
|
+
"pending_count": sum(1 for dream in localized_dreams if dream["analysis"] is None),
|
|
479
|
+
"mood_spectrum": _mood_spectrum(localized_dreams),
|
|
480
|
+
"lang": lang,
|
|
481
|
+
"page": page,
|
|
482
|
+
"t": TRANSLATIONS[lang],
|
|
483
|
+
"analysis_error": analysis_error,
|
|
484
|
+
"draft": draft,
|
|
485
|
+
"draft_content": draft_content,
|
|
486
|
+
"reflection_fields": _reflection_fields(lang, (draft or {}).get("reflections") or draft_reflections),
|
|
487
|
+
"settings_saved": settings_saved,
|
|
488
|
+
"date_filter": date_filter,
|
|
489
|
+
"symbol_filter": symbol_filter,
|
|
490
|
+
"theme_filter": theme_filter,
|
|
491
|
+
},
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
@app.get("/", response_class=HTMLResponse)
|
|
495
|
+
def dashboard(request: Request, lang: str = "en", analysis_error: str = "") -> Any:
|
|
496
|
+
return render_home(request, lang, page="dashboard", analysis_error=bool(analysis_error))
|
|
497
|
+
|
|
498
|
+
@app.get("/patterns", response_class=HTMLResponse)
|
|
499
|
+
def patterns(request: Request, lang: str = "en") -> Any:
|
|
500
|
+
return render_home(request, lang, page="patterns")
|
|
501
|
+
|
|
502
|
+
@app.get("/insights")
|
|
503
|
+
def insights(lang: str = "en") -> RedirectResponse:
|
|
504
|
+
return RedirectResponse(_page_url("patterns", lang), status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
|
505
|
+
|
|
506
|
+
@app.get("/gallery", response_class=HTMLResponse)
|
|
507
|
+
def gallery(request: Request, lang: str = "en") -> Any:
|
|
508
|
+
return render_home(request, lang, page="gallery")
|
|
509
|
+
|
|
510
|
+
@app.get("/log", response_class=HTMLResponse)
|
|
511
|
+
def logbook(
|
|
512
|
+
request: Request,
|
|
513
|
+
lang: str = "en",
|
|
514
|
+
date: str = "",
|
|
515
|
+
symbol: str = "",
|
|
516
|
+
theme: str = "",
|
|
517
|
+
) -> Any:
|
|
518
|
+
return render_home(
|
|
519
|
+
request,
|
|
520
|
+
lang,
|
|
521
|
+
page="log",
|
|
522
|
+
date_filter=date,
|
|
523
|
+
symbol_filter=symbol,
|
|
524
|
+
theme_filter=theme,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
@app.get("/settings", response_class=HTMLResponse)
|
|
528
|
+
def settings(request: Request, lang: str = "en", saved: str = "") -> Any:
|
|
529
|
+
return render_home(request, lang, page="settings", settings_saved=bool(saved))
|
|
530
|
+
|
|
531
|
+
@app.post("/settings/ai")
|
|
532
|
+
def save_ai_settings(
|
|
533
|
+
lang: str = "en",
|
|
534
|
+
provider: str = Form(...),
|
|
535
|
+
model: str = Form(""),
|
|
536
|
+
base_url: str = Form(""),
|
|
537
|
+
api_key: str = Form(""),
|
|
538
|
+
) -> RedirectResponse:
|
|
539
|
+
provider = provider.strip().lower()
|
|
540
|
+
if provider not in {"ollama", "deepseek", "openai", "custom", "none"}:
|
|
541
|
+
raise HTTPException(status_code=400, detail="Unsupported AI provider")
|
|
542
|
+
save_ai_config(loop.root, provider=provider, model=model.strip() or None, base_url=base_url.strip() or None)
|
|
543
|
+
if api_key.strip() and provider in {"deepseek", "openai", "custom"}:
|
|
544
|
+
secret_name = {
|
|
545
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
546
|
+
"openai": "OPENAI_API_KEY",
|
|
547
|
+
"custom": "CUSTOM_API_KEY",
|
|
548
|
+
}[provider]
|
|
549
|
+
save_secret(loop.root, secret_name, api_key.strip())
|
|
550
|
+
return RedirectResponse(_page_url("settings", lang, saved="1"), status_code=status.HTTP_303_SEE_OTHER)
|
|
551
|
+
|
|
552
|
+
@app.post("/dreams")
|
|
553
|
+
def create_dream_form(
|
|
554
|
+
lang: str = "en",
|
|
555
|
+
content: str = Form(...),
|
|
556
|
+
tags: str = Form(""),
|
|
557
|
+
manual_mood: str = Form(""),
|
|
558
|
+
strongest_emotion: str = Form(""),
|
|
559
|
+
waking_feeling: str = Form(""),
|
|
560
|
+
important_elements: str = Form(""),
|
|
561
|
+
real_life_context: str = Form(""),
|
|
562
|
+
personal_association: str = Form(""),
|
|
563
|
+
) -> RedirectResponse:
|
|
564
|
+
tag_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
|
|
565
|
+
dream_id = loop.add_dream(
|
|
566
|
+
content,
|
|
567
|
+
tags=tag_list,
|
|
568
|
+
mood=manual_mood or None,
|
|
569
|
+
reflections=_collect_reflections(
|
|
570
|
+
strongest_emotion,
|
|
571
|
+
waking_feeling,
|
|
572
|
+
important_elements,
|
|
573
|
+
real_life_context,
|
|
574
|
+
personal_association,
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
return RedirectResponse(_dream_url(dream_id, lang), status_code=status.HTTP_303_SEE_OTHER)
|
|
578
|
+
|
|
579
|
+
@app.post("/drafts/analyze", response_class=HTMLResponse)
|
|
580
|
+
def analyze_draft(
|
|
581
|
+
request: Request,
|
|
582
|
+
lang: str = "en",
|
|
583
|
+
content: str = Form(...),
|
|
584
|
+
strongest_emotion: str = Form(""),
|
|
585
|
+
waking_feeling: str = Form(""),
|
|
586
|
+
important_elements: str = Form(""),
|
|
587
|
+
real_life_context: str = Form(""),
|
|
588
|
+
personal_association: str = Form(""),
|
|
589
|
+
) -> Any:
|
|
590
|
+
lang = _lang(lang)
|
|
591
|
+
reflections = _collect_reflections(
|
|
592
|
+
strongest_emotion,
|
|
593
|
+
waking_feeling,
|
|
594
|
+
important_elements,
|
|
595
|
+
real_life_context,
|
|
596
|
+
personal_association,
|
|
597
|
+
)
|
|
598
|
+
analyzer = _analyzer_override(request.app) or build_analyzer(loop.root)
|
|
599
|
+
if analyzer is None:
|
|
600
|
+
return render_home(
|
|
601
|
+
request,
|
|
602
|
+
lang,
|
|
603
|
+
page="log",
|
|
604
|
+
analysis_error=True,
|
|
605
|
+
draft_content=content,
|
|
606
|
+
draft_reflections=reflections,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
normalized = normalize_analysis(call_analyzer(analyzer, content, lang, reflections))
|
|
611
|
+
except Exception:
|
|
612
|
+
return render_home(
|
|
613
|
+
request,
|
|
614
|
+
lang,
|
|
615
|
+
page="log",
|
|
616
|
+
analysis_error=True,
|
|
617
|
+
draft_content=content,
|
|
618
|
+
draft_reflections=reflections,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
return render_home(
|
|
622
|
+
request,
|
|
623
|
+
lang,
|
|
624
|
+
page="log",
|
|
625
|
+
draft={
|
|
626
|
+
"content": content.strip(),
|
|
627
|
+
"reflections": reflections,
|
|
628
|
+
"reflections_json": json.dumps(reflections, ensure_ascii=False),
|
|
629
|
+
"analysis": normalized,
|
|
630
|
+
"analysis_json": normalized["raw_json"],
|
|
631
|
+
"language": lang,
|
|
632
|
+
},
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
@app.post("/drafts/save")
|
|
636
|
+
def save_draft(
|
|
637
|
+
lang: str = "en",
|
|
638
|
+
content: str = Form(...),
|
|
639
|
+
analysis_json: str = Form(...),
|
|
640
|
+
analysis_language: str = Form("en"),
|
|
641
|
+
reflections_json: str = Form("{}"),
|
|
642
|
+
) -> RedirectResponse:
|
|
643
|
+
try:
|
|
644
|
+
analysis = json.loads(analysis_json)
|
|
645
|
+
except json.JSONDecodeError as exc:
|
|
646
|
+
raise HTTPException(status_code=400, detail="Invalid analysis JSON") from exc
|
|
647
|
+
try:
|
|
648
|
+
reflections_payload = json.loads(reflections_json)
|
|
649
|
+
except json.JSONDecodeError:
|
|
650
|
+
reflections_payload = {}
|
|
651
|
+
dream_id = loop.add_dream_with_analysis(
|
|
652
|
+
content,
|
|
653
|
+
analysis,
|
|
654
|
+
language=_lang(analysis_language),
|
|
655
|
+
reflections=reflections_payload if isinstance(reflections_payload, dict) else {},
|
|
656
|
+
)
|
|
657
|
+
return RedirectResponse(_dream_url(dream_id, lang), status_code=status.HTTP_303_SEE_OTHER)
|
|
658
|
+
|
|
659
|
+
@app.post("/dreams/{dream_id}/analyze")
|
|
660
|
+
def analyze_dream_form(request: Request, dream_id: int, lang: str = "en") -> RedirectResponse:
|
|
661
|
+
lang = _lang(lang)
|
|
662
|
+
analyzer = _analyzer_override(request.app)
|
|
663
|
+
try:
|
|
664
|
+
loop.analyze_dream(dream_id, analyzer, language=lang)
|
|
665
|
+
except Exception:
|
|
666
|
+
return RedirectResponse(
|
|
667
|
+
_page_url("dashboard", lang, analysis_error="1"),
|
|
668
|
+
status_code=status.HTTP_303_SEE_OTHER,
|
|
669
|
+
)
|
|
670
|
+
return RedirectResponse(_dream_url(dream_id, lang), status_code=status.HTTP_303_SEE_OTHER)
|
|
671
|
+
|
|
672
|
+
@app.get("/dreams/{dream_id}", response_class=HTMLResponse)
|
|
673
|
+
def dream_detail(request: Request, dream_id: int, lang: str = "en") -> Any:
|
|
674
|
+
lang = _lang(lang)
|
|
675
|
+
try:
|
|
676
|
+
dream = loop.get_dream(dream_id, language=lang)
|
|
677
|
+
except KeyError as exc:
|
|
678
|
+
raise HTTPException(status_code=404, detail="Dream not found") from exc
|
|
679
|
+
context = loop.day_context(date.fromisoformat(dream["dreamed_on"]))
|
|
680
|
+
return templates.TemplateResponse(
|
|
681
|
+
request,
|
|
682
|
+
"detail.html",
|
|
683
|
+
{
|
|
684
|
+
"dream": dream,
|
|
685
|
+
"context": context,
|
|
686
|
+
"ai": ai_status(loop.root),
|
|
687
|
+
"lang": lang,
|
|
688
|
+
"t": TRANSLATIONS[lang],
|
|
689
|
+
},
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
@app.post("/api/dreams", status_code=201)
|
|
693
|
+
def api_create_dream(payload: DreamCreate) -> dict[str, int]:
|
|
694
|
+
dream_id = loop.add_dream(
|
|
695
|
+
payload.content,
|
|
696
|
+
tags=payload.tags,
|
|
697
|
+
mood=payload.manual_mood,
|
|
698
|
+
dreamed_on=payload.dreamed_on,
|
|
699
|
+
reflections=payload.reflections,
|
|
700
|
+
)
|
|
701
|
+
return {"id": dream_id}
|
|
702
|
+
|
|
703
|
+
@app.get("/api/dreams")
|
|
704
|
+
def api_list_dreams() -> list[dict[str, Any]]:
|
|
705
|
+
return loop.list_dreams()
|
|
706
|
+
|
|
707
|
+
@app.get("/api/dreams/{dream_id}")
|
|
708
|
+
def api_get_dream(dream_id: int, lang: str = "en") -> dict[str, Any]:
|
|
709
|
+
try:
|
|
710
|
+
return loop.get_dream(dream_id, language=_lang(lang))
|
|
711
|
+
except KeyError as exc:
|
|
712
|
+
raise HTTPException(status_code=404, detail="Dream not found") from exc
|
|
713
|
+
|
|
714
|
+
@app.get("/api/dreams/{dream_id}/similar")
|
|
715
|
+
def api_similar_dreams(dream_id: int) -> list[dict[str, Any]]:
|
|
716
|
+
try:
|
|
717
|
+
return loop.similar_dreams(dream_id)
|
|
718
|
+
except KeyError as exc:
|
|
719
|
+
raise HTTPException(status_code=404, detail="Dream not found") from exc
|
|
720
|
+
|
|
721
|
+
@app.post("/api/dreams/{dream_id}/analyze")
|
|
722
|
+
def api_analyze_dream(request: Request, dream_id: int, lang: str = "en") -> dict[str, Any]:
|
|
723
|
+
lang = _lang(lang)
|
|
724
|
+
analyzer = _analyzer_override(request.app)
|
|
725
|
+
status_payload = ai_status(loop.root)
|
|
726
|
+
if analyzer is None and not status_payload.ready:
|
|
727
|
+
raise HTTPException(status_code=409, detail=status_payload.warning or "AI provider is not ready.")
|
|
728
|
+
try:
|
|
729
|
+
analyzed = loop.analyze_dream(dream_id, analyzer, language=lang)
|
|
730
|
+
except KeyError as exc:
|
|
731
|
+
raise HTTPException(status_code=404, detail="Dream not found") from exc
|
|
732
|
+
return {
|
|
733
|
+
"analyzed": analyzed,
|
|
734
|
+
"ai_configured": True,
|
|
735
|
+
"provider": "test" if analyzer is not None else status_payload.provider,
|
|
736
|
+
"language": lang,
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
@app.post("/api/analyze/pending")
|
|
740
|
+
def api_analyze_pending(lang: str = "en") -> dict[str, Any]:
|
|
741
|
+
analyzed = loop.analyze_pending(language=_lang(lang))
|
|
742
|
+
status_payload = ai_status(loop.root)
|
|
743
|
+
return {
|
|
744
|
+
"analyzed": analyzed,
|
|
745
|
+
"ai_configured": status_payload.ready,
|
|
746
|
+
"provider": status_payload.provider,
|
|
747
|
+
"language": _lang(lang),
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
@app.post("/api/import/ics")
|
|
751
|
+
def api_import_ics(path: str) -> dict[str, int]:
|
|
752
|
+
return {"imported": loop.import_ics(path)}
|
|
753
|
+
|
|
754
|
+
@app.post("/api/weather/sync")
|
|
755
|
+
def api_weather_sync(payload: WeatherSync) -> dict[str, int]:
|
|
756
|
+
return {"synced": loop.sync_weather(payload.lat, payload.lon)}
|
|
757
|
+
|
|
758
|
+
@app.get("/api/insights/heatmap")
|
|
759
|
+
def api_heatmap() -> list[dict[str, Any]]:
|
|
760
|
+
return loop.heatmap()
|
|
761
|
+
|
|
762
|
+
@app.get("/api/insights/trends")
|
|
763
|
+
def api_trends(lang: str = "en") -> dict[str, list[dict[str, Any]]]:
|
|
764
|
+
return loop.trends(language=_lang(lang))
|
|
765
|
+
|
|
766
|
+
return app
|