skyloom 1.11.0 → 1.13.0

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.
Files changed (135) hide show
  1. package/.github/workflows/ci.yml +36 -36
  2. package/README.md +142 -46
  3. package/config/default.yaml +43 -47
  4. package/config/models.yaml +155 -0
  5. package/config/providers.yaml +39 -39
  6. package/config/skills/api_integrator/SKILL.md +15 -15
  7. package/config/skills/arch_designer/SKILL.md +13 -13
  8. package/config/skills/ci_cd_manager/SKILL.md +14 -14
  9. package/config/skills/code_analysis/SKILL.md +13 -13
  10. package/config/skills/code_generator/SKILL.md +12 -12
  11. package/config/skills/code_reviewer/SKILL.md +13 -13
  12. package/config/skills/content_writer/SKILL.md +14 -14
  13. package/config/skills/data_transformer/SKILL.md +15 -15
  14. package/config/skills/document_analysis/SKILL.md +13 -13
  15. package/config/skills/emotional_companion/SKILL.md +15 -15
  16. package/config/skills/performance_checker/SKILL.md +14 -14
  17. package/config/skills/security_auditor/SKILL.md +14 -14
  18. package/config/skills/self_evolve/SKILL.md +13 -13
  19. package/config/skills/sys_operator/SKILL.md +15 -15
  20. package/config/skills/task_planner/SKILL.md +14 -14
  21. package/config/skills/web_research/SKILL.md +14 -14
  22. package/config/skills/workflow_designer/SKILL.md +13 -13
  23. package/dist/agents/dew.js +52 -52
  24. package/dist/agents/fair.js +84 -84
  25. package/dist/agents/fog.js +30 -30
  26. package/dist/agents/frost.js +32 -32
  27. package/dist/agents/rain.js +32 -32
  28. package/dist/agents/snow.js +68 -68
  29. package/dist/cli/main.js +172 -47
  30. package/dist/cli/main.js.map +1 -1
  31. package/dist/cli/tui.d.ts.map +1 -1
  32. package/dist/cli/tui.js +9 -1
  33. package/dist/cli/tui.js.map +1 -1
  34. package/dist/core/agent/task.d.ts +58 -0
  35. package/dist/core/agent/task.d.ts.map +1 -0
  36. package/dist/core/agent/task.js +83 -0
  37. package/dist/core/agent/task.js.map +1 -0
  38. package/dist/core/agent.d.ts +2 -45
  39. package/dist/core/agent.d.ts.map +1 -1
  40. package/dist/core/agent.js +61 -145
  41. package/dist/core/agent.js.map +1 -1
  42. package/dist/core/agent_helpers.d.ts +10 -0
  43. package/dist/core/agent_helpers.d.ts.map +1 -1
  44. package/dist/core/agent_helpers.js +39 -0
  45. package/dist/core/agent_helpers.js.map +1 -1
  46. package/dist/core/catalog.d.ts +71 -0
  47. package/dist/core/catalog.d.ts.map +1 -0
  48. package/dist/core/catalog.js +176 -0
  49. package/dist/core/catalog.js.map +1 -0
  50. package/dist/core/config.d.ts +8 -0
  51. package/dist/core/config.d.ts.map +1 -1
  52. package/dist/core/config.js +12 -4
  53. package/dist/core/config.js.map +1 -1
  54. package/dist/core/factory.js +16 -16
  55. package/dist/core/llm.d.ts +7 -0
  56. package/dist/core/llm.d.ts.map +1 -1
  57. package/dist/core/llm.js +139 -7
  58. package/dist/core/llm.js.map +1 -1
  59. package/dist/core/longdoc.js +5 -5
  60. package/dist/core/memory.d.ts.map +1 -1
  61. package/dist/core/memory.js +69 -62
  62. package/dist/core/memory.js.map +1 -1
  63. package/dist/core/theme.d.ts +46 -0
  64. package/dist/core/theme.d.ts.map +1 -0
  65. package/dist/core/theme.js +42 -0
  66. package/dist/core/theme.js.map +1 -0
  67. package/dist/web/server.js +542 -519
  68. package/dist/web/server.js.map +1 -1
  69. package/docs/AESTHETIC_DESIGN.md +144 -0
  70. package/docs/OPTIMIZATION_PLAN.md +178 -0
  71. package/package.json +60 -60
  72. package/scripts/install.js +48 -48
  73. package/scripts/link.js +10 -10
  74. package/setup.bat +79 -79
  75. package/skill-test-ty2fOA/test.md +10 -10
  76. package/src/agents/dew.ts +70 -70
  77. package/src/agents/fair.ts +102 -102
  78. package/src/agents/fog.ts +48 -48
  79. package/src/agents/frost.ts +50 -50
  80. package/src/agents/rain.ts +50 -50
  81. package/src/agents/snow.ts +239 -239
  82. package/src/cli/main.ts +425 -316
  83. package/src/cli/mode.ts +58 -58
  84. package/src/cli/tui.ts +272 -268
  85. package/src/core/agent/task.ts +100 -0
  86. package/src/core/agent.ts +1446 -1549
  87. package/src/core/agent_helpers.ts +496 -461
  88. package/src/core/arbitrate.ts +162 -162
  89. package/src/core/catalog.ts +178 -0
  90. package/src/core/checkpoint.ts +94 -94
  91. package/src/core/config.ts +20 -4
  92. package/src/core/estimate.ts +104 -104
  93. package/src/core/evolve.ts +191 -191
  94. package/src/core/factory.ts +627 -627
  95. package/src/core/filter.ts +103 -103
  96. package/src/core/graph.ts +156 -156
  97. package/src/core/icons.ts +53 -53
  98. package/src/core/index.ts +37 -37
  99. package/src/core/learn.ts +146 -146
  100. package/src/core/llm.ts +108 -5
  101. package/src/core/longdoc.ts +155 -155
  102. package/src/core/mcp_server.ts +176 -176
  103. package/src/core/memory.ts +1178 -1171
  104. package/src/core/profile.ts +255 -255
  105. package/src/core/router.ts +124 -124
  106. package/src/core/sandbox.ts +142 -142
  107. package/src/core/security.ts +243 -243
  108. package/src/core/skill.ts +342 -342
  109. package/src/core/theme.ts +65 -0
  110. package/src/core/tool_router.ts +193 -193
  111. package/src/core/vector.ts +152 -152
  112. package/src/core/workspace.ts +150 -150
  113. package/src/plugins/loader.ts +66 -66
  114. package/src/skills/loader.ts +46 -46
  115. package/src/sql.js.d.ts +29 -29
  116. package/src/tools/builtin.ts +380 -380
  117. package/src/tools/computer.ts +269 -269
  118. package/src/tools/delegate.ts +49 -49
  119. package/src/web/server.ts +660 -634
  120. package/src/web/tts.ts +93 -93
  121. package/tests/agent_helpers.test.ts +48 -0
  122. package/tests/bus.test.ts +121 -121
  123. package/tests/catalog.test.ts +86 -0
  124. package/tests/config.test.ts +41 -0
  125. package/tests/icons.test.ts +45 -45
  126. package/tests/memory.test.ts +147 -0
  127. package/tests/router.test.ts +86 -86
  128. package/tests/schemas.test.ts +51 -51
  129. package/tests/semantic.test.ts +83 -83
  130. package/tests/setup.ts +10 -10
  131. package/tests/skill.test.ts +172 -172
  132. package/tests/task.test.ts +60 -0
  133. package/tests/tool.test.ts +108 -108
  134. package/tests/tool_router.test.ts +71 -71
  135. package/vitest.config.ts +17 -17
package/src/web/server.ts CHANGED
@@ -1,634 +1,660 @@
1
- /**
2
- * Web server for Skyloom — HTTP API + 水墨气象台 chat UI.
3
- */
4
-
5
- import { createServer, IncomingMessage, ServerResponse } from "http";
6
- import { createSystemContext } from "../core/factory";
7
- import { getLogger } from "../core/logger";
8
-
9
- const log = getLogger("web-server");
10
-
11
- /* ── Agent personalities: pigment + atmosphere + motion ── */
12
- const PIGMENTS: Record<string, {
13
- color: string; nameZH: string; kanji: string; poem: string;
14
- ambient: string; // CSS for ambient layer
15
- msgStyle: string; // CSS for message treatment
16
- dotPulse: string; // animation name for dot
17
- kanjiAnim: string; // kanji decoration animation
18
- }> = {
19
- fog: {
20
- color: "#4a4a44", nameZH: "松烟墨", kanji: "霧",
21
- poem: "山色有无中",
22
- ambient: `background:radial-gradient(ellipse at 30% 60%,rgba(180,175,165,.18) 0%,transparent 70%),
23
- radial-gradient(ellipse at 70% 30%,rgba(190,185,175,.10) 0%,transparent 55%);`,
24
- msgStyle: `border-style:dashed;border-color:rgba(140,135,125,.25);`,
25
- dotPulse: "fog-pulse", kanjiAnim: "fog-drift",
26
- },
27
- rain: {
28
- color: "#2a5c8a", nameZH: "石青", kanji: "雨",
29
- poem: "一蓑烟雨任平生",
30
- ambient: `background:radial-gradient(ellipse at 50% 20%,rgba(42,92,138,.10) 0%,transparent 50%),
31
- repeating-linear-gradient(175deg,transparent,transparent 3px,rgba(42,92,138,.02) 3px,rgba(42,92,138,.02) 6px);`,
32
- msgStyle: `border-style:solid;border-color:rgba(42,92,138,.18);box-shadow:inset 0 -1px 0 rgba(42,92,138,.06);`,
33
- dotPulse: "rain-ripple", kanjiAnim: "rain-fall",
34
- },
35
- frost: {
36
- color: "#3a7a6e", nameZH: "石绿", kanji: "霜",
37
- poem: "月落乌啼霜满天",
38
- ambient: `background:radial-gradient(ellipse at 60% 40%,rgba(58,122,110,.09) 0%,transparent 55%),
39
- conic-gradient(from 0deg at 75% 25%,transparent 0deg,rgba(58,122,110,.03) 2deg,transparent 4deg,transparent 180deg);`,
40
- msgStyle: `border-style:solid;border-color:rgba(58,122,110,.20);border-radius:2px 8px 8px 2px;`,
41
- dotPulse: "frost-glint", kanjiAnim: "frost-sparkle",
42
- },
43
- snow: {
44
- color: "#8a8a82", nameZH: "铅白", kanji: "雪",
45
- poem: "千树万树梨花开",
46
- ambient: `background:radial-gradient(circle at 25% 70%,rgba(210,208,200,.10) 0%,transparent 50%),
47
- radial-gradient(circle at 80% 35%,rgba(220,218,210,.07) 0%,transparent 45%);`,
48
- msgStyle: `border-style:solid;border-color:rgba(180,178,170,.15);border-radius:8px 8px 8px 2px;`,
49
- dotPulse: "snow-float", kanjiAnim: "snow-fall",
50
- },
51
- dew: {
52
- color: "#8b6914", nameZH: "赭石", kanji: "露",
53
- poem: "金风玉露一相逢",
54
- ambient: `background:radial-gradient(ellipse at 50% 90%,rgba(139,105,20,.08) 0%,transparent 55%),
55
- radial-gradient(ellipse at 80% 95%,rgba(139,105,20,.04) 0%,transparent 40%);`,
56
- msgStyle: `border-style:solid;border-color:rgba(139,105,20,.22);border-width:0 0 2px 2px;`,
57
- dotPulse: "dew-bead", kanjiAnim: "dew-still",
58
- },
59
- fair: {
60
- color: "#b3342d", nameZH: "朱砂", kanji: "晴",
61
- poem: "道是无晴却有晴",
62
- ambient: `background:radial-gradient(ellipse at 80% 15%,rgba(179,52,45,.12) 0%,transparent 60%),
63
- radial-gradient(ellipse at 30% 40%,rgba(200,100,50,.05) 0%,transparent 50%);`,
64
- msgStyle: `border-style:solid;border-color:rgba(179,52,45,.20);box-shadow:0 0 1px rgba(179,52,45,.04);`,
65
- dotPulse: "fair-glow", kanjiAnim: "fair-warm",
66
- },
67
- };
68
-
69
- /* ──────────────────────────────────────────────
70
- Server
71
- ────────────────────────────────────────────── */
72
- export async function startWebServer(port: number = 3000): Promise<void> {
73
- const ctx = createSystemContext();
74
-
75
- const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
76
- res.setHeader("Access-Control-Allow-Origin", "*");
77
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
78
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
79
- if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
80
- const url = new URL(req.url || "/", `http://${req.headers.host}`);
81
- try {
82
- if (url.pathname === "/api/chat" && req.method === "POST") await handleChat(req, res, ctx);
83
- else if (url.pathname === "/api/agents" && req.method === "GET") handleAgents(res, ctx);
84
- else if (url.pathname === "/api/status" && req.method === "GET") handleStatus(res, ctx);
85
- else if (url.pathname.startsWith("/api/")) res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "Not found" }));
86
- else serveInkWashUI(res);
87
- } catch (e) { res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ error: String(e) })); }
88
- });
89
-
90
- return new Promise((resolve) => { server.listen(port, () => { console.log(`\n 水墨气象台 · Skyloom\n http://localhost:${port}\n`); resolve(); }); });
91
- }
92
-
93
- async function handleChat(req: IncomingMessage, res: ServerResponse, ctx: ReturnType<typeof createSystemContext>) {
94
- const buffers: Buffer[] = [];
95
- for await (const chunk of req) buffers.push(chunk as Buffer);
96
- const { message, agent: agentName = "fog" } = JSON.parse(Buffer.concat(buffers).toString("utf-8"));
97
- if (!message) { res.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "message is required" })); return; }
98
- const agent = ctx.agentMap.get(agentName);
99
- if (!agent) { res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: `Agent '${agentName}' not found` })); return; }
100
- await agent.init();
101
- try { res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ response: await agent.chat(message) })); }
102
- catch (e) { res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ error: String(e) })); }
103
- }
104
-
105
- function handleAgents(res: ServerResponse, ctx: ReturnType<typeof createSystemContext>) {
106
- res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
107
- agents: [...ctx.agentMap.entries()].map(([n, a]) => ({ name: n, displayName: a.displayName, emoji: a.emoji, specialty: a.specialty, state: a.state })),
108
- }));
109
- }
110
- function handleStatus(res: ServerResponse, ctx: ReturnType<typeof createSystemContext>) {
111
- const s: Record<string, any> = {}; for (const [n, a] of ctx.agentMap) s[n] = a.getStatus();
112
- res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ agents: s, workspace: ctx.workspacePath }));
113
- }
114
-
115
- /* ──────────────────────────────────────────────
116
- 水墨气象台 · Ink Wash Weather Station
117
- ────────────────────────────────────────────── */
118
- function serveInkWashUI(res: ServerResponse): void {
119
- const html = `<!DOCTYPE html>
120
- <html lang="zh">
121
- <head>
122
- <meta charset="UTF-8">
123
- <meta name="viewport" content="width=device-width,initial-scale=1.0">
124
- <title>水墨气象台 · Skyloom</title>
125
- <link rel="preconnect" href="https://fonts.googleapis.com">
126
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
127
- <link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;600;700&family=Noto+Serif:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
128
- <style>
129
- /* ═══════════════════════════════════
130
- 水墨气象台 · 全局
131
- ═══════════════════════════════════ */
132
- :root{
133
- --paper:#f8f4ec;
134
- --paper-warm:#f3ede2;
135
- --paper-edge:rgba(180,160,130,.06);
136
- --ink-deep:#1a1614;
137
- --ink-mid:#3d3833;
138
- --ink-light:#8c8680;
139
- --ink-faint:#c4bfb8;
140
- --pigment:#4a4a44;
141
- --pigment-soft:rgba(74,74,68,.12);
142
- --pigment-seal:#4a4a44;
143
- --dot-anim:fog-pulse;
144
- --kanji-anim:fog-drift;
145
- --gutter:clamp(24px,5vw,56px);
146
- --sidebar-w:200px;
147
- }
148
- *,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
149
- html,body{height:100%;overflow:hidden}
150
- body{
151
- font-family:'Noto Serif SC','Noto Serif',Georgia,serif;
152
- background:var(--paper);
153
- color:var(--ink-deep);
154
- display:flex;height:100vh;font-weight:400;font-size:16px;line-height:1.8;
155
- -webkit-font-smoothing:antialiased;
156
- }
157
-
158
- /* ── Aged paper texture with vignette edges ── */
159
- #paper-grain{
160
- position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.28;
161
- background:
162
- repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(139,119,90,.02) 2px,rgba(139,119,90,.02) 4px),
163
- repeating-linear-gradient(90deg,transparent,transparent 3px,rgba(139,119,90,.012) 3px,rgba(139,119,90,.012) 6px),
164
- linear-gradient(135deg,rgba(80,60,30,.04) 0%,transparent 15%,transparent 85%,rgba(80,60,30,.04) 100%),
165
- linear-gradient(225deg,rgba(80,60,30,.03) 0%,transparent 10%,transparent 90%,rgba(80,60,30,.03) 100%);
166
- }
167
- /* Ink wash mountain silhouette at top */
168
- #paper-grain::before{
169
- content:'';position:fixed;top:0;left:0;right:0;height:clamp(80px,12vh,160px);z-index:0;pointer-events:none;opacity:.12;
170
- background:
171
- radial-gradient(ellipse 120% 100% at 25% 100%,rgba(60,55,50,.5) 0%,transparent 45%),
172
- radial-gradient(ellipse 80% 70% at 55% 100%,rgba(50,45,40,.4) 0%,transparent 55%),
173
- radial-gradient(ellipse 100% 60% at 70% 100%,rgba(70,65,60,.3) 0%,transparent 60%),
174
- radial-gradient(ellipse 60% 80% at 40% 100%,rgba(55,50,45,.25) 0%,transparent 50%),
175
- radial-gradient(ellipse 140% 90% at 60% 100%,rgba(65,60,55,.15) 0%,transparent 70%);
176
- mask:linear-gradient(0deg,transparent 0%,#000 100%);
177
- -webkit-mask:linear-gradient(0deg,transparent 0%,#000 100%);
178
- }
179
-
180
- /* ── Ambient layer — agent-specific atmosphere ── */
181
- #ambient-layer{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.7;transition:background .8s ease;background:var(--ambient-bg)}
182
-
183
- /* ═══════════════════════════════════
184
- 雾 Fog · 松烟墨 · mist particles
185
- ═══════════════════════════════════ */
186
- .mist-particles{position:absolute;inset:0;overflow:hidden;pointer-events:none}
187
- .mist-particles i{
188
- position:absolute;border-radius:50%;background:rgba(160,155,145,.10);
189
- animation:fog-drift var(--dur,8s) linear infinite;
190
- animation-delay:var(--delay,0s);
191
- width:var(--w,120px);height:var(--h,40px);left:var(--x,10%);top:var(--y,30%);
192
- filter:blur(20px);
193
- }
194
- @keyframes fog-drift{0%{transform:translateX(-30px);opacity:.3}50%{transform:translateX(40px);opacity:.7}100%{transform:translateX(-30px);opacity:.3}}
195
-
196
- /* ═══════════════════════════════════
197
- Rain · 石青 · falling streaks
198
- ═══════════════════════════════════ */
199
- .rain-streaks{position:absolute;inset:0;overflow:hidden;pointer-events:none}
200
- .rain-streaks i{
201
- position:absolute;width:1px;height:var(--h,40px);
202
- background:linear-gradient(0deg,rgba(42,92,138,.12),transparent);
203
- left:var(--x,15%);top:-10%;
204
- animation:rain-fall var(--dur,1.2s) linear infinite;
205
- animation-delay:var(--delay,0s);
206
- }
207
- @keyframes rain-fall{0%{transform:translateY(-10vh)}100%{transform:translateY(110vh)}}
208
-
209
- /* ═══════════════════════════════════
210
- Frost · 石绿 · crystalline sparks
211
- ═══════════════════════════════════ */
212
- .frost-crystals{position:absolute;inset:0;overflow:hidden;pointer-events:none}
213
- .frost-crystals i{
214
- position:absolute;
215
- width:var(--w,4px);height:var(--h,4px);
216
- background:rgba(58,122,110,.15);
217
- left:var(--x,20%);top:var(--y,30%);
218
- clip-path:polygon(50% 0%,100% 50%,50% 100%,0% 50%);
219
- animation:frost-glint var(--dur,3s) ease-in-out infinite;
220
- animation-delay:var(--delay,0s);
221
- }
222
- @keyframes frost-glint{0%,100%{opacity:.1;transform:scale(.8) rotate(0deg)}50%{opacity:.6;transform:scale(1.4) rotate(45deg)}}
223
-
224
- /* ═══════════════════════════════════
225
- Snow · 铅白 · gentle snowfall
226
- ═══════════════════════════════════ */
227
- .snow-particles{position:absolute;inset:0;overflow:hidden;pointer-events:none}
228
- .snow-particles i{
229
- position:absolute;border-radius:50%;background:rgba(190,188,180,.14);
230
- width:var(--w,6px);height:var(--w,6px);
231
- left:var(--x,20%);top:-5%;
232
- animation:snow-fall var(--dur,10s) linear infinite;
233
- animation-delay:var(--delay,0s);
234
- }
235
- @keyframes snow-fall{0%{transform:translateY(-5vh) translateX(0)}25%{transform:translateY(25vh) translateX(15px)}50%{transform:translateY(50vh) translateX(-10px)}75%{transform:translateY(75vh) translateX(8px)}100%{transform:translateY(110vh) translateX(-5px)}}
236
-
237
- /* ═══════════════════════════════════
238
- Dew · 赭石 · morning dew beads
239
- ═══════════════════════════════════ */
240
- .dew-beads{position:absolute;inset:0;overflow:hidden;pointer-events:none}
241
- .dew-beads i{
242
- position:absolute;border-radius:50%;
243
- background:radial-gradient(circle at 40% 35%,rgba(200,170,100,.10),rgba(139,105,20,.06));
244
- width:var(--w,8px);height:var(--w,8px);
245
- left:var(--x,25%);bottom:var(--y,15%);
246
- animation:dew-bead var(--dur,4s) ease-in-out infinite;
247
- animation-delay:var(--delay,0s);
248
- }
249
- @keyframes dew-bead{0%,100%{opacity:.3;transform:scale(1)}50%{opacity:.7;transform:scale(1.5)}}
250
-
251
- /* ═══════════════════════════════════
252
- Fair · 朱砂 · sun motes rising
253
- ═══════════════════════════════════ */
254
- .sun-motes{position:absolute;inset:0;overflow:hidden;pointer-events:none}
255
- .sun-motes i{
256
- position:absolute;border-radius:50%;
257
- background:rgba(200,120,50,.06);
258
- width:var(--w,3px);height:var(--w,3px);
259
- left:var(--x,50%);bottom:-5%;
260
- animation:fair-warm var(--dur,7s) ease-in infinite;
261
- animation-delay:var(--delay,0s);
262
- }
263
- @keyframes fair-warm{0%{transform:translateY(0) translateX(0);opacity:0}20%{opacity:.8}80%{opacity:.4}100%{transform:translateY(-100vh) translateX(var(--drift,20px));opacity:0}}
264
-
265
- /* ═══════════════════════════════════
266
- Layout scroll aesthetic
267
- ═══════════════════════════════════ */
268
- #sidebar{
269
- width:var(--sidebar-w);flex-shrink:0;position:relative;z-index:1;
270
- padding:var(--gutter) clamp(20px,3vw,32px);
271
- display:flex;flex-direction:column;gap:0;
272
- background:linear-gradient(90deg,rgba(0,0,0,.015),transparent 40%);
273
- }
274
-
275
- /* Logo: vertical seal style */
276
- #logo-block{margin-bottom:clamp(28px,7vh,56px);text-align:center}
277
- #logo{
278
- font-size:clamp(1.5rem,2.5vw,2rem);font-weight:700;
279
- letter-spacing:.2em;color:var(--ink-deep);line-height:1.2;
280
- writing-mode:horizontal-tb;
281
- }
282
- #logo small{
283
- display:block;font-weight:300;font-size:.7rem;color:var(--ink-light);
284
- letter-spacing:.2em;margin-top:4px;
285
- }
286
-
287
- #agents-list{display:flex;flex-direction:column;gap:2px;flex:1;overflow-y:auto}
288
-
289
- .agent-item{
290
- display:flex;align-items:center;gap:12px;
291
- padding:11px 14px;cursor:pointer;
292
- border-radius:4px;position:relative;transition:all .4s ease;
293
- border-left:2px solid transparent;
294
- }
295
- .agent-item:hover{background:var(--paper-edge)}
296
- .agent-item.active{background:var(--pigment-soft);border-left-color:var(--pigment)}
297
-
298
- /* Seal stamp effect for active agent */
299
- .agent-item.active::after{
300
- content:attr(data-seal);
301
- position:absolute;right:10px;top:50%;transform:translateY(-50%);
302
- font-size:1.3rem;font-weight:700;color:var(--pigment);
303
- opacity:.55;font-family:'Noto Serif SC',serif;
304
- letter-spacing:0;
305
- }
306
-
307
- .agent-dot{display:none}
308
-
309
- .agent-info{display:flex;flex-direction:column;gap:1px}
310
- .agent-label{font-size:.92rem;font-weight:600;color:var(--ink-mid);letter-spacing:.04em;transition:color .35s;line-height:1.3}
311
- .agent-item.active .agent-label{color:var(--ink-deep)}
312
- .agent-sublabel{font-size:.7rem;color:var(--ink-light);font-weight:300;letter-spacing:.04em}
313
- .agent-item.active .agent-sublabel{color:var(--pigment)}
314
-
315
- #sidebar-verse{margin-top:auto;padding-top:20px;text-align:center}
316
- #sidebar-verse p{font-size:.72rem;color:var(--ink-light);font-style:italic;line-height:2.2;letter-spacing:.05em;font-weight:300}
317
-
318
- /* ── Main chat ── */
319
- #main{flex:1;display:flex;flex-direction:column;position:relative;z-index:1;min-width:0}
320
- #chat-strip{
321
- display:flex;align-items:center;gap:10px;
322
- padding:clamp(12px,3vh,18px) var(--gutter);
323
- }
324
- .strip-dot{
325
- width:6px;height:6px;border-radius:50%;background:var(--pigment);
326
- flex-shrink:0;animation:var(--dot-anim) 2s ease-in-out infinite;
327
- transition:background .5s;
328
- }
329
- #strip-name{font-weight:600;font-size:.95rem;color:var(--ink-deep);letter-spacing:.06em}
330
- #strip-pigment{font-size:.75rem;color:var(--ink-light);font-weight:300}
331
-
332
- /* ── Divider line with brush feel ── */
333
- #chat-strip::after{
334
- content:'';position:absolute;bottom:0;left:var(--gutter);right:var(--gutter);
335
- height:1px;background:linear-gradient(90deg,transparent,var(--ink-faint) 20%,var(--ink-faint) 80%,transparent);
336
- }
337
-
338
- /* ── Messages ── */
339
- #messages{
340
- flex:1;overflow-y:auto;padding:var(--gutter);
341
- display:flex;flex-direction:column;gap:clamp(18px,3.5vh,32px);
342
- scroll-behavior:smooth;
343
- }
344
- #messages::-webkit-scrollbar{width:4px}
345
- #messages::-webkit-scrollbar-track{background:transparent}
346
- #messages::-webkit-scrollbar-thumb{background:var(--ink-faint);border-radius:2px}
347
-
348
- .msg{max-width:66%;line-height:1.85;animation:msg-in .5s ease both;position:relative}
349
- @keyframes msg-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
350
-
351
- .msg.user{align-self:flex-end;margin-right:clamp(0px,5vw,60px)}
352
- .msg.user .msg-body{padding-right:18px;border-right:2px solid var(--pigment);text-align:right;color:var(--ink-deep);transition:border-color .5s}
353
-
354
- .msg.assistant{align-self:flex-start;margin-left:clamp(0px,3vw,36px)}
355
- .msg.assistant .msg-body{
356
- padding:14px 18px;color:var(--ink-mid);
357
- background:linear-gradient(135deg,rgba(255,255,255,.35),rgba(255,255,255,.15));
358
- border-left:3px solid var(--pigment);
359
- border-radius:0 6px 6px 0;
360
- transition:border-color .5s,background .5s;
361
- box-shadow:1px 1px 3px rgba(0,0,0,.03);
362
- }
363
-
364
- .msg.system{align-self:center;max-width:88%}
365
- .msg.system .msg-body{color:var(--ink-light);font-size:.76rem;text-align:center;font-style:italic;border:none;padding:0}
366
-
367
- .msg-time{font-size:.65rem;color:var(--ink-faint);margin-top:5px;letter-spacing:.08em;display:block}
368
- .msg.typing .msg-body{animation:ink-bleed 2s infinite}
369
- @keyframes ink-bleed{0%,100%{opacity:.28}50%{opacity:.6}}
370
-
371
- /* ── Input ── */
372
- #input-area{
373
- padding:14px var(--gutter) clamp(18px,3.5vh,28px);
374
- background:linear-gradient(0deg,var(--paper-warm),transparent 40%);
375
- }
376
- #input-wrap{
377
- display:flex;gap:0;align-items:stretch;
378
- border-bottom:1.5px solid var(--ink-faint);
379
- transition:border-color .4s ease;padding-bottom:4px;
380
- }
381
- #input-wrap:focus-within{border-bottom-color:var(--pigment)}
382
- #input-wrap textarea{
383
- flex:1;background:transparent;border:none;outline:none;color:var(--ink-deep);
384
- font-family:inherit;font-size:.95rem;font-weight:300;padding:8px 0;
385
- resize:none;min-height:24px;max-height:140px;line-height:1.7;
386
- }
387
- #input-wrap textarea::placeholder{color:var(--ink-faint);font-style:italic}
388
- #send-btn{
389
- background:none;border:none;color:var(--ink-light);cursor:pointer;
390
- padding:8px 14px;font-size:1.1rem;transition:all .3s ease;
391
- font-family:inherit;display:flex;align-items:center;opacity:.45;
392
- }
393
- #send-btn:hover{opacity:1;color:var(--pigment)}
394
- #send-btn:disabled{opacity:.12}
395
- #input-hint{font-size:.66rem;color:var(--ink-faint);margin-top:8px;letter-spacing:.06em;text-align:right}
396
-
397
- /* ── Kanji seal stamp ── */
398
- #kanji-decoration{position:fixed;right:clamp(20px,5vw,56px);bottom:clamp(80px,14vh,140px);z-index:0;pointer-events:none
399
- ;font-size:clamp(2.5rem,6vw,4.5rem);font-weight:700;font-family:'Noto Serif SC',serif;user-select:none;
400
- color:var(--pigment);opacity:.05;animation:var(--kanji-anim) 8s ease-in-out infinite;
401
- transition:color .8s ease;
402
- border:2px solid var(--pigment);border-radius:4px;padding:clamp(4px,1vw,10px) clamp(6px,1.5vw,16px);
403
- writing-mode:vertical-rl;letter-spacing:.1em;
404
- }
405
-
406
- /* Dot animations for header indicator */
407
- @keyframes fog-pulse{0%,100%{opacity:.4;transform:scale(1)}50%{opacity:1;transform:scale(1.8)}}
408
- @keyframes rain-ripple{0%,100%{opacity:.5;transform:scale(1)}30%{opacity:1;transform:scale(1.6)}60%{opacity:.5;transform:scale(1)}90%{opacity:1;transform:scale(1.4)}}
409
- @keyframes frost-glint{0%,100%{opacity:1;transform:scale(1)}45%{opacity:.2;transform:scale(.5)}50%{opacity:1;transform:scale(1.7)}55%{opacity:.2;transform:scale(.5)}}
410
- @keyframes snow-float{0%,100%{transform:translateY(0);opacity:.6}50%{transform:translateY(-4px);opacity:1}}
411
- @keyframes dew-bead{0%,100%{transform:scale(1);opacity:.5}50%{transform:scale(1.6);opacity:1}}
412
- @keyframes fair-glow{0%,100%{opacity:.5;transform:scale(1)}50%{opacity:1;transform:scale(1.5)}}
413
-
414
- .agent-label{font-size:.95rem;font-weight:600;color:var(--ink-mid);letter-spacing:.02em;transition:color .35s}
415
- .agent-item.active .agent-label{color:var(--ink-deep)}
416
- .agent-sublabel{font-size:.72rem;color:var(--ink-light);font-weight:300;letter-spacing:.04em}
417
- .agent-item.active .agent-sublabel{color:var(--pigment)}
418
-
419
- #sidebar-verse{margin-top:auto;padding-top:24px}
420
- #sidebar-verse p{font-size:.75rem;color:var(--ink-light);font-style:italic;line-height:2;letter-spacing:.03em;font-weight:300}
421
-
422
- /* ── Main chat ── */
423
- #main{flex:1;display:flex;flex-direction:column;position:relative;z-index:1;min-width:0}
424
- #chat-strip{display:flex;align-items:center;gap:12px;padding:clamp(12px,3vh,20px) var(--gutter);border-bottom:1px solid var(--ink-faint)}
425
- .strip-dot{width:7px;height:7px;border-radius:50%;background:var(--pigment);flex-shrink:0;animation:var(--dot-anim) 2s ease-in-out infinite;transition:background .5s}
426
- #strip-name{font-weight:600;font-size:1rem;color:var(--ink-deep);letter-spacing:.04em}
427
- #strip-pigment{font-size:.78rem;color:var(--ink-light);font-weight:300}
428
-
429
- /* ── Messages ── */
430
- #messages{flex:1;overflow-y:auto;padding:var(--gutter);display:flex;flex-direction:column;gap:clamp(16px,3vh,28px);scroll-behavior:smooth}
431
- #messages::-webkit-scrollbar{width:3px}
432
- #messages::-webkit-scrollbar-track{background:transparent}
433
- #messages::-webkit-scrollbar-thumb{background:var(--ink-faint);border-radius:2px}
434
-
435
- .msg{max-width:68%;line-height:1.8;animation:msg-in .5s ease both;position:relative}
436
- @keyframes msg-in{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
437
-
438
- .msg.user{align-self:flex-end;margin-right:clamp(0px,4vw,40px)}
439
- .msg.user .msg-body{padding-right:16px;border-right:2px solid var(--pigment);text-align:right;color:var(--ink-deep);transition:border-color .5s}
440
-
441
- .msg.assistant{align-self:flex-start;margin-left:clamp(0px,2vw,24px)}
442
- .msg.assistant .msg-body{
443
- padding:12px 16px;color:var(--ink-mid);
444
- border:1px solid var(--ink-faint);border-left:3px solid var(--pigment);
445
- background:rgba(255,255,255,.25);
446
- transition:border-color .5s,background .5s;
447
- }
448
-
449
- .msg.system{align-self:center;max-width:90%}
450
- .msg.system .msg-body{color:var(--ink-light);font-size:.78rem;text-align:center;font-style:italic;border:none;padding:0}
451
-
452
- .msg-time{font-size:.67rem;color:var(--ink-faint);margin-top:6px;letter-spacing:.06em;display:block}
453
- .msg.typing .msg-body{animation:ink-bleed 2s infinite}
454
- @keyframes ink-bleed{0%,100%{opacity:.3}50%{opacity:.65}}
455
-
456
- /* ── Input ── */
457
- #input-area{padding:16px var(--gutter) clamp(16px,3vh,28px);border-top:1px solid var(--ink-faint);background:linear-gradient(0deg,var(--paper-warm),var(--paper))}
458
- #input-wrap{display:flex;gap:0;align-items:stretch;border-bottom:1px solid var(--ink-faint);transition:border-color .4s ease;padding-bottom:0}
459
- #input-wrap:focus-within{border-bottom-color:var(--pigment)}
460
- #input-wrap textarea{
461
- flex:1;background:transparent;border:none;outline:none;color:var(--ink-deep);
462
- font-family:inherit;font-size:.95rem;font-weight:300;padding:10px 0;
463
- resize:none;min-height:26px;max-height:140px;line-height:1.7;
464
- }
465
- #input-wrap textarea::placeholder{color:var(--ink-faint);font-style:italic}
466
- #send-btn{background:none;border:none;color:var(--ink-light);cursor:pointer;padding:8px 12px;font-size:1.2rem;transition:all .3s ease;font-family:inherit;display:flex;align-items:center;opacity:.5}
467
- #send-btn:hover{opacity:1;color:var(--pigment)}
468
- #send-btn:disabled{opacity:.15}
469
- #input-hint{font-size:.68rem;color:var(--ink-faint);margin-top:6px;letter-spacing:.04em}
470
-
471
- /* ── Kanji decoration ── */
472
- #kanji-decoration{
473
- position:fixed;right:clamp(16px,4vw,48px);bottom:clamp(80px,15vh,140px);
474
- font-size:clamp(3.5rem,8vw,6rem);z-index:0;pointer-events:none;
475
- color:var(--pigment);opacity:.06;font-weight:700;
476
- font-family:'Noto Serif SC',serif;user-select:none;
477
- animation:var(--kanji-anim) 8s ease-in-out infinite;
478
- transition:color .8s ease,animation .8s ease;
479
- }
480
-
481
- @keyframes fog-drift{0%,100%{opacity:.04;transform:translateX(0)}50%{opacity:.09;transform:translateX(-8px)}}
482
- @keyframes rain-fall{0%,100%{opacity:.04;transform:translateY(0)}50%{opacity:.1;transform:translateY(5px)}}
483
- @keyframes frost-sparkle{0%{opacity:.04;transform:scale(1) rotate(0deg)}45%{opacity:.12;transform:scale(1.04) rotate(1.5deg)}50%{opacity:.04;transform:scale(1) rotate(0deg)}100%{opacity:.04;transform:scale(1) rotate(0deg)}}
484
- @keyframes snow-fall{0%,100%{opacity:.04;transform:translateY(0) rotate(0deg)}50%{opacity:.08;transform:translateY(6px) rotate(.5deg)}}
485
- @keyframes dew-still{0%,100%{opacity:.05;transform:scale(1)}}
486
- @keyframes fair-warm{0%,100%{opacity:.04;transform:scale(1)}50%{opacity:.1;transform:scale(1.02)}}
487
-
488
- /* ── Mobile ── */
489
- @media(max-width:720px){
490
- body{flex-direction:column}
491
- #sidebar{width:100%;flex-direction:row;align-items:center;gap:4px;padding:8px 12px;overflow-x:auto;flex-shrink:0;background:var(--paper-warm)}
492
- #logo-block{display:none}#sidebar-verse{display:none}
493
- .agent-item{border-left:none;border-bottom:2px solid transparent;border-radius:0;padding:8px 12px;white-space:nowrap}
494
- .agent-item.active{border-bottom-color:var(--pigment);border-left-color:transparent;background:transparent}
495
- .agent-item.active::after{display:none}
496
- .agent-sublabel{display:none}
497
- .msg{max-width:90%}
498
- #kanji-decoration{right:8px;bottom:40px;font-size:1.8rem;border-width:1px;padding:4px 6px;opacity:.06}
499
- #messages{padding:16px 12px}#chat-strip{padding:8px 12px}#input-area{padding:10px 12px}
500
- }
501
- </style>
502
- </head>
503
- <body>
504
-
505
- <div id="paper-grain"></div>
506
- <div id="ambient-layer"><div class="mist-particles"></div></div>
507
- <div id="kanji-decoration">霧</div>
508
-
509
- <div id="sidebar">
510
- <div id="logo-block"><div id="logo">气象台<small>skyloom</small></div></div>
511
- <div id="agents-list"></div>
512
- <div id="sidebar-verse"><p id="verse-text">山色有无中</p></div>
513
- </div>
514
-
515
- <div id="main">
516
- <div id="chat-strip">
517
- <div class="strip-dot"></div>
518
- <span id="strip-name">雾 Fog</span>
519
- <span id="strip-pigment">· 松烟墨</span>
520
- </div>
521
- <div id="messages"></div>
522
- <div id="input-area">
523
- <div id="input-wrap">
524
- <textarea id="chat-input" rows="1" placeholder="写下你想说的话…" autofocus></textarea>
525
- <button id="send-btn" aria-label="发送">→</button>
526
- </div>
527
- <div id="input-hint">enter 发送 · shift+enter 换行 · ⌘1-6 切换灵</div>
528
- </div>
529
- </div>
530
-
531
- <script>
532
- const PIGMENTS = ${JSON.stringify(PIGMENTS)};
533
- const AGENTS = [
534
- {name:'fog', label:'雾 Fog', sub:'松烟墨 · 探索洞察', pigment:PIGMENTS.fog, particles:'mist-particles', count:8, nameZH:'松烟墨',kanji:'霧'},
535
- {name:'rain', label:'雨 Rain', sub:'石青 · 创造产出', pigment:PIGMENTS.rain, particles:'rain-streaks', count:20, nameZH:'石青',kanji:'雨'},
536
- {name:'frost',label:'霜 Frost', sub:'石绿 · 精炼品质', pigment:PIGMENTS.frost, particles:'frost-crystals', count:14, nameZH:'石绿',kanji:'霜'},
537
- {name:'snow', label:'雪 Snow', sub:'铅白 · 架构规划', pigment:PIGMENTS.snow, particles:'snow-particles', count:12, nameZH:'铅白',kanji:'雪'},
538
- {name:'dew', label:'露 Dew', sub:'赭石 · 可靠守护', pigment:PIGMENTS.dew, particles:'dew-beads', count:10, nameZH:'赭石',kanji:'露'},
539
- {name:'fair', label:'晴 Fair', sub:'朱砂 · 情感陪伴', pigment:PIGMENTS.fair, particles:'sun-motes', count:16, nameZH:'朱砂',kanji:'晴'},
540
- ];
541
-
542
- const root=document.documentElement;
543
- const ambientLayer=document.getElementById('ambient-layer');
544
- const kanjiEl=document.getElementById('kanji-decoration');
545
- let currentAgent=AGENTS[0],isStreaming=false;
546
-
547
- /* Build ambient particles */
548
- const particleMap=new Map();
549
- AGENTS.forEach(a=>{
550
- const container=document.createElement('div');container.className=a.particles;
551
- for(let i=0;i<a.count;i++){
552
- const el=document.createElement('i');
553
- const seed=Math.random();
554
- el.style.cssText='--x:'+(8+seed*84)+'%;--y:'+(5+seed*90)+'%;--dur:'+(2+seed*8)+'s;--delay:'+(seed*-6)+'s;--w:'+(4+seed*10)+'px;--h:'+(3+seed*8)+'px;--drift:'+((seed-.5)*40)+'px';
555
- container.appendChild(el);
556
- }
557
- ambientLayer.appendChild(container);
558
- });
559
-
560
- function applyTheme(agent){
561
- if(currentAgent===agent)return;currentAgent=agent;
562
- const p=agent.pigment;
563
- root.style.setProperty('--pigment',p.color);
564
- root.style.setProperty('--ambient-bg',p.ambient);
565
- root.style.setProperty('--msg-border',p.msgStyle);
566
- root.style.setProperty('--dot-anim',p.dotPulse);
567
- root.style.setProperty('--kanji-anim',p.kanjiAnim);
568
-
569
- /* Show only this agent's particles */
570
- ambientLayer.querySelectorAll('div').forEach(d=>d.style.display=d.classList.contains(agent.particles)?'block':'none');
571
- kanjiEl.textContent=agent.kanji;
572
- kanjiEl.style.animation='none';void kanjiEl.offsetWidth;kanjiEl.style.animation=p.kanjiAnim+' 8s ease-in-out infinite';
573
-
574
- document.querySelectorAll('.agent-item').forEach(e=>e.classList.remove('active'));
575
- const card=document.querySelector('[data-agent="'+agent.name+'"]');if(card)card.classList.add('active');
576
- document.getElementById('strip-name').textContent=agent.label;
577
- document.getElementById('strip-pigment').textContent='· '+agent.nameZH;
578
- document.getElementById('verse-text').textContent=agent.pigment.poem;
579
- }
580
-
581
- /* Build sidebar */
582
- const list=document.getElementById('agents-list');
583
- AGENTS.forEach((a,i)=>{
584
- const el=document.createElement('div');el.className='agent-item';el.dataset.agent=a.name;el.dataset.seal=a.kanji;
585
- el.innerHTML='<span class="agent-info"><span class="agent-label">'+a.label+'</span><span class="agent-sublabel">'+a.sub+'</span></span>';
586
- el.addEventListener('click',()=>applyTheme(a));
587
- list.appendChild(el);
588
- });
589
- applyTheme(AGENTS[0]);
590
-
591
- /* ── Chat ── */
592
- const msgsEl=document.getElementById('messages'),input=document.getElementById('chat-input'),sendBtn=document.getElementById('send-btn');
593
-
594
- function addMsg(role,text){
595
- const w=document.createElement('div');w.className='msg '+role;
596
- w.innerHTML='<div class="msg-body">'+text+'</div><span class="msg-time">'+new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})+'</span>';
597
- msgsEl.appendChild(w);scrollBottom();return w;
598
- }
599
- function addSys(t){addMsg('system',t)}
600
- function scrollBottom(){msgsEl.scrollTop=msgsEl.scrollHeight}
601
-
602
- function typewriter(el,text){
603
- return new Promise(resolve=>{
604
- let i=0;el.innerHTML='';
605
- function tick(){if(i<text.length){el.innerHTML+=text[i]==='\\n'?'<br>':text[i];i++;scrollBottom();setTimeout(tick,text[i-1]==='。'||text[i-1]===','?70:text[i-1]===' '?18:22+Math.random()*14)}else resolve()}
606
- tick();
607
- });
608
- }
609
-
610
- async function sendMessage(){
611
- const text=input.value.trim();if(!text||isStreaming)return;
612
- input.value='';input.style.height='auto';isStreaming=true;sendBtn.disabled=true;
613
- addMsg('user',esc(text));const thinking=addMsg('assistant','<span class="typing">…</span>');
614
- try{
615
- const resp=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,agent:currentAgent.name})});
616
- const data=await resp.json();thinking.remove();
617
- if(data.error){addSys('× '+esc(data.error))}else{const el=addMsg('assistant','');await typewriter(el,data.response)}
618
- }catch(e){thinking.remove();addSys('× 连接中断')}
619
- isStreaming=false;sendBtn.disabled=false;input.focus();
620
- }
621
- function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
622
-
623
- input.addEventListener('input',()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,140)+'px'});
624
- input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage()}});
625
- sendBtn.addEventListener('click',sendMessage);
626
- document.addEventListener('keydown',e=>{if(e.ctrlKey||e.metaKey){const n=parseInt(e.key);if(n>=1&&n<=6){e.preventDefault();applyTheme(AGENTS[n-1])}}});
627
- addSys('气象台已就绪 · ⌘1-6 唤灵 · enter 传讯');
628
- </script>
629
- </body>
630
- </html>`;
631
-
632
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
633
- res.end(html);
634
- }
1
+ /**
2
+ * Web server for Skyloom — HTTP API + 水墨气象台 chat UI.
3
+ */
4
+
5
+ import { createServer, IncomingMessage, ServerResponse } from "http";
6
+ import { createSystemContext } from "../core/factory";
7
+ import { getLogger } from "../core/logger";
8
+
9
+ const log = getLogger("web-server");
10
+
11
+ /* ── Agent personalities: pigment + atmosphere + motion ── */
12
+ const PIGMENTS: Record<string, {
13
+ color: string; nameZH: string; kanji: string; poem: string;
14
+ ambient: string; // CSS for ambient layer
15
+ msgStyle: string; // CSS for message treatment
16
+ dotPulse: string; // animation name for dot
17
+ kanjiAnim: string; // kanji decoration animation
18
+ }> = {
19
+ fog: {
20
+ color: "#4a4a44", nameZH: "松烟墨", kanji: "霧",
21
+ poem: "山色有无中",
22
+ ambient: `background:radial-gradient(ellipse at 30% 60%,rgba(180,175,165,.18) 0%,transparent 70%),
23
+ radial-gradient(ellipse at 70% 30%,rgba(190,185,175,.10) 0%,transparent 55%);`,
24
+ msgStyle: `border-style:dashed;border-color:rgba(140,135,125,.25);`,
25
+ dotPulse: "fog-pulse", kanjiAnim: "fog-drift",
26
+ },
27
+ rain: {
28
+ color: "#2a5c8a", nameZH: "石青", kanji: "雨",
29
+ poem: "一蓑烟雨任平生",
30
+ ambient: `background:radial-gradient(ellipse at 50% 20%,rgba(42,92,138,.10) 0%,transparent 50%),
31
+ repeating-linear-gradient(175deg,transparent,transparent 3px,rgba(42,92,138,.02) 3px,rgba(42,92,138,.02) 6px);`,
32
+ msgStyle: `border-style:solid;border-color:rgba(42,92,138,.18);box-shadow:inset 0 -1px 0 rgba(42,92,138,.06);`,
33
+ dotPulse: "rain-ripple", kanjiAnim: "rain-fall",
34
+ },
35
+ frost: {
36
+ color: "#3a7a6e", nameZH: "石绿", kanji: "霜",
37
+ poem: "月落乌啼霜满天",
38
+ ambient: `background:radial-gradient(ellipse at 60% 40%,rgba(58,122,110,.09) 0%,transparent 55%),
39
+ conic-gradient(from 0deg at 75% 25%,transparent 0deg,rgba(58,122,110,.03) 2deg,transparent 4deg,transparent 180deg);`,
40
+ msgStyle: `border-style:solid;border-color:rgba(58,122,110,.20);border-radius:2px 8px 8px 2px;`,
41
+ dotPulse: "frost-glint", kanjiAnim: "frost-sparkle",
42
+ },
43
+ snow: {
44
+ color: "#8a8a82", nameZH: "铅白", kanji: "雪",
45
+ poem: "千树万树梨花开",
46
+ ambient: `background:radial-gradient(circle at 25% 70%,rgba(210,208,200,.10) 0%,transparent 50%),
47
+ radial-gradient(circle at 80% 35%,rgba(220,218,210,.07) 0%,transparent 45%);`,
48
+ msgStyle: `border-style:solid;border-color:rgba(180,178,170,.15);border-radius:8px 8px 8px 2px;`,
49
+ dotPulse: "snow-float", kanjiAnim: "snow-fall",
50
+ },
51
+ dew: {
52
+ color: "#8b6914", nameZH: "赭石", kanji: "露",
53
+ poem: "金风玉露一相逢",
54
+ ambient: `background:radial-gradient(ellipse at 50% 90%,rgba(139,105,20,.08) 0%,transparent 55%),
55
+ radial-gradient(ellipse at 80% 95%,rgba(139,105,20,.04) 0%,transparent 40%);`,
56
+ msgStyle: `border-style:solid;border-color:rgba(139,105,20,.22);border-width:0 0 2px 2px;`,
57
+ dotPulse: "dew-bead", kanjiAnim: "dew-still",
58
+ },
59
+ fair: {
60
+ color: "#b3342d", nameZH: "朱砂", kanji: "晴",
61
+ poem: "道是无晴却有晴",
62
+ ambient: `background:radial-gradient(ellipse at 80% 15%,rgba(179,52,45,.12) 0%,transparent 60%),
63
+ radial-gradient(ellipse at 30% 40%,rgba(200,100,50,.05) 0%,transparent 50%);`,
64
+ msgStyle: `border-style:solid;border-color:rgba(179,52,45,.20);box-shadow:0 0 1px rgba(179,52,45,.04);`,
65
+ dotPulse: "fair-glow", kanjiAnim: "fair-warm",
66
+ },
67
+ };
68
+
69
+ /* ──────────────────────────────────────────────
70
+ Server
71
+ ────────────────────────────────────────────── */
72
+ export async function startWebServer(port: number = 3000): Promise<void> {
73
+ const ctx = createSystemContext();
74
+
75
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
76
+ res.setHeader("Access-Control-Allow-Origin", "*");
77
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
78
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
79
+ if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
80
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
81
+ try {
82
+ if (url.pathname === "/api/chat" && req.method === "POST") await handleChat(req, res, ctx);
83
+ else if (url.pathname === "/api/agents" && req.method === "GET") handleAgents(res, ctx);
84
+ else if (url.pathname === "/api/status" && req.method === "GET") handleStatus(res, ctx);
85
+ else if (url.pathname.startsWith("/api/")) res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "Not found" }));
86
+ else serveInkWashUI(res);
87
+ } catch (e) { res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ error: String(e) })); }
88
+ });
89
+
90
+ return new Promise((resolve) => { server.listen(port, () => { console.log(`\n 水墨气象台 · Skyloom\n http://localhost:${port}\n`); resolve(); }); });
91
+ }
92
+
93
+ async function handleChat(req: IncomingMessage, res: ServerResponse, ctx: ReturnType<typeof createSystemContext>) {
94
+ const buffers: Buffer[] = [];
95
+ for await (const chunk of req) buffers.push(chunk as Buffer);
96
+ const { message, agent: agentName = "fog" } = JSON.parse(Buffer.concat(buffers).toString("utf-8"));
97
+ if (!message) { res.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "message is required" })); return; }
98
+ const agent = ctx.agentMap.get(agentName);
99
+ if (!agent) { res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: `Agent '${agentName}' not found` })); return; }
100
+ await agent.init();
101
+
102
+ // Real streaming over SSE — tokens, reasoning, and tool events as they happen.
103
+ res.writeHead(200, {
104
+ "Content-Type": "text/event-stream; charset=utf-8",
105
+ "Cache-Control": "no-cache, no-transform",
106
+ "Connection": "keep-alive",
107
+ "X-Accel-Buffering": "no",
108
+ });
109
+ const send = (ev: Record<string, unknown>) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
110
+ try {
111
+ for await (const ev of agent.chatStream(message)) send(ev as Record<string, unknown>);
112
+ } catch (e) {
113
+ send({ type: "error", text: String(e) });
114
+ }
115
+ send({ type: "end" });
116
+ res.end();
117
+ }
118
+
119
+ function handleAgents(res: ServerResponse, ctx: ReturnType<typeof createSystemContext>) {
120
+ res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
121
+ agents: [...ctx.agentMap.entries()].map(([n, a]) => ({ name: n, displayName: a.displayName, emoji: a.emoji, specialty: a.specialty, state: a.state })),
122
+ }));
123
+ }
124
+ function handleStatus(res: ServerResponse, ctx: ReturnType<typeof createSystemContext>) {
125
+ const s: Record<string, any> = {}; for (const [n, a] of ctx.agentMap) s[n] = a.getStatus();
126
+ res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ agents: s, workspace: ctx.workspacePath }));
127
+ }
128
+
129
+ /* ──────────────────────────────────────────────
130
+ 水墨气象台 · Ink Wash Weather Station
131
+ ────────────────────────────────────────────── */
132
+ function serveInkWashUI(res: ServerResponse): void {
133
+ const html = `<!DOCTYPE html>
134
+ <html lang="zh">
135
+ <head>
136
+ <meta charset="UTF-8">
137
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
138
+ <title>水墨气象台 · Skyloom</title>
139
+ <link rel="preconnect" href="https://fonts.googleapis.com">
140
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
141
+ <link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;600;700&family=Noto+Serif:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
142
+ <style>
143
+ /* ═══════════════════════════════════
144
+ 水墨气象台 · 全局
145
+ ═══════════════════════════════════ */
146
+ :root{
147
+ --paper:#f8f4ec;
148
+ --paper-warm:#f3ede2;
149
+ --paper-edge:rgba(180,160,130,.06);
150
+ --ink-deep:#1a1614;
151
+ --ink-mid:#3d3833;
152
+ --ink-light:#8c8680;
153
+ --ink-faint:#c4bfb8;
154
+ --pigment:#4a4a44;
155
+ --pigment-soft:rgba(74,74,68,.12);
156
+ --pigment-seal:#4a4a44;
157
+ --dot-anim:fog-pulse;
158
+ --kanji-anim:fog-drift;
159
+ --gutter:clamp(24px,5vw,56px);
160
+ --sidebar-w:200px;
161
+ }
162
+ *,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
163
+ html,body{height:100%;overflow:hidden}
164
+ body{
165
+ font-family:'Noto Serif SC','Noto Serif',Georgia,serif;
166
+ background:var(--paper);
167
+ color:var(--ink-deep);
168
+ display:flex;height:100vh;font-weight:400;font-size:16px;line-height:1.8;
169
+ -webkit-font-smoothing:antialiased;
170
+ }
171
+
172
+ /* ── Aged paper texture with vignette edges ── */
173
+ #paper-grain{
174
+ position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.28;
175
+ background:
176
+ repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(139,119,90,.02) 2px,rgba(139,119,90,.02) 4px),
177
+ repeating-linear-gradient(90deg,transparent,transparent 3px,rgba(139,119,90,.012) 3px,rgba(139,119,90,.012) 6px),
178
+ linear-gradient(135deg,rgba(80,60,30,.04) 0%,transparent 15%,transparent 85%,rgba(80,60,30,.04) 100%),
179
+ linear-gradient(225deg,rgba(80,60,30,.03) 0%,transparent 10%,transparent 90%,rgba(80,60,30,.03) 100%);
180
+ }
181
+ /* Ink wash mountain silhouette at top */
182
+ #paper-grain::before{
183
+ content:'';position:fixed;top:0;left:0;right:0;height:clamp(80px,12vh,160px);z-index:0;pointer-events:none;opacity:.12;
184
+ background:
185
+ radial-gradient(ellipse 120% 100% at 25% 100%,rgba(60,55,50,.5) 0%,transparent 45%),
186
+ radial-gradient(ellipse 80% 70% at 55% 100%,rgba(50,45,40,.4) 0%,transparent 55%),
187
+ radial-gradient(ellipse 100% 60% at 70% 100%,rgba(70,65,60,.3) 0%,transparent 60%),
188
+ radial-gradient(ellipse 60% 80% at 40% 100%,rgba(55,50,45,.25) 0%,transparent 50%),
189
+ radial-gradient(ellipse 140% 90% at 60% 100%,rgba(65,60,55,.15) 0%,transparent 70%);
190
+ mask:linear-gradient(0deg,transparent 0%,#000 100%);
191
+ -webkit-mask:linear-gradient(0deg,transparent 0%,#000 100%);
192
+ }
193
+
194
+ /* ── Ambient layer — agent-specific atmosphere ── */
195
+ #ambient-layer{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.7;transition:background .8s ease;background:var(--ambient-bg)}
196
+
197
+ /* ═══════════════════════════════════
198
+ Fog · 松烟墨 · mist particles
199
+ ═══════════════════════════════════ */
200
+ .mist-particles{position:absolute;inset:0;overflow:hidden;pointer-events:none}
201
+ .mist-particles i{
202
+ position:absolute;border-radius:50%;background:rgba(160,155,145,.10);
203
+ animation:fog-drift var(--dur,8s) linear infinite;
204
+ animation-delay:var(--delay,0s);
205
+ width:var(--w,120px);height:var(--h,40px);left:var(--x,10%);top:var(--y,30%);
206
+ filter:blur(20px);
207
+ }
208
+ @keyframes fog-drift{0%{transform:translateX(-30px);opacity:.3}50%{transform:translateX(40px);opacity:.7}100%{transform:translateX(-30px);opacity:.3}}
209
+
210
+ /* ═══════════════════════════════════
211
+ Rain · 石青 · falling streaks
212
+ ═══════════════════════════════════ */
213
+ .rain-streaks{position:absolute;inset:0;overflow:hidden;pointer-events:none}
214
+ .rain-streaks i{
215
+ position:absolute;width:1px;height:var(--h,40px);
216
+ background:linear-gradient(0deg,rgba(42,92,138,.12),transparent);
217
+ left:var(--x,15%);top:-10%;
218
+ animation:rain-fall var(--dur,1.2s) linear infinite;
219
+ animation-delay:var(--delay,0s);
220
+ }
221
+ @keyframes rain-fall{0%{transform:translateY(-10vh)}100%{transform:translateY(110vh)}}
222
+
223
+ /* ═══════════════════════════════════
224
+ Frost · 石绿 · crystalline sparks
225
+ ═══════════════════════════════════ */
226
+ .frost-crystals{position:absolute;inset:0;overflow:hidden;pointer-events:none}
227
+ .frost-crystals i{
228
+ position:absolute;
229
+ width:var(--w,4px);height:var(--h,4px);
230
+ background:rgba(58,122,110,.15);
231
+ left:var(--x,20%);top:var(--y,30%);
232
+ clip-path:polygon(50% 0%,100% 50%,50% 100%,0% 50%);
233
+ animation:frost-glint var(--dur,3s) ease-in-out infinite;
234
+ animation-delay:var(--delay,0s);
235
+ }
236
+ @keyframes frost-glint{0%,100%{opacity:.1;transform:scale(.8) rotate(0deg)}50%{opacity:.6;transform:scale(1.4) rotate(45deg)}}
237
+
238
+ /* ═══════════════════════════════════
239
+ Snow · 铅白 · gentle snowfall
240
+ ═══════════════════════════════════ */
241
+ .snow-particles{position:absolute;inset:0;overflow:hidden;pointer-events:none}
242
+ .snow-particles i{
243
+ position:absolute;border-radius:50%;background:rgba(190,188,180,.14);
244
+ width:var(--w,6px);height:var(--w,6px);
245
+ left:var(--x,20%);top:-5%;
246
+ animation:snow-fall var(--dur,10s) linear infinite;
247
+ animation-delay:var(--delay,0s);
248
+ }
249
+ @keyframes snow-fall{0%{transform:translateY(-5vh) translateX(0)}25%{transform:translateY(25vh) translateX(15px)}50%{transform:translateY(50vh) translateX(-10px)}75%{transform:translateY(75vh) translateX(8px)}100%{transform:translateY(110vh) translateX(-5px)}}
250
+
251
+ /* ═══════════════════════════════════
252
+ Dew · 赭石 · morning dew beads
253
+ ═══════════════════════════════════ */
254
+ .dew-beads{position:absolute;inset:0;overflow:hidden;pointer-events:none}
255
+ .dew-beads i{
256
+ position:absolute;border-radius:50%;
257
+ background:radial-gradient(circle at 40% 35%,rgba(200,170,100,.10),rgba(139,105,20,.06));
258
+ width:var(--w,8px);height:var(--w,8px);
259
+ left:var(--x,25%);bottom:var(--y,15%);
260
+ animation:dew-bead var(--dur,4s) ease-in-out infinite;
261
+ animation-delay:var(--delay,0s);
262
+ }
263
+ @keyframes dew-bead{0%,100%{opacity:.3;transform:scale(1)}50%{opacity:.7;transform:scale(1.5)}}
264
+
265
+ /* ═══════════════════════════════════
266
+ Fair · 朱砂 · sun motes rising
267
+ ═══════════════════════════════════ */
268
+ .sun-motes{position:absolute;inset:0;overflow:hidden;pointer-events:none}
269
+ .sun-motes i{
270
+ position:absolute;border-radius:50%;
271
+ background:rgba(200,120,50,.06);
272
+ width:var(--w,3px);height:var(--w,3px);
273
+ left:var(--x,50%);bottom:-5%;
274
+ animation:fair-warm var(--dur,7s) ease-in infinite;
275
+ animation-delay:var(--delay,0s);
276
+ }
277
+ @keyframes fair-warm{0%{transform:translateY(0) translateX(0);opacity:0}20%{opacity:.8}80%{opacity:.4}100%{transform:translateY(-100vh) translateX(var(--drift,20px));opacity:0}}
278
+
279
+ /* ═══════════════════════════════════
280
+ Layout — scroll aesthetic
281
+ ═══════════════════════════════════ */
282
+ #sidebar{
283
+ width:var(--sidebar-w);flex-shrink:0;position:relative;z-index:1;
284
+ padding:var(--gutter) clamp(20px,3vw,32px);
285
+ display:flex;flex-direction:column;gap:0;
286
+ background:linear-gradient(90deg,rgba(0,0,0,.015),transparent 40%);
287
+ }
288
+
289
+ /* Logo: vertical seal style */
290
+ #logo-block{margin-bottom:clamp(28px,7vh,56px);text-align:center}
291
+ #logo{
292
+ font-size:clamp(1.5rem,2.5vw,2rem);font-weight:700;
293
+ letter-spacing:.2em;color:var(--ink-deep);line-height:1.2;
294
+ writing-mode:horizontal-tb;
295
+ }
296
+ #logo small{
297
+ display:block;font-weight:300;font-size:.7rem;color:var(--ink-light);
298
+ letter-spacing:.2em;margin-top:4px;
299
+ }
300
+
301
+ #agents-list{display:flex;flex-direction:column;gap:2px;flex:1;overflow-y:auto}
302
+
303
+ .agent-item{
304
+ display:flex;align-items:center;gap:12px;
305
+ padding:11px 14px;cursor:pointer;
306
+ border-radius:4px;position:relative;transition:all .4s ease;
307
+ border-left:2px solid transparent;
308
+ }
309
+ .agent-item:hover{background:var(--paper-edge)}
310
+ .agent-item.active{background:var(--pigment-soft);border-left-color:var(--pigment)}
311
+
312
+ /* Seal stamp effect for active agent */
313
+ .agent-item.active::after{
314
+ content:attr(data-seal);
315
+ position:absolute;right:10px;top:50%;transform:translateY(-50%);
316
+ font-size:1.3rem;font-weight:700;color:var(--pigment);
317
+ opacity:.55;font-family:'Noto Serif SC',serif;
318
+ letter-spacing:0;
319
+ }
320
+
321
+ .agent-dot{display:none}
322
+
323
+ .agent-info{display:flex;flex-direction:column;gap:1px}
324
+ .agent-label{font-size:.92rem;font-weight:600;color:var(--ink-mid);letter-spacing:.04em;transition:color .35s;line-height:1.3}
325
+ .agent-item.active .agent-label{color:var(--ink-deep)}
326
+ .agent-sublabel{font-size:.7rem;color:var(--ink-light);font-weight:300;letter-spacing:.04em}
327
+ .agent-item.active .agent-sublabel{color:var(--pigment)}
328
+
329
+ #sidebar-verse{margin-top:auto;padding-top:20px;text-align:center}
330
+ #sidebar-verse p{font-size:.72rem;color:var(--ink-light);font-style:italic;line-height:2.2;letter-spacing:.05em;font-weight:300}
331
+
332
+ /* ── Main chat ── */
333
+ #main{flex:1;display:flex;flex-direction:column;position:relative;z-index:1;min-width:0}
334
+ #chat-strip{
335
+ display:flex;align-items:center;gap:10px;
336
+ padding:clamp(12px,3vh,18px) var(--gutter);
337
+ }
338
+ .strip-dot{
339
+ width:6px;height:6px;border-radius:50%;background:var(--pigment);
340
+ flex-shrink:0;animation:var(--dot-anim) 2s ease-in-out infinite;
341
+ transition:background .5s;
342
+ }
343
+ #strip-name{font-weight:600;font-size:.95rem;color:var(--ink-deep);letter-spacing:.06em}
344
+ #strip-pigment{font-size:.75rem;color:var(--ink-light);font-weight:300}
345
+
346
+ /* ── Divider line with brush feel ── */
347
+ #chat-strip::after{
348
+ content:'';position:absolute;bottom:0;left:var(--gutter);right:var(--gutter);
349
+ height:1px;background:linear-gradient(90deg,transparent,var(--ink-faint) 20%,var(--ink-faint) 80%,transparent);
350
+ }
351
+
352
+ /* ── Messages ── */
353
+ #messages{
354
+ flex:1;overflow-y:auto;padding:var(--gutter);
355
+ display:flex;flex-direction:column;gap:clamp(18px,3.5vh,32px);
356
+ scroll-behavior:smooth;
357
+ }
358
+ #messages::-webkit-scrollbar{width:4px}
359
+ #messages::-webkit-scrollbar-track{background:transparent}
360
+ #messages::-webkit-scrollbar-thumb{background:var(--ink-faint);border-radius:2px}
361
+
362
+ .msg{max-width:66%;line-height:1.85;animation:msg-in .5s ease both;position:relative}
363
+ @keyframes msg-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
364
+
365
+ .msg.user{align-self:flex-end;margin-right:clamp(0px,5vw,60px)}
366
+ .msg.user .msg-body{padding-right:18px;border-right:2px solid var(--pigment);text-align:right;color:var(--ink-deep);transition:border-color .5s}
367
+
368
+ .msg.assistant{align-self:flex-start;margin-left:clamp(0px,3vw,36px)}
369
+ .msg.assistant .msg-body{
370
+ padding:14px 18px;color:var(--ink-mid);
371
+ background:linear-gradient(135deg,rgba(255,255,255,.35),rgba(255,255,255,.15));
372
+ border-left:3px solid var(--pigment);
373
+ border-radius:0 6px 6px 0;
374
+ transition:border-color .5s,background .5s;
375
+ box-shadow:1px 1px 3px rgba(0,0,0,.03);
376
+ }
377
+
378
+ .msg.system{align-self:center;max-width:88%}
379
+ .msg.system .msg-body{color:var(--ink-light);font-size:.76rem;text-align:center;font-style:italic;border:none;padding:0}
380
+
381
+ .msg-time{font-size:.65rem;color:var(--ink-faint);margin-top:5px;letter-spacing:.08em;display:block}
382
+ .msg.typing .msg-body{animation:ink-bleed 2s infinite}
383
+ @keyframes ink-bleed{0%,100%{opacity:.28}50%{opacity:.6}}
384
+
385
+ /* ── Input ── */
386
+ #input-area{
387
+ padding:14px var(--gutter) clamp(18px,3.5vh,28px);
388
+ background:linear-gradient(0deg,var(--paper-warm),transparent 40%);
389
+ }
390
+ #input-wrap{
391
+ display:flex;gap:0;align-items:stretch;
392
+ border-bottom:1.5px solid var(--ink-faint);
393
+ transition:border-color .4s ease;padding-bottom:4px;
394
+ }
395
+ #input-wrap:focus-within{border-bottom-color:var(--pigment)}
396
+ #input-wrap textarea{
397
+ flex:1;background:transparent;border:none;outline:none;color:var(--ink-deep);
398
+ font-family:inherit;font-size:.95rem;font-weight:300;padding:8px 0;
399
+ resize:none;min-height:24px;max-height:140px;line-height:1.7;
400
+ }
401
+ #input-wrap textarea::placeholder{color:var(--ink-faint);font-style:italic}
402
+ #send-btn{
403
+ background:none;border:none;color:var(--ink-light);cursor:pointer;
404
+ padding:8px 14px;font-size:1.1rem;transition:all .3s ease;
405
+ font-family:inherit;display:flex;align-items:center;opacity:.45;
406
+ }
407
+ #send-btn:hover{opacity:1;color:var(--pigment)}
408
+ #send-btn:disabled{opacity:.12}
409
+ #input-hint{font-size:.66rem;color:var(--ink-faint);margin-top:8px;letter-spacing:.06em;text-align:right}
410
+
411
+ /* ── Kanji seal stamp ── */
412
+ #kanji-decoration{position:fixed;right:clamp(20px,5vw,56px);bottom:clamp(80px,14vh,140px);z-index:0;pointer-events:none
413
+ ;font-size:clamp(2.5rem,6vw,4.5rem);font-weight:700;font-family:'Noto Serif SC',serif;user-select:none;
414
+ color:var(--pigment);opacity:.05;animation:var(--kanji-anim) 8s ease-in-out infinite;
415
+ transition:color .8s ease;
416
+ border:2px solid var(--pigment);border-radius:4px;padding:clamp(4px,1vw,10px) clamp(6px,1.5vw,16px);
417
+ writing-mode:vertical-rl;letter-spacing:.1em;
418
+ }
419
+
420
+ /* Dot animations for header indicator */
421
+ @keyframes fog-pulse{0%,100%{opacity:.4;transform:scale(1)}50%{opacity:1;transform:scale(1.8)}}
422
+ @keyframes rain-ripple{0%,100%{opacity:.5;transform:scale(1)}30%{opacity:1;transform:scale(1.6)}60%{opacity:.5;transform:scale(1)}90%{opacity:1;transform:scale(1.4)}}
423
+ @keyframes frost-glint{0%,100%{opacity:1;transform:scale(1)}45%{opacity:.2;transform:scale(.5)}50%{opacity:1;transform:scale(1.7)}55%{opacity:.2;transform:scale(.5)}}
424
+ @keyframes snow-float{0%,100%{transform:translateY(0);opacity:.6}50%{transform:translateY(-4px);opacity:1}}
425
+ @keyframes dew-bead{0%,100%{transform:scale(1);opacity:.5}50%{transform:scale(1.6);opacity:1}}
426
+ @keyframes fair-glow{0%,100%{opacity:.5;transform:scale(1)}50%{opacity:1;transform:scale(1.5)}}
427
+
428
+ .agent-label{font-size:.95rem;font-weight:600;color:var(--ink-mid);letter-spacing:.02em;transition:color .35s}
429
+ .agent-item.active .agent-label{color:var(--ink-deep)}
430
+ .agent-sublabel{font-size:.72rem;color:var(--ink-light);font-weight:300;letter-spacing:.04em}
431
+ .agent-item.active .agent-sublabel{color:var(--pigment)}
432
+
433
+ #sidebar-verse{margin-top:auto;padding-top:24px}
434
+ #sidebar-verse p{font-size:.75rem;color:var(--ink-light);font-style:italic;line-height:2;letter-spacing:.03em;font-weight:300}
435
+
436
+ /* ── Main chat ── */
437
+ #main{flex:1;display:flex;flex-direction:column;position:relative;z-index:1;min-width:0}
438
+ #chat-strip{display:flex;align-items:center;gap:12px;padding:clamp(12px,3vh,20px) var(--gutter);border-bottom:1px solid var(--ink-faint)}
439
+ .strip-dot{width:7px;height:7px;border-radius:50%;background:var(--pigment);flex-shrink:0;animation:var(--dot-anim) 2s ease-in-out infinite;transition:background .5s}
440
+ #strip-name{font-weight:600;font-size:1rem;color:var(--ink-deep);letter-spacing:.04em}
441
+ #strip-pigment{font-size:.78rem;color:var(--ink-light);font-weight:300}
442
+
443
+ /* ── Messages ── */
444
+ #messages{flex:1;overflow-y:auto;padding:var(--gutter);display:flex;flex-direction:column;gap:clamp(16px,3vh,28px);scroll-behavior:smooth}
445
+ #messages::-webkit-scrollbar{width:3px}
446
+ #messages::-webkit-scrollbar-track{background:transparent}
447
+ #messages::-webkit-scrollbar-thumb{background:var(--ink-faint);border-radius:2px}
448
+
449
+ .msg{max-width:68%;line-height:1.8;animation:msg-in .5s ease both;position:relative}
450
+ @keyframes msg-in{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
451
+
452
+ .msg.user{align-self:flex-end;margin-right:clamp(0px,4vw,40px)}
453
+ .msg.user .msg-body{padding-right:16px;border-right:2px solid var(--pigment);text-align:right;color:var(--ink-deep);transition:border-color .5s}
454
+
455
+ .msg.assistant{align-self:flex-start;margin-left:clamp(0px,2vw,24px)}
456
+ .msg.assistant .msg-body{
457
+ padding:12px 16px;color:var(--ink-mid);
458
+ border:1px solid var(--ink-faint);border-left:3px solid var(--pigment);
459
+ background:rgba(255,255,255,.25);
460
+ transition:border-color .5s,background .5s;
461
+ }
462
+
463
+ .msg.system{align-self:center;max-width:90%}
464
+ .msg.system .msg-body{color:var(--ink-light);font-size:.78rem;text-align:center;font-style:italic;border:none;padding:0}
465
+
466
+ .msg-time{font-size:.67rem;color:var(--ink-faint);margin-top:6px;letter-spacing:.06em;display:block}
467
+ .msg.typing .msg-body{animation:ink-bleed 2s infinite}
468
+ @keyframes ink-bleed{0%,100%{opacity:.3}50%{opacity:.65}}
469
+
470
+ /* ── Input ── */
471
+ #input-area{padding:16px var(--gutter) clamp(16px,3vh,28px);border-top:1px solid var(--ink-faint);background:linear-gradient(0deg,var(--paper-warm),var(--paper))}
472
+ #input-wrap{display:flex;gap:0;align-items:stretch;border-bottom:1px solid var(--ink-faint);transition:border-color .4s ease;padding-bottom:0}
473
+ #input-wrap:focus-within{border-bottom-color:var(--pigment)}
474
+ #input-wrap textarea{
475
+ flex:1;background:transparent;border:none;outline:none;color:var(--ink-deep);
476
+ font-family:inherit;font-size:.95rem;font-weight:300;padding:10px 0;
477
+ resize:none;min-height:26px;max-height:140px;line-height:1.7;
478
+ }
479
+ #input-wrap textarea::placeholder{color:var(--ink-faint);font-style:italic}
480
+ #send-btn{background:none;border:none;color:var(--ink-light);cursor:pointer;padding:8px 12px;font-size:1.2rem;transition:all .3s ease;font-family:inherit;display:flex;align-items:center;opacity:.5}
481
+ #send-btn:hover{opacity:1;color:var(--pigment)}
482
+ #send-btn:disabled{opacity:.15}
483
+ #input-hint{font-size:.68rem;color:var(--ink-faint);margin-top:6px;letter-spacing:.04em}
484
+
485
+ /* ── Kanji decoration ── */
486
+ #kanji-decoration{
487
+ position:fixed;right:clamp(16px,4vw,48px);bottom:clamp(80px,15vh,140px);
488
+ font-size:clamp(3.5rem,8vw,6rem);z-index:0;pointer-events:none;
489
+ color:var(--pigment);opacity:.06;font-weight:700;
490
+ font-family:'Noto Serif SC',serif;user-select:none;
491
+ animation:var(--kanji-anim) 8s ease-in-out infinite;
492
+ transition:color .8s ease,animation .8s ease;
493
+ }
494
+
495
+ @keyframes fog-drift{0%,100%{opacity:.04;transform:translateX(0)}50%{opacity:.09;transform:translateX(-8px)}}
496
+ @keyframes rain-fall{0%,100%{opacity:.04;transform:translateY(0)}50%{opacity:.1;transform:translateY(5px)}}
497
+ @keyframes frost-sparkle{0%{opacity:.04;transform:scale(1) rotate(0deg)}45%{opacity:.12;transform:scale(1.04) rotate(1.5deg)}50%{opacity:.04;transform:scale(1) rotate(0deg)}100%{opacity:.04;transform:scale(1) rotate(0deg)}}
498
+ @keyframes snow-fall{0%,100%{opacity:.04;transform:translateY(0) rotate(0deg)}50%{opacity:.08;transform:translateY(6px) rotate(.5deg)}}
499
+ @keyframes dew-still{0%,100%{opacity:.05;transform:scale(1)}}
500
+ @keyframes fair-warm{0%,100%{opacity:.04;transform:scale(1)}50%{opacity:.1;transform:scale(1.02)}}
501
+
502
+ /* ── Mobile ── */
503
+ @media(max-width:720px){
504
+ body{flex-direction:column}
505
+ #sidebar{width:100%;flex-direction:row;align-items:center;gap:4px;padding:8px 12px;overflow-x:auto;flex-shrink:0;background:var(--paper-warm)}
506
+ #logo-block{display:none}#sidebar-verse{display:none}
507
+ .agent-item{border-left:none;border-bottom:2px solid transparent;border-radius:0;padding:8px 12px;white-space:nowrap}
508
+ .agent-item.active{border-bottom-color:var(--pigment);border-left-color:transparent;background:transparent}
509
+ .agent-item.active::after{display:none}
510
+ .agent-sublabel{display:none}
511
+ .msg{max-width:90%}
512
+ #kanji-decoration{right:8px;bottom:40px;font-size:1.8rem;border-width:1px;padding:4px 6px;opacity:.06}
513
+ #messages{padding:16px 12px}#chat-strip{padding:8px 12px}#input-area{padding:10px 12px}
514
+ }
515
+ </style>
516
+ </head>
517
+ <body>
518
+
519
+ <div id="paper-grain"></div>
520
+ <div id="ambient-layer"><div class="mist-particles"></div></div>
521
+ <div id="kanji-decoration">霧</div>
522
+
523
+ <div id="sidebar">
524
+ <div id="logo-block"><div id="logo">气象台<small>skyloom</small></div></div>
525
+ <div id="agents-list"></div>
526
+ <div id="sidebar-verse"><p id="verse-text">山色有无中</p></div>
527
+ </div>
528
+
529
+ <div id="main">
530
+ <div id="chat-strip">
531
+ <div class="strip-dot"></div>
532
+ <span id="strip-name">雾 Fog</span>
533
+ <span id="strip-pigment">· 松烟墨</span>
534
+ </div>
535
+ <div id="messages"></div>
536
+ <div id="input-area">
537
+ <div id="input-wrap">
538
+ <textarea id="chat-input" rows="1" placeholder="写下你想说的话…" autofocus></textarea>
539
+ <button id="send-btn" aria-label="发送">→</button>
540
+ </div>
541
+ <div id="input-hint">enter 发送 · shift+enter 换行 · ⌘1-6 切换灵</div>
542
+ </div>
543
+ </div>
544
+
545
+ <script>
546
+ const PIGMENTS = ${JSON.stringify(PIGMENTS)};
547
+ const AGENTS = [
548
+ {name:'fog', label:'雾 Fog', sub:'松烟墨 · 探索洞察', pigment:PIGMENTS.fog, particles:'mist-particles', count:8, nameZH:'松烟墨',kanji:'霧'},
549
+ {name:'rain', label:'雨 Rain', sub:'石青 · 创造产出', pigment:PIGMENTS.rain, particles:'rain-streaks', count:20, nameZH:'石青',kanji:'雨'},
550
+ {name:'frost',label:'霜 Frost', sub:'石绿 · 精炼品质', pigment:PIGMENTS.frost, particles:'frost-crystals', count:14, nameZH:'石绿',kanji:'霜'},
551
+ {name:'snow', label:'雪 Snow', sub:'铅白 · 架构规划', pigment:PIGMENTS.snow, particles:'snow-particles', count:12, nameZH:'铅白',kanji:'雪'},
552
+ {name:'dew', label:'露 Dew', sub:'赭石 · 可靠守护', pigment:PIGMENTS.dew, particles:'dew-beads', count:10, nameZH:'赭石',kanji:'露'},
553
+ {name:'fair', label:'晴 Fair', sub:'朱砂 · 情感陪伴', pigment:PIGMENTS.fair, particles:'sun-motes', count:16, nameZH:'朱砂',kanji:'晴'},
554
+ ];
555
+
556
+ const root=document.documentElement;
557
+ const ambientLayer=document.getElementById('ambient-layer');
558
+ const kanjiEl=document.getElementById('kanji-decoration');
559
+ let currentAgent=AGENTS[0],isStreaming=false;
560
+
561
+ /* Build ambient particles */
562
+ const particleMap=new Map();
563
+ AGENTS.forEach(a=>{
564
+ const container=document.createElement('div');container.className=a.particles;
565
+ for(let i=0;i<a.count;i++){
566
+ const el=document.createElement('i');
567
+ const seed=Math.random();
568
+ el.style.cssText='--x:'+(8+seed*84)+'%;--y:'+(5+seed*90)+'%;--dur:'+(2+seed*8)+'s;--delay:'+(seed*-6)+'s;--w:'+(4+seed*10)+'px;--h:'+(3+seed*8)+'px;--drift:'+((seed-.5)*40)+'px';
569
+ container.appendChild(el);
570
+ }
571
+ ambientLayer.appendChild(container);
572
+ });
573
+
574
+ function applyTheme(agent){
575
+ if(currentAgent===agent)return;currentAgent=agent;
576
+ const p=agent.pigment;
577
+ root.style.setProperty('--pigment',p.color);
578
+ root.style.setProperty('--ambient-bg',p.ambient);
579
+ root.style.setProperty('--msg-border',p.msgStyle);
580
+ root.style.setProperty('--dot-anim',p.dotPulse);
581
+ root.style.setProperty('--kanji-anim',p.kanjiAnim);
582
+
583
+ /* Show only this agent's particles */
584
+ ambientLayer.querySelectorAll('div').forEach(d=>d.style.display=d.classList.contains(agent.particles)?'block':'none');
585
+ kanjiEl.textContent=agent.kanji;
586
+ kanjiEl.style.animation='none';void kanjiEl.offsetWidth;kanjiEl.style.animation=p.kanjiAnim+' 8s ease-in-out infinite';
587
+
588
+ document.querySelectorAll('.agent-item').forEach(e=>e.classList.remove('active'));
589
+ const card=document.querySelector('[data-agent="'+agent.name+'"]');if(card)card.classList.add('active');
590
+ document.getElementById('strip-name').textContent=agent.label;
591
+ document.getElementById('strip-pigment').textContent='· '+agent.nameZH;
592
+ document.getElementById('verse-text').textContent=agent.pigment.poem;
593
+ }
594
+
595
+ /* Build sidebar */
596
+ const list=document.getElementById('agents-list');
597
+ AGENTS.forEach((a,i)=>{
598
+ const el=document.createElement('div');el.className='agent-item';el.dataset.agent=a.name;el.dataset.seal=a.kanji;
599
+ el.innerHTML='<span class="agent-info"><span class="agent-label">'+a.label+'</span><span class="agent-sublabel">'+a.sub+'</span></span>';
600
+ el.addEventListener('click',()=>applyTheme(a));
601
+ list.appendChild(el);
602
+ });
603
+ applyTheme(AGENTS[0]);
604
+
605
+ /* ── Chat ── */
606
+ const msgsEl=document.getElementById('messages'),input=document.getElementById('chat-input'),sendBtn=document.getElementById('send-btn');
607
+
608
+ function addMsg(role,text){
609
+ const w=document.createElement('div');w.className='msg '+role;
610
+ w.innerHTML='<div class="msg-body">'+text+'</div><span class="msg-time">'+new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})+'</span>';
611
+ msgsEl.appendChild(w);scrollBottom();return w;
612
+ }
613
+ function addSys(t){addMsg('system',t)}
614
+ function scrollBottom(){msgsEl.scrollTop=msgsEl.scrollHeight}
615
+
616
+ function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
617
+ function render(s){return esc(s).replace(/\\n/g,'<br>')}
618
+
619
+ /* Consume the SSE stream: tokens render live, tool calls appear as weather events. */
620
+ async function sendMessage(){
621
+ const text=input.value.trim();if(!text||isStreaming)return;
622
+ input.value='';input.style.height='auto';isStreaming=true;sendBtn.disabled=true;
623
+ addMsg('user',esc(text));
624
+ const el=addMsg('assistant','<span class="typing">…</span>');
625
+ const body=el.querySelector('.msg-body');
626
+ let content='',started=false;
627
+ try{
628
+ const resp=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,agent:currentAgent.name})});
629
+ const reader=resp.body.getReader(),dec=new TextDecoder();let buf='';
630
+ while(true){
631
+ const {done,value}=await reader.read();if(done)break;
632
+ buf+=dec.decode(value,{stream:true});
633
+ const parts=buf.split('\\n\\n');buf=parts.pop();
634
+ for(const p of parts){
635
+ const line=p.replace(/^data: /,'').trim();if(!line)continue;
636
+ let ev;try{ev=JSON.parse(line)}catch{continue}
637
+ if(ev.type==='content'){if(!started){started=true;body.innerHTML=''}content+=ev.text;body.innerHTML=render(content);scrollBottom()}
638
+ else if(ev.type==='tool_status'){addSys(currentAgent.kanji+' '+esc(ev.tool_name)+' …')}
639
+ else if(ev.type==='tool_done'){addSys((ev.success?'✓ ':'× ')+esc(ev.tool_name))}
640
+ else if(ev.type==='error'){addSys('× '+esc(ev.text||'出错了'))}
641
+ else if(ev.type==='truncated'){addSys('⚠ '+esc(ev.reason||'截断'))}
642
+ }
643
+ }
644
+ if(!content.trim())body.innerHTML='<span style="opacity:.5">(无回复)</span>';
645
+ }catch(e){body.innerHTML='';addSys('× 连接中断')}
646
+ isStreaming=false;sendBtn.disabled=false;input.focus();
647
+ }
648
+
649
+ input.addEventListener('input',()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,140)+'px'});
650
+ input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage()}});
651
+ sendBtn.addEventListener('click',sendMessage);
652
+ document.addEventListener('keydown',e=>{if(e.ctrlKey||e.metaKey){const n=parseInt(e.key);if(n>=1&&n<=6){e.preventDefault();applyTheme(AGENTS[n-1])}}});
653
+ addSys('气象台已就绪 · ⌘1-6 唤灵 · enter 传讯');
654
+ </script>
655
+ </body>
656
+ </html>`;
657
+
658
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
659
+ res.end(html);
660
+ }