skyloom 1.12.0 → 1.13.1

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 (137) hide show
  1. package/.github/workflows/ci.yml +36 -36
  2. package/README.md +137 -46
  3. package/config/default.yaml +43 -47
  4. package/config/models.yaml +155 -155
  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 +127 -74
  30. package/dist/cli/main.js.map +1 -1
  31. package/dist/cli/tui.d.ts +52 -19
  32. package/dist/cli/tui.d.ts.map +1 -1
  33. package/dist/cli/tui.js +198 -265
  34. package/dist/cli/tui.js.map +1 -1
  35. package/dist/core/agent/task.d.ts +58 -0
  36. package/dist/core/agent/task.d.ts.map +1 -0
  37. package/dist/core/agent/task.js +83 -0
  38. package/dist/core/agent/task.js.map +1 -0
  39. package/dist/core/agent.d.ts +2 -45
  40. package/dist/core/agent.d.ts.map +1 -1
  41. package/dist/core/agent.js +61 -145
  42. package/dist/core/agent.js.map +1 -1
  43. package/dist/core/agent_helpers.d.ts +10 -0
  44. package/dist/core/agent_helpers.d.ts.map +1 -1
  45. package/dist/core/agent_helpers.js +39 -0
  46. package/dist/core/agent_helpers.js.map +1 -1
  47. package/dist/core/catalog.d.ts +71 -0
  48. package/dist/core/catalog.d.ts.map +1 -0
  49. package/dist/core/catalog.js +176 -0
  50. package/dist/core/catalog.js.map +1 -0
  51. package/dist/core/config.d.ts +8 -0
  52. package/dist/core/config.d.ts.map +1 -1
  53. package/dist/core/config.js +12 -4
  54. package/dist/core/config.js.map +1 -1
  55. package/dist/core/factory.js +16 -16
  56. package/dist/core/llm.d.ts +7 -0
  57. package/dist/core/llm.d.ts.map +1 -1
  58. package/dist/core/llm.js +139 -7
  59. package/dist/core/llm.js.map +1 -1
  60. package/dist/core/longdoc.js +5 -5
  61. package/dist/core/memory.d.ts.map +1 -1
  62. package/dist/core/memory.js +69 -62
  63. package/dist/core/memory.js.map +1 -1
  64. package/dist/core/theme.d.ts +46 -0
  65. package/dist/core/theme.d.ts.map +1 -0
  66. package/dist/core/theme.js +42 -0
  67. package/dist/core/theme.js.map +1 -0
  68. package/dist/web/server.js +542 -519
  69. package/dist/web/server.js.map +1 -1
  70. package/docs/AESTHETIC_DESIGN.md +144 -0
  71. package/docs/OPTIMIZATION_PLAN.md +178 -0
  72. package/package.json +60 -60
  73. package/scripts/install.js +48 -48
  74. package/scripts/link.js +10 -10
  75. package/setup.bat +79 -79
  76. package/skill-test-ty2fOA/test.md +10 -10
  77. package/src/agents/dew.ts +70 -70
  78. package/src/agents/fair.ts +102 -102
  79. package/src/agents/fog.ts +48 -48
  80. package/src/agents/frost.ts +50 -50
  81. package/src/agents/rain.ts +50 -50
  82. package/src/agents/snow.ts +239 -239
  83. package/src/cli/main.ts +417 -372
  84. package/src/cli/mode.ts +58 -58
  85. package/src/cli/tui.ts +174 -223
  86. package/src/core/agent/task.ts +100 -0
  87. package/src/core/agent.ts +1446 -1549
  88. package/src/core/agent_helpers.ts +496 -461
  89. package/src/core/arbitrate.ts +162 -162
  90. package/src/core/catalog.ts +178 -0
  91. package/src/core/checkpoint.ts +94 -94
  92. package/src/core/config.ts +20 -4
  93. package/src/core/estimate.ts +104 -104
  94. package/src/core/evolve.ts +191 -191
  95. package/src/core/factory.ts +627 -627
  96. package/src/core/filter.ts +103 -103
  97. package/src/core/graph.ts +156 -156
  98. package/src/core/icons.ts +53 -53
  99. package/src/core/index.ts +37 -37
  100. package/src/core/learn.ts +146 -146
  101. package/src/core/llm.ts +108 -5
  102. package/src/core/longdoc.ts +155 -155
  103. package/src/core/mcp_server.ts +176 -176
  104. package/src/core/memory.ts +1178 -1171
  105. package/src/core/profile.ts +255 -255
  106. package/src/core/router.ts +124 -124
  107. package/src/core/sandbox.ts +142 -142
  108. package/src/core/security.ts +243 -243
  109. package/src/core/skill.ts +342 -342
  110. package/src/core/theme.ts +65 -0
  111. package/src/core/tool_router.ts +193 -193
  112. package/src/core/vector.ts +152 -152
  113. package/src/core/workspace.ts +150 -150
  114. package/src/plugins/loader.ts +66 -66
  115. package/src/skills/loader.ts +46 -46
  116. package/src/sql.js.d.ts +29 -29
  117. package/src/tools/builtin.ts +380 -380
  118. package/src/tools/computer.ts +269 -269
  119. package/src/tools/delegate.ts +49 -49
  120. package/src/web/server.ts +660 -634
  121. package/src/web/tts.ts +93 -93
  122. package/tests/agent_helpers.test.ts +48 -0
  123. package/tests/bus.test.ts +121 -121
  124. package/tests/catalog.test.ts +86 -0
  125. package/tests/config.test.ts +41 -0
  126. package/tests/icons.test.ts +45 -45
  127. package/tests/memory.test.ts +147 -0
  128. package/tests/router.test.ts +86 -86
  129. package/tests/schemas.test.ts +51 -51
  130. package/tests/semantic.test.ts +83 -83
  131. package/tests/setup.ts +10 -10
  132. package/tests/skill.test.ts +172 -172
  133. package/tests/task.test.ts +60 -0
  134. package/tests/tool.test.ts +108 -108
  135. package/tests/tool_router.test.ts +71 -71
  136. package/tests/tui.test.ts +67 -0
  137. 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
+ }