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/__init__.py
ADDED
dreamloop/analysis.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1"
|
|
13
|
+
DEFAULT_OLLAMA_MODEL = "qwen3:8b"
|
|
14
|
+
DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
|
15
|
+
DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-flash"
|
|
16
|
+
DEFAULT_OPENAI_MODEL = "gpt-4.1-mini"
|
|
17
|
+
|
|
18
|
+
REFLECTION_LABELS = {
|
|
19
|
+
"strongest_emotion": "Dream's strongest emotion",
|
|
20
|
+
"waking_feeling": "Feeling after waking",
|
|
21
|
+
"important_elements": "Most important people, objects, or scenes",
|
|
22
|
+
"real_life_context": "Recent real-life situations that may be related",
|
|
23
|
+
"personal_association": "What this dream makes the dreamer think of",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Analyzer(Protocol):
|
|
28
|
+
def analyze(
|
|
29
|
+
self,
|
|
30
|
+
content: str,
|
|
31
|
+
language: str = "en",
|
|
32
|
+
reflections: dict[str, str] | None = None,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""Return structured dream analysis for a dream text."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class AIStatus:
|
|
39
|
+
provider: str
|
|
40
|
+
model: str | None
|
|
41
|
+
base_url: str | None
|
|
42
|
+
mode: str
|
|
43
|
+
ready: bool
|
|
44
|
+
warning: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class StaticAnalyzer:
|
|
49
|
+
result: dict[str, Any]
|
|
50
|
+
|
|
51
|
+
def analyze(
|
|
52
|
+
self,
|
|
53
|
+
content: str,
|
|
54
|
+
language: str = "en",
|
|
55
|
+
reflections: dict[str, str] | None = None,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
return dict(self.result)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class OpenAICompatibleAnalyzer:
|
|
62
|
+
provider: str
|
|
63
|
+
model: str
|
|
64
|
+
base_url: str
|
|
65
|
+
api_key: str
|
|
66
|
+
response_format: dict[str, str]
|
|
67
|
+
|
|
68
|
+
def analyze(
|
|
69
|
+
self,
|
|
70
|
+
content: str,
|
|
71
|
+
language: str = "en",
|
|
72
|
+
reflections: dict[str, str] | None = None,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
try:
|
|
75
|
+
from openai import OpenAI
|
|
76
|
+
except ImportError as exc:
|
|
77
|
+
raise RuntimeError("Install dreamloop[ai] to enable cloud model analysis.") from exc
|
|
78
|
+
|
|
79
|
+
output_language = "Simplified Chinese" if language == "zh" else "English"
|
|
80
|
+
client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
|
81
|
+
response = client.chat.completions.create(
|
|
82
|
+
model=self.model,
|
|
83
|
+
messages=[
|
|
84
|
+
{
|
|
85
|
+
"role": "system",
|
|
86
|
+
"content": analysis_system_prompt(language),
|
|
87
|
+
},
|
|
88
|
+
{"role": "user", "content": build_analysis_user_payload(content, reflections or {})},
|
|
89
|
+
],
|
|
90
|
+
response_format=self.response_format,
|
|
91
|
+
)
|
|
92
|
+
text = response.choices[0].message.content or "{}"
|
|
93
|
+
return json.loads(text)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DeepSeekAnalyzer(OpenAICompatibleAnalyzer):
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
api_key: str,
|
|
100
|
+
model: str = DEFAULT_DEEPSEEK_MODEL,
|
|
101
|
+
base_url: str = DEFAULT_DEEPSEEK_BASE_URL,
|
|
102
|
+
) -> None:
|
|
103
|
+
super().__init__(
|
|
104
|
+
provider="deepseek",
|
|
105
|
+
model=model,
|
|
106
|
+
base_url=base_url,
|
|
107
|
+
api_key=api_key,
|
|
108
|
+
response_format={"type": "json_object"},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class OllamaAnalyzer(OpenAICompatibleAnalyzer):
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
model: str = DEFAULT_OLLAMA_MODEL,
|
|
116
|
+
base_url: str = DEFAULT_OLLAMA_BASE_URL,
|
|
117
|
+
) -> None:
|
|
118
|
+
super().__init__(
|
|
119
|
+
provider="ollama",
|
|
120
|
+
model=model,
|
|
121
|
+
base_url=base_url,
|
|
122
|
+
api_key="ollama",
|
|
123
|
+
response_format={"type": "json_object"},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class OpenAIAnalyzer(OpenAICompatibleAnalyzer):
|
|
128
|
+
def __init__(self, model: str = DEFAULT_OPENAI_MODEL, api_key: str | None = None) -> None:
|
|
129
|
+
key = api_key or os.getenv("OPENAI_API_KEY")
|
|
130
|
+
if not key:
|
|
131
|
+
raise RuntimeError("OPENAI_API_KEY is not configured.")
|
|
132
|
+
super().__init__(
|
|
133
|
+
provider="openai",
|
|
134
|
+
model=model,
|
|
135
|
+
base_url="https://api.openai.com/v1",
|
|
136
|
+
api_key=key,
|
|
137
|
+
response_format={"type": "json_object"},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class LegacyResponsesAnalyzer:
|
|
142
|
+
def __init__(self, model: str = DEFAULT_OPENAI_MODEL) -> None:
|
|
143
|
+
self.model = model
|
|
144
|
+
|
|
145
|
+
def analyze(
|
|
146
|
+
self,
|
|
147
|
+
content: str,
|
|
148
|
+
language: str = "en",
|
|
149
|
+
reflections: dict[str, str] | None = None,
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
try:
|
|
152
|
+
from openai import OpenAI
|
|
153
|
+
except ImportError as exc:
|
|
154
|
+
raise RuntimeError("Install dreamloop[ai] to enable OpenAI analysis.") from exc
|
|
155
|
+
|
|
156
|
+
if not os.getenv("OPENAI_API_KEY"):
|
|
157
|
+
raise RuntimeError("OPENAI_API_KEY is not configured.")
|
|
158
|
+
|
|
159
|
+
client = OpenAI()
|
|
160
|
+
response = client.responses.create(
|
|
161
|
+
model=self.model,
|
|
162
|
+
input=[
|
|
163
|
+
{
|
|
164
|
+
"role": "system",
|
|
165
|
+
"content": analysis_system_prompt(language),
|
|
166
|
+
},
|
|
167
|
+
{"role": "user", "content": build_analysis_user_payload(content, reflections or {})},
|
|
168
|
+
],
|
|
169
|
+
)
|
|
170
|
+
text = response.output_text
|
|
171
|
+
return json.loads(text)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def save_ai_config(
|
|
175
|
+
root: str | Path | None = None,
|
|
176
|
+
*,
|
|
177
|
+
provider: str,
|
|
178
|
+
model: str | None = None,
|
|
179
|
+
base_url: str | None = None,
|
|
180
|
+
) -> Path:
|
|
181
|
+
config = default_ai_config(provider)
|
|
182
|
+
if model:
|
|
183
|
+
config["model"] = model
|
|
184
|
+
if base_url:
|
|
185
|
+
config["base_url"] = base_url
|
|
186
|
+
path = dreamloop_dir(root) / "config.json"
|
|
187
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
path.write_text(json.dumps({"ai": config}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
189
|
+
ensure_gitignore(root)
|
|
190
|
+
return path
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def save_secret(root: str | Path | None, name: str, value: str) -> Path:
|
|
194
|
+
path = dreamloop_dir(root) / "secrets.env"
|
|
195
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
secrets = read_secret_file(path)
|
|
197
|
+
secrets[name] = value
|
|
198
|
+
path.write_text(
|
|
199
|
+
"\n".join(f"{key}={val}" for key, val in sorted(secrets.items())) + "\n",
|
|
200
|
+
encoding="utf-8",
|
|
201
|
+
)
|
|
202
|
+
ensure_gitignore(root)
|
|
203
|
+
return path
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def ai_status(root: str | Path | None = None) -> AIStatus:
|
|
207
|
+
config = load_ai_config(root)
|
|
208
|
+
provider = config["provider"]
|
|
209
|
+
secrets = load_secrets(root)
|
|
210
|
+
model = config.get("model")
|
|
211
|
+
base_url = config.get("base_url")
|
|
212
|
+
|
|
213
|
+
if provider == "none":
|
|
214
|
+
return AIStatus("none", None, None, "local", False, "AI analysis disabled.")
|
|
215
|
+
if provider == "ollama":
|
|
216
|
+
return AIStatus("ollama", model, base_url, "local", True, "Ollama optional; capture works if it is offline.")
|
|
217
|
+
if provider == "deepseek":
|
|
218
|
+
ready = bool(secrets.get("DEEPSEEK_API_KEY"))
|
|
219
|
+
warning = None if ready else "DEEPSEEK_API_KEY is not configured."
|
|
220
|
+
return AIStatus("deepseek", model, base_url, "cloud", ready, warning)
|
|
221
|
+
if provider == "openai":
|
|
222
|
+
ready = bool(secrets.get("OPENAI_API_KEY"))
|
|
223
|
+
warning = None if ready else "OPENAI_API_KEY is not configured."
|
|
224
|
+
return AIStatus("openai", model, base_url, "cloud", ready, warning)
|
|
225
|
+
if provider == "custom":
|
|
226
|
+
ready = bool(model and base_url)
|
|
227
|
+
warning = None if ready else "Custom provider needs both model and base URL."
|
|
228
|
+
return AIStatus("custom", model, base_url, "custom", ready, warning)
|
|
229
|
+
return AIStatus(provider, model, base_url, "unknown", False, f"Unknown AI provider: {provider}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def ai_is_configured(root: str | Path | None = None) -> bool:
|
|
233
|
+
return ai_status(root).ready
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def build_analyzer(root: str | Path | None = None) -> Analyzer | None:
|
|
237
|
+
status = ai_status(root)
|
|
238
|
+
if not status.ready:
|
|
239
|
+
return None
|
|
240
|
+
secrets = load_secrets(root)
|
|
241
|
+
if status.provider == "ollama":
|
|
242
|
+
return OllamaAnalyzer(model=status.model or DEFAULT_OLLAMA_MODEL, base_url=status.base_url or DEFAULT_OLLAMA_BASE_URL)
|
|
243
|
+
if status.provider == "deepseek":
|
|
244
|
+
return DeepSeekAnalyzer(
|
|
245
|
+
api_key=secrets["DEEPSEEK_API_KEY"],
|
|
246
|
+
model=status.model or DEFAULT_DEEPSEEK_MODEL,
|
|
247
|
+
base_url=status.base_url or DEFAULT_DEEPSEEK_BASE_URL,
|
|
248
|
+
)
|
|
249
|
+
if status.provider == "openai":
|
|
250
|
+
return OpenAIAnalyzer(model=status.model or DEFAULT_OPENAI_MODEL, api_key=secrets["OPENAI_API_KEY"])
|
|
251
|
+
if status.provider == "custom":
|
|
252
|
+
return OpenAICompatibleAnalyzer(
|
|
253
|
+
provider="custom",
|
|
254
|
+
model=status.model or "model",
|
|
255
|
+
base_url=status.base_url or "",
|
|
256
|
+
api_key=secrets.get("CUSTOM_API_KEY") or "local",
|
|
257
|
+
response_format={"type": "json_object"},
|
|
258
|
+
)
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_provider_connection(root: str | Path | None = None) -> str:
|
|
263
|
+
status = ai_status(root)
|
|
264
|
+
if not status.ready:
|
|
265
|
+
return status.warning or "AI provider is not ready."
|
|
266
|
+
if status.provider == "ollama":
|
|
267
|
+
url = (status.base_url or DEFAULT_OLLAMA_BASE_URL).rstrip("/").removesuffix("/v1")
|
|
268
|
+
try:
|
|
269
|
+
response = httpx.get(f"{url}/api/tags", timeout=2)
|
|
270
|
+
response.raise_for_status()
|
|
271
|
+
except httpx.HTTPError as exc:
|
|
272
|
+
return f"Ollama configured, but local server is not responding: {exc}"
|
|
273
|
+
return f"Ollama ready at {status.base_url}"
|
|
274
|
+
return f"{status.provider} configured with model {status.model}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def load_ai_config(root: str | Path | None = None) -> dict[str, str]:
|
|
278
|
+
path = dreamloop_dir(root) / "config.json"
|
|
279
|
+
if not path.exists():
|
|
280
|
+
return default_ai_config("ollama")
|
|
281
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
282
|
+
provider = payload.get("ai", {}).get("provider", "ollama")
|
|
283
|
+
config = default_ai_config(provider)
|
|
284
|
+
config.update({key: val for key, val in payload.get("ai", {}).items() if val is not None})
|
|
285
|
+
return config
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def default_ai_config(provider: str) -> dict[str, str]:
|
|
289
|
+
if provider == "deepseek":
|
|
290
|
+
return {"provider": "deepseek", "model": DEFAULT_DEEPSEEK_MODEL, "base_url": DEFAULT_DEEPSEEK_BASE_URL}
|
|
291
|
+
if provider == "openai":
|
|
292
|
+
return {"provider": "openai", "model": DEFAULT_OPENAI_MODEL, "base_url": "https://api.openai.com/v1"}
|
|
293
|
+
if provider == "none":
|
|
294
|
+
return {"provider": "none", "model": "", "base_url": ""}
|
|
295
|
+
if provider == "custom":
|
|
296
|
+
return {"provider": "custom", "model": "", "base_url": ""}
|
|
297
|
+
return {"provider": "ollama", "model": DEFAULT_OLLAMA_MODEL, "base_url": DEFAULT_OLLAMA_BASE_URL}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def load_secrets(root: str | Path | None = None) -> dict[str, str]:
|
|
301
|
+
secrets = read_secret_file(dreamloop_dir(root) / "secrets.env")
|
|
302
|
+
for name in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CUSTOM_API_KEY"):
|
|
303
|
+
if os.getenv(name):
|
|
304
|
+
secrets[name] = os.environ[name]
|
|
305
|
+
return secrets
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def read_secret_file(path: Path) -> dict[str, str]:
|
|
309
|
+
if not path.exists():
|
|
310
|
+
return {}
|
|
311
|
+
secrets: dict[str, str] = {}
|
|
312
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
313
|
+
if not line.strip() or line.strip().startswith("#") or "=" not in line:
|
|
314
|
+
continue
|
|
315
|
+
key, value = line.split("=", 1)
|
|
316
|
+
secrets[key.strip().lstrip("\ufeff")] = value.strip()
|
|
317
|
+
return secrets
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def clean_reflections(reflections: dict[str, Any] | None) -> dict[str, str]:
|
|
321
|
+
if not reflections:
|
|
322
|
+
return {}
|
|
323
|
+
cleaned: dict[str, str] = {}
|
|
324
|
+
for key in REFLECTION_LABELS:
|
|
325
|
+
value = reflections.get(key)
|
|
326
|
+
if value is None:
|
|
327
|
+
continue
|
|
328
|
+
text = str(value).strip()
|
|
329
|
+
if text:
|
|
330
|
+
cleaned[key] = text
|
|
331
|
+
return cleaned
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def build_analysis_user_payload(content: str, reflections: dict[str, Any] | None = None) -> str:
|
|
335
|
+
cleaned = clean_reflections(reflections)
|
|
336
|
+
lines = ["梦境内容 / Dream content:", content.strip()]
|
|
337
|
+
if cleaned:
|
|
338
|
+
lines.append("")
|
|
339
|
+
lines.append("用户可选补充 / Optional dreamer context:")
|
|
340
|
+
for key, value in cleaned.items():
|
|
341
|
+
lines.append(f"{key}: {value}")
|
|
342
|
+
return "\n".join(lines)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def analysis_system_prompt(language: str = "en") -> str:
|
|
346
|
+
output_language = "Simplified Chinese" if language == "zh" else "English"
|
|
347
|
+
length_hint = "800-1500 Chinese characters" if language == "zh" else "900-1600 English words"
|
|
348
|
+
zh_requirements = (
|
|
349
|
+
"不要把梦说死。不要用玄学断言。必须回到梦境具体细节,重视情绪多于物品符号,"
|
|
350
|
+
"并联系用户提供的现实处境。至少 2 个 possible_interpretations。"
|
|
351
|
+
)
|
|
352
|
+
return (
|
|
353
|
+
"You are DreamLoop's dream analysis engine. Return only valid JSON. "
|
|
354
|
+
"Keep JSON keys in English and write all field values in "
|
|
355
|
+
f"{output_language}. Produce a detailed, reality-grounded report of about {length_hint}. "
|
|
356
|
+
f"{zh_requirements} "
|
|
357
|
+
"The analysis must be specific to the dream text, not a generic template. "
|
|
358
|
+
"Prefer emotional dynamics and recent life context over mystical symbolism. "
|
|
359
|
+
"Offer multiple hypotheses and make each one verifiable by the dreamer. "
|
|
360
|
+
"Never claim certainty, diagnose, predict the future, or say one dream proves something. "
|
|
361
|
+
"Return this schema: analysis_version, emotional_tone, symbols, themes, summary, confidence, "
|
|
362
|
+
"dream_details, core_emotion, waking_feeling, important_elements, real_life_links, "
|
|
363
|
+
"personal_associations, possible_interpretations, real_life_questions, verification_prompts. "
|
|
364
|
+
"possible_interpretations must contain at least 2 objects with title, interpretation, "
|
|
365
|
+
"dream_evidence, real_life_connection, and verification_question. "
|
|
366
|
+
"real_life_questions should focus on what reality problem the dream may help the user notice."
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def dreamloop_dir(root: str | Path | None = None) -> Path:
|
|
371
|
+
return Path(root or Path.cwd()) / ".dreamloop"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def ensure_gitignore(root: str | Path | None = None) -> None:
|
|
375
|
+
root_path = Path(root or Path.cwd())
|
|
376
|
+
gitignore = root_path / ".gitignore"
|
|
377
|
+
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
378
|
+
if ".dreamloop/" in existing.splitlines():
|
|
379
|
+
return
|
|
380
|
+
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
|
381
|
+
gitignore.write_text(existing + prefix + ".dreamloop/\n", encoding="utf-8")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def normalize_analysis(result: dict[str, Any]) -> dict[str, Any]:
|
|
385
|
+
symbols = result.get("symbols") or []
|
|
386
|
+
themes = result.get("themes") or []
|
|
387
|
+
if isinstance(symbols, str):
|
|
388
|
+
symbols = [symbols]
|
|
389
|
+
if isinstance(themes, str):
|
|
390
|
+
themes = [themes]
|
|
391
|
+
|
|
392
|
+
confidence = result.get("confidence", 0.0)
|
|
393
|
+
try:
|
|
394
|
+
confidence_value = float(confidence)
|
|
395
|
+
except (TypeError, ValueError):
|
|
396
|
+
confidence_value = 0.0
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
"emotional_tone": str(result.get("emotional_tone") or "unknown"),
|
|
400
|
+
"symbols": [str(item) for item in symbols],
|
|
401
|
+
"themes": [str(item) for item in themes],
|
|
402
|
+
"summary": str(result.get("summary") or ""),
|
|
403
|
+
"confidence": max(0.0, min(confidence_value, 1.0)),
|
|
404
|
+
"report": result,
|
|
405
|
+
"raw_json": json.dumps(result, ensure_ascii=False),
|
|
406
|
+
}
|
dreamloop/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .analysis import ai_status, save_ai_config, test_provider_connection
|
|
11
|
+
from .core import DreamLoop
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Local-first AI dream journal.")
|
|
14
|
+
import_app = typer.Typer(help="Import local data.")
|
|
15
|
+
weather_app = typer.Typer(help="Sync local context such as weather.")
|
|
16
|
+
ai_app = typer.Typer(help="Configure local and optional cloud AI providers.")
|
|
17
|
+
app.add_typer(import_app, name="import")
|
|
18
|
+
app.add_typer(weather_app, name="weather")
|
|
19
|
+
app.add_typer(ai_app, name="ai")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def init() -> None:
|
|
24
|
+
loop = DreamLoop()
|
|
25
|
+
loop.init()
|
|
26
|
+
typer.echo(f"DreamLoop initialized at {loop.data_dir}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def add(
|
|
31
|
+
content: Annotated[str, typer.Argument(help="Dream text to save.")],
|
|
32
|
+
tag: Annotated[list[str] | None, typer.Option("--tag", "-t")] = None,
|
|
33
|
+
mood: Annotated[str | None, typer.Option("--mood", "-m")] = None,
|
|
34
|
+
dreamed_on: Annotated[str | None, typer.Option("--date", help="Dream date as YYYY-MM-DD.")] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
parsed_date = date.fromisoformat(dreamed_on) if dreamed_on else None
|
|
37
|
+
dream_id = DreamLoop().add_dream(content, tags=tag or [], mood=mood, dreamed_on=parsed_date)
|
|
38
|
+
typer.echo(f"Saved dream #{dream_id} (analysis pending)")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("list")
|
|
42
|
+
def list_dreams() -> None:
|
|
43
|
+
dreams = DreamLoop().list_dreams()
|
|
44
|
+
for dream in dreams:
|
|
45
|
+
tags = ", ".join(dream["tags"]) or "no tags"
|
|
46
|
+
mood = dream["manual_mood"] or "no mood"
|
|
47
|
+
typer.echo(f"#{dream['id']} {dream['dreamed_on']} [{mood}] {tags} - {dream['content']}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def show(dream_id: int) -> None:
|
|
52
|
+
dream = DreamLoop().get_dream(dream_id)
|
|
53
|
+
typer.echo(json.dumps(dream, ensure_ascii=False, indent=2))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def analyze(pending: Annotated[bool, typer.Option("--pending")] = False) -> None:
|
|
58
|
+
if not pending:
|
|
59
|
+
typer.echo("Use --pending to analyze queued dreams.")
|
|
60
|
+
raise typer.Exit(code=1)
|
|
61
|
+
status = ai_status()
|
|
62
|
+
if not status.ready:
|
|
63
|
+
typer.echo(status.warning or "AI provider is not ready. Keep using DreamLoop as a local journal.")
|
|
64
|
+
return
|
|
65
|
+
analyzed = DreamLoop().analyze_pending()
|
|
66
|
+
typer.echo(f"Analyzed {len(analyzed)} pending dream(s).")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def web(host: str = "127.0.0.1", port: int = 8765) -> None:
|
|
71
|
+
import uvicorn
|
|
72
|
+
|
|
73
|
+
uvicorn.run("dreamloop.web:create_app", factory=True, host=host, port=port)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def export() -> None:
|
|
78
|
+
loop = DreamLoop()
|
|
79
|
+
out = loop.data_dir / "exports" / f"dreamloop-export-{date.today().isoformat()}.json"
|
|
80
|
+
loop.init()
|
|
81
|
+
out.write_text(json.dumps(loop.list_dreams(), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
82
|
+
typer.echo(f"Exported dreams to {out}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@import_app.command("ics")
|
|
86
|
+
def import_ics(path: Path) -> None:
|
|
87
|
+
count = DreamLoop().import_ics(path)
|
|
88
|
+
typer.echo(f"Imported {count} calendar event(s).")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@weather_app.command("sync")
|
|
92
|
+
def weather_sync(lat: float, lon: float) -> None:
|
|
93
|
+
count = DreamLoop().sync_weather(lat, lon)
|
|
94
|
+
typer.echo(f"Synced {count} weather day(s).")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@ai_app.command("status")
|
|
98
|
+
def ai_status_command() -> None:
|
|
99
|
+
status = ai_status()
|
|
100
|
+
typer.echo(f"provider: {status.provider}")
|
|
101
|
+
typer.echo(f"model: {status.model or 'none'}")
|
|
102
|
+
typer.echo(f"mode: {status.mode}")
|
|
103
|
+
typer.echo(f"base_url: {status.base_url or 'none'}")
|
|
104
|
+
typer.echo(f"ready: {status.ready}")
|
|
105
|
+
if status.warning:
|
|
106
|
+
typer.echo(f"warning: {status.warning}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@ai_app.command("use")
|
|
110
|
+
def ai_use(
|
|
111
|
+
provider: Annotated[str, typer.Argument(help="Provider: ollama, deepseek, openai, custom, or none.")],
|
|
112
|
+
model: Annotated[str | None, typer.Option("--model")] = None,
|
|
113
|
+
base_url: Annotated[str | None, typer.Option("--base-url")] = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
if provider not in {"ollama", "deepseek", "openai", "custom", "none"}:
|
|
116
|
+
typer.echo("Provider must be one of: ollama, deepseek, openai, custom, none.")
|
|
117
|
+
raise typer.Exit(code=1)
|
|
118
|
+
path = save_ai_config(provider=provider, model=model, base_url=base_url)
|
|
119
|
+
status = ai_status()
|
|
120
|
+
typer.echo(f"AI provider set to {status.provider} ({status.model or 'no model'}).")
|
|
121
|
+
typer.echo(f"Config saved to {path}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@ai_app.command("test")
|
|
125
|
+
def ai_test() -> None:
|
|
126
|
+
typer.echo(test_provider_connection())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main() -> None:
|
|
130
|
+
app()
|