jinzd-ai-cli 0.1.76 → 0.1.77
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.
- package/CLAUDE.md +61 -0
- package/dist/{chunk-RROTI54R.js → chunk-LKYVW34F.js} +1 -1
- package/dist/{chunk-6FYQR6TY.js → chunk-MSQW4A3S.js} +8 -1
- package/dist/index.js +4 -4
- package/dist/{run-tests-W44VYI2J.js → run-tests-S3XK43NB.js} +1 -1
- package/dist/{server-HINOHGE2.js → server-WO2OXHJ6.js} +187 -4
- package/dist/web/client/app.js +363 -23
- package/dist/web/client/index.html +29 -10
- package/dist/web/client/style.css +59 -2
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -1853,3 +1853,64 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
|
|
|
1853
1853
|
- [ ] **IDE 集成**:VS Code / JetBrains 扩展(架构已准备就绪)
|
|
1854
1854
|
- [ ] **OAuth/浏览器登录**:无需手动填 API Key
|
|
1855
1855
|
- [x] **Extended Thinking**(v0.1.38):Claude 深度推理模式集成,`/think` 运行时切换,thinking 块折叠显示
|
|
1856
|
+
|
|
1857
|
+
---
|
|
1858
|
+
|
|
1859
|
+
## Web UI 增强路线图(`aicli web`)
|
|
1860
|
+
|
|
1861
|
+
> 目标:将 `aicli web` 从"可用的聊天界面"升级为功能完备、体验出色的 AI 代码助手 Web 端。
|
|
1862
|
+
|
|
1863
|
+
### 当前 Web UI 已实现功能(v0.1.76 基线)
|
|
1864
|
+
|
|
1865
|
+
- 实时流式聊天(WebSocket)
|
|
1866
|
+
- 完整 Agentic 循环(25 轮工具调用 + 流式工具调用)
|
|
1867
|
+
- Provider/Model 下拉切换
|
|
1868
|
+
- DaisyUI 主题切换(8 主题 + localStorage 持久化)
|
|
1869
|
+
- 工具确认对话框(单个 confirm + 批量 batch confirm + ask_user)
|
|
1870
|
+
- Extended Thinking 折叠显示
|
|
1871
|
+
- Plan Mode 只读规划
|
|
1872
|
+
- Markdown 渲染 + 代码高亮(marked.js + highlight.js)
|
|
1873
|
+
- 代码块复制按钮
|
|
1874
|
+
- 图片上传协议支持(C2S `images` 字段)
|
|
1875
|
+
- 用户纠偏(interjection)— 处理中可发送纠正指令
|
|
1876
|
+
- /clear /compact /think /plan /status /provider /model 命令
|
|
1877
|
+
|
|
1878
|
+
### P0 — 最痛问题(当前就会遇到)
|
|
1879
|
+
|
|
1880
|
+
| # | 功能 | 状态 | 说明 |
|
|
1881
|
+
|---|------|------|------|
|
|
1882
|
+
| P0-1 | **Session 持久化与管理** | [ ] | 刷新页面不丢对话;会话列表侧边栏;新建/切换/恢复/删除会话;历史搜索 |
|
|
1883
|
+
| P0-2 | **`@文件` 引用** | [ ] | CLI 支持 `@file.ts` 读文件注入上下文,Web 需要:`@` 触发文件选择 + 服务端文件读取 API |
|
|
1884
|
+
| P0-3 | **图片拖拽/粘贴上传** | [ ] | 协议层已有 `images` 字段,缺少客户端 UI:拖拽到输入区 + `Ctrl+V` 粘贴截图 + 预览缩略图 |
|
|
1885
|
+
| P0-4 | **工具卡片折叠** | [ ] | 多轮工具调用后聊天区被淹没;safe 级别默认折叠、write/destructive 展开;可手动 toggle |
|
|
1886
|
+
| P0-5 | **`/help` 命令** | [ ] | 显示所有支持的 Web 命令及用法说明;输入不存在的命令给出提示 |
|
|
1887
|
+
|
|
1888
|
+
### P1 — 明显提升体验
|
|
1889
|
+
|
|
1890
|
+
| # | 功能 | 状态 | 说明 |
|
|
1891
|
+
|---|------|------|------|
|
|
1892
|
+
| P1-1 | **侧边栏布局** | [ ] | 左侧:Session 列表 + Skills + MCP 工具列表 + 搜索;右侧:聊天区;响应式 mobile 折叠 |
|
|
1893
|
+
| P1-2 | **Diff 渲染增强** | [ ] | confirm 对话框中的 diff 从纯文本升级为语法高亮的 diff 视图(绿/红增删行) |
|
|
1894
|
+
| P1-3 | **更多命令支持** | [ ] | `/cost`(token 用量统计)、`/session list\|load`、`/undo`、`/export [md\|json]`、`/tools`(工具列表) |
|
|
1895
|
+
| P1-4 | **键盘快捷键** | [ ] | `Ctrl+L` 清屏、`Ctrl+K` 清空输入、`Esc` 停止生成、`↑` 历史消息回溯 |
|
|
1896
|
+
| P1-5 | **Markdown 导出** | [ ] | 一键导出当前对话为 `.md` 文件下载 |
|
|
1897
|
+
| P1-6 | **断线重连恢复** | [ ] | WebSocket 重连后恢复 session 状态(当前重连=全丢);心跳 ping/pong |
|
|
1898
|
+
|
|
1899
|
+
### P2 — 差异化功能(Web 独有优势)
|
|
1900
|
+
|
|
1901
|
+
| # | 功能 | 状态 | 说明 |
|
|
1902
|
+
|---|------|------|------|
|
|
1903
|
+
| P2-1 | **多 Tab 会话** | [ ] | 浏览器内多 Tab 并行对话(CLI 做不到),类似 ChatGPT |
|
|
1904
|
+
| P2-2 | **文件树面板** | [ ] | 浏览项目目录结构,点击文件查看/插入上下文,可视化 `@` 引用 |
|
|
1905
|
+
| P2-3 | **工具执行可视化** | [ ] | 进度条、工具调用时间线、Agentic 循环图示 |
|
|
1906
|
+
| P2-4 | **Prompt 模板库** | [ ] | 保存常用 prompt 为模板,一键复用(localStorage) |
|
|
1907
|
+
| P2-5 | **代码主题联动** | [ ] | highlight.js 主题随 DaisyUI 主题切换(亮色 → github-light,暗色 → github-dark) |
|
|
1908
|
+
|
|
1909
|
+
### P3 — 长远方向
|
|
1910
|
+
|
|
1911
|
+
| # | 功能 | 状态 | 说明 |
|
|
1912
|
+
|---|------|------|------|
|
|
1913
|
+
| P3-1 | **多用户支持** | [ ] | 认证 + 多 session handler(当前 `activeHandler` 单连接限制) |
|
|
1914
|
+
| P3-2 | **PWA 支持** | [ ] | Service Worker + manifest.json,可安装为桌面应用 |
|
|
1915
|
+
| P3-3 | **移动端适配** | [ ] | 响应式布局 + 触摸手势 |
|
|
1916
|
+
| P3-4 | **Electron 打包** | [ ] | 复用 Web UI 代码打包为桌面应用 |
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
2
8
|
|
|
3
9
|
// src/tools/builtin/run-tests.ts
|
|
4
10
|
import { execSync } from "child_process";
|
|
@@ -8,7 +14,7 @@ import { platform } from "os";
|
|
|
8
14
|
import chalk from "chalk";
|
|
9
15
|
|
|
10
16
|
// src/core/constants.ts
|
|
11
|
-
var VERSION = "0.1.
|
|
17
|
+
var VERSION = "0.1.77";
|
|
12
18
|
var APP_NAME = "ai-cli";
|
|
13
19
|
var CONFIG_DIR_NAME = ".aicli";
|
|
14
20
|
var CONFIG_FILE_NAME = "config.json";
|
|
@@ -441,6 +447,7 @@ var runTestsTool = {
|
|
|
441
447
|
};
|
|
442
448
|
|
|
443
449
|
export {
|
|
450
|
+
__require,
|
|
444
451
|
VERSION,
|
|
445
452
|
APP_NAME,
|
|
446
453
|
CONFIG_DIR_NAME,
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
theme,
|
|
36
36
|
truncateOutput,
|
|
37
37
|
undoStack
|
|
38
|
-
} from "./chunk-
|
|
38
|
+
} from "./chunk-LKYVW34F.js";
|
|
39
39
|
import {
|
|
40
40
|
AGENTIC_BEHAVIOR_GUIDELINE,
|
|
41
41
|
AUTHOR,
|
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
REPO_URL,
|
|
56
56
|
SKILLS_DIR_NAME,
|
|
57
57
|
VERSION
|
|
58
|
-
} from "./chunk-
|
|
58
|
+
} from "./chunk-MSQW4A3S.js";
|
|
59
59
|
|
|
60
60
|
// src/index.ts
|
|
61
61
|
import { program } from "commander";
|
|
@@ -1904,7 +1904,7 @@ ${hint}` : "")
|
|
|
1904
1904
|
description: "Run project tests and show structured report",
|
|
1905
1905
|
usage: "/test [command|filter]",
|
|
1906
1906
|
async execute(args, _ctx) {
|
|
1907
|
-
const { executeTests } = await import("./run-tests-
|
|
1907
|
+
const { executeTests } = await import("./run-tests-S3XK43NB.js");
|
|
1908
1908
|
const argStr = args.join(" ").trim();
|
|
1909
1909
|
let testArgs = {};
|
|
1910
1910
|
if (argStr) {
|
|
@@ -5291,7 +5291,7 @@ program.command("web").description("Start Web UI server with browser-based chat
|
|
|
5291
5291
|
console.error("Error: Invalid port number. Must be between 1 and 65535.");
|
|
5292
5292
|
process.exit(1);
|
|
5293
5293
|
}
|
|
5294
|
-
const { startWebServer } = await import("./server-
|
|
5294
|
+
const { startWebServer } = await import("./server-WO2OXHJ6.js");
|
|
5295
5295
|
await startWebServer({ port, host: options.host });
|
|
5296
5296
|
});
|
|
5297
5297
|
program.command("sessions").description("List recent conversation sessions").action(async () => {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
SkillManager,
|
|
9
9
|
TOOL_CALL_REMINDER,
|
|
10
10
|
ToolRegistry,
|
|
11
|
+
askUserContext,
|
|
11
12
|
checkPermission,
|
|
12
13
|
detectsHallucinatedFileOp,
|
|
13
14
|
getContentText,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
setupProxy,
|
|
23
24
|
spawnAgentContext,
|
|
24
25
|
truncateOutput
|
|
25
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-LKYVW34F.js";
|
|
26
27
|
import {
|
|
27
28
|
AGENTIC_BEHAVIOR_GUIDELINE,
|
|
28
29
|
CONTEXT_FILE_CANDIDATES,
|
|
@@ -33,8 +34,9 @@ import {
|
|
|
33
34
|
PLAN_MODE_READONLY_TOOLS,
|
|
34
35
|
PLAN_MODE_SYSTEM_ADDON,
|
|
35
36
|
SKILLS_DIR_NAME,
|
|
36
|
-
VERSION
|
|
37
|
-
|
|
37
|
+
VERSION,
|
|
38
|
+
__require
|
|
39
|
+
} from "./chunk-MSQW4A3S.js";
|
|
38
40
|
|
|
39
41
|
// src/web/server.ts
|
|
40
42
|
import express from "express";
|
|
@@ -445,6 +447,8 @@ var SessionHandler = class {
|
|
|
445
447
|
const defaultPermission = this.config.get("defaultPermission");
|
|
446
448
|
this.toolExecutor.setConfig({ hookConfig: hooks, permissionRules, defaultPermission });
|
|
447
449
|
this.sendStatus();
|
|
450
|
+
askUserContext.rl = null;
|
|
451
|
+
askUserContext.prompting = false;
|
|
448
452
|
}
|
|
449
453
|
send(msg) {
|
|
450
454
|
if (this.ws.readyState === this.ws.OPEN) {
|
|
@@ -895,8 +899,94 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
|
|
|
895
899
|
});
|
|
896
900
|
break;
|
|
897
901
|
}
|
|
902
|
+
case "session": {
|
|
903
|
+
const sub = args[0];
|
|
904
|
+
if (sub === "new") {
|
|
905
|
+
this.sessions.save();
|
|
906
|
+
this.sessions.createSession(this.currentProvider, this.currentModel);
|
|
907
|
+
this.sessionTokenUsage = { inputTokens: 0, outputTokens: 0 };
|
|
908
|
+
this.send({ type: "info", message: "New session created." });
|
|
909
|
+
this.sendStatus();
|
|
910
|
+
this.sendSessionList();
|
|
911
|
+
} else if (sub === "load" && args[1]) {
|
|
912
|
+
const targetId = args[1];
|
|
913
|
+
this.sessions.save();
|
|
914
|
+
const list = this.sessions.listSessions();
|
|
915
|
+
const found = list.find((s) => s.id.startsWith(targetId));
|
|
916
|
+
if (found) {
|
|
917
|
+
this.sessions.loadSession(found.id);
|
|
918
|
+
this.sessionTokenUsage = { inputTokens: 0, outputTokens: 0 };
|
|
919
|
+
if (found.provider) this.currentProvider = found.provider;
|
|
920
|
+
if (found.model) this.currentModel = found.model;
|
|
921
|
+
this.send({ type: "info", message: `Loaded session: ${found.id.slice(0, 8)} "${found.title ?? ""}" (${found.messageCount} messages)` });
|
|
922
|
+
this.sendSessionMessages();
|
|
923
|
+
this.sendStatus();
|
|
924
|
+
this.sendSessionList();
|
|
925
|
+
} else {
|
|
926
|
+
this.send({ type: "error", message: `Session not found: ${targetId}` });
|
|
927
|
+
}
|
|
928
|
+
} else if (sub === "list") {
|
|
929
|
+
this.sendSessionList();
|
|
930
|
+
} else if (sub === "delete" && args[1]) {
|
|
931
|
+
const targetId = args[1];
|
|
932
|
+
const list = this.sessions.listSessions();
|
|
933
|
+
const found = list.find((s) => s.id.startsWith(targetId));
|
|
934
|
+
if (found) {
|
|
935
|
+
this.sessions.deleteSession(found.id);
|
|
936
|
+
this.send({ type: "info", message: `Deleted session: ${found.id.slice(0, 8)}` });
|
|
937
|
+
this.sendSessionList();
|
|
938
|
+
} else {
|
|
939
|
+
this.send({ type: "error", message: `Session not found: ${targetId}` });
|
|
940
|
+
}
|
|
941
|
+
} else {
|
|
942
|
+
this.send({ type: "info", message: "Usage: /session new | list | load <id> | delete <id>" });
|
|
943
|
+
}
|
|
944
|
+
break;
|
|
945
|
+
}
|
|
946
|
+
case "help":
|
|
947
|
+
this.send({
|
|
948
|
+
type: "info",
|
|
949
|
+
message: [
|
|
950
|
+
"\u{1F4D6} Available Web UI commands:",
|
|
951
|
+
"",
|
|
952
|
+
" /provider <id> \u2014 Switch AI provider",
|
|
953
|
+
" /model <id> \u2014 Switch model",
|
|
954
|
+
" /clear \u2014 Clear conversation & start new session",
|
|
955
|
+
" /compact [hint] \u2014 Compress conversation history",
|
|
956
|
+
" /think [on|off] \u2014 Toggle extended thinking mode",
|
|
957
|
+
" /plan [enter|exit] \u2014 Toggle read-only planning mode",
|
|
958
|
+
" /session new \u2014 Create a new session",
|
|
959
|
+
" /session list \u2014 List saved sessions",
|
|
960
|
+
" /session load <id> \u2014 Resume a saved session",
|
|
961
|
+
" /session delete <id> \u2014 Delete a session",
|
|
962
|
+
" /status \u2014 Show session info & token usage",
|
|
963
|
+
" /cost \u2014 Show cumulative token usage",
|
|
964
|
+
" /help \u2014 Show this help message",
|
|
965
|
+
"",
|
|
966
|
+
"\u{1F4A1} Tips:",
|
|
967
|
+
" \u2022 Change provider/model with the dropdowns above",
|
|
968
|
+
" \u2022 Drag & drop or Ctrl+V to paste images",
|
|
969
|
+
" \u2022 Type @ to reference files from your project",
|
|
970
|
+
" \u2022 Use Shift+Enter for multi-line input",
|
|
971
|
+
" \u2022 During AI processing, type to send corrections"
|
|
972
|
+
].join("\n")
|
|
973
|
+
});
|
|
974
|
+
break;
|
|
975
|
+
case "cost": {
|
|
976
|
+
const total = this.sessionTokenUsage.inputTokens + this.sessionTokenUsage.outputTokens;
|
|
977
|
+
this.send({
|
|
978
|
+
type: "info",
|
|
979
|
+
message: `\u{1F4CA} Token Usage
|
|
980
|
+
Provider: ${this.currentProvider}
|
|
981
|
+
Model: ${this.currentModel}
|
|
982
|
+
Input: ${this.sessionTokenUsage.inputTokens.toLocaleString()}
|
|
983
|
+
Output: ${this.sessionTokenUsage.outputTokens.toLocaleString()}
|
|
984
|
+
Total: ${total.toLocaleString()}`
|
|
985
|
+
});
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
898
988
|
default:
|
|
899
|
-
this.send({ type: "error", message: `Unknown command: /${name}
|
|
989
|
+
this.send({ type: "error", message: `Unknown command: /${name}. Type /help for available commands.` });
|
|
900
990
|
}
|
|
901
991
|
}
|
|
902
992
|
async compactSession(instruction) {
|
|
@@ -931,6 +1021,34 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
|
|
|
931
1021
|
this.send({ type: "error", message: `Compact failed: ${err.message}` });
|
|
932
1022
|
}
|
|
933
1023
|
}
|
|
1024
|
+
sendSessionList() {
|
|
1025
|
+
const list = this.sessions.listSessions();
|
|
1026
|
+
this.send({
|
|
1027
|
+
type: "session_list",
|
|
1028
|
+
sessions: list.slice(0, 50).map((s) => ({
|
|
1029
|
+
id: s.id,
|
|
1030
|
+
title: s.title ?? "",
|
|
1031
|
+
provider: s.provider ?? "",
|
|
1032
|
+
model: s.model ?? "",
|
|
1033
|
+
messageCount: s.messageCount,
|
|
1034
|
+
updated: s.updated instanceof Date ? s.updated.toISOString() : String(s.updated),
|
|
1035
|
+
isCurrent: s.id === this.sessions.current?.id
|
|
1036
|
+
}))
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
sendSessionMessages() {
|
|
1040
|
+
const session = this.sessions.current;
|
|
1041
|
+
if (!session) return;
|
|
1042
|
+
const messages = session.messages.map((m) => ({
|
|
1043
|
+
role: m.role,
|
|
1044
|
+
content: getContentText(m.content),
|
|
1045
|
+
timestamp: m.timestamp?.toISOString()
|
|
1046
|
+
}));
|
|
1047
|
+
this.send({
|
|
1048
|
+
type: "session_messages",
|
|
1049
|
+
messages
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
934
1052
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
935
1053
|
buildSystemPrompt() {
|
|
936
1054
|
const skillContent = this.skillManager?.getActivePromptContent();
|
|
@@ -1099,6 +1217,71 @@ async function startWebServer(options = {}) {
|
|
|
1099
1217
|
tools: toolRegistry.getDefinitions().length
|
|
1100
1218
|
});
|
|
1101
1219
|
});
|
|
1220
|
+
app.get("/api/files", (req, res) => {
|
|
1221
|
+
const { readdirSync, statSync } = __require("fs");
|
|
1222
|
+
const { join: pjoin, relative } = __require("path");
|
|
1223
|
+
const cwd = process.cwd();
|
|
1224
|
+
const prefix = req.query.prefix || "";
|
|
1225
|
+
const targetDir = pjoin(cwd, prefix);
|
|
1226
|
+
if (!resolve2(targetDir).startsWith(resolve2(cwd))) {
|
|
1227
|
+
res.json({ files: [] });
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
try {
|
|
1231
|
+
const SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "dist-cjs", "release", "__pycache__", ".next", ".nuxt", "coverage", ".cache"]);
|
|
1232
|
+
const entries = readdirSync(targetDir, { withFileTypes: true });
|
|
1233
|
+
const files = entries.filter((e) => !SKIP.has(e.name) && !e.name.startsWith(".")).slice(0, 50).map((e) => ({
|
|
1234
|
+
name: e.name,
|
|
1235
|
+
path: relative(cwd, pjoin(targetDir, e.name)).replace(/\\/g, "/"),
|
|
1236
|
+
isDir: e.isDirectory()
|
|
1237
|
+
}));
|
|
1238
|
+
res.json({ files });
|
|
1239
|
+
} catch {
|
|
1240
|
+
res.json({ files: [] });
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
app.get("/api/sessions", (_req, res) => {
|
|
1244
|
+
try {
|
|
1245
|
+
const list = sessions.listSessions();
|
|
1246
|
+
res.json({
|
|
1247
|
+
sessions: list.slice(0, 50).map((s) => ({
|
|
1248
|
+
id: s.id,
|
|
1249
|
+
title: s.title,
|
|
1250
|
+
provider: s.provider,
|
|
1251
|
+
model: s.model,
|
|
1252
|
+
messageCount: s.messageCount,
|
|
1253
|
+
updated: s.updated
|
|
1254
|
+
}))
|
|
1255
|
+
});
|
|
1256
|
+
} catch {
|
|
1257
|
+
res.json({ sessions: [] });
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
app.get("/api/file-content", (req, res) => {
|
|
1261
|
+
const filePath = req.query.path;
|
|
1262
|
+
if (!filePath) {
|
|
1263
|
+
res.json({ error: "Missing path" });
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
const cwd = process.cwd();
|
|
1267
|
+
const fullPath = resolve2(join3(cwd, filePath));
|
|
1268
|
+
if (!fullPath.startsWith(resolve2(cwd))) {
|
|
1269
|
+
res.json({ error: "Access denied" });
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
try {
|
|
1273
|
+
const { statSync, readFileSync: readFileSync5 } = __require("fs");
|
|
1274
|
+
const stat = statSync(fullPath);
|
|
1275
|
+
if (stat.size > 512 * 1024) {
|
|
1276
|
+
res.json({ error: `File too large (${(stat.size / 1024).toFixed(0)} KB, max 512 KB)` });
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
const content = readFileSync5(fullPath, "utf-8");
|
|
1280
|
+
res.json({ content, size: stat.size });
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
res.json({ error: `Cannot read: ${err.message}` });
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1102
1285
|
let activeHandler = null;
|
|
1103
1286
|
wss.on("connection", (ws) => {
|
|
1104
1287
|
if (activeHandler) {
|
package/dist/web/client/app.js
CHANGED
|
@@ -13,6 +13,7 @@ let currentAssistantContent = '';
|
|
|
13
13
|
let currentThinkingEl = null;
|
|
14
14
|
let currentThinkingContent = '';
|
|
15
15
|
let providers = [];
|
|
16
|
+
let pendingImages = []; // { name, data (base64), mime }
|
|
16
17
|
|
|
17
18
|
// ── DOM refs ───────────────────────────────────────────────────────
|
|
18
19
|
|
|
@@ -30,6 +31,10 @@ const modelSelect = document.getElementById('model-select');
|
|
|
30
31
|
const statusSession = document.getElementById('status-session');
|
|
31
32
|
const statusTokens = document.getElementById('status-tokens');
|
|
32
33
|
const connectionStatus = document.getElementById('connection-status');
|
|
34
|
+
const sidebar = document.getElementById('sidebar');
|
|
35
|
+
const sessionListEl = document.getElementById('session-list');
|
|
36
|
+
const btnNewSession = document.getElementById('btn-new-session');
|
|
37
|
+
const sidebarToggle = document.getElementById('sidebar-toggle');
|
|
33
38
|
|
|
34
39
|
// ── Configure marked.js ────────────────────────────────────────────
|
|
35
40
|
|
|
@@ -55,6 +60,7 @@ function connect() {
|
|
|
55
60
|
connected = true;
|
|
56
61
|
connectionStatus.textContent = '🟢 Connected';
|
|
57
62
|
connectionStatus.className = 'status-connected';
|
|
63
|
+
requestSessionList();
|
|
58
64
|
};
|
|
59
65
|
|
|
60
66
|
ws.onclose = () => {
|
|
@@ -100,6 +106,8 @@ function handleServerMessage(msg) {
|
|
|
100
106
|
case 'thinking_end': handleThinkingEnd(); break;
|
|
101
107
|
case 'todo_update': handleTodoUpdate(msg.todos); break;
|
|
102
108
|
case 'status': handleStatus(msg); break;
|
|
109
|
+
case 'session_list': renderSessionList(msg.sessions); break;
|
|
110
|
+
case 'session_messages':renderSessionMessages(msg.messages); break;
|
|
103
111
|
case 'info': addInfoMessage(msg.message); break;
|
|
104
112
|
case 'error': addErrorMessage(msg.message); setProcessing(false); break;
|
|
105
113
|
case 'round_progress': break;
|
|
@@ -146,15 +154,21 @@ function handleToolCallStart(msg) {
|
|
|
146
154
|
const levelBadge = msg.dangerLevel === 'destructive' ? 'badge-error'
|
|
147
155
|
: msg.dangerLevel === 'write' ? 'badge-warning' : 'badge-info';
|
|
148
156
|
|
|
149
|
-
|
|
157
|
+
// safe tools: collapsed by default; write/destructive: expanded
|
|
158
|
+
const isCollapsible = msg.dangerLevel === 'safe';
|
|
159
|
+
const el = document.createElement('details');
|
|
150
160
|
el.id = `tool-${msg.callId}`;
|
|
151
161
|
el.className = `tool-card ${levelBorder} my-1`;
|
|
162
|
+
if (!isCollapsible) el.open = true;
|
|
152
163
|
el.innerHTML = `
|
|
153
|
-
<
|
|
164
|
+
<summary class="flex items-center gap-2 w-full cursor-pointer select-none py-1">
|
|
154
165
|
<span class="badge ${levelBadge} badge-sm gap-1">${levelIcon} ${escapeHtml(msg.toolName)}</span>
|
|
155
166
|
<span class="text-xs opacity-50">${msg.round}/${msg.totalRounds}</span>
|
|
167
|
+
<span class="tool-result-badge text-xs ml-auto"></span>
|
|
168
|
+
</summary>
|
|
169
|
+
<div class="tool-details-body pt-1">
|
|
170
|
+
<div class="tool-args w-full">${formatToolArgs(msg.args)}</div>
|
|
156
171
|
</div>
|
|
157
|
-
<div class="tool-args w-full">${formatToolArgs(msg.args)}</div>
|
|
158
172
|
`;
|
|
159
173
|
messagesEl.appendChild(el);
|
|
160
174
|
scrollToBottom();
|
|
@@ -163,10 +177,21 @@ function handleToolCallStart(msg) {
|
|
|
163
177
|
function handleToolCallResult(msg) {
|
|
164
178
|
const el = document.getElementById(`tool-${msg.callId}`);
|
|
165
179
|
if (el) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
180
|
+
// Add result inside the details body
|
|
181
|
+
const body = el.querySelector('.tool-details-body');
|
|
182
|
+
if (body) {
|
|
183
|
+
const resultDiv = document.createElement('div');
|
|
184
|
+
resultDiv.className = `tool-result-content mt-2 pt-2 border-t border-base-content/10 w-full ${msg.isError ? 'text-error' : 'text-success'}`;
|
|
185
|
+
const truncated = msg.content.length > 500 ? msg.content.slice(0, 500) + '...' : msg.content;
|
|
186
|
+
resultDiv.textContent = `${msg.isError ? '✗' : '✓'} ${truncated}`;
|
|
187
|
+
body.appendChild(resultDiv);
|
|
188
|
+
}
|
|
189
|
+
// Update the summary badge (visible when collapsed)
|
|
190
|
+
const badge = el.querySelector('.tool-result-badge');
|
|
191
|
+
if (badge) {
|
|
192
|
+
badge.textContent = msg.isError ? '✗' : '✓';
|
|
193
|
+
badge.className = `tool-result-badge text-xs ml-auto ${msg.isError ? 'text-error' : 'text-success'}`;
|
|
194
|
+
}
|
|
170
195
|
}
|
|
171
196
|
scrollToBottom();
|
|
172
197
|
}
|
|
@@ -290,6 +315,11 @@ function handleStatus(msg) {
|
|
|
290
315
|
if (msg.tokenUsage) {
|
|
291
316
|
statusTokens.textContent = `📊 in: ${msg.tokenUsage.inputTokens} out: ${msg.tokenUsage.outputTokens}`;
|
|
292
317
|
}
|
|
318
|
+
|
|
319
|
+
// Update sidebar active state
|
|
320
|
+
sessionListEl.querySelectorAll('.session-item').forEach(el => {
|
|
321
|
+
el.classList.toggle('active', el.dataset.sessionId === msg.sessionId);
|
|
322
|
+
});
|
|
293
323
|
}
|
|
294
324
|
|
|
295
325
|
// ── Response helpers ───────────────────────────────────────────────
|
|
@@ -353,10 +383,16 @@ function createAssistantMessage() {
|
|
|
353
383
|
return el;
|
|
354
384
|
}
|
|
355
385
|
|
|
356
|
-
function addUserMessage(text) {
|
|
386
|
+
function addUserMessage(text, images) {
|
|
357
387
|
const wrapper = document.createElement('div');
|
|
358
388
|
wrapper.className = 'chat chat-end';
|
|
359
|
-
|
|
389
|
+
let imagesHtml = '';
|
|
390
|
+
if (images && images.length > 0) {
|
|
391
|
+
imagesHtml = `<div class="flex gap-1 flex-wrap mb-1">${images.map(img =>
|
|
392
|
+
`<img src="data:${img.mime};base64,${img.data}" class="rounded max-h-24 max-w-[150px] object-contain" alt="${escapeHtml(img.name)}">`
|
|
393
|
+
).join('')}</div>`;
|
|
394
|
+
}
|
|
395
|
+
wrapper.innerHTML = `<div class="chat-bubble chat-bubble-user chat-bubble-primary">${imagesHtml}${escapeHtml(text)}</div>`;
|
|
360
396
|
messagesEl.appendChild(wrapper);
|
|
361
397
|
scrollToBottom();
|
|
362
398
|
}
|
|
@@ -457,10 +493,12 @@ function updateModelSelect(providerId, currentModelId) {
|
|
|
457
493
|
|
|
458
494
|
// ── Event handlers ─────────────────────────────────────────────────
|
|
459
495
|
|
|
460
|
-
function sendMessage() {
|
|
496
|
+
async function sendMessage() {
|
|
461
497
|
const text = userInput.value.trim();
|
|
462
498
|
if (!text || !connected) return;
|
|
463
499
|
|
|
500
|
+
hideFileDropdown();
|
|
501
|
+
|
|
464
502
|
// Processing state: send as interjection
|
|
465
503
|
if (processing) {
|
|
466
504
|
send({ type: 'interjection', content: text });
|
|
@@ -481,8 +519,35 @@ function sendMessage() {
|
|
|
481
519
|
return;
|
|
482
520
|
}
|
|
483
521
|
|
|
484
|
-
|
|
485
|
-
|
|
522
|
+
// Resolve @file references: replace @path with file content
|
|
523
|
+
let resolvedText = text;
|
|
524
|
+
const atRefs = text.match(/@([\w./_-]+[\w._-])/g);
|
|
525
|
+
if (atRefs) {
|
|
526
|
+
for (const ref of atRefs) {
|
|
527
|
+
const filePath = ref.slice(1); // remove @
|
|
528
|
+
try {
|
|
529
|
+
const resp = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
|
|
530
|
+
const data = await resp.json();
|
|
531
|
+
if (data.content) {
|
|
532
|
+
resolvedText = resolvedText.replace(ref,
|
|
533
|
+
`[File: ${filePath}]\n\`\`\`\n${data.content}\n\`\`\``);
|
|
534
|
+
} else if (data.error) {
|
|
535
|
+
addInfoMessage(`⚠ ${ref}: ${data.error}`);
|
|
536
|
+
}
|
|
537
|
+
} catch {
|
|
538
|
+
addInfoMessage(`⚠ Failed to read ${ref}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
addUserMessage(text, pendingImages);
|
|
544
|
+
const msg = { type: 'chat', content: resolvedText };
|
|
545
|
+
if (pendingImages.length > 0) {
|
|
546
|
+
msg.images = pendingImages.map(img => ({ name: img.name, data: img.data, mime: img.mime }));
|
|
547
|
+
}
|
|
548
|
+
send(msg);
|
|
549
|
+
pendingImages = [];
|
|
550
|
+
clearImagePreview();
|
|
486
551
|
userInput.value = '';
|
|
487
552
|
userInput.style.height = 'auto';
|
|
488
553
|
setProcessing(true);
|
|
@@ -494,17 +559,7 @@ btnStop.addEventListener('click', () => {
|
|
|
494
559
|
send({ type: 'abort' });
|
|
495
560
|
});
|
|
496
561
|
|
|
497
|
-
|
|
498
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
499
|
-
e.preventDefault();
|
|
500
|
-
sendMessage();
|
|
501
|
-
}
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
userInput.addEventListener('input', () => {
|
|
505
|
-
userInput.style.height = 'auto';
|
|
506
|
-
userInput.style.height = Math.min(userInput.scrollHeight, 200) + 'px';
|
|
507
|
-
});
|
|
562
|
+
// keydown and input handlers are in the @ file reference section above
|
|
508
563
|
|
|
509
564
|
btnClear.addEventListener('click', () => {
|
|
510
565
|
send({ type: 'command', name: 'clear', args: [] });
|
|
@@ -534,6 +589,291 @@ modelSelect.addEventListener('change', () => {
|
|
|
534
589
|
send({ type: 'command', name: 'model', args: [modelSelect.value] });
|
|
535
590
|
});
|
|
536
591
|
|
|
592
|
+
// ── Session management ──────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
function renderSessionList(sessions) {
|
|
595
|
+
if (!sessions || sessions.length === 0) {
|
|
596
|
+
sessionListEl.innerHTML = '<div class="text-xs opacity-40 text-center py-4">No sessions yet</div>';
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
sessionListEl.innerHTML = sessions.map(s => {
|
|
600
|
+
const title = s.title || 'Untitled';
|
|
601
|
+
const date = new Date(s.updated);
|
|
602
|
+
const timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
603
|
+
return `<div class="session-item ${s.isCurrent ? 'active' : ''}" data-session-id="${s.id}" title="${escapeHtml(title)}">
|
|
604
|
+
<div class="session-title">${escapeHtml(title)}</div>
|
|
605
|
+
<div class="session-meta">${s.messageCount} msgs · ${timeStr}</div>
|
|
606
|
+
</div>`;
|
|
607
|
+
}).join('');
|
|
608
|
+
|
|
609
|
+
// Click to load session
|
|
610
|
+
sessionListEl.querySelectorAll('.session-item').forEach(el => {
|
|
611
|
+
el.addEventListener('click', () => {
|
|
612
|
+
const id = el.dataset.sessionId;
|
|
613
|
+
if (!id) return;
|
|
614
|
+
send({ type: 'command', name: 'session', args: ['load', id] });
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function renderSessionMessages(messages) {
|
|
620
|
+
// Clear chat and re-render all messages from loaded session
|
|
621
|
+
messagesEl.innerHTML = '';
|
|
622
|
+
for (const msg of messages) {
|
|
623
|
+
if (msg.role === 'user') {
|
|
624
|
+
addUserMessage(msg.content);
|
|
625
|
+
} else if (msg.role === 'assistant') {
|
|
626
|
+
const el = createAssistantMessage();
|
|
627
|
+
renderMarkdown(el, msg.content);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
scrollToBottom();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Sidebar toggle for mobile
|
|
634
|
+
sidebarToggle.addEventListener('click', () => {
|
|
635
|
+
sidebar.classList.toggle('sidebar-open');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// New session button
|
|
639
|
+
btnNewSession.addEventListener('click', () => {
|
|
640
|
+
send({ type: 'command', name: 'session', args: ['new'] });
|
|
641
|
+
// Clear chat area
|
|
642
|
+
messagesEl.innerHTML = '';
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// Request session list on connect
|
|
646
|
+
function requestSessionList() {
|
|
647
|
+
send({ type: 'command', name: 'session', args: ['list'] });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── @ File reference autocomplete ───────────────────────────────────
|
|
651
|
+
|
|
652
|
+
const fileDropdown = document.createElement('div');
|
|
653
|
+
fileDropdown.id = 'file-dropdown';
|
|
654
|
+
fileDropdown.className = 'hidden absolute bg-base-200 border border-base-content/20 rounded-lg shadow-lg max-h-[200px] overflow-y-auto z-50 w-72 text-sm';
|
|
655
|
+
document.body.appendChild(fileDropdown);
|
|
656
|
+
|
|
657
|
+
let fileDropdownVisible = false;
|
|
658
|
+
let fileDropdownItems = [];
|
|
659
|
+
let fileDropdownIndex = -1;
|
|
660
|
+
let atStartPos = -1; // cursor position where @ was typed
|
|
661
|
+
|
|
662
|
+
async function fetchFiles(prefix) {
|
|
663
|
+
try {
|
|
664
|
+
const resp = await fetch(`/api/files?prefix=${encodeURIComponent(prefix)}`);
|
|
665
|
+
const data = await resp.json();
|
|
666
|
+
return data.files || [];
|
|
667
|
+
} catch { return []; }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function showFileDropdown(items) {
|
|
671
|
+
if (items.length === 0) { hideFileDropdown(); return; }
|
|
672
|
+
fileDropdownItems = items;
|
|
673
|
+
fileDropdownIndex = 0;
|
|
674
|
+
fileDropdown.innerHTML = items.map((f, i) =>
|
|
675
|
+
`<div class="file-dropdown-item px-3 py-1.5 cursor-pointer hover:bg-primary/20 flex items-center gap-2 ${i === 0 ? 'bg-primary/20' : ''}" data-index="${i}">
|
|
676
|
+
<span>${f.isDir ? '📁' : '📄'}</span>
|
|
677
|
+
<span>${escapeHtml(f.name)}${f.isDir ? '/' : ''}</span>
|
|
678
|
+
</div>`
|
|
679
|
+
).join('');
|
|
680
|
+
|
|
681
|
+
// Position above the textarea
|
|
682
|
+
const rect = userInput.getBoundingClientRect();
|
|
683
|
+
fileDropdown.style.left = `${rect.left}px`;
|
|
684
|
+
fileDropdown.style.bottom = `${window.innerHeight - rect.top + 4}px`;
|
|
685
|
+
fileDropdown.style.position = 'fixed';
|
|
686
|
+
fileDropdown.classList.remove('hidden');
|
|
687
|
+
fileDropdownVisible = true;
|
|
688
|
+
|
|
689
|
+
// Click handler
|
|
690
|
+
fileDropdown.querySelectorAll('.file-dropdown-item').forEach(el => {
|
|
691
|
+
el.addEventListener('mousedown', (e) => {
|
|
692
|
+
e.preventDefault();
|
|
693
|
+
selectFileItem(parseInt(el.dataset.index));
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function hideFileDropdown() {
|
|
699
|
+
fileDropdown.classList.add('hidden');
|
|
700
|
+
fileDropdownVisible = false;
|
|
701
|
+
fileDropdownItems = [];
|
|
702
|
+
fileDropdownIndex = -1;
|
|
703
|
+
atStartPos = -1;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function updateFileDropdownHighlight() {
|
|
707
|
+
fileDropdown.querySelectorAll('.file-dropdown-item').forEach((el, i) => {
|
|
708
|
+
el.classList.toggle('bg-primary/20', i === fileDropdownIndex);
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function selectFileItem(index) {
|
|
713
|
+
const item = fileDropdownItems[index];
|
|
714
|
+
if (!item) return;
|
|
715
|
+
|
|
716
|
+
const val = userInput.value;
|
|
717
|
+
const before = val.slice(0, atStartPos); // text before @
|
|
718
|
+
const after = val.slice(userInput.selectionStart);
|
|
719
|
+
|
|
720
|
+
if (item.isDir) {
|
|
721
|
+
// Replace @ prefix with directory path, keep dropdown open for subdir
|
|
722
|
+
userInput.value = before + '@' + item.path + '/' + after;
|
|
723
|
+
const newPos = before.length + 1 + item.path.length + 1;
|
|
724
|
+
userInput.setSelectionRange(newPos, newPos);
|
|
725
|
+
// Fetch sub-directory contents
|
|
726
|
+
fetchFiles(item.path).then(files => showFileDropdown(files));
|
|
727
|
+
} else {
|
|
728
|
+
// Insert file reference and close
|
|
729
|
+
userInput.value = before + '@' + item.path + ' ' + after;
|
|
730
|
+
const newPos = before.length + 1 + item.path.length + 1;
|
|
731
|
+
userInput.setSelectionRange(newPos, newPos);
|
|
732
|
+
hideFileDropdown();
|
|
733
|
+
}
|
|
734
|
+
userInput.focus();
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
userInput.addEventListener('input', async () => {
|
|
738
|
+
userInput.style.height = 'auto';
|
|
739
|
+
userInput.style.height = Math.min(userInput.scrollHeight, 200) + 'px';
|
|
740
|
+
|
|
741
|
+
// Check if we're in a @ reference context
|
|
742
|
+
const pos = userInput.selectionStart;
|
|
743
|
+
const val = userInput.value;
|
|
744
|
+
|
|
745
|
+
// Find the @ that started this reference
|
|
746
|
+
let atPos = -1;
|
|
747
|
+
for (let i = pos - 1; i >= 0; i--) {
|
|
748
|
+
if (val[i] === '@') { atPos = i; break; }
|
|
749
|
+
if (val[i] === ' ' || val[i] === '\n') break;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (atPos >= 0 && (atPos === 0 || val[atPos - 1] === ' ' || val[atPos - 1] === '\n')) {
|
|
753
|
+
atStartPos = atPos;
|
|
754
|
+
const prefix = val.slice(atPos + 1, pos);
|
|
755
|
+
// Extract directory part for the API
|
|
756
|
+
const lastSlash = prefix.lastIndexOf('/');
|
|
757
|
+
const dirPrefix = lastSlash >= 0 ? prefix.slice(0, lastSlash) : '';
|
|
758
|
+
const nameFilter = lastSlash >= 0 ? prefix.slice(lastSlash + 1) : prefix;
|
|
759
|
+
|
|
760
|
+
const files = await fetchFiles(dirPrefix);
|
|
761
|
+
const filtered = nameFilter
|
|
762
|
+
? files.filter(f => f.name.toLowerCase().includes(nameFilter.toLowerCase()))
|
|
763
|
+
: files;
|
|
764
|
+
showFileDropdown(filtered);
|
|
765
|
+
} else if (fileDropdownVisible) {
|
|
766
|
+
hideFileDropdown();
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
userInput.addEventListener('keydown', (e) => {
|
|
771
|
+
if (fileDropdownVisible) {
|
|
772
|
+
if (e.key === 'ArrowDown') {
|
|
773
|
+
e.preventDefault();
|
|
774
|
+
fileDropdownIndex = Math.min(fileDropdownIndex + 1, fileDropdownItems.length - 1);
|
|
775
|
+
updateFileDropdownHighlight();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (e.key === 'ArrowUp') {
|
|
779
|
+
e.preventDefault();
|
|
780
|
+
fileDropdownIndex = Math.max(fileDropdownIndex - 1, 0);
|
|
781
|
+
updateFileDropdownHighlight();
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
785
|
+
if (fileDropdownIndex >= 0) {
|
|
786
|
+
e.preventDefault();
|
|
787
|
+
selectFileItem(fileDropdownIndex);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (e.key === 'Escape') {
|
|
792
|
+
e.preventDefault();
|
|
793
|
+
hideFileDropdown();
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
799
|
+
e.preventDefault();
|
|
800
|
+
sendMessage();
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// ── Image upload (drag & drop + paste) ──────────────────────────────
|
|
805
|
+
|
|
806
|
+
const imagePreviewArea = document.createElement('div');
|
|
807
|
+
imagePreviewArea.id = 'image-preview';
|
|
808
|
+
imagePreviewArea.className = 'hidden max-w-4xl mx-auto flex gap-2 flex-wrap px-1 py-1';
|
|
809
|
+
// Insert before the input row
|
|
810
|
+
const inputRow = userInput.closest('.flex');
|
|
811
|
+
inputRow.parentElement.insertBefore(imagePreviewArea, inputRow);
|
|
812
|
+
|
|
813
|
+
function clearImagePreview() {
|
|
814
|
+
pendingImages = [];
|
|
815
|
+
imagePreviewArea.innerHTML = '';
|
|
816
|
+
imagePreviewArea.classList.add('hidden');
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function addImageToPreview(file) {
|
|
820
|
+
if (!file.type.startsWith('image/')) return;
|
|
821
|
+
if (file.size > 10 * 1024 * 1024) {
|
|
822
|
+
addErrorMessage(`Image too large (${(file.size / 1024 / 1024).toFixed(1)} MB, max 10 MB): ${file.name}`);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const reader = new FileReader();
|
|
826
|
+
reader.onload = () => {
|
|
827
|
+
const base64 = reader.result.split(',')[1];
|
|
828
|
+
pendingImages.push({ name: file.name, data: base64, mime: file.type });
|
|
829
|
+
|
|
830
|
+
const thumb = document.createElement('div');
|
|
831
|
+
thumb.className = 'image-thumb relative';
|
|
832
|
+
thumb.innerHTML = `
|
|
833
|
+
<img src="${reader.result}" class="rounded max-h-16 max-w-[100px] object-contain border border-base-content/20" alt="${escapeHtml(file.name)}">
|
|
834
|
+
<button class="btn btn-xs btn-circle btn-error absolute -top-1 -right-1 opacity-80" title="Remove">✕</button>
|
|
835
|
+
`;
|
|
836
|
+
thumb.querySelector('button').onclick = () => {
|
|
837
|
+
const idx = pendingImages.findIndex(img => img.name === file.name && img.data === base64);
|
|
838
|
+
if (idx >= 0) pendingImages.splice(idx, 1);
|
|
839
|
+
thumb.remove();
|
|
840
|
+
if (pendingImages.length === 0) imagePreviewArea.classList.add('hidden');
|
|
841
|
+
};
|
|
842
|
+
imagePreviewArea.appendChild(thumb);
|
|
843
|
+
imagePreviewArea.classList.remove('hidden');
|
|
844
|
+
};
|
|
845
|
+
reader.readAsDataURL(file);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Drag & drop on chat area
|
|
849
|
+
chatArea.addEventListener('dragover', (e) => {
|
|
850
|
+
e.preventDefault();
|
|
851
|
+
chatArea.classList.add('ring-2', 'ring-primary', 'ring-inset');
|
|
852
|
+
});
|
|
853
|
+
chatArea.addEventListener('dragleave', () => {
|
|
854
|
+
chatArea.classList.remove('ring-2', 'ring-primary', 'ring-inset');
|
|
855
|
+
});
|
|
856
|
+
chatArea.addEventListener('drop', (e) => {
|
|
857
|
+
e.preventDefault();
|
|
858
|
+
chatArea.classList.remove('ring-2', 'ring-primary', 'ring-inset');
|
|
859
|
+
for (const file of e.dataTransfer.files) {
|
|
860
|
+
addImageToPreview(file);
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// Ctrl+V paste image
|
|
865
|
+
userInput.addEventListener('paste', (e) => {
|
|
866
|
+
const items = e.clipboardData?.items;
|
|
867
|
+
if (!items) return;
|
|
868
|
+
for (const item of items) {
|
|
869
|
+
if (item.type.startsWith('image/')) {
|
|
870
|
+
e.preventDefault();
|
|
871
|
+
const file = item.getAsFile();
|
|
872
|
+
if (file) addImageToPreview(file);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
|
|
537
877
|
// ── Initialize ─────────────────────────────────────────────────────
|
|
538
878
|
|
|
539
879
|
// Restore theme
|
|
@@ -45,18 +45,37 @@
|
|
|
45
45
|
</div>
|
|
46
46
|
</div>
|
|
47
47
|
|
|
48
|
-
<!-- ──
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
<!-- ── Main content: sidebar + chat ──────────────── -->
|
|
49
|
+
<div class="flex flex-1 overflow-hidden">
|
|
50
|
+
|
|
51
|
+
<!-- Sidebar -->
|
|
52
|
+
<aside id="sidebar" class="sidebar bg-base-200 border-r border-base-content/10 flex flex-col w-64 flex-shrink-0 overflow-hidden transition-all duration-200">
|
|
53
|
+
<div class="p-3 border-b border-base-content/10 flex items-center justify-between">
|
|
54
|
+
<span class="font-semibold text-sm">Sessions</span>
|
|
55
|
+
<button id="btn-new-session" class="btn btn-xs btn-primary btn-outline" title="New session">+ New</button>
|
|
56
|
+
</div>
|
|
57
|
+
<div id="session-list" class="flex-1 overflow-y-auto p-2 flex flex-col gap-1 text-sm">
|
|
58
|
+
<div class="text-xs opacity-40 text-center py-4">No sessions yet</div>
|
|
59
|
+
</div>
|
|
60
|
+
</aside>
|
|
61
|
+
|
|
62
|
+
<!-- Sidebar toggle (mobile) -->
|
|
63
|
+
<button id="sidebar-toggle" class="btn btn-xs btn-ghost absolute top-[3.75rem] left-0 z-10 rounded-l-none" title="Toggle sidebar">☰</button>
|
|
64
|
+
|
|
65
|
+
<!-- Chat Area -->
|
|
66
|
+
<main id="chat-area" class="flex-1 overflow-y-auto px-4 py-4">
|
|
67
|
+
<div id="messages" class="max-w-4xl mx-auto flex flex-col gap-3">
|
|
68
|
+
<!-- Welcome message -->
|
|
69
|
+
<div class="chat chat-start">
|
|
70
|
+
<div class="chat-bubble chat-bubble-primary">
|
|
71
|
+
Welcome to <strong>ai-cli Web UI</strong>! Select a provider & model above, then start chatting.
|
|
72
|
+
<br><span class="text-xs opacity-70">Type <code>/command</code> for REPL commands. Shift+Enter for newline. Type <code>@</code> to reference files.</span>
|
|
73
|
+
</div>
|
|
56
74
|
</div>
|
|
57
75
|
</div>
|
|
58
|
-
</
|
|
59
|
-
|
|
76
|
+
</main>
|
|
77
|
+
|
|
78
|
+
</div>
|
|
60
79
|
|
|
61
80
|
<!-- ── Input Area ─────────────────────────────────── -->
|
|
62
81
|
<footer class="bg-base-200 border-t border-base-content/10 px-4 py-3 flex-shrink-0">
|
|
@@ -110,14 +110,32 @@
|
|
|
110
110
|
50% { opacity: 0; }
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/* ── Tool call cards (
|
|
113
|
+
/* ── Tool call cards (collapsible <details>) ────────── */
|
|
114
114
|
.tool-card {
|
|
115
115
|
background: oklch(var(--b2));
|
|
116
116
|
border-radius: 0.5rem;
|
|
117
|
-
padding: 0.
|
|
117
|
+
padding: 0.5rem 1rem;
|
|
118
118
|
font-size: 0.85rem;
|
|
119
119
|
border-left: 3px solid transparent;
|
|
120
120
|
}
|
|
121
|
+
.tool-card summary {
|
|
122
|
+
list-style: none;
|
|
123
|
+
}
|
|
124
|
+
.tool-card summary::-webkit-details-marker { display: none; }
|
|
125
|
+
.tool-card summary::before {
|
|
126
|
+
content: '▶';
|
|
127
|
+
font-size: 0.65rem;
|
|
128
|
+
margin-right: 0.4rem;
|
|
129
|
+
transition: transform 0.15s;
|
|
130
|
+
display: inline-block;
|
|
131
|
+
opacity: 0.5;
|
|
132
|
+
}
|
|
133
|
+
.tool-card[open] summary::before {
|
|
134
|
+
transform: rotate(90deg);
|
|
135
|
+
}
|
|
136
|
+
.tool-card .tool-details-body {
|
|
137
|
+
padding-top: 0.25rem;
|
|
138
|
+
}
|
|
121
139
|
.tool-border-safe { border-left-color: oklch(var(--in)); }
|
|
122
140
|
.tool-border-write { border-left-color: oklch(var(--wa)); }
|
|
123
141
|
.tool-border-destructive { border-left-color: oklch(var(--er)); }
|
|
@@ -221,7 +239,46 @@
|
|
|
221
239
|
.status-connected { color: oklch(var(--su)); }
|
|
222
240
|
.status-disconnected { color: oklch(var(--er)); }
|
|
223
241
|
|
|
242
|
+
/* ── Sidebar ───────────────────────────────────────── */
|
|
243
|
+
.sidebar .session-item {
|
|
244
|
+
padding: 0.5rem 0.6rem;
|
|
245
|
+
border-radius: 0.375rem;
|
|
246
|
+
cursor: pointer;
|
|
247
|
+
transition: background 0.15s;
|
|
248
|
+
border-left: 2px solid transparent;
|
|
249
|
+
}
|
|
250
|
+
.sidebar .session-item:hover {
|
|
251
|
+
background: oklch(var(--b3));
|
|
252
|
+
}
|
|
253
|
+
.sidebar .session-item.active {
|
|
254
|
+
background: oklch(var(--p) / 0.15);
|
|
255
|
+
border-left-color: oklch(var(--p));
|
|
256
|
+
}
|
|
257
|
+
.sidebar .session-item .session-title {
|
|
258
|
+
white-space: nowrap;
|
|
259
|
+
overflow: hidden;
|
|
260
|
+
text-overflow: ellipsis;
|
|
261
|
+
font-size: 0.85rem;
|
|
262
|
+
}
|
|
263
|
+
.sidebar .session-item .session-meta {
|
|
264
|
+
font-size: 0.7rem;
|
|
265
|
+
opacity: 0.5;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ── Image upload preview ──────────────────────────── */
|
|
269
|
+
#image-preview {
|
|
270
|
+
border-top: 1px dashed oklch(var(--bc) / 0.15);
|
|
271
|
+
padding-top: 0.5rem;
|
|
272
|
+
}
|
|
273
|
+
.image-thumb {
|
|
274
|
+
display: inline-block;
|
|
275
|
+
}
|
|
276
|
+
|
|
224
277
|
/* ── Responsive ─────────────────────────────────────── */
|
|
278
|
+
@media (max-width: 768px) {
|
|
279
|
+
.sidebar { width: 0; padding: 0; border: none; }
|
|
280
|
+
.sidebar.sidebar-open { width: 16rem; position: absolute; z-index: 20; height: calc(100vh - 3.5rem); top: 3.5rem; }
|
|
281
|
+
}
|
|
225
282
|
@media (max-width: 640px) {
|
|
226
283
|
.navbar-start .select { width: 6rem; font-size: 0.75rem; }
|
|
227
284
|
.msg-assistant { padding: 0.75rem; }
|