v0.3.8
@@ -0,0 +1,321 @@
|
||||
---
|
||||
name: coding-guidance
|
||||
description: 本地模式实现指南
|
||||
---
|
||||
当用户在处理本地模式的时候,使用此指南
|
||||
|
||||
先遵守这个约束:
|
||||
|
||||
- 不要修改已安装的 Cursor 客户端代码、bundle 或 app 副本。
|
||||
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议与仓库代码。
|
||||
- 如果用户提到“临时 patch 客户端做 e2e”,也要改成只读排查:核对实际运行副本、采集证据、对照仓库实现,然后把修复落在本仓库代码或输出明确结论。
|
||||
|
||||
如果问题已经涉及以下任一事项,请同时读取 `../cursor-client-e2e-debugging/SKILL.md`:
|
||||
|
||||
- 需要只读核对已安装的 Cursor 客户端 bundle、日志或运行副本
|
||||
- 需要确认当前到底是哪一个 app 副本在运行
|
||||
- 需要同时排查客户端 bundle 与本仓库 forwarder 的协同问题
|
||||
- 需要对照已安装客户端行为与本仓库实现差异
|
||||
|
||||
本地模式协议需要优先核对这些文件:
|
||||
- proto/agent_v1.proto
|
||||
- proto/aiserver_v1.proto
|
||||
客户端是:/Users/leokun/Library/Application\ Support/Cursor
|
||||
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
|
||||
|
||||
## Cursor 客户端格式化快照
|
||||
|
||||
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
|
||||
- 如果本仓库存在 `.cursor-app-formatted/`,排查客户端 bundle 时优先读取这里的格式化副本。
|
||||
- `.cursor-app-formatted/` 是从 `/Applications/Cursor.app/Contents/Resources/app` 只读提取后格式化生成的本地快照;它不应写回、替换或影响已安装的 Cursor.app。
|
||||
- 常用格式化路径:
|
||||
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
|
||||
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
|
||||
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
|
||||
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
|
||||
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
|
||||
- 如果 `.cursor-app-formatted/` 不存在、明显过期,或需要核对真实安装包 hash,再只读读取 `/Applications/Cursor.app` 原始 bundle。
|
||||
|
||||
## 本仓库已固定的会话承接规则
|
||||
|
||||
- 下一轮真实请求给 LLM 的历史承接,以 `history/<conversationId>/state.json` + `history/<conversationId>/context.json` 为持久化事实源。
|
||||
- `state.json` 保存会话元数据和当前状态,例如 `next_turn_seq`、`next_entry_seq`、`context_version`、`current_todos`、`current_plans`、`latest_request_prefix`、`last_provider_call`。
|
||||
- `context.json.items` 保存 append-only 的语义历史 entries;provider messages 不是主存储事实,而是由 `ProjectPromptReplay()` 从 entries 投影出来。
|
||||
- 模型渠道唯一性不再由 `modelID` 决定;当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hash,resolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`。
|
||||
- 可 replay 的历史应以 entry 顺序稳定追加,不能把已发送给模型且仍需保留的历史移动到新位置。
|
||||
- 最新态、易变态,例如 active todo、current plan、最新编辑保护和动态 reminder,应优先作为 `state.json` 状态或本轮 latest-only suffix;不要无意持久化成会在后续轮次无限 replay 的历史。
|
||||
- 新一轮 `run_request` 到来时,服务端应通过 `LoadConversation()` 读取 `state.json + context.json`,再由 projector 投影 prompt replay;客户端带回来的 checkpoint/replay 不参与历史承接真相判定。
|
||||
- `summary.json`、`replay.json`、`runtime.json`、`request.json`、`conversation.json`、`entries.jsonl`、`turns/` 和数字 turn 目录都属于旧持久化产物,会被 history maintenance 当 legacy artifact 清理。
|
||||
- 如果发现请求历史与本地状态不一致,优先检查 `context.json.items` 是否缺失、重复、顺序异常,以及 `state.json` 的 `next_entry_seq`、`next_turn_seq`、`context_version`、当前状态字段是否与 entries 派生结果一致;不要再按旧 `summary.json` 路径排查。
|
||||
|
||||
# 已确认结论
|
||||
|
||||
## 1. `AgentServerMessage` 不是统一都要“回复完成”
|
||||
|
||||
要按 `oneof message` 分类看:
|
||||
|
||||
- `exec_server_message`
|
||||
- 这是服务端发给客户端的“执行请求”。
|
||||
- 客户端需要显式回 `ExecClientMessage`。
|
||||
- 流式/异常场景下还会回 `ExecClientControlMessage`,常见是:
|
||||
- `stream_close`
|
||||
- `throw`
|
||||
- `heartbeat`
|
||||
- `interaction_query`
|
||||
- 这是服务端发给客户端的“交互请求”。
|
||||
- 客户端需要显式回 `InteractionResponse`。
|
||||
- `interaction_update`
|
||||
- 这是展示/状态更新消息,通常不需要客户端回包。
|
||||
- `conversation_checkpoint_update`
|
||||
- 这是 checkpoint 同步消息,通常不需要客户端回包。
|
||||
- `kv_server_message`
|
||||
- 这是 KV 同步消息,通常不需要客户端回包。
|
||||
- `exec_server_control_message`
|
||||
- 这是服务端对执行桥的控制消息(例如 abort),客户端要按控制语义处理,但不是通用“完成 ack”。
|
||||
|
||||
## 2. Cursor 客户端没有“收到任意 `ServerMessage` 自动回 ack”的通用层
|
||||
|
||||
在 `cursor-always-local/dist/main.js` 里,`BidiTransport.startYieldingInputsToTheServer` 只会把“客户端主动产出的消息”送到 `BidiAppend`:
|
||||
|
||||
- 它对输入 iterable 做 `p.value.toBinary()` 后 hex 编码,再发 `BidiAppendRequest.data`
|
||||
- 说明只有客户端业务逻辑主动产出的 `AgentClientMessage` 才会上行
|
||||
- 没有发现“收到一个 `AgentServerMessage` 就自动回 completed/ack”的统一机制
|
||||
|
||||
因此客户端是否回包,取决于上层业务逻辑有没有因为某个下行消息而主动构造新的 `AgentClientMessage`。
|
||||
|
||||
## 2.1 更具体的客户端侧结论
|
||||
|
||||
从 `cursor-always-local/dist/main.js` 里能直接确认:
|
||||
|
||||
- `AgentServerMessage` 的下行类型里有:
|
||||
- `interaction_update`
|
||||
- `exec_server_message`
|
||||
- `exec_server_control_message`
|
||||
- `conversation_checkpoint_update`
|
||||
- `interaction_query`
|
||||
- `AgentClientMessage` 的上行类型里有:
|
||||
- `run_request`
|
||||
- `exec_client_message`
|
||||
- `exec_client_control_message`
|
||||
- `interaction_response`
|
||||
|
||||
这意味着本地模式不是“server message -> 通用 ack”模型,而是:
|
||||
|
||||
- `exec_server_message`
|
||||
-> 客户端执行本地工具
|
||||
-> 产出 `exec_client_message`
|
||||
-> 以及可选 `exec_client_control_message`
|
||||
- `interaction_query`
|
||||
-> 客户端展示或处理交互
|
||||
-> 产出 `interaction_response`
|
||||
- 其他下行消息
|
||||
-> 一般只更新 UI / checkpoint /流状态
|
||||
-> 不会自然地产生一个“完成 ack”
|
||||
|
||||
## 2.2 `exec_server_message` 常见的客户端回包形态
|
||||
|
||||
客户端协议模型里已确认这些回包类型:
|
||||
|
||||
- `ExecClientMessage`
|
||||
- 正常结果面
|
||||
- 包括 `read_result` / `write_result` / `grep_result` / `ls_result` / `diagnostics_result` / `mcp_result` / `shell_stream` 等
|
||||
- `ExecClientControlMessage`
|
||||
- 控制面
|
||||
- 包括:
|
||||
- `stream_close`
|
||||
- `throw`
|
||||
- `heartbeat`
|
||||
|
||||
所以调查本地模式 exec 问题时,不要只盯 `ExecClientMessage`:
|
||||
|
||||
- 有些工具只回一次结果面消息
|
||||
- shell 之类的流式工具会混合回:
|
||||
- 多次 `shell_stream`
|
||||
- 以及控制消息(例如 `stream_close` / `heartbeat`)
|
||||
|
||||
## 2.3 `exec_server_message` 的完整回包形态
|
||||
|
||||
事实依据:
|
||||
|
||||
- `proto/agent_v1.proto`
|
||||
- `ExecServerMessage.oneof message`
|
||||
- `ExecClientMessage.oneof message`
|
||||
- `ExecClientControlMessage.oneof message`
|
||||
- `cursor-always-local/dist/main.js`
|
||||
- bundle 内含同名 proto 模型
|
||||
- `BidiTransport.startYieldingInputsToTheServer` 说明客户端上行消息来自业务逻辑主动构造,不存在通用自动 ack
|
||||
|
||||
### 结果面回包:`ExecServerMessage` -> `ExecClientMessage`
|
||||
|
||||
`ExecServerMessage` 的 `message` 分支与 `ExecClientMessage` 的 `message` 分支是一一对应的:
|
||||
|
||||
- `shell_args`
|
||||
-> `shell_result`
|
||||
- `write_args`
|
||||
-> `write_result`
|
||||
- `delete_args`
|
||||
-> `delete_result`
|
||||
- `grep_args`
|
||||
-> `grep_result`
|
||||
- `read_args`
|
||||
-> `read_result`
|
||||
- `ls_args`
|
||||
-> `ls_result`
|
||||
- `diagnostics_args`
|
||||
-> `diagnostics_result`
|
||||
- `request_context_args`
|
||||
-> `request_context_result`
|
||||
- `mcp_args`
|
||||
-> `mcp_result`
|
||||
- `shell_stream_args`
|
||||
-> `shell_stream`
|
||||
- `background_shell_spawn_args`
|
||||
-> `background_shell_spawn_result`
|
||||
- `list_mcp_resources_exec_args`
|
||||
-> `list_mcp_resources_exec_result`
|
||||
- `read_mcp_resource_exec_args`
|
||||
-> `read_mcp_resource_exec_result`
|
||||
- `fetch_args`
|
||||
-> `fetch_result`
|
||||
- `record_screen_args`
|
||||
-> `record_screen_result`
|
||||
- `computer_use_args`
|
||||
-> `computer_use_result`
|
||||
- `write_shell_stdin_args`
|
||||
-> `write_shell_stdin_result`
|
||||
- `execute_hook_args`
|
||||
-> `execute_hook_result`
|
||||
- `subagent_args`
|
||||
-> `subagent_result`
|
||||
|
||||
所有这些结果面回包都带:
|
||||
|
||||
- `id`
|
||||
- `exec_id`
|
||||
|
||||
服务端匹配时通常优先用:
|
||||
|
||||
1. `exec_id`
|
||||
2. `id`
|
||||
|
||||
### 控制面回包:`ExecServerMessage` -> `ExecClientControlMessage`
|
||||
|
||||
除了结果面回包外,客户端还可能回控制面消息:
|
||||
|
||||
- `stream_close`
|
||||
- 表示当前 exec 流已关闭
|
||||
- 只有 `id`
|
||||
- `throw`
|
||||
- 表示执行异常
|
||||
- 只有 `id` + `error` + 可选 `stack_trace`
|
||||
- `heartbeat`
|
||||
- 表示执行过程中的心跳
|
||||
- 只有 `id`
|
||||
|
||||
### 关键理解
|
||||
|
||||
- `ExecClientControlMessage` 不是某个单独 `ExecServerMessage` 分支的“专属结果类型”
|
||||
- 它是跨 exec 通用的控制面回包
|
||||
- 因此调查时必须同时看两类上行:
|
||||
- `ExecClientMessage`
|
||||
- `ExecClientControlMessage`
|
||||
|
||||
### 调查规则
|
||||
|
||||
对于任意 `exec_server_message`,至少要确认以下之一是否发生:
|
||||
|
||||
- 收到对应的 `ExecClientMessage`
|
||||
- 或收到 `ExecClientControlMessage.throw`
|
||||
- 对流式 exec,还要看:
|
||||
- 是否有多次增量 `ExecClientMessage`
|
||||
- 是否最终有 `stream_close`
|
||||
|
||||
如果只看到 started / pending,没有任何结果面或控制面回包,服务端 pending 大概率不会收口。
|
||||
|
||||
排查时优先搜索这些关键字:
|
||||
|
||||
## 3. forwarder 状态机实现规则
|
||||
|
||||
在本仓库修本地模式 forwarder 时,默认遵守下面这些稳定约束,避免再次引入“工具晚到污染当前轮”或“同一 request 在 `[DONE]` 后又续跑一轮”的问题。
|
||||
|
||||
### 3.1 resume 必须按 provider pass 隔离
|
||||
|
||||
- `request_id` 不是 provider 调用代次;同一个 request 可以合法包含多次 provider pass。
|
||||
- `scheduleProviderResume` 不能只依赖 request 级布尔态(例如单个 `ResumePending`)。
|
||||
- resume 请求必须带来源 pass,至少要能区分:
|
||||
- 当前 pass 的结果触发的合法续跑
|
||||
- 上一轮工具终态晚到造成的陈旧 resume
|
||||
- `driveProvider` 开始与结束时都要显式清理上一轮的 resume 状态,不能让旧状态跨 pass 残留。
|
||||
|
||||
### 3.2 工具晚到是常态,只能影响所属 pass
|
||||
|
||||
- `ExecClientMessage` / `ExecClientControlMessage` 晚于 provider `[DONE]` 到达是正常现象。
|
||||
- 晚到结果只能驱动其所属 pass 的 checkpoint / history / resume 判定,不能影响后续 pass。
|
||||
- 非流式 exec 的 `stream_close` synthetic recovery 也必须沿用原工具的来源 pass,不能按 request 级全局状态续跑。
|
||||
|
||||
### 3.3 pending exec 必须严格按 id 匹配
|
||||
|
||||
- `selectPendingExec` / `selectPendingExecByControl` 只允许按:
|
||||
- `exec_id`
|
||||
- `message_id`
|
||||
进行匹配。
|
||||
- 不允许再用“当前只有一个 pending,就直接返回它”的兜底逻辑。
|
||||
- 迟到的 result / `stream_close` / `throw` 如果 pending 已不存在:
|
||||
- 优先看 `RecentCompletedExecs` 做幂等忽略
|
||||
- 不要把它重新落到当前轮的 pending 上
|
||||
|
||||
### 3.4 看到这些现象时,优先怀疑 stale resume / stale exec
|
||||
|
||||
如果出现下面任一现象,先查 forwarder 状态机,不要先怪客户端:
|
||||
|
||||
- 同一个 `request_id` 在 `[DONE]` 后又出现新的 `model_call_id`
|
||||
- `turns/<n+1>/request.json` 与 `turns/<n>/request.json` messages 几乎完全相同
|
||||
- 上一轮工具 `grepResult/readResult/...` 晚于上一轮 `[DONE]`
|
||||
- 晚到的 `stream_close` 恰好跨到下一轮 provider 已经启动之后
|
||||
|
||||
优先核对:
|
||||
|
||||
- `ProviderPassCount`
|
||||
- resume 请求的来源 pass
|
||||
- `PendingExec.ProviderPass`
|
||||
- `selectPendingExec` 是否存在跨轮误匹配
|
||||
|
||||
- `startYieldingInputsToTheServer`
|
||||
- `bidiAppend({requestId:A,appendSeqno`
|
||||
- `ExecServerMessage`
|
||||
- `ExecClientMessage`
|
||||
- `ExecClientControlMessage`
|
||||
- `InteractionQuery`
|
||||
- `InteractionResponse`
|
||||
|
||||
## 3. 对本地模式最重要的协议理解
|
||||
|
||||
- `exec_server_message` / `interaction_query` 属于“请求型下行消息”
|
||||
- 如果客户端不回对应结果,服务端 pending 不会收口
|
||||
- 后续重连后可能出现 “No tool output found for function call ...” 这类 provider 400
|
||||
- `interaction_update` / `conversation_checkpoint_update` 属于“通知型下行消息”
|
||||
- 它们用于 UI 展示、同一 backend 进程内的 live checkpoint 同步、状态同步
|
||||
- 一般不要求客户端再回一个“完成”消息
|
||||
|
||||
## 4. 调查本地模式时的优先顺序
|
||||
|
||||
1. 先确认收到的 `AgentServerMessage` 是哪一类
|
||||
2. 如果是 `exec_server_message`
|
||||
- 查客户端是否回了 `ExecClientMessage`
|
||||
- 查是否只回了 `stream_close` 但没有真正结果
|
||||
- 查 `exec_id` / `id` 是否匹配
|
||||
3. 如果是 `interaction_query`
|
||||
- 查客户端是否回了 `InteractionResponse`
|
||||
4. 如果是 `conversation_checkpoint_update`
|
||||
- 重点查里面的 `pending_tool_calls` / `root_prompt_messages_json` / `turns`
|
||||
- 不要误以为它本身需要回 ack
|
||||
|
||||
## 5. 对服务端实现的直接要求
|
||||
|
||||
- 服务端必须区分“请求型下行”和“通知型下行”
|
||||
- 服务端不能把 `ServerMessage` 统一建模成“发出去就等一个完成 ack”
|
||||
- 对请求型消息,必须在本地状态机里维护 pending:
|
||||
- `PendingExec`
|
||||
- `PendingInteraction`
|
||||
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
|
||||
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "本地模式实现指南"
|
||||
short_description: "当用户在尝试解决本地模式问题时,使用此技能"
|
||||
default_prompt: "使用 $coding-guidance 来解决本地模式问题。"
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: cursor-app-formatted
|
||||
description: Use when extracting, formatting, refreshing, or investigating a read-only formatted snapshot of the installed Cursor.app bundle under .cursor-app-formatted; includes git-ignore rules, snapshot generation workflow, and the rule to inspect formatted code without patching either the snapshot code or the installed app.
|
||||
---
|
||||
|
||||
# Cursor App Formatted Snapshot
|
||||
|
||||
Use this skill whenever a task involves reading, searching, formatting, refreshing, or relying on a formatted copy of the installed Cursor client bundle.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Never modify `/Applications/Cursor.app`, any installed app bundle, signatures, or app copies.
|
||||
- Never patch bundled code under `.cursor-app-formatted/` as a fix target. It is an ignored investigation snapshot only.
|
||||
- If `.cursor-app-formatted/` is stale or wrong, regenerate it from the installed app instead of hand-editing its code.
|
||||
- Fixes should land in this repository's real source code, scripts, or docs, not in formatted snapshot code.
|
||||
- Keep `.cursor-app-formatted/` git-ignored. Do not stage or commit generated snapshot contents.
|
||||
|
||||
## Preferred Investigation Flow
|
||||
|
||||
1. If `.cursor-app-formatted/` exists, search and read that formatted snapshot first.
|
||||
2. Use `/Applications/Cursor.app` only for read-only authenticity checks, hash comparison, or when the snapshot is missing or stale.
|
||||
3. Prefer stable formatted paths for line references and control-flow reading:
|
||||
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
|
||||
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
|
||||
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
|
||||
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
|
||||
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
|
||||
4. When investigating installed-client behavior, compare formatted findings back to original source hashes or original bundle content only as needed.
|
||||
|
||||
## Git Ignore Rule
|
||||
|
||||
Ensure `.gitignore` contains:
|
||||
|
||||
```gitignore
|
||||
.cursor-app-formatted/
|
||||
```
|
||||
|
||||
If the entry is missing and the user asked to create or refresh the snapshot, add it before generating the snapshot.
|
||||
|
||||
## Snapshot Generation Workflow
|
||||
|
||||
Run from the repository root. This workflow copies only from the installed app into the ignored snapshot, then formats the copy.
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
SNAPSHOT=.cursor-app-formatted
|
||||
SOURCE=/Applications/Cursor.app/Contents/Resources/app
|
||||
|
||||
rm -rf "$SNAPSHOT"
|
||||
mkdir -p "$SNAPSHOT"
|
||||
|
||||
/usr/bin/ditto "$SOURCE/extensions" "$SNAPSHOT/extensions"
|
||||
mkdir -p "$SNAPSHOT/out/vs/workbench/api/node"
|
||||
/usr/bin/ditto "$SOURCE/out/vs/workbench/workbench.desktop.main.js" "$SNAPSHOT/out/vs/workbench/workbench.desktop.main.js"
|
||||
/usr/bin/ditto "$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" "$SNAPSHOT/out/vs/workbench/api/node/extensionHostProcess.js"
|
||||
|
||||
/usr/bin/shasum -a 256 \
|
||||
"$SOURCE/out/vs/workbench/workbench.desktop.main.js" \
|
||||
"$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" \
|
||||
> "$SNAPSHOT/source-sha256.txt"
|
||||
|
||||
/usr/bin/find "$SOURCE/extensions" -type f \( -name '*.js' -o -name '*.json' -o -name '*.css' \) -print \
|
||||
| /usr/bin/sed "s#^$SOURCE/##" \
|
||||
| while IFS= read -r rel; do
|
||||
/usr/bin/shasum -a 256 "$SOURCE/$rel"
|
||||
done >> "$SNAPSHOT/source-sha256.txt"
|
||||
```
|
||||
|
||||
Format large JS bundles with `js-beautify`; Prettier can OOM on very large Cursor bundles and also skips ignored paths unless forced.
|
||||
|
||||
```bash
|
||||
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) -size +1M -print \
|
||||
| while IFS= read -r file; do
|
||||
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
|
||||
done
|
||||
|
||||
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) ! -size +1M -print \
|
||||
| while IFS= read -r file; do
|
||||
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
|
||||
done
|
||||
|
||||
EMPTY_IGNORE="$(mktemp)"
|
||||
trap 'rm -f "$EMPTY_IGNORE"' EXIT
|
||||
find .cursor-app-formatted -type f \( -name '*.json' -o -name '*.css' \) -print0 \
|
||||
| xargs -0 -n 25 npx --yes prettier --ignore-path "$EMPTY_IGNORE" --with-node-modules --write --log-level warn
|
||||
```
|
||||
|
||||
Optionally add a small `.cursor-app-formatted/README.md` describing the source path, observed Cursor version, and that the snapshot is read-only.
|
||||
|
||||
## Validation
|
||||
|
||||
After generation, verify the snapshot is ignored and key files are readable:
|
||||
|
||||
```bash
|
||||
git status --short --ignored | rg '\.cursor-app-formatted'
|
||||
wc -l \
|
||||
.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js \
|
||||
.cursor-app-formatted/extensions/cursor-always-local/dist/main.js \
|
||||
.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js
|
||||
```
|
||||
|
||||
Useful investigation check:
|
||||
|
||||
```bash
|
||||
rg -n 'localMode|runLocalAgent|localProvider|BidiTransport|startYieldingInputsToTheServer' .cursor-app-formatted
|
||||
```
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: cursor-client-e2e-debugging
|
||||
description: Use when debugging Cursor client agent/local-mode/tool/backend-store/provider-replay failures in this repo, especially after the state/context history-store refactor, when triaging installed app bundles read-only, correlating installed-client behavior with repo code, mapping a user-provided id to conversation/request/model-call evidence, replaying provider requests from debug logs, or locating the current client/backend/protocol/log files quickly.
|
||||
---
|
||||
|
||||
当用户反馈 Cursor agent、本地模式、工具调用、协议桥接、客户端 bundle 行为异常,或需要只读核对已安装客户端与仓库实现/日志差异时,使用此技能。
|
||||
|
||||
当用户只给一个 UUID / id,希望反查它是 `conversationId`、`requestId`、`modelCallId`、`toolCallId` 还是其它运行期 id,并继续定位对应的会话 history、provider 调用状态或协议日志时,也使用此技能。
|
||||
|
||||
当用户遇到 provider 400/参数错误、SSE `event: error`、需要从 `debug/provider.jsonl` 抽取最终 provider body 并用 curl 独立复现时,也使用此技能。
|
||||
|
||||
## 首要约束
|
||||
|
||||
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
|
||||
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议事件与本仓库实现。
|
||||
- 如果本仓库存在 `.cursor-app-formatted/`,优先读取这个格式化快照来搜索和引用客户端 bundle;只有在快照缺失、过期或需要 hash/真实性核对时,才只读读取 `/Applications/Cursor.app`。
|
||||
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill;调查时可以读格式化代码,但不要 patch 格式化快照里的 bundle 代码。
|
||||
- 如果用户要求 patch 客户端做 e2e,要改成只读证据采集与差异定位,不执行客户端修改。
|
||||
- 当前本仓库已经重构为 `state.json + context.json` history-store;不要沿用旧 `data.sqlite`、`conversation.json`、`turns/<n>/request.json|sse.jsonl|summary.json` 排查路径。
|
||||
|
||||
## 先做路由判断
|
||||
|
||||
- `history + logs` 反查层
|
||||
- 现象:用户发来一个 id,要判断它是 `conversationId`、`requestId`、`modelCallId`、`toolCallId`;需要从 `history/<conversationId>/state.json` 与 `history/<conversationId>/context.json` 追运行状态和语义历史。
|
||||
- 先读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
|
||||
- `provider replay / debug` 层
|
||||
- 现象:provider 返回 400/参数错误、SSE `event: error`、需要验证最终出站 provider body 是否能被独立 curl 复现。
|
||||
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md),必要时使用 [scripts/provider-replay.sh](scripts/provider-replay.sh)
|
||||
- `cursor-agent` 层
|
||||
- 现象:`CursorAgentProvider`、`ClaudeSDKClient`、`AnthropicProxy`、`registerAgentProvider`、`InteractionUpdate` 映射、模型桥接异常。
|
||||
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
|
||||
- `cursor-always-local` / 本地模式协议层
|
||||
- 现象:`BidiAppend`、`RunSSE`、`AgentServerMessage`、`ExecClientMessage`、`InteractionResponse`、live checkpoint / pending 收口异常。
|
||||
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
|
||||
- 客户端 bundle 只读定位层
|
||||
- 现象:需要核对已安装 app bundle、确认实际运行副本、只读验证行为是否命中,并判断差异来自客户端还是本仓库。
|
||||
- 先读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
|
||||
|
||||
如果问题同时涉及多层,优先从最靠近故障表象的一层开始,不要一开始就同时追所有链路。
|
||||
|
||||
## 当前工作流
|
||||
|
||||
1. 如果用户给了一个 id,先用 `history/` 目录、`context.json.items`、`state.json` 和 `logs/app.log` 判断它属于哪类 id;不要假设它一定是 `requestId`。
|
||||
2. 一旦拿到 `conversationId`,同时看两份事实源:
|
||||
- `history/<conversationId>/state.json`:会话元数据和当前状态,例如 loop、token、current todo/plan、`latest_request_prefix`、`last_provider_call`。
|
||||
- `history/<conversationId>/context.json`:append-only 的语义历史 entries;prompt replay 由 `ProjectPromptReplay()` 从这里投影。
|
||||
3. 不要去找旧 provider 调用工件:当前 `RecordLLMRequest` 不再落 `request.json`,`AppendLLMResponseChunk` 是 no-op,`RecordLLMSummary` 只补齐内存态并更新 `state.latest_request_prefix` / usage。
|
||||
4. 再确认故障主要落在 `cursor-agent`、`cursor-always-local`,还是本仓库 `internal/backend` 的协议兼容层。
|
||||
5. 用 references 里的固定搜索词快速找到入口函数、协议消息和桥接点。
|
||||
6. 如果 provider 返回 400/参数错误、SSE `event: error`,或需要验证最终出站 provider body:
|
||||
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)。
|
||||
- 通过 id 反查拿到 `conversationId`、`requestId`、`modelCallId`,再定位 `history/<conversationId>/debug/provider.jsonl`。
|
||||
- 必要时运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),只保存 replay 产物,不把 API key 或完整 request body 写进技能/回复。
|
||||
7. 如果用户要核对 prefix cache / cache hit:
|
||||
- 优先运行 `go run ./scripts/historymetrics [conversationId|path]`
|
||||
- 它读取当前 `history/<conversationId>/state.json` 与 `history/<conversationId>/context.json`,并结合 `history/usage.json` 统计。
|
||||
- 关注 `cache_read_tokens / prompt_tokens_total`,并检查 `context.json.items` 是否缺失、重复或顺序异常。
|
||||
8. 如果需要对照已安装 app 与仓库行为:
|
||||
- 只做只读核对与证据采集,不修改客户端 bundle / app 副本 / 签名。
|
||||
- 优先用 `.cursor-app-formatted/` 中的格式化副本定位符号、行号和控制流;再按需只读核对 `/Applications/Cursor.app` 原始文件 hash 或运行副本。
|
||||
- 先确认实际运行的 app 副本和目标 bundle 路径。
|
||||
- 再读取 bundle 内容、日志、端口与 history 状态,并与本仓库实现对照。
|
||||
9. 如果证据显示问题更像是客户端 bundle 行为差异:
|
||||
- 记录具体文件、符号、日志和协议证据链。
|
||||
- 继续判断本仓库是否可以兼容、绕过,或直接输出分析结论。
|
||||
- 不要对已安装 Cursor 客户端做 patch、重签名、替换文件或写入式验证。
|
||||
|
||||
## 约束
|
||||
|
||||
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
|
||||
- 不要默认复刻整套 Cursor backend;先确认是不是只需要改模型桥接层。
|
||||
- 不要先假设用户给的是 `requestId`;必须同时考虑 `conversationId`、`requestId`、`modelCallId`、`toolCallId`。
|
||||
- 不要把 `history/<conversationId>/state.json` 和 `history/<conversationId>/context.json` 混为一谈:前者是元数据与当前状态,后者是 replayable 语义历史。
|
||||
- 不要再依赖 `agent_request_runs`、`agent_conversations`、`agent_history_entries`、`protocol_traces`、`data.sqlite`;当前实现已经不支持 DB-backed store / trace debug UI。
|
||||
- 不要把当前排查进度、临时结论、一次性的 request_id / 端口 / token 写进技能。
|
||||
- 技能里只保留稳定流程、固定入口、可复用搜索词和只读验证规则。
|
||||
|
||||
## 模型渠道规则
|
||||
|
||||
- 模型渠道唯一性不再由 `modelID` 决定。
|
||||
- 当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hash(前 16 个十六进制字符)。
|
||||
- resolver 仍兼容 legacy 渠道 ID:`baseURL + modelID + apiKey + displayName`。
|
||||
- `modelID` 只表示 provider model;排查选择器、默认模型和命中渠道时,要优先看渠道 ID 和 `openAIEndpoint`。
|
||||
|
||||
## 参考加载规则
|
||||
|
||||
- `history + logs` 路径、id 反查、state/context 生成链路:读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
|
||||
- provider 400/参数错误、SSE `event: error`、最终出站 provider body curl 重放:读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)
|
||||
- 文件地图:读 [references/file-map.md](references/file-map.md)
|
||||
- 搜索词与判断树:读 [references/search-patterns.md](references/search-patterns.md)
|
||||
- 已安装客户端的只读核对、进程确认、行为验证:读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
|
||||
|
||||
## 自带脚本
|
||||
|
||||
- 统计 prefix cache / cache hit:运行 `go run ./scripts/historymetrics [conversationId|path]`
|
||||
- 兼容壳脚本:运行 [scripts/cache-hit-rate.mjs](scripts/cache-hit-rate.mjs)
|
||||
- provider curl 重放:运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),必填 `REQUEST_LOG`、`REQUEST_ID`、`MODEL_CALL_ID`
|
||||
@@ -0,0 +1,177 @@
|
||||
# backend/store 日志反查
|
||||
|
||||
当用户只给一个 id,或明确让你从本地记录里反查一次请求、会话、模型调用、工具调用或 provider 错误时,优先读这份参考。
|
||||
|
||||
## 当前固定路径
|
||||
|
||||
当前用户机器上的固定助手根目录:
|
||||
|
||||
- `~/.cursor-local-assistant-v2`
|
||||
|
||||
当前最关键的是三类内容:
|
||||
|
||||
- `history/<conversationId>/state.json`
|
||||
- 会话元数据与当前状态。
|
||||
- 重点字段:`request_id` `conversation_id`、`root_conversation_id`、`parent_conversation_id`、`parent_tool_call_id`、`mode`、`current_loop_status`、`current_request_id`、`current_turn_seq`、`context_version`、`next_turn_seq`、`next_entry_seq`、`latest_request_prefix`、`last_provider_call`、`current_todos`、`current_plans`、token / compaction 字段。
|
||||
- `history/<conversationId>/context.json`
|
||||
- append-only 语义历史。
|
||||
- 重点字段:`version`、`items[]`。
|
||||
- `items[]` 中每个 entry 通常有 `seq`、`turn_seq`、`request_id`、`role`、`kind`、`tool_call_id`、`parent_tool_call_id`、`payload`、`created_at`。
|
||||
- `history/usage.json`
|
||||
- provider call 与 turn usage 聚合。
|
||||
- 重点字段:`totals`、`daily`、`recent_events`、`event_index`。
|
||||
|
||||
`logs/app.log` 是运行日志;它只用于补充运行时证据,不是会话事实源。
|
||||
|
||||
当前实现不再支持:
|
||||
|
||||
- DB-backed store / searchable conversation memory
|
||||
- HTTP/protocol trace debug UI
|
||||
- `data/data.sqlite` / `protocol_traces`
|
||||
- `history/<conversationId>/conversation.json`
|
||||
- `history/<conversationId>/turns/<n>/request.json|sse.jsonl|summary.json`
|
||||
- 根目录或会话目录下的旧 `latest.json`、`summary.json`、`replay.json`、`runtime.json`、`request.json`、`recovery.json`、`entries.jsonl`、数字 turn 目录
|
||||
|
||||
这些旧产物会被 `internal/backend/forwarder/history_maintenance.go` 清理。不要把它们当成当前事实源。
|
||||
|
||||
## 用户发来一个 id 时的固定步骤
|
||||
|
||||
### 1. 先判断 id 类型
|
||||
|
||||
不要先假设它是 `requestId`。按这个顺序缩小范围:
|
||||
|
||||
1. 是否是 `conversationId`
|
||||
- 检查 `history/<id>/state.json` 和 `history/<id>/context.json` 是否存在。
|
||||
2. 是否是 `requestId`
|
||||
- 在 `history/*/state.json` 中查 `current_request_id`、`latest_request_prefix.request_id`、`last_provider_call.request_id`。
|
||||
- 在 `history/*/context.json` 的 `items[].request_id` 中查。
|
||||
- 在 `logs/app.log` 中查。
|
||||
3. 是否是 `modelCallId`
|
||||
- 在 `state.json` 中查 `latest_request_prefix.model_call_id`、`last_provider_call.model_call_id`。
|
||||
- 在 `context.json.items[].payload` 中查 `model_call_id`。
|
||||
- 在 `logs/app.log` 中查 `model_call_id=<id>`。
|
||||
4. 是否是 `toolCallId` / `exec_id`
|
||||
- 在 `context.json.items[].tool_call_id`、`items[].payload` 中查。
|
||||
- 在协议/工具相关日志中查。
|
||||
|
||||
可以用本地脚本或 `rg` 做只读反查。不要再用 SQLite 查询模板。
|
||||
|
||||
### 2. 拿到 `conversationId` 后看两份事实源
|
||||
|
||||
```bash
|
||||
HISTORY_ROOT="$HOME/.cursor-local-assistant-v2/history"
|
||||
CONV_ID="<conversation-id>"
|
||||
|
||||
ls -la "$HISTORY_ROOT/$CONV_ID"
|
||||
```
|
||||
|
||||
重点检查:
|
||||
|
||||
- `state.json`
|
||||
- `current_loop_status`:`idle`、`running`、`waiting_tool`、`completed`、`canceled`、`provider_error`、`failed`
|
||||
- `current_request_id`、`current_turn_seq`
|
||||
- `latest_request_prefix`:最近一次 provider 请求的 provider/model/openai_endpoint/model_call_id/prompt token 摘要
|
||||
- `last_provider_call`:最近 provider 状态与错误文本
|
||||
- `next_entry_seq`、`next_turn_seq`、`context_version`
|
||||
- `current_todos`、`current_plans`
|
||||
- `context.json`
|
||||
- `version` 是否与 `state.context_version` 对齐
|
||||
- `items[]` 是否按 `seq` 稳定递增
|
||||
- 同一 `turn_seq` 下是否有预期的 user/request_context/prompt_context/assistant/tool_result/metadata entries
|
||||
- 是否有重复、缺失或顺序异常
|
||||
- `usage.json`
|
||||
- 通过 `event_index` 或 `recent_events` 查 request/model-call 相关 usage
|
||||
- `totals.cache_read_tokens / (totals.cache_read_tokens + totals.input_tokens)` 可粗略看 cache hit
|
||||
|
||||
### 3. Provider 调用证据现在在哪里
|
||||
|
||||
当前 provider artifact recorder 的行为:
|
||||
|
||||
- `RecordLLMRequest(...)`
|
||||
- 只缓存当前 provider call 的请求摘要。
|
||||
- 如果 payload 可解析 provider/model/openai_endpoint,会更新 `state.latest_request_prefix`。
|
||||
- 不再写 `request.json`。
|
||||
- `AppendLLMResponseChunk(...)`
|
||||
- 当前是 no-op。
|
||||
- 不再写 `sse.jsonl`。
|
||||
- `RecordLLMSummary(...)`
|
||||
- 只补齐当前 provider call summary,并更新 `state.latest_request_prefix.prompt_tokens_total`。
|
||||
- usage 聚合写入 `history/usage.json`。
|
||||
- 不再写 `summary.json`。
|
||||
|
||||
所以 provider 错误排查应优先看:
|
||||
|
||||
- `state.last_provider_call`
|
||||
- `state.latest_request_prefix`
|
||||
- `context.json.items` 里的 `metadata/provider_error/turn_completed` 等 payload
|
||||
- `history/usage.json`
|
||||
- `logs/app.log`
|
||||
- `internal/backend/agent/model/openai.go` / `anthropic.go` 的请求构造和错误解析
|
||||
|
||||
## 这些文件是怎么生成的
|
||||
|
||||
### 根路径
|
||||
|
||||
- `internal/appdata/paths.go`
|
||||
- `RootDir()` 固定为 `~/.cursor-local-assistant-v2`
|
||||
- `HistoryRootPath()` 为 `~/.cursor-local-assistant-v2/history`
|
||||
- `UsageFilePath()` 为 `~/.cursor-local-assistant-v2/history/usage.json`
|
||||
- `LogsRootPath()` 为 `~/.cursor-local-assistant-v2/logs`
|
||||
|
||||
### `state.json + context.json`
|
||||
|
||||
来源链路:
|
||||
|
||||
- `internal/backend/forwarder/file_store.go`
|
||||
- `CreateConversation`
|
||||
- `LoadConversation`
|
||||
- `AppendEntries`
|
||||
- `SaveConversationWithEntries`
|
||||
- `UpdateConversationMeta`
|
||||
- `ReplaceEntries`
|
||||
- `internal/backend/forwarder/service.go`
|
||||
- `handleRunIntent` 开始新 loop / turn
|
||||
- `appendConversationEntries` 追加语义事件
|
||||
- `internal/backend/forwarder/projector.go`
|
||||
- `ProjectPromptReplay()` 把 `context.json.items` 投影为 provider messages
|
||||
|
||||
稳定结论:
|
||||
|
||||
- `state.json` 是当前状态和可变元数据。
|
||||
- `context.json.items` 是 replayable 语义历史。
|
||||
- 发给 LLM 的历史由 projector 从 `context.json.items` 投影,不是从 provider artifacts 重放。
|
||||
- `state.json.entries` 只是内存结构 `ConversationFile` 的字段;落盘时可投影历史在 `context.json.items`。
|
||||
|
||||
### `usage.json`
|
||||
|
||||
来源链路:
|
||||
|
||||
- `internal/backend/forwarder/usage_store.go`
|
||||
- `UsageFileStore.UpsertEvent`
|
||||
- `UsageFileStore.LookupEvent`
|
||||
- `internal/backend/forwarder/token_usage.go`
|
||||
- `internal/historymetrics/`
|
||||
|
||||
稳定结论:
|
||||
|
||||
- `usage.json` 是全局 usage 聚合,不属于单个 conversation 的语义历史。
|
||||
- `recent_events` 只保留最近有限数量事件;长期总量看 `totals` / `daily`。
|
||||
|
||||
### legacy 清理
|
||||
|
||||
来源链路:
|
||||
|
||||
- `internal/backend/forwarder/history_maintenance.go`
|
||||
|
||||
稳定结论:
|
||||
|
||||
- `turns/`、`conversation.json`、`entries.jsonl`、`request.json`、`summary.json` 等都是 legacy artifact。
|
||||
- 启动后的 history maintenance 会清理这些旧产物。
|
||||
|
||||
## 快速判断规则
|
||||
|
||||
- 用户只发一个 id 时,先查 `history/<id>/state.json` 是否存在;不存在再扫 `state.json/context.json/logs`。
|
||||
- 请求失败时,先看 `state.last_provider_call`、`context.json.items` 的错误 metadata、`logs/app.log`;不要找 `turns/<n>/summary.json`。
|
||||
- pending / 工具不收口时,先看同一 `turn_seq` 的 tool call 和 tool result entries,再对照协议上行 `exec_client_message` / `exec_client_control_message` / `interaction_response`。
|
||||
- prefix cache 异常时,先看 `context.json.items` 的稳定追加顺序和 `usage.json` 的 cache token 字段。
|
||||
- 如果 history 与日志冲突,优先相信当前仍在更新的 `state.json/context.json`,再用日志解释运行时经过了哪条路径。
|
||||
@@ -0,0 +1,147 @@
|
||||
# 文件地图
|
||||
|
||||
## 已安装客户端 bundle
|
||||
|
||||
优先核对这些实际运行中的客户端文件:
|
||||
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/gitWorker.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
|
||||
|
||||
当前安装包里 `cursor-agent` 已拆成 split bundle;旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 通常不存在。不要按旧路径下结论;需要先列出 `extensions/`,再确认实际存在的 `cursor-agent-exec`、`cursor-agent-worker`、`cursor-always-local` 及其 `dist/` 文件。
|
||||
|
||||
大致归属:
|
||||
|
||||
- `out/vs/workbench/workbench.desktop.main.js`:主 UI、agent window / titlebar、feature flag、用户可点击入口和只读/禁用态。
|
||||
- `cursor-always-local/dist/main.js`:本地模式协议、`BidiAppend`、`RunSSE`、`AgentServerMessage` / `AgentClientMessage` 桥接。
|
||||
- `cursor-agent-exec/dist/main.js` 与同目录数字 chunk:agent 执行侧、SDK/canvas runtime、工具执行、proto 消息定义与拆分 chunk。当前构建中 `411.js` 可能包含 agent 执行链路关键片段,但 chunk 编号不是稳定接口,先用 `dist/*.js` 搜索。
|
||||
- `cursor-agent-worker/dist/main.js`:agent worker 侧后台逻辑。
|
||||
|
||||
用户机器上可能还存在其它 app 副本,例如:
|
||||
|
||||
- `~/Applications/Cursor Hooked.app`
|
||||
- `/Applications/Cursor Patched.app`
|
||||
|
||||
不要假设哪一个在跑,先看进程路径。
|
||||
|
||||
## 当前 backend/store 与 history
|
||||
|
||||
当前用户机器上的固定助手目录:
|
||||
|
||||
- `~/.cursor-local-assistant-v2/`
|
||||
|
||||
重点看:
|
||||
|
||||
- `~/.cursor-local-assistant-v2/config.yaml`
|
||||
- `~/.cursor-local-assistant-v2/data/ca.crt`
|
||||
- `~/.cursor-local-assistant-v2/data/ads/`
|
||||
- `~/.cursor-local-assistant-v2/history/usage.json`
|
||||
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json`
|
||||
- `~/.cursor-local-assistant-v2/history/<conversationId>/context.json`
|
||||
- `~/.cursor-local-assistant-v2/history/<conversationId>/conversation.lock`
|
||||
- `~/.cursor-local-assistant-v2/logs/app.log`
|
||||
|
||||
其中:
|
||||
|
||||
- `state.json` 是会话元数据、loop 状态、latest provider/request prefix、当前 todos/plans、token/compaction 状态。
|
||||
- `context.json.items` 是 append-only 语义历史,也是 prompt replay 的事实源。
|
||||
- `usage.json` 是全局 provider call / turn usage 聚合。
|
||||
- `conversation.lock` 是会话级文件锁。
|
||||
- checkpoint 只表示同一 backend 进程内的 live state,不是持久化恢复事实源。
|
||||
- legacy artifacts:`conversation.json`、`entries.jsonl`、`turns/`、`request.json`、`summary.json`、`sse.jsonl`、`replay.json`、`runtime.json`、`latest.json`、数字 turn 目录,当前会被 history maintenance 清理。
|
||||
|
||||
这些内容的生成入口主要在:
|
||||
|
||||
- `internal/appdata/paths.go`
|
||||
- `internal/backend/host.go`
|
||||
- `internal/backend/README.md`
|
||||
- `internal/backend/forwarder/file_store.go`
|
||||
- `internal/backend/forwarder/history_maintenance.go`
|
||||
- `internal/backend/forwarder/usage_store.go`
|
||||
- `internal/backend/forwarder/token_usage.go`
|
||||
- `internal/backend/forwarder/artifacts.go`
|
||||
|
||||
## 本仓库协议与本地模式实现
|
||||
|
||||
协议定义:
|
||||
|
||||
- `proto/agent_v1.proto`
|
||||
- `proto/aiserver_v1.proto`
|
||||
- `proto/from_extensions/agent_v1.proto`
|
||||
- `proto/from_extensions/aiserver_v1.proto`
|
||||
|
||||
扩展快照与提取:
|
||||
|
||||
- `proto/extensions-cursor-app/cursor-always-local/package.json`
|
||||
- `proto/extract_extensions_proto.sh`
|
||||
- `proto/ext_tool/main.go`
|
||||
|
||||
本地后端入口:
|
||||
|
||||
- `internal/backend/host.go`
|
||||
- `internal/backend/server/route.go`
|
||||
- `internal/backend/server/policy.go`
|
||||
- `internal/backend/server/local.go`
|
||||
- `internal/backend/server/config/types.go`
|
||||
- `internal/backend/server/config/manager.go`
|
||||
- `internal/backend/server/config/resolver.go`
|
||||
|
||||
forwarder 主链路:
|
||||
|
||||
- `internal/backend/forwarder/module.go`
|
||||
- `internal/backend/forwarder/service.go`
|
||||
- `internal/backend/forwarder/actor.go`
|
||||
- `internal/backend/forwarder/broker.go`
|
||||
- `internal/backend/forwarder/events.go`
|
||||
- `internal/backend/forwarder/compiler.go`
|
||||
- `internal/backend/forwarder/projector.go`
|
||||
- `internal/backend/forwarder/provider.go`
|
||||
- `internal/backend/forwarder/checkpoint_memory.go`
|
||||
- `internal/backend/forwarder/runtime_summary.go`
|
||||
|
||||
协议解码:
|
||||
|
||||
- `internal/backend/agent/protocol/inbound.go`
|
||||
|
||||
执行桥 / 交互桥:
|
||||
|
||||
- `internal/backend/agent/bridge/exec/bridge.go`
|
||||
- `internal/backend/agent/bridge/interaction/bridge.go`
|
||||
|
||||
模型适配:
|
||||
|
||||
- `internal/backend/agent/model/router.go`
|
||||
- `internal/backend/agent/model/openai.go`
|
||||
- `internal/backend/agent/model/anthropic.go`
|
||||
- `internal/backend/agent/model/artifacts.go`
|
||||
- `internal/backend/agent/model/http_error.go`
|
||||
- `internal/backend/agent/model/tool_call_id.go`
|
||||
- `internal/modelchannel/identity.go`
|
||||
- `internal/runtime/local_runtime.go`
|
||||
|
||||
Prompt / replay:
|
||||
|
||||
- `internal/backend/agent/prompt/engine.go`
|
||||
- `internal/backend/agent/prompt/replay.go`
|
||||
- `internal/backend/agent/prompt/content_parts.go`
|
||||
- `internal/backend/forwarder/prompt_context.go`
|
||||
- `internal/backend/forwarder/request_context.go`
|
||||
- `internal/backend/forwarder/reminders.go`
|
||||
- `internal/backend/forwarder/prompt_guard.go`
|
||||
|
||||
## 构建相关参考(只读)
|
||||
|
||||
仓库内已有 macOS 构建与签名相关文件,可用于理解产物结构或历史处理方式,但不要把它们当成修改已安装 Cursor 客户端的操作指南:
|
||||
|
||||
- `Taskfile.yml`
|
||||
- `build/darwin/Taskfile.yml`
|
||||
- `build/dmg-extras/提示损坏?点我.command`
|
||||
|
||||
重点看:
|
||||
|
||||
- `build/darwin/Taskfile.yml` 中的 `codesign:adhoc`
|
||||
- `build/dmg-extras/提示损坏?点我.command` 中的 `xattr -cr`
|
||||
@@ -0,0 +1,87 @@
|
||||
# 已安装客户端只读核对与验证
|
||||
|
||||
首要原则:
|
||||
|
||||
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
|
||||
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、端口和 history 状态。
|
||||
- 目标是定位差异、收集证据、判断问题归属,而不是 patch 客户端。
|
||||
|
||||
## 1. 先确认实际运行的 app 副本
|
||||
|
||||
优先用非交互命令核对:
|
||||
|
||||
```bash
|
||||
pgrep -fal 'Cursor Hooked|Cursor Patched|/Contents/MacOS/Cursor'
|
||||
ps -axo pid,ppid,command | rg 'Cursor(.app)?/Contents/MacOS/Cursor|extension-host'
|
||||
```
|
||||
|
||||
不要在没确认实际运行副本前就下结论,也不要修改客户端文件。
|
||||
|
||||
## 2. 只读定位目标 bundle 与关键文件
|
||||
|
||||
优先定位并读取这些文件,而不是改写它们:
|
||||
|
||||
```bash
|
||||
ls -l "/absolute/path/Target.app/Contents/Resources/app/extensions"
|
||||
shasum -a 256 "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
|
||||
```
|
||||
|
||||
重点关注:
|
||||
|
||||
- `out/vs/workbench/workbench.desktop.main.js`
|
||||
- `out/vs/workbench/api/node/extensionHostProcess.js`
|
||||
- `cursor-always-local/dist/main.js`
|
||||
- `cursor-always-local/dist/gitWorker.js`
|
||||
- `cursor-agent-exec/dist/main.js`
|
||||
- `cursor-agent-exec/dist/*.js`
|
||||
- `cursor-agent-worker/dist/main.js`
|
||||
|
||||
当前安装包里旧路径 `cursor-agent/dist/main.js` 通常不存在;先确认 `extensions/` 里的实际扩展名和 `dist/` 文件,再选择 `workbench` / `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 对应排查。
|
||||
|
||||
## 3. 只读读取 bundle 内容
|
||||
|
||||
常用定位关键词:
|
||||
|
||||
```bash
|
||||
rg -n 'BidiTransport|ExecClientMessage|InteractionResponse|conversation_checkpoint_update' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
|
||||
rg -n 'CursorAgentProvider|AnthropicProxy|ANTHROPIC_BASE_URL|InteractionUpdate|checkpoint|agent window' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js"
|
||||
rg -n 'agent window|open_agent_window|NameAgent|UpdateConversationMetadata|shouldShowAgentWindowTitleHelperText' "/absolute/path/Target.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js
|
||||
```
|
||||
|
||||
读取具体文件内容时,优先用读取工具按需查看相关片段,不要修改 bundle。
|
||||
|
||||
如果需要和仓库实现对照,优先同时打开:
|
||||
|
||||
- `proto/agent_v1.proto`
|
||||
- `proto/aiserver_v1.proto`
|
||||
- `internal/backend/...`
|
||||
- `internal/runtime/local_runtime.go`
|
||||
|
||||
## 4. 验证行为是否命中目标副本
|
||||
|
||||
至少做其中两项:
|
||||
|
||||
- 进程路径是否是目标 app
|
||||
- 目标扩展 host 是否起来
|
||||
- 本地监听端口是否存在
|
||||
- `~/.cursor-local-assistant-v2/logs/app.log` 是否更新
|
||||
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json` / `context.json` 是否更新
|
||||
- 请求/协议事件是否真的经过你正在分析的 bundle 文件
|
||||
|
||||
常用验证:
|
||||
|
||||
```bash
|
||||
pgrep -fal '/absolute/path/Target.app/Contents/MacOS/Cursor'
|
||||
lsof -nP -iTCP -sTCP:LISTEN | rg 'Cursor|127.0.0.1'
|
||||
```
|
||||
|
||||
## 5. 记录证据并输出归因
|
||||
|
||||
如果确认“已安装 app 行为”和“仓库代码理解”存在差异,优先记录:
|
||||
|
||||
1. 实际运行的 app 路径
|
||||
2. 命中的 bundle 文件路径与关键符号
|
||||
3. 对应日志、端口、`history/state.json`、`history/context.json`、`usage.json` 证据
|
||||
4. 仓库里对应实现的位置
|
||||
|
||||
如果结论指向客户端侧,也停留在分析和归因,不要继续 patch、重签名、替换文件或做写入式验证。
|
||||
@@ -0,0 +1,153 @@
|
||||
# Provider replay / debug
|
||||
|
||||
当现象是 provider 返回错误、SSE 里只有 `event: error`、需要确认最终出站 provider body 是否能被独立复现时,优先读这份参考。
|
||||
|
||||
这套流程只用于还原“后端最终发给 provider 的请求形状”和“provider 对该请求的真实响应”。它不是语义 history,也不是客户端输入事实源。
|
||||
|
||||
## 证据边界
|
||||
|
||||
- `history/<conversationId>/debug/provider.jsonl`
|
||||
- 最接近 provider 出站边界。
|
||||
- `event=llm_request` 的 `payload.body` 是最终 provider request body。
|
||||
- 用它做 curl replay,判断问题是否已经出现在出站请求形状。
|
||||
- `history/<conversationId>/state.json`
|
||||
- 当前状态和最近 provider 摘要。
|
||||
- 重点看 `latest_request_prefix`、`last_provider_call`。
|
||||
- `history/<conversationId>/context.json`
|
||||
- replayable 语义历史。
|
||||
- 用来解释为什么会形成这次 prompt,不用来直接重放 provider HTTP 请求。
|
||||
- `history/<conversationId>/debug/bidi.raw.jsonl`
|
||||
- 客户端原始上行字节证据。
|
||||
- `history/<conversationId>/debug/bidi.decoded.jsonl`
|
||||
- 当前 known-schema 解码后的客户端上行证据。
|
||||
- `history/<conversationId>/debug/runtime.jsonl`
|
||||
- 后端把哪些字段挂到 active request / stream 上。
|
||||
- `history/<conversationId>/debug/runsse.jsonl`
|
||||
- 后端尝试发回客户端的消息。
|
||||
|
||||
不要把这些证据混用:provider replay 只能证明最终 provider HTTP 请求与响应,不能单独证明客户端原始上传了什么,也不能替代 `context.json` 的语义历史。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 请求发生时 `config.yaml` 中 `log: true`,或已有 `history/<conversationId>/debug/provider.jsonl`。
|
||||
- 已经通过 id 反查拿到:
|
||||
- `conversationId`
|
||||
- `requestId`
|
||||
- `modelCallId`
|
||||
- 已经确认要测的是 Anthropic-compatible `/v1/messages` 请求。
|
||||
|
||||
如果没有 debug 文件,先回到 `state.json`、`context.json`、`usage.json`、`logs/app.log` 做推断,并明确“没有直接 provider body 证据”。
|
||||
|
||||
## 最小流程
|
||||
|
||||
1. 定位 provider debug 文件:
|
||||
|
||||
```bash
|
||||
ROOT="$HOME/.cursor-local-assistant-v2"
|
||||
CONV="<conversationId>"
|
||||
REQ="<requestId>"
|
||||
MODEL_CALL="<modelCallId>"
|
||||
REQUEST_LOG="$ROOT/history/$CONV/debug/provider.jsonl"
|
||||
```
|
||||
|
||||
2. 确认 `llm_request` 存在:
|
||||
|
||||
```bash
|
||||
jq -c --arg req "$REQ" --arg mc "$MODEL_CALL" '
|
||||
select(.event == "llm_request" and .request_id == $req and .model_call_id == $mc)
|
||||
| {at, conversation_id, request_id, model_call_id, provider: .payload.provider, model: .payload.body.model}
|
||||
' "$REQUEST_LOG"
|
||||
```
|
||||
|
||||
3. 执行通用重放脚本:
|
||||
|
||||
```bash
|
||||
REQUEST_LOG="$REQUEST_LOG" \
|
||||
REQUEST_ID="$REQ" \
|
||||
MODEL_CALL_ID="$MODEL_CALL" \
|
||||
CHANNEL_NAME="GLM" \
|
||||
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
|
||||
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
|
||||
```
|
||||
|
||||
也可以直接传入 provider 配置,避免读取 `config.yaml`:
|
||||
|
||||
```bash
|
||||
REQUEST_LOG="$REQUEST_LOG" \
|
||||
REQUEST_ID="$REQ" \
|
||||
MODEL_CALL_ID="$MODEL_CALL" \
|
||||
BASE_URL="<provider-base-url>" \
|
||||
API_KEY="<provider-api-key>" \
|
||||
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
|
||||
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
|
||||
```
|
||||
|
||||
4. 查看产物:
|
||||
|
||||
- `request.body.json`:抽取出的最终 provider body。
|
||||
- `response.headers`:HTTP 响应头。
|
||||
- `response.sse`:SSE 响应体。
|
||||
- `replay.meta.json`:本次重放引用的 id 和 provider log 路径。
|
||||
|
||||
## 结果判断
|
||||
|
||||
- `curl_exit_code != 0`
|
||||
- 网络、TLS、超时、连接或本机 curl 问题。
|
||||
- 先看 stderr、`response.headers` 是否存在,再判断是否真的到达 provider。
|
||||
- HTTP 非 2xx
|
||||
- provider 网关或鉴权层拒绝。
|
||||
- 优先看 `response.headers` 和 provider 错误体。
|
||||
- HTTP 2xx 但 SSE 中有 `event: error`
|
||||
- provider 已接受连接,但认为请求参数不合法或模型侧拒绝。
|
||||
- 这种情况下重点比对 `request.body.json` 的消息结构、tool schema、thinking/reasoning 参数、model 名称和 endpoint 兼容性。
|
||||
- SSE 正常流式输出
|
||||
- 原始 provider 请求形状基本可用。
|
||||
- 如果客户端仍失败,回到 `runsse.jsonl`、forwarder 状态机或客户端协议层继续查。
|
||||
|
||||
## 常见收敛方向
|
||||
|
||||
provider 参数错误时,优先检查:
|
||||
|
||||
- `model` 是否是目标 endpoint 支持的名称。
|
||||
- `messages` 是否符合 Anthropic-compatible 形态。
|
||||
- `system` 是否被目标 provider 支持,或需要改成 message。
|
||||
- `tools` / `tool_choice` 是否符合目标 provider 方言。
|
||||
- `thinking` / `reasoning` 字段是否被目标 provider 支持。
|
||||
- 图片、文件、cache_control、metadata 等扩展字段是否超出 provider 兼容范围。
|
||||
- `max_tokens`、`temperature`、`top_p`、`stop_sequences` 是否落在 provider 允许范围内。
|
||||
|
||||
## 敏感信息规则
|
||||
|
||||
- 不把 API key 写进 skill、reference、脚本默认值或提交内容。
|
||||
- 回复用户时不要粘贴完整 API key;最多说明“已使用用户提供的 key / config 中的 key”。
|
||||
- 不把完整 `request.body.json` 大段贴给用户;只摘和结论相关的字段形状。
|
||||
- 不把一次性 `conversationId/requestId/modelCallId` 写进 skill 文档。
|
||||
- 临时 replay 产物默认放 `/tmp`;如果需要保留,明确说明路径和原因。
|
||||
|
||||
## 脚本参数
|
||||
|
||||
`scripts/provider-replay.sh` 使用环境变量控制:
|
||||
|
||||
- 必填:
|
||||
- `REQUEST_LOG`
|
||||
- `REQUEST_ID`
|
||||
- `MODEL_CALL_ID`
|
||||
- 可选:
|
||||
- `BASE_URL`
|
||||
- `API_KEY`
|
||||
- `GLM_BASE_URL`
|
||||
- `GLM_API_KEY`
|
||||
- `ANTHROPIC_BASE_URL`
|
||||
- `ANTHROPIC_API_KEY`
|
||||
- `CONFIG_FILE`,默认 `~/.cursor-local-assistant-v2/config.yaml`
|
||||
- `CHANNEL_NAME`,默认 `GLM`
|
||||
- `OUT_DIR`,默认 `/tmp/cursor-provider-replay-<requestId>`
|
||||
- `MAX_TIME`,默认 `240`
|
||||
- `ENDPOINT_PATH`,默认 `/v1/messages`
|
||||
|
||||
脚本输出四个稳定产物:
|
||||
|
||||
- `request.body.json`
|
||||
- `response.headers`
|
||||
- `response.sse`
|
||||
- `replay.meta.json`
|
||||
@@ -0,0 +1,223 @@
|
||||
# 搜索词与判断树
|
||||
|
||||
## 先判断层级
|
||||
|
||||
### `history + logs` / id 反查层
|
||||
|
||||
当现象是“用户只给了一个 id”“需要判断它是 `conversationId`、`requestId`、`modelCallId`、`toolCallId`”“要从本地 history 和日志追状态”:
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `state.json`
|
||||
- `context.json`
|
||||
- `usage.json`
|
||||
- `logs/app.log`
|
||||
- `conversation_id`
|
||||
- `current_request_id`
|
||||
- `request_id`
|
||||
- `model_call_id`
|
||||
- `tool_call_id`
|
||||
- `latest_request_prefix`
|
||||
- `last_provider_call`
|
||||
- `current_loop_status`
|
||||
- `context_version`
|
||||
- `next_entry_seq`
|
||||
- `next_turn_seq`
|
||||
- `LoadConversation`
|
||||
- `CreateConversation`
|
||||
- `SaveConversationWithEntries`
|
||||
- `AppendEntries`
|
||||
- `UpdateConversationMeta`
|
||||
- `ReplaceEntries`
|
||||
- `ProjectPromptReplay`
|
||||
- `UsageFileStore`
|
||||
- `UpsertEvent`
|
||||
- `LookupEvent`
|
||||
|
||||
不要再优先搜索或依赖:
|
||||
|
||||
- `data.sqlite`
|
||||
- `protocol_traces`
|
||||
- `agent_request_runs`
|
||||
- `conversation.json`
|
||||
- `entries.jsonl`
|
||||
- `turns/<n>`
|
||||
- `request.json`
|
||||
- `sse.jsonl`
|
||||
- `summary.json`
|
||||
|
||||
这些是旧实现或 legacy artifact 相关线索,只在排查迁移/清理逻辑时作为历史背景。
|
||||
|
||||
### `cursor-agent-exec` / `cursor-agent-worker` 层
|
||||
|
||||
当现象涉及 agent 主循环、模型桥接、`InteractionUpdate` 映射、工具 started/completed、session/provider 状态:
|
||||
|
||||
优先在已安装客户端 split bundle 中搜索:
|
||||
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `registerAgentProvider`
|
||||
- `CursorAgentProvider`
|
||||
- `CursorAgentProviderHandle`
|
||||
- `ClaudeSDKClient`
|
||||
- `streamInteractionUpdates`
|
||||
- `handlePartialMessage`
|
||||
- `AnthropicProxy`
|
||||
- `getAnthropicProxyPort`
|
||||
- `getAnthropicProxyAuthToken`
|
||||
- `ANTHROPIC_BASE_URL`
|
||||
- `ANTHROPIC_API_KEY`
|
||||
- `InteractionUpdate`
|
||||
- `checkpoint`
|
||||
|
||||
旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 可能不存在。先确认实际 `extensions/` 结构,再按 `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 分层排查。
|
||||
|
||||
### agent window / conversation metadata UI 层
|
||||
|
||||
当现象涉及 agent window 标题、窗口信息、titlebar 按钮、是否可点击修改、会话名/metadata 更新:
|
||||
|
||||
优先在已安装客户端主 UI 和 split bundle 中搜索:
|
||||
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `shouldShowAgentWindowTitleHelperText`
|
||||
- `glass_open_agents_titlebar_button`
|
||||
- `open_agent_window_top`
|
||||
- `open_agent_window_bottom_convo`
|
||||
- `glass.enable_open_agent_in_window`
|
||||
- `NameAgentRequest`
|
||||
- `NameAgentResponse`
|
||||
- `UpdateConversationMetadataRequest`
|
||||
- `UpdateConversationMetadataResponse`
|
||||
- `CreateTranscriptOverviewRequest`
|
||||
- `createTranscriptOverview`
|
||||
- `updateConversationMetadata`
|
||||
- `conversation_checkpoint_update`
|
||||
|
||||
判断规则:
|
||||
|
||||
- `ConversationStateStructure` / `conversation_checkpoint_update` 是 UI 同步快照,不应作为持久化修改入口。
|
||||
- 如果客户端调用 `UpdateConversationMetadata` / `NameAgent`,要继续确认本地后端是否显式注册对应 `/agent.v1.AgentService/*` 路由;不能只看 proto message 存在。
|
||||
- 本地模式修改会话名/metadata 时,应落到 `history/<conversationId>/state.json` 或等价持久化会话元数据,再 publish checkpoint 同步 UI。
|
||||
|
||||
### `cursor-always-local` / 协议层
|
||||
|
||||
当现象涉及本地模式、客户端没有回包、pending 不收口、同一 backend 进程内的 live checkpoint 重连错乱:
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `BidiTransport`
|
||||
- `startYieldingInputsToTheServer`
|
||||
- `BidiAppend`
|
||||
- `RunSSE`
|
||||
- `AgentServerMessage`
|
||||
- `AgentClientMessage`
|
||||
- `ExecServerMessage`
|
||||
- `ExecClientMessage`
|
||||
- `ExecClientControlMessage`
|
||||
- `InteractionQuery`
|
||||
- `InteractionResponse`
|
||||
- `conversation_checkpoint_update`
|
||||
|
||||
### 本仓库 forwarder 层
|
||||
|
||||
当现象涉及本地后端收发、provider 继续/暂停、exec/interaction 桥接、history 投影:
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `handleRunIntent`
|
||||
- `driveProvider`
|
||||
- `startStreamActor`
|
||||
- `streamCommandEnvelope`
|
||||
- `handleToolInvocation`
|
||||
- `handleExecResult`
|
||||
- `handleExecControl`
|
||||
- `publishCheckpoint`
|
||||
- `CheckpointConversation`
|
||||
- `snapshotCheckpointConversation`
|
||||
- `appendConversationEntries`
|
||||
- `OpenExec`
|
||||
- `OpenQuery`
|
||||
- `StartStream`
|
||||
- `deriveConversationLoopState`
|
||||
- `historyEntryToolCallID`
|
||||
- `recordProviderUsage`
|
||||
- `recordTurnUsage`
|
||||
|
||||
### provider / 模型适配层
|
||||
|
||||
当现象是 provider 400/500、thinking/reasoning、tool_call_id、OpenAI/Anthropic 请求形状、usage/cache 不对:
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `StartStream`
|
||||
- `StreamRequest`
|
||||
- `ResolvedChannelID`
|
||||
- `ResolvedChannelName`
|
||||
- `ProviderModelID`
|
||||
- `ThinkingEnabled`
|
||||
- `buildAnthropicThinkingConfig`
|
||||
- `normalizeAnthropicProviderMessages`
|
||||
- `normalizeOpenAIProviderMessages`
|
||||
- `normalizeOpenAIResponsesInput`
|
||||
- `reasoning_content`
|
||||
- `ReasoningContent`
|
||||
- `ReasoningSignature`
|
||||
- `RecordLLMRequest`
|
||||
- `RecordLLMSummary`
|
||||
- `http_error`
|
||||
- `namespaceToolCallID`
|
||||
|
||||
## 快速判断规则
|
||||
|
||||
- 如果问题是“给你一个 id,让你先判断是什么 ID,再找日志”,先看 `history/<id>/state.json` 是否存在,再扫 `history/*/state.json`、`history/*/context.json` 和 `logs/app.log`。
|
||||
- 如果问题是“模型输出语义不对”,先看 `context.json.items` 到 `ProjectPromptReplay()` 的投影,再看 provider request normalization。
|
||||
- 如果问题是“provider 报 400/参数错误”,先看模型适配层请求构造、`state.latest_request_prefix`、`state.last_provider_call`、`logs/app.log`。
|
||||
- 如果问题是“客户端没回某个工具结果 / pending 不收口”,先看 `cursor-always-local` 与 forwarder,同时核对同一 `turn_seq` 是否有 `tool_result` 或控制面错误 entry。
|
||||
- 如果问题是“backend 重启后为什么 checkpoint 没法继续恢复 pending”,不要找磁盘 checkpoint;checkpoint 是 live state,重启后的事实源是 `state.json + context.json`。
|
||||
- 如果问题是“为什么同一个 `modelID` 还能出现多个渠道”,先检查渠道 ID:规范化后 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 SHA-256;resolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`。
|
||||
- 如果问题是“只想桥接到其他 LLM”,优先看模型桥接层,不要默认深入整套 local runtime。
|
||||
- 如果问题是“已安装 app 行为和仓库代码不一致”,先核对实际运行 bundle,再做只读比对;不要 patch 客户端。
|
||||
|
||||
## 协议关键词
|
||||
|
||||
上行:
|
||||
|
||||
- `run_request`
|
||||
- `exec_client_message`
|
||||
- `exec_client_control_message`
|
||||
- `interaction_response`
|
||||
|
||||
下行:
|
||||
|
||||
- `interaction_update`
|
||||
- `exec_server_message`
|
||||
- `exec_server_control_message`
|
||||
- `interaction_query`
|
||||
- `conversation_checkpoint_update`
|
||||
|
||||
如果只看到下行请求,没有对应上行结果或控制消息,优先排查:
|
||||
|
||||
- `exec_id`
|
||||
- `id`
|
||||
- `tool_call_id`
|
||||
- `request_id`
|
||||
- `model_call_id`
|
||||
- pending 收口逻辑
|
||||
|
||||
如果用户给的是一个裸 id,不要直接把它当成 `request_id`。先同时查:
|
||||
|
||||
- `history/<id>/state.json`
|
||||
- `history/*/state.json` 的 `current_request_id`、`latest_request_prefix`、`last_provider_call`
|
||||
- `history/*/context.json` 的 `items[].request_id`、`items[].tool_call_id`、`items[].payload`
|
||||
- `history/usage.json` 的 `event_index` / `recent_events`
|
||||
- `logs/app.log`
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const skillRoot = path.resolve(path.dirname(scriptPath), "..");
|
||||
const repoRoot = path.resolve(skillRoot, "../../..");
|
||||
const args = ["run", "./scripts/historymetrics", ...process.argv.slice(2)];
|
||||
|
||||
const child = spawn("go", args, {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`cache-hit-rate.mjs failed: ${message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (typeof code === "number") {
|
||||
process.exitCode = code;
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
console.error(`cache-hit-rate.mjs terminated by signal: ${signal}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
REQUEST_LOG=<path/to/provider.jsonl> REQUEST_ID=<request-id> MODEL_CALL_ID=<model-call-id> \
|
||||
[BASE_URL=<provider-base-url>] [API_KEY=<provider-api-key>] [CONFIG_FILE=<config.yaml>] \
|
||||
[OUT_DIR=<output-dir>] [MAX_TIME=240] provider-replay.sh
|
||||
|
||||
Required:
|
||||
REQUEST_LOG Path to history/<conversationId>/debug/provider.jsonl
|
||||
REQUEST_ID Provider request_id to replay
|
||||
MODEL_CALL_ID Provider model_call_id to replay
|
||||
|
||||
Optional:
|
||||
BASE_URL Provider base URL. Falls back to GLM_BASE_URL or ANTHROPIC_BASE_URL.
|
||||
API_KEY Provider API key. Falls back to ANTHROPIC_API_KEY or GLM_API_KEY.
|
||||
CONFIG_FILE Defaults to ~/.cursor-local-assistant-v2/config.yaml.
|
||||
CHANNEL_NAME Display name to read from config.yaml when BASE_URL/API_KEY is missing. Defaults to GLM.
|
||||
OUT_DIR Output directory. Defaults to /tmp/cursor-provider-replay-<request-id>.
|
||||
MAX_TIME curl max-time seconds. Defaults to 240.
|
||||
ENDPOINT_PATH Provider path. Defaults to /v1/messages.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REQUEST_LOG="${REQUEST_LOG:-}"
|
||||
REQUEST_ID="${REQUEST_ID:-}"
|
||||
MODEL_CALL_ID="${MODEL_CALL_ID:-}"
|
||||
CONFIG_FILE="${CONFIG_FILE:-$HOME/.cursor-local-assistant-v2/config.yaml}"
|
||||
CHANNEL_NAME="${CHANNEL_NAME:-GLM}"
|
||||
BASE_URL="${BASE_URL:-${GLM_BASE_URL:-${ANTHROPIC_BASE_URL:-}}}"
|
||||
API_KEY="${API_KEY:-${ANTHROPIC_API_KEY:-${GLM_API_KEY:-}}}"
|
||||
ENDPOINT_PATH="${ENDPOINT_PATH:-/v1/messages}"
|
||||
MAX_TIME="${MAX_TIME:-240}"
|
||||
OUT_DIR="${OUT_DIR:-/tmp/cursor-provider-replay-${REQUEST_ID:-unknown}}"
|
||||
BODY_FILE="$OUT_DIR/request.body.json"
|
||||
RESP_FILE="$OUT_DIR/response.sse"
|
||||
HEADER_FILE="$OUT_DIR/response.headers"
|
||||
META_FILE="$OUT_DIR/replay.meta.json"
|
||||
|
||||
require_value() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
if [[ -z "$value" ]]; then
|
||||
echo "缺少 $name。运行 --help 查看用法。" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
read_channel_config() {
|
||||
local field="$1"
|
||||
python3 - "$CONFIG_FILE" "$CHANNEL_NAME" "$field" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
config_path = Path(sys.argv[1]).expanduser()
|
||||
channel_name = sys.argv[2]
|
||||
field = sys.argv[3]
|
||||
if not config_path.exists():
|
||||
raise SystemExit
|
||||
|
||||
lines = config_path.read_text(encoding="utf-8").splitlines()
|
||||
in_channel = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- displayName:"):
|
||||
in_channel = stripped.split(":", 1)[1].strip().strip('"') == channel_name
|
||||
continue
|
||||
if in_channel and stripped.startswith(field + ":"):
|
||||
print(stripped.split(":", 1)[1].strip().strip('"'))
|
||||
raise SystemExit
|
||||
PY
|
||||
}
|
||||
|
||||
require_value "REQUEST_LOG" "$REQUEST_LOG"
|
||||
require_value "REQUEST_ID" "$REQUEST_ID"
|
||||
require_value "MODEL_CALL_ID" "$MODEL_CALL_ID"
|
||||
|
||||
if [[ ! -f "$REQUEST_LOG" ]]; then
|
||||
echo "REQUEST_LOG 不存在: $REQUEST_LOG" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -z "$BASE_URL" ]]; then
|
||||
BASE_URL="$(read_channel_config baseURL || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$API_KEY" ]]; then
|
||||
API_KEY="$(read_channel_config apiKey || true)"
|
||||
fi
|
||||
|
||||
require_value "BASE_URL/GLM_BASE_URL/ANTHROPIC_BASE_URL 或 config[$CHANNEL_NAME].baseURL" "$BASE_URL"
|
||||
require_value "API_KEY/ANTHROPIC_API_KEY/GLM_API_KEY 或 config[$CHANNEL_NAME].apiKey" "$API_KEY"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
: > "$HEADER_FILE"
|
||||
: > "$RESP_FILE"
|
||||
|
||||
python3 - "$REQUEST_LOG" "$REQUEST_ID" "$MODEL_CALL_ID" "$BODY_FILE" "$META_FILE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
log_path = Path(sys.argv[1]).expanduser()
|
||||
request_id = sys.argv[2]
|
||||
model_call_id = sys.argv[3]
|
||||
body_path = Path(sys.argv[4])
|
||||
meta_path = Path(sys.argv[5])
|
||||
|
||||
body = None
|
||||
meta = None
|
||||
with log_path.open(encoding="utf-8") as f:
|
||||
for raw in f:
|
||||
if not raw.strip():
|
||||
continue
|
||||
row = json.loads(raw)
|
||||
if row.get("event") != "llm_request":
|
||||
continue
|
||||
if row.get("request_id") != request_id:
|
||||
continue
|
||||
if row.get("model_call_id") != model_call_id:
|
||||
continue
|
||||
payload = row.get("payload") or {}
|
||||
body = payload.get("body")
|
||||
meta = {
|
||||
"at": row.get("at"),
|
||||
"event": row.get("event"),
|
||||
"conversation_id": row.get("conversation_id"),
|
||||
"request_id": row.get("request_id"),
|
||||
"model_call_id": row.get("model_call_id"),
|
||||
"provider_log": str(log_path),
|
||||
}
|
||||
break
|
||||
|
||||
if body is None:
|
||||
raise SystemExit(f"未找到 llm_request: request_id={request_id} model_call_id={model_call_id}")
|
||||
|
||||
body_path.write_text(json.dumps(body, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
url="${BASE_URL%/}${ENDPOINT_PATH}"
|
||||
|
||||
set +e
|
||||
curl --no-buffer --silent --show-error \
|
||||
--connect-timeout 30 \
|
||||
--max-time "$MAX_TIME" \
|
||||
--request POST "$url" \
|
||||
--header "content-type: application/json" \
|
||||
--header "anthropic-version: 2023-06-01" \
|
||||
--header "User-Agent: claude-cli/1.0.25" \
|
||||
--header "x-api-key: $API_KEY" \
|
||||
--header "Authorization: Bearer $API_KEY" \
|
||||
--data-binary "@$BODY_FILE" \
|
||||
--dump-header "$HEADER_FILE" \
|
||||
--output "$RESP_FILE"
|
||||
code=$?
|
||||
set -e
|
||||
|
||||
echo "curl_exit_code=$code"
|
||||
echo "body=$BODY_FILE"
|
||||
echo "headers=$HEADER_FILE"
|
||||
echo "response=$RESP_FILE"
|
||||
echo "meta=$META_FILE"
|
||||
|
||||
echo "--- response headers ---"
|
||||
if [[ -f "$HEADER_FILE" ]]; then
|
||||
python3 - "$HEADER_FILE" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:40]:
|
||||
print(line)
|
||||
PY
|
||||
fi
|
||||
|
||||
echo "--- response first 120 lines ---"
|
||||
if [[ -f "$RESP_FILE" ]]; then
|
||||
python3 - "$RESP_FILE" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:120]:
|
||||
print(line)
|
||||
PY
|
||||
else
|
||||
echo "响应文件不存在。"
|
||||
fi
|
||||
|
||||
exit "$code"
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
name: cursor-debug-log
|
||||
description: 当需要调查 Cursor 本地模式 debug/log 证据时使用:config.yaml 的 log 热加载、history/<conversationId>/debug JSONL 文件、Bidi 原始/解码记录、RunSSE 记录、runtime/provider debug 记录、debug 文件缺失原因,或解释这些 debug 文件如何生成与如何查询。
|
||||
---
|
||||
|
||||
# Cursor Debug Log
|
||||
|
||||
使用这个技能来解释和检查本地 debug log 体系。目标是在不修改已安装 Cursor 客户端、不依赖旧版 legacy artifact 的前提下,还原一次请求附近发生了什么。
|
||||
|
||||
## 作用定位
|
||||
|
||||
debug log 是本地模式请求链路的可选证据层。它和模型可见历史是分开的:
|
||||
|
||||
- 用来回答“客户端到底发了什么”。
|
||||
- 用来回答“后端解码后认为这是什么请求”。
|
||||
- 用来回答“哪些字段被挂到了当前 active request 上”。
|
||||
- 用来回答“最终 provider request body 是什么样”。
|
||||
- 用来回答“RunSSE 实际给客户端发送了什么”。
|
||||
- 不要把它当成 replay history、prompt 输入或状态事实源。
|
||||
|
||||
稳定事实源仍然是:
|
||||
|
||||
- `history/<conversationId>/state.json`
|
||||
- `history/<conversationId>/context.json`
|
||||
- `history/usage.json`
|
||||
- `logs/app.log`
|
||||
|
||||
debug 文件是在这些事实源之外,补充原始或近原始链路证据。
|
||||
|
||||
## 固定路径
|
||||
|
||||
- 助手根目录:`~/.cursor-local-assistant-v2`
|
||||
- 配置文件:`~/.cursor-local-assistant-v2/config.yaml`
|
||||
- history 根目录:`~/.cursor-local-assistant-v2/history`
|
||||
- app 日志:`~/.cursor-local-assistant-v2/logs/app.log`
|
||||
- 会话 debug 目录:`history/<conversationId>/debug/`
|
||||
- 孤儿 debug 目录:`history/_debug/orphan/<requestId>/`
|
||||
|
||||
通过配置开启 debug logging:
|
||||
|
||||
```yaml
|
||||
log: true
|
||||
```
|
||||
|
||||
当前实现会用轻量文件快照检查热加载 `config.yaml`。改完 `log` 后,预留大约 500ms,再期待下一次请求事件使用新值。旧二进制可能仍然需要重启。
|
||||
|
||||
## 文件如何生成
|
||||
|
||||
debug 层随着请求穿过后端边界逐步落盘:
|
||||
|
||||
1. `BidiAppend` 收到客户端上行数据。
|
||||
- 原始 hex 写入 `bidi.raw.jsonl`。
|
||||
- 解码后的 known-schema protobuf 与后端提取出的 intent 写入 `bidi.decoded.jsonl`。
|
||||
2. forwarder 把解码结果转成 active runtime state。
|
||||
- stream/request 状态决策写入 `runtime.jsonl`。
|
||||
3. provider pass 被准备并执行。
|
||||
- adapter 前的请求摘要、`model_call_id`、`provider_pass` 等写入 `provider.jsonl`。
|
||||
- provider artifact callback 追加最终 request/summary payload 到 `provider.jsonl`。
|
||||
4. `RunSSE` 把后端输出流式发送给客户端。
|
||||
- 已发送消息、终态事件、发送错误、断连和 heartbeat 写入 `runsse.jsonl`。
|
||||
|
||||
如果某条消息到达时后端还不知道 `conversationId`,早期事件可能写到 `_debug/orphan/<requestId>/`。后续一旦知道 `conversationId`,新事件应进入 `history/<conversationId>/debug/`。还原早期或乱序请求时,两处都要查。
|
||||
|
||||
## Debug 文件含义
|
||||
|
||||
`bidi.raw.jsonl`
|
||||
|
||||
- 方向:客户端到后端。
|
||||
- 包含 `request_id`、可选 `conversation_id`、`append_seqno`、`status`、原始 `data_hex`。
|
||||
- 当需要精确确认客户端上传字节时先看它。
|
||||
|
||||
`bidi.decoded.jsonl`
|
||||
|
||||
- 方向:客户端到后端,protobuf 解码后。
|
||||
- 当前 schema v2 包含完整的 known-schema `AgentClientMessage` protojson:`message`。
|
||||
- 同时包含后端从上行包提取出的 intent:`intent`,其中会展开相关 proto 子对象,例如 `client_message`、`user_message`、`request_context`、`conversation_state`、exec/interaction/kv 回包等。
|
||||
- 还包含 `message_case`、`requested_model`、`conversation_action` 等检索索引;这些索引只方便搜索,不是完整证据本体。
|
||||
- 当需要确认后端如何理解客户端请求时看它。若要证明客户端原始上传字节,仍以 `bidi.raw.jsonl` 为准。
|
||||
- 旧二进制或旧日志可能只有 schema v1 摘要,未必展开 `message` 和 `intent` 里的完整字段。
|
||||
|
||||
`runtime.jsonl`
|
||||
|
||||
- 方向:后端内部 runtime。
|
||||
- 包含状态流转,以及挂到 active stream/request 上的字段。
|
||||
- 当需要把 decoded input 和后续 provider 行为串起来时看它。
|
||||
|
||||
`provider.jsonl`
|
||||
|
||||
- 方向:后端到 provider adapter/provider。
|
||||
- 包含 provider pass 元数据、`model_call_id`、request knobs、最终 provider request artifact、provider summary artifact。
|
||||
- 当最终出站 provider body 或 provider summary 是关键证据时看它。
|
||||
|
||||
`runsse.jsonl`
|
||||
|
||||
- 方向:后端到客户端。
|
||||
- 包含解码后的 `AgentServerMessage` 发送、终态事件、发送错误、断连和 heartbeat。
|
||||
- 用来检查后端尝试返回给客户端的内容。它是解码后的消息证据,不是原始 HTTP/SSE framing。
|
||||
|
||||
## 查询流程
|
||||
|
||||
1. 先判断 id 类型。
|
||||
- 先查 `history/<id>/state.json`,确认它是不是 `conversationId`。
|
||||
- 再在 `history/*/{state.json,context.json}`、`history/usage.json`、`logs/app.log` 里搜索 request/model-call/tool id。
|
||||
2. 拿到 `conversationId` 后,列出 debug 目录。
|
||||
- `ls -la "$HOME/.cursor-local-assistant-v2/history/<conversationId>/debug"`
|
||||
3. 如果 debug 目录不存在,确认请求发生时 debug 是否已开启。
|
||||
- 读取 `config.yaml`。
|
||||
- 对比 `config.yaml`、`state.json`、`context.json` 的 mtime。
|
||||
- 搜索 `logs/app.log` 里的 config hot reload 或 provider start 记录。
|
||||
4. 按时间顺序读 JSONL,并用这些字段串联:
|
||||
- `request_id`
|
||||
- `conversation_id`
|
||||
- `model_call_id`
|
||||
- `provider_pass`
|
||||
- `append_seqno`
|
||||
- event timestamp
|
||||
5. 最终回复只总结结论所需字段。不要粘贴 secret、API key、完整 provider body 或大段原始 payload。
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
ROOT="$HOME/.cursor-local-assistant-v2"
|
||||
REQ="<requestId>"
|
||||
CONV="<conversationId>"
|
||||
|
||||
rg -n "$REQ" "$ROOT/history" "$ROOT/logs/app.log"
|
||||
find "$ROOT/history" -path "*/debug/*" -type f | sort
|
||||
rg -n "$REQ|model_call_id|provider_request_prepared|llm_request" "$ROOT/history/$CONV/debug"
|
||||
```
|
||||
|
||||
紧凑查看 JSONL:
|
||||
|
||||
```bash
|
||||
jq -c 'select(.request_id == "<requestId>")' "$ROOT/history/$CONV/debug/provider.jsonl"
|
||||
jq -c 'select(.request_id == "<requestId>") | {append_seqno, message_case, conversation_action, message, intent}' "$ROOT/history/$CONV/debug/bidi.decoded.jsonl"
|
||||
```
|
||||
|
||||
## 证据怎么用
|
||||
|
||||
根据问题选择对应文件:
|
||||
|
||||
- 客户端原始上行问题:先看 `bidi.raw.jsonl`。这是精确原始包证据。
|
||||
- 客户端 known-schema 字段问题:看 `bidi.decoded.jsonl` 的 `message`。例如 `user_message.message_id`、selected image、conversation state bytes 等字段是否在解码结果里。
|
||||
- 后端如何理解请求:看 `bidi.decoded.jsonl` 的 `intent`,再接 `runtime.jsonl`。
|
||||
- provider request 问题:看 `provider.jsonl`,尤其是 `llm_request`。
|
||||
- UI/流式输出问题:看 `runsse.jsonl`。
|
||||
- 请求状态问题:先看 `state.json`、`context.json`、`usage.json`,再用 debug 文件补证。
|
||||
- debug 缺失问题:看 `config.yaml`、mtime、app log、orphan debug 目录。
|
||||
|
||||
runtime model parameters,例如 thinking strength,只是 provider request 证据的一类例子:
|
||||
|
||||
- `bidi.raw.jsonl` 说明客户端原始上传了什么。
|
||||
- `bidi.decoded.jsonl.message` 说明上行包按当前 known schema 解码出了什么。
|
||||
- `bidi.decoded.jsonl.intent` 说明后端从 decoded input 里提取并准备使用了什么。
|
||||
- `runtime.jsonl` 说明后端把什么挂到了请求状态上。
|
||||
- `provider.jsonl` 说明最终为 provider 准备了什么。
|
||||
|
||||
只有普通 history 时不要过度断言。例如 `context.json` 里的 `reasoning_content` 能说明产生过 reasoning 文本,但不能单独证明是哪一个 runtime parameter value 导致的。
|
||||
|
||||
注意证据边界:
|
||||
|
||||
- `bidi.decoded.jsonl` 使用当前已知 proto schema 做解码。未知字段或原始 framing 差异不能靠 decoded 证明,必须回到 `bidi.raw.jsonl`。
|
||||
- `context.json` 仍是持久化历史事实源;debug 文件只能证明某次请求链路附近发生过什么。
|
||||
- `provider.jsonl` 的 provider body 和 `bidi.raw.jsonl` / `bidi.decoded.jsonl` 都可能很大,回复用户时只摘必要字段,不粘贴完整图片、完整 body 或 secret。
|
||||
|
||||
## Debug 文件缺失
|
||||
|
||||
如果某个 request 没有 debug 文件,要明确说明“没有直接 debug 证据”。常见原因:
|
||||
|
||||
- 请求发生时 `log: false`。
|
||||
- 正在运行的二进制版本早于 debug logging 或 hot reload 实现。
|
||||
- 事件发生时还没有解析到 conversation id,记录在 `_debug/orphan/<requestId>/`。
|
||||
- 请求在开启 `log` 前已经完成。
|
||||
- 写文件失败;如果该版本有相关记录,app log 里可能有 warning。
|
||||
|
||||
debug 证据缺失时,回退到 `state.json`、`context.json`、`usage.json`、`logs/app.log`,并把结论标成推断,而不是直接证明。
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: prefix-cache-stability
|
||||
description: Use when changing prompt compilation, history replay, persisted conversation state, model request construction, or dynamic reminders in this repo; protects prefix-cache hit rate by keeping model-visible history append-only and dynamic attention scoped to the latest request.
|
||||
---
|
||||
|
||||
# Prefix Cache Stability
|
||||
|
||||
Use this skill before editing prompt, history replay, persisted conversation state, or provider request code.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
- Model-visible history is append-only. If a message was sent to the model and is meant to remain historical context, persist it and replay it at the same relative position.
|
||||
- Do not move previously sent model-visible messages to a new position in later requests.
|
||||
- Keep the largest stable prefix first: system prompt, imported replay, persisted user/request/tool history, then current-turn suffix context.
|
||||
- Truly dynamic attention is latest-only. Current state blocks, latest edit guards, and other volatile reminders should be appended near the end of the current request and should not become long-lived prefix content unless they are intentionally persisted as historical facts.
|
||||
- Persisted prompt context must be worded so it is safe as history. Avoid stale wording like "currently" unless the context is only latest-only.
|
||||
- Never optimize cache by dropping correctness-critical context.
|
||||
- Never remove, strip, reorder, or suppress historical `reasoning_content` replay merely to reduce repetitive thinking. Some providers need prior reasoning for valid continuation; optimize the latest tool guidance or current-turn prompt behavior instead.
|
||||
|
||||
## Implementation Pattern
|
||||
|
||||
1. Classify each prompt addition:
|
||||
- Stable system policy: belongs in the fixed system prompt.
|
||||
- Historical model-visible context: persist as replayable history.
|
||||
- Latest-only attention: append as current suffix, do not persist.
|
||||
2. For persisted context, store enough metadata to dedupe the same turn, usually `source` plus a content hash.
|
||||
3. Replay persisted context from history/projector, not by regenerating and inserting it into old positions.
|
||||
4. On provider retries or same-turn follow-up passes, do not duplicate an already persisted prompt context.
|
||||
5. Persisted conversation state must include replayable prompt context so a restarted conversation preserves the same prefix. In this repo, use `context.json.items` for replayable semantic history and `state.json` for mutable latest state.
|
||||
|
||||
## Verification
|
||||
|
||||
- Compare adjacent provider request artifacts or captured canonical request bodies and compute the longest common prefix.
|
||||
- Check final raw SSE usage fields before blaming local metrics. Some OpenAI-compatible providers do not return cached-token fields.
|
||||
- A healthy change should make old request prefixes stable while allowing only the newest suffix to vary.
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
name: test-requirements
|
||||
description: 本仓库代码禁止写任何测试
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "测试要求"
|
||||
short_description: "根据测试要求,生成测试用例"
|
||||
default_prompt: "使用 $test-requirements 来根据测试要求,生成测试用例。"
|
||||
@@ -0,0 +1,9 @@
|
||||
*
|
||||
!go.mod
|
||||
!go.sum
|
||||
!cursor-tab-server/
|
||||
!cursor-tab-server/**
|
||||
!gen/
|
||||
!gen/**
|
||||
!internal/
|
||||
!internal/**
|
||||
@@ -0,0 +1,26 @@
|
||||
claude-server.tar
|
||||
dist
|
||||
.task
|
||||
bin
|
||||
logs/
|
||||
gen/
|
||||
logs.zip
|
||||
frontend/bindings
|
||||
dist
|
||||
node_modules
|
||||
cursor-server.tar
|
||||
server-node/cursor.tar
|
||||
server-go/cursor.tar
|
||||
server-go/log/
|
||||
.cursor-local-assistant
|
||||
.cursor-local-assistant-v2
|
||||
.cursor-app-formatted/
|
||||
ads-server-linux-amd64.tar
|
||||
cmd/ads-server/*.db
|
||||
cmd/ads-server/*.db-*
|
||||
cmd/ads-server/*.sqlite
|
||||
cmd/ads-server/*.sqlite-*
|
||||
cmd/ads-server/data/
|
||||
cmd/ads-server/ads-server
|
||||
cmd/ads-server/ads-server-linux-amd64.tar
|
||||
cursor-tab-server/cursor-tab-server-linux-amd64.tar
|
||||
@@ -0,0 +1,3 @@
|
||||
task release:prepare
|
||||
task release:verify:assets
|
||||
task release:github
|
||||
@@ -0,0 +1,50 @@
|
||||
## 为什么做这个项目
|
||||
|
||||
公司喜欢把 Agent 服务与模型绑定在一起,让用户只能在指定模型、指定订阅和指定计费方式下使用工具。
|
||||
|
||||
我希望打破这种绑定关系:模型应该可以自由选择。开发者应该能够把自己的模型 API 接入到任何 IDE、Chat、Agent 或开发工具中,也可以自托管整套服务,避免被单一平台锁定。
|
||||
|
||||
这个项目的目标,是让模型选择权重新回到用户手里。
|
||||
|
||||
## 路线图
|
||||
|
||||
[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)
|
||||
[详细使用教程](https://dcne38qm5vlg.feishu.cn/wiki/JeP7wdGnziBXuikNaF5czWbrn8c)
|
||||
|
||||
|
||||
注:正式版发布后的不久,本代码库将会全面开源,并迎来更多有趣的工具,local-first是我们的终极目标
|
||||
|
||||
## 后续
|
||||
|
||||
后续会继续扩展更多工具和使用场景,包括但不限于:
|
||||
|
||||
- 支持更多 IDE 接入
|
||||
- 支持更多 Chat 类应用
|
||||
- 支持更多 Agent 工具和工作流
|
||||
- 提供更完善的自托管部署方式
|
||||
- 持续优化不同模型 API 的兼容性
|
||||
- 降低接入成本,让已有模型额度可以被更充分地利用
|
||||
|
||||
最终希望做到:让你的模型 API 可以自由接入到你想使用的任何工具中。
|
||||
|
||||
|
||||
## 截图
|
||||
|
||||
|
||||
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/2e1710b0-cdbd-4576-bd24-1614df016219" />
|
||||
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/00885453-6a91-4052-aadf-f686daeec881" />
|
||||
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=leookun%2Fcursor-byok&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -0,0 +1,559 @@
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
common: ./build/Taskfile.yml
|
||||
windows: ./build/windows/Taskfile.yml
|
||||
darwin: ./build/darwin/Taskfile.yml
|
||||
linux: ./build/linux/Taskfile.yml
|
||||
|
||||
vars:
|
||||
APP_NAME: "Cursor助手"
|
||||
BIN_DIR: "bin"
|
||||
APP_VERSION:
|
||||
sh: 'go run ./scripts/release version -config ./build/config.yml'
|
||||
RELEASE_REPO: "leookun/cursor-byok"
|
||||
RELEASE_BASE_NAME: "cursor-byok"
|
||||
RELEASE_SOURCE_PATH: "release-notes.md"
|
||||
RELEASE_DIR: '{{.BIN_DIR}}/release/{{.APP_VERSION}}'
|
||||
RELEASE_NOTES_PATH: '{{.RELEASE_DIR}}/.release-notes.md'
|
||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||
SCAN: '{{.SCAN | default "false"}}'
|
||||
CURRENT_WINDOWS_ARCH: '{{if eq ARCH "386"}}386{{else}}amd64{{end}}'
|
||||
CURRENT_WINDOWS_NAME: '{{if eq .CURRENT_WINDOWS_ARCH "386"}}windows-32{{else}}windows-64{{end}}'
|
||||
CURRENT_DARWIN_NAME: '{{if eq ARCH "arm64"}}macos-arm64{{else}}macos-intel{{end}}'
|
||||
CURRENT_LINUX_ARCH: "amd64"
|
||||
CURRENT_LINUX_NAME: "linux-amd64"
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: 构建当前系统分发包
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "windows" ] || [ "{{OS}}" = "linux" ]'
|
||||
msg: "仅支持在 macOS、Windows 或 Linux 上执行 task build"
|
||||
cmds:
|
||||
- task: clean:dist
|
||||
- task: build:current
|
||||
|
||||
run:
|
||||
summary: 运行当前系统版本
|
||||
cmds:
|
||||
- task: "{{OS}}:run"
|
||||
|
||||
build:all:
|
||||
summary: 构建全部 macOS/Windows 分发包(Linux 需在原生 Linux 主机单独构建)
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "darwin" ]'
|
||||
msg: "task build:all 仅支持在 macOS 上执行;Linux 需在原生 Linux 主机构建。"
|
||||
cmds:
|
||||
- task: clean:dist
|
||||
- task: build:darwin:arm64
|
||||
- task: build:darwin:amd64
|
||||
- task: build:windows:386
|
||||
- task: build:windows:amd64
|
||||
|
||||
build:current:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: '{{if eq OS "darwin"}}build:darwin:current{{else if eq OS "linux"}}build:linux:current{{else}}build:windows:current{{end}}'
|
||||
|
||||
build:darwin:current:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: darwin:package:dmg
|
||||
vars:
|
||||
ARCH: '{{ARCH}}'
|
||||
BINARY_NAME: '{{.CURRENT_DARWIN_NAME}}'
|
||||
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_DARWIN_NAME}}'
|
||||
APP_BUNDLE: '{{.CURRENT_DARWIN_NAME}}.app'
|
||||
DMG_NAME: '{{.CURRENT_DARWIN_NAME}}.dmg'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:darwin:arm64:
|
||||
internal: false
|
||||
cmds:
|
||||
- task: darwin:package:dmg
|
||||
vars:
|
||||
ARCH: arm64
|
||||
BINARY_NAME: macos-arm64
|
||||
OUTPUT: '{{.BIN_DIR}}/macos-arm64'
|
||||
APP_BUNDLE: macos-arm64.app
|
||||
DMG_NAME: macos-arm64.dmg
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:darwin:amd64:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: darwin:package:dmg
|
||||
vars:
|
||||
ARCH: amd64
|
||||
BINARY_NAME: macos-intel
|
||||
OUTPUT: '{{.BIN_DIR}}/macos-intel'
|
||||
APP_BUNDLE: macos-intel.app
|
||||
DMG_NAME: macos-intel.dmg
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:windows:current:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: windows:create:zip
|
||||
vars:
|
||||
ARCH: '{{.CURRENT_WINDOWS_ARCH}}'
|
||||
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_WINDOWS_NAME}}.exe'
|
||||
ZIP_NAME: '{{.CURRENT_WINDOWS_NAME}}.zip'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:windows:386:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: windows:create:zip
|
||||
vars:
|
||||
ARCH: 386
|
||||
OUTPUT: '{{.BIN_DIR}}/windows-32.exe'
|
||||
ZIP_NAME: windows-32.zip
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:windows:amd64:
|
||||
cmds:
|
||||
- task: windows:create:zip
|
||||
vars:
|
||||
ARCH: amd64
|
||||
OUTPUT: '{{.BIN_DIR}}/windows-64.exe'
|
||||
ZIP_NAME: windows-64.zip
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:linux:current:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: linux:package:archive
|
||||
vars:
|
||||
ARCH: '{{.CURRENT_LINUX_ARCH}}'
|
||||
BINARY_NAME: '{{.CURRENT_LINUX_NAME}}'
|
||||
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}'
|
||||
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
|
||||
ARCHIVE_PATH: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}.tar.gz'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:linux:amd64:
|
||||
cmds:
|
||||
- task: linux:package:archive
|
||||
vars:
|
||||
ARCH: amd64
|
||||
BINARY_NAME: linux-amd64
|
||||
OUTPUT: '{{.BIN_DIR}}/linux-amd64'
|
||||
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
|
||||
ARCHIVE_PATH: '{{.BIN_DIR}}/linux-amd64.tar.gz'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
cursor-tab-server:docker:amd64:
|
||||
summary: 构建 cursor-tab-server 的 linux/amd64 Docker 镜像并保存到 cursor-tab-server 目录
|
||||
vars:
|
||||
IMAGE_NAME: '{{.IMAGE_NAME | default "cursor-tab-server:linux-amd64"}}'
|
||||
IMAGE_TAR: '{{.IMAGE_TAR | default "cursor-tab-server/cursor-tab-server-linux-amd64.tar"}}'
|
||||
CONTEXT_DIR: cursor-tab-server
|
||||
DOCKERFILE: cursor-tab-server/Dockerfile
|
||||
preconditions:
|
||||
- sh: docker info >/dev/null 2>&1
|
||||
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
|
||||
- sh: test -f "{{.DOCKERFILE}}"
|
||||
msg: "未找到 cursor-tab-server/Dockerfile。"
|
||||
- sh: test -f "{{.CONTEXT_DIR}}/go.mod"
|
||||
msg: "未找到 cursor-tab-server/go.mod。"
|
||||
cmds:
|
||||
- mkdir -p "{{.CONTEXT_DIR}}"
|
||||
- docker build --platform linux/amd64 -t "{{.IMAGE_NAME}}" -f "{{.DOCKERFILE}}" "{{.CONTEXT_DIR}}"
|
||||
- docker save "{{.IMAGE_NAME}}" -o "{{.IMAGE_TAR}}"
|
||||
- ls -lh "{{.IMAGE_TAR}}"
|
||||
|
||||
clean:dist:
|
||||
internal: true
|
||||
cmds:
|
||||
- mkdir -p "{{.BIN_DIR}}"
|
||||
- rm -f "{{.BIN_DIR}}/macos-arm64.dmg" "{{.BIN_DIR}}/macos-intel.dmg" "{{.BIN_DIR}}/windows-32.zip" "{{.BIN_DIR}}/windows-64.zip" "{{.BIN_DIR}}/linux-amd64.tar.gz"
|
||||
- rm -f "{{.BIN_DIR}}/macos-arm64" "{{.BIN_DIR}}/macos-intel" "{{.BIN_DIR}}/windows-32.exe" "{{.BIN_DIR}}/windows-64.exe" "{{.BIN_DIR}}/linux-amd64"
|
||||
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
|
||||
- rm -rf "{{.BIN_DIR}}/macos-arm64.app" "{{.BIN_DIR}}/macos-intel.app" "{{.BIN_DIR}}/{{.APP_NAME}}.app" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||
|
||||
dev:
|
||||
summary: 启动开发模式(前台,终端会保持占用)
|
||||
cmds:
|
||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||
|
||||
ads:install:
|
||||
summary: 安装广告页依赖
|
||||
dir: '{{.TASKFILE_DIR}}/ads-page'
|
||||
sources:
|
||||
- package.json
|
||||
- yarn.lock
|
||||
generates:
|
||||
- node_modules
|
||||
cmds:
|
||||
- yarn install --frozen-lockfile
|
||||
|
||||
ads:dev:
|
||||
summary: 启动广告页开发服务(http://127.0.0.1:5174/ad/)
|
||||
dir: '{{.TASKFILE_DIR}}/ads-page'
|
||||
deps:
|
||||
- task: ads:install
|
||||
cmds:
|
||||
- yarn dev
|
||||
|
||||
ads:build:
|
||||
summary: 构建广告 ZIP 产物
|
||||
dir: '{{.TASKFILE_DIR}}/ads-page'
|
||||
deps:
|
||||
- task: ads:install
|
||||
cmds:
|
||||
- yarn build
|
||||
|
||||
setup:docker:
|
||||
summary: 构建 Linux 交叉编译 Docker 镜像
|
||||
cmds:
|
||||
- task: common:setup:docker
|
||||
|
||||
release:clean:
|
||||
summary: 清理当前版本的发布目录
|
||||
cmds:
|
||||
- rm -rf "{{.RELEASE_DIR}}"
|
||||
- mkdir -p "{{.RELEASE_DIR}}"
|
||||
|
||||
release:ensure:dir:
|
||||
internal: true
|
||||
cmds:
|
||||
- mkdir -p "{{.RELEASE_DIR}}"
|
||||
|
||||
release:notes:
|
||||
summary: 从 release-notes.md 生成当前版本的发布说明文件(README.md 仅用于公开仓库同步)
|
||||
internal: true
|
||||
preconditions:
|
||||
- sh: test -f "{{.RELEASE_SOURCE_PATH}}"
|
||||
msg: "未找到 release-notes.md,请先创建发布日志文件。"
|
||||
- sh: test -n "$(tr -d '[:space:]' < "{{.RELEASE_SOURCE_PATH}}")"
|
||||
msg: "release-notes.md 不能为空,请先填写发布日志。"
|
||||
cmds:
|
||||
- go run ./scripts/release notes -config ./build/config.yml -out "{{.RELEASE_NOTES_PATH}}" -source "{{.RELEASE_SOURCE_PATH}}"
|
||||
|
||||
release:build:macos:arm64:
|
||||
cmds:
|
||||
- task: darwin:package:archive
|
||||
vars:
|
||||
ARCH: arm64
|
||||
BINARY_NAME: cursor-release-macos-arm64
|
||||
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-arm64'
|
||||
APP_BUNDLE: '{{.APP_NAME}}.app'
|
||||
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
release:build:macos:amd64:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: darwin:package:archive
|
||||
vars:
|
||||
ARCH: amd64
|
||||
BINARY_NAME: cursor-release-macos-amd64
|
||||
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-amd64'
|
||||
APP_BUNDLE: '{{.APP_NAME}}.app'
|
||||
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
release:build:windows:amd64:
|
||||
cmds:
|
||||
- task: windows:create:zip
|
||||
vars:
|
||||
ARCH: amd64
|
||||
OUTPUT: '{{.BIN_DIR}}/{{.RELEASE_BASE_NAME}}-windows-amd64.exe'
|
||||
ZIP_NAME: 'release/{{.APP_VERSION}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
release:build:linux:amd64:
|
||||
cmds:
|
||||
- task: linux:package:archive
|
||||
vars:
|
||||
ARCH: amd64
|
||||
BINARY_NAME: cursor-release-linux-amd64
|
||||
OUTPUT: '{{.BIN_DIR}}/cursor-release-linux-amd64'
|
||||
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
|
||||
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
release:manifest:
|
||||
summary: 生成 update.json
|
||||
internal: true
|
||||
cmds:
|
||||
- go run ./scripts/release manifest -config ./build/config.yml -assets-dir "{{.RELEASE_DIR}}" -out "{{.RELEASE_DIR}}/update.json" -repo "{{.RELEASE_REPO}}" -base-name "{{.RELEASE_BASE_NAME}}" -notes "{{.RELEASE_NOTES_PATH}}"
|
||||
|
||||
release:prepare:
|
||||
summary: 在当前主机上生成对应的发布资产
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
|
||||
msg: "release:prepare 仅支持在 macOS 或 Linux 上执行。"
|
||||
- sh: wails3 version >/dev/null 2>&1
|
||||
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
|
||||
cmds:
|
||||
- task: '{{if eq OS "darwin"}}release:prepare:darwin{{else}}release:prepare:linux{{end}}'
|
||||
|
||||
release:prepare:darwin:
|
||||
summary: 在 macOS 上生成 macOS/Windows/Linux 发布资产
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "darwin" ]'
|
||||
msg: "release:prepare:darwin 仅支持在 macOS 上执行。"
|
||||
- sh: wails3 version >/dev/null 2>&1
|
||||
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
|
||||
cmds:
|
||||
- task: release:clean
|
||||
- task: common:update:build-assets
|
||||
- task: release:notes
|
||||
- task: release:build:macos:arm64
|
||||
- task: release:build:macos:amd64
|
||||
- task: release:build:windows:amd64
|
||||
- task: release:build:linux:amd64
|
||||
|
||||
release:prepare:linux:
|
||||
summary: 在当前主机上生成 Linux 发布资产
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "linux" ] || [ "{{OS}}" = "darwin" ]'
|
||||
msg: "release:prepare:linux 当前仅支持在 Linux 或 macOS 上执行。"
|
||||
- sh: wails3 version >/dev/null 2>&1
|
||||
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
|
||||
cmds:
|
||||
- task: release:ensure:dir
|
||||
- task: common:update:build-assets
|
||||
- task: release:notes
|
||||
- task: release:build:linux:amd64
|
||||
|
||||
release:verify:assets:
|
||||
summary: 验证当前版本发布资产是否齐全
|
||||
preconditions:
|
||||
- sh: test -f "{{.RELEASE_NOTES_PATH}}"
|
||||
msg: "未找到发布说明,请先执行对应的 release:prepare 任务。"
|
||||
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz"
|
||||
msg: "缺少 macOS arm64 发布资产。"
|
||||
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz"
|
||||
msg: "缺少 macOS amd64 发布资产。"
|
||||
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip"
|
||||
msg: "缺少 Windows amd64 发布资产。"
|
||||
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz"
|
||||
msg: "缺少 Linux amd64 发布资产。"
|
||||
cmds:
|
||||
- echo "release assets verified"
|
||||
|
||||
release:sync:readme:
|
||||
summary: 同步 README 到公开发布仓库(发布日志来源固定为 release-notes.md)
|
||||
internal: true
|
||||
env:
|
||||
GH_PAGER: cat
|
||||
GIT_PAGER: cat
|
||||
PAGER: cat
|
||||
preconditions:
|
||||
- sh: gh --version >/dev/null 2>&1
|
||||
msg: "未检测到 gh,请先安装 GitHub CLI。"
|
||||
- sh: gh auth token >/dev/null 2>&1
|
||||
msg: "gh 未登录,请先执行 gh auth login。"
|
||||
cmds:
|
||||
- |
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
|
||||
|
||||
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
|
||||
last = None
|
||||
for attempt in range(1, retries + 1):
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
input=input_text,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
return completed
|
||||
stderr = (completed.stderr or "").strip()
|
||||
stdout = (completed.stdout or "").strip()
|
||||
combined = f"{stderr}\n{stdout}".strip()
|
||||
if allow_404 and "404" in combined:
|
||||
return completed
|
||||
last = completed
|
||||
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
|
||||
return completed
|
||||
if attempt < retries:
|
||||
time.sleep(min(2 ** (attempt - 1), 5))
|
||||
return last
|
||||
|
||||
repo = "{{.RELEASE_REPO}}"
|
||||
readme_path = pathlib.Path("README.md")
|
||||
content = readme_path.read_bytes()
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
|
||||
get_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/contents/README.md",
|
||||
]
|
||||
result = run_gh(get_cmd, allow_404=True)
|
||||
|
||||
payload = {
|
||||
"message": "docs: sync README from source repo",
|
||||
"content": encoded,
|
||||
"branch": "main",
|
||||
}
|
||||
|
||||
if result.returncode == 0:
|
||||
existing = json.loads(result.stdout)
|
||||
existing_content = base64.b64decode(existing["content"])
|
||||
if existing_content == content:
|
||||
sys.exit(0)
|
||||
payload["sha"] = existing["sha"]
|
||||
|
||||
put_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/contents/README.md",
|
||||
"--method", "PUT",
|
||||
"--input", "-",
|
||||
]
|
||||
completed = run_gh(put_cmd, input_text=json.dumps(payload))
|
||||
if completed.returncode != 0 and completed.stderr:
|
||||
sys.stderr.write(completed.stderr)
|
||||
sys.exit(completed.returncode)
|
||||
PY
|
||||
|
||||
release:github:
|
||||
summary: 发布当前版本到 GitHub Releases
|
||||
env:
|
||||
GH_PAGER: cat
|
||||
GIT_PAGER: cat
|
||||
PAGER: cat
|
||||
preconditions:
|
||||
- sh: gh --version >/dev/null 2>&1
|
||||
msg: "未检测到 gh,请先安装 GitHub CLI。"
|
||||
- sh: gh auth token >/dev/null 2>&1
|
||||
msg: "gh 未登录,请先执行 gh auth login。"
|
||||
cmds:
|
||||
- task: release:verify:assets
|
||||
- task: release:manifest
|
||||
- task: release:sync:readme
|
||||
- |
|
||||
python3 - <<'PY'
|
||||
import mimetypes
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
|
||||
|
||||
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
|
||||
last = None
|
||||
for attempt in range(1, retries + 1):
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
input=input_text,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
return completed
|
||||
stderr = (completed.stderr or "").strip()
|
||||
stdout = (completed.stdout or "").strip()
|
||||
combined = f"{stderr}\n{stdout}".strip()
|
||||
if allow_404 and "404" in combined:
|
||||
return completed
|
||||
last = completed
|
||||
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
|
||||
return completed
|
||||
if attempt < retries:
|
||||
time.sleep(min(2 ** (attempt - 1), 5))
|
||||
return last
|
||||
|
||||
repo = "{{.RELEASE_REPO}}"
|
||||
version = "{{.APP_VERSION}}"
|
||||
tag = f"v{version}"
|
||||
notes = pathlib.Path("{{.RELEASE_NOTES_PATH}}").read_text(encoding="utf-8")
|
||||
assets = sorted([
|
||||
pathlib.Path("{{.RELEASE_DIR}}/update.json"),
|
||||
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.tar.gz"),
|
||||
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.zip"),
|
||||
])
|
||||
|
||||
get_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/releases/tags/{tag}",
|
||||
]
|
||||
result = run_gh(get_cmd, allow_404=True)
|
||||
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"target_commitish": "main",
|
||||
"name": tag,
|
||||
"body": notes,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
|
||||
if result.returncode == 0:
|
||||
release = json.loads(result.stdout)
|
||||
release_id = release["id"]
|
||||
edit_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/releases/{release_id}",
|
||||
"--method", "PATCH",
|
||||
"--input", "-",
|
||||
]
|
||||
completed = run_gh(edit_cmd, input_text=json.dumps(payload))
|
||||
if completed.returncode != 0:
|
||||
if completed.stderr:
|
||||
sys.stderr.write(completed.stderr)
|
||||
sys.exit(completed.returncode)
|
||||
refreshed = run_gh(get_cmd)
|
||||
if refreshed.returncode != 0:
|
||||
if refreshed.stderr:
|
||||
sys.stderr.write(refreshed.stderr)
|
||||
sys.exit(refreshed.returncode)
|
||||
release = json.loads(refreshed.stdout)
|
||||
else:
|
||||
create_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/releases",
|
||||
"--method", "POST",
|
||||
"--input", "-",
|
||||
]
|
||||
completed = run_gh(create_cmd, input_text=json.dumps(payload))
|
||||
if completed.returncode != 0:
|
||||
sys.stderr.write(completed.stderr)
|
||||
sys.exit(completed.returncode)
|
||||
release = json.loads(completed.stdout)
|
||||
|
||||
upload_url = release["upload_url"].split("{", 1)[0]
|
||||
existing = {asset["name"]: asset["id"] for asset in release.get("assets", [])}
|
||||
|
||||
for asset in assets:
|
||||
name = asset.name
|
||||
if name in existing:
|
||||
delete_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/releases/assets/{existing[name]}",
|
||||
"--method", "DELETE",
|
||||
]
|
||||
deleted = run_gh(delete_cmd)
|
||||
if deleted.returncode != 0:
|
||||
if deleted.stderr:
|
||||
sys.stderr.write(deleted.stderr)
|
||||
sys.exit(deleted.returncode)
|
||||
|
||||
content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
upload_cmd = [
|
||||
"gh", "api",
|
||||
f"{upload_url}?name={name}",
|
||||
"--method", "POST",
|
||||
"--header", f"Content-Type: {content_type}",
|
||||
"--input", str(asset),
|
||||
]
|
||||
uploaded = run_gh(upload_cmd)
|
||||
if uploaded.returncode != 0:
|
||||
if uploaded.stderr:
|
||||
sys.stderr.write(uploaded.stderr)
|
||||
sys.exit(uploaded.returncode)
|
||||
PY
|
||||
@@ -0,0 +1,153 @@
|
||||
version: "3"
|
||||
|
||||
vars:
|
||||
ROOT_DIR:
|
||||
sh: 'cd "{{.TASKFILE_DIR}}/.." && pwd'
|
||||
BUILD_DIR:
|
||||
sh: 'cd "{{.TASKFILE_DIR}}" && pwd'
|
||||
|
||||
tasks:
|
||||
extract:proto:
|
||||
summary: 从 Cursor 扩展快照提取 proto
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
preconditions:
|
||||
- sh: 'test -z "{{.PROTO_INPUT}}" || test -f "{{.PROTO_INPUT}}"'
|
||||
msg: "PROTO_INPUT 指向的 Cursor 扩展 bundle 不存在。"
|
||||
- sh: 'test -n "{{.PROTO_INPUT}}" || test -f ./proto/extensions-cursor-app/cursor-always-local/dist/main.js || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
|
||||
msg: "未找到 Cursor 扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
|
||||
cmds:
|
||||
- chmod +x ./proto/extract_extensions_proto.sh
|
||||
- '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}'
|
||||
|
||||
sync:proto:
|
||||
summary: 从 Cursor 扩展快照同步 proto 并重新生成 Go 代码
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
deps:
|
||||
- task: extract:proto
|
||||
cmds:
|
||||
- cp ./proto/from_extensions/agent_v1.proto ./proto/agent_v1.proto
|
||||
- cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto
|
||||
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto
|
||||
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto
|
||||
- rm -rf ./gen/agentv1 ./gen/aiserverv1
|
||||
- task: generate:proto
|
||||
|
||||
generate:proto:
|
||||
summary: 生成 proto Go/Connect 代码
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
sources:
|
||||
- proto/*.proto
|
||||
generates:
|
||||
- gen/agentv1/*.go
|
||||
- gen/aiserverv1/*.go
|
||||
- gen/aiserverv1/aiserverv1connect/*.go
|
||||
preconditions:
|
||||
- sh: protoc --version
|
||||
msg: "未检测到 protoc,请先安装 Protocol Buffers 编译器。"
|
||||
- sh: protoc-gen-go --version >/dev/null 2>&1 || which protoc-gen-go
|
||||
msg: "未检测到 protoc-gen-go,请先安装。"
|
||||
- sh: protoc-gen-connect-go --version >/dev/null 2>&1 || which protoc-gen-connect-go
|
||||
msg: "未检测到 protoc-gen-connect-go,请先安装。"
|
||||
cmds:
|
||||
- rm -rf ./gen/agentv1 ./gen/aiserverv1
|
||||
- protoc -I ./proto --go_out=. --go_opt=module=cursor --connect-go_out=. --connect-go_opt=module=cursor ./proto/agent_v1.proto ./proto/aiserver_v1.proto
|
||||
- gofmt -w $(find ./gen -name '*.go' -type f)
|
||||
|
||||
go:mod:tidy:
|
||||
summary: 整理 Go 依赖
|
||||
internal: true
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
cmds:
|
||||
- go mod tidy
|
||||
|
||||
install:frontend:deps:
|
||||
summary: 安装前端依赖(Yarn)
|
||||
dir: '{{.ROOT_DIR}}/frontend'
|
||||
sources:
|
||||
- package.json
|
||||
- yarn.lock
|
||||
generates:
|
||||
- node_modules
|
||||
preconditions:
|
||||
- sh: yarn --version
|
||||
msg: "未检测到 Yarn,请先安装 Yarn。"
|
||||
cmds:
|
||||
- yarn install --frozen-lockfile
|
||||
|
||||
generate:bindings:
|
||||
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
|
||||
summary: 生成前端绑定
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
deps:
|
||||
- task: generate:proto
|
||||
- task: go:mod:tidy
|
||||
sources:
|
||||
- "**/*.[jt]s"
|
||||
- exclude: frontend/**/*
|
||||
- frontend/bindings/**/*
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
generates:
|
||||
- frontend/bindings/**/*
|
||||
cmds:
|
||||
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
|
||||
|
||||
build:frontend:
|
||||
label: build:frontend (DEV={{.DEV}} SCAN={{.SCAN}})
|
||||
summary: 构建前端资源
|
||||
dir: '{{.ROOT_DIR}}/frontend'
|
||||
sources:
|
||||
- "**/*"
|
||||
generates:
|
||||
- dist/**/*
|
||||
deps:
|
||||
- task: install:frontend:deps
|
||||
- task: generate:bindings
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
cmds:
|
||||
- yarn run {{.BUILD_COMMAND}}{{if eq .SCAN "true"}} --scan{{end}}
|
||||
env:
|
||||
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
|
||||
vars:
|
||||
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
|
||||
SCAN: '{{.SCAN | default "false"}}'
|
||||
|
||||
dev:frontend:
|
||||
summary: 启动前端开发服务
|
||||
dir: '{{.ROOT_DIR}}/frontend'
|
||||
deps:
|
||||
- task: install:frontend:deps
|
||||
cmds:
|
||||
- yarn run dev --port {{.VITE_PORT}} --strictPort
|
||||
|
||||
generate:icons:
|
||||
summary: 仅使用 appicon.png 生成 Windows 与 macOS 图标
|
||||
dir: '{{.BUILD_DIR}}'
|
||||
sources:
|
||||
- "appicon.png"
|
||||
generates:
|
||||
- "darwin/icons.icns"
|
||||
- "windows/icon.ico"
|
||||
cmds:
|
||||
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico
|
||||
|
||||
update:build-assets:
|
||||
summary: 根据配置更新构建资产
|
||||
dir: '{{.BUILD_DIR}}'
|
||||
cmds:
|
||||
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
|
||||
- rm -rf ios
|
||||
|
||||
setup:docker:
|
||||
summary: 构建 Linux 交叉编译 Docker 镜像
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
preconditions:
|
||||
- sh: docker info >/dev/null 2>&1
|
||||
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
|
||||
- sh: test -f ./build/docker/Dockerfile.cross
|
||||
msg: "未找到 build/docker/Dockerfile.cross。"
|
||||
cmds:
|
||||
- docker build --platform linux/amd64{{if .BASE_IMAGE}} --build-arg BASE_IMAGE="{{.BASE_IMAGE}}"{{end}}{{if .GO_TARBALL_URL}} --build-arg GO_TARBALL_URL="{{.GO_TARBALL_URL}}"{{end}} -t cursor-linux-amd64-cross -f ./build/docker/Dockerfile.cross ./build/docker
|
||||
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,43 @@
|
||||
version: "3"
|
||||
|
||||
# 构建元信息(修改后可执行:wails3 task common:update:build-assets)
|
||||
info:
|
||||
companyName: "Cursor助手"
|
||||
productName: "Cursor助手"
|
||||
productIdentifier: "com.cursor.wuxianxubei"
|
||||
description: "Cursor助手"
|
||||
copyright: "© 2026, Cursor助手"
|
||||
comments: "Cursor助手"
|
||||
version: "0.0.38"
|
||||
|
||||
dev_mode:
|
||||
root_path: .
|
||||
log_level: warn
|
||||
debounce: 1000
|
||||
ignore:
|
||||
dir:
|
||||
- .git
|
||||
- node_modules
|
||||
- frontend
|
||||
- bin
|
||||
file:
|
||||
- .DS_Store
|
||||
- .gitignore
|
||||
- .gitkeep
|
||||
watched_extension:
|
||||
- "*.go"
|
||||
- "*.js"
|
||||
- "*.ts"
|
||||
git_ignore: true
|
||||
executes:
|
||||
- cmd: wails3 build DEV=true
|
||||
type: blocking
|
||||
- cmd: wails3 task common:dev:frontend
|
||||
type: background
|
||||
- cmd: wails3 task run
|
||||
type: primary
|
||||
|
||||
fileAssociations:
|
||||
|
||||
other:
|
||||
- name: 其他数据
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icons</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>appicon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.cursor.wuxianxubei</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.38</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.38</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026, Cursor助手</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icons</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>appicon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.cursor.wuxianxubei</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.38</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.38</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026, Cursor助手</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,273 @@
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: 构建 macOS 程序
|
||||
internal: true
|
||||
cmds:
|
||||
- task: build:native
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
|
||||
build:native:
|
||||
summary: 使用本机构建 macOS 程序
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:go:mod:tidy
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: common:generate:icons
|
||||
cmds:
|
||||
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
|
||||
vars:
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.BINARY_NAME}}'
|
||||
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||
env:
|
||||
GOOS: darwin
|
||||
CGO_ENABLED: 1
|
||||
GOARCH: '{{.ARCH | default ARCH}}'
|
||||
CGO_CFLAGS: "-mmacosx-version-min=10.15"
|
||||
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
|
||||
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||
|
||||
build:universal:
|
||||
summary: 构建 macOS 通用包(amd64 + arm64)
|
||||
internal: true
|
||||
deps:
|
||||
- task: build
|
||||
vars:
|
||||
ARCH: amd64
|
||||
BINARY_NAME: "{{.APP_NAME}}-amd64"
|
||||
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
|
||||
- task: build
|
||||
vars:
|
||||
ARCH: arm64
|
||||
BINARY_NAME: "{{.APP_NAME}}-arm64"
|
||||
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||
cmds:
|
||||
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||
|
||||
package:
|
||||
summary: 打包 macOS .app(可通过 APP_BUNDLE 指定输出包名)
|
||||
internal: true
|
||||
cmds:
|
||||
- task: build
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: create:app:bundle
|
||||
vars:
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
|
||||
package:dmg:
|
||||
summary: 打包 macOS DMG(可通过 APP_BUNDLE/DMG_NAME 指定输出文件名)
|
||||
internal: true
|
||||
cmds:
|
||||
- task: package
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: create:dmg
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
DMG_NAME:
|
||||
ref: .DMG_NAME
|
||||
- task: cleanup:package:artifacts
|
||||
vars:
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
|
||||
package:archive:
|
||||
summary: 打包 macOS updater 归档(.tar.gz)
|
||||
internal: true
|
||||
cmds:
|
||||
- task: package
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: create:archive
|
||||
vars:
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
ARCHIVE_PATH:
|
||||
ref: .ARCHIVE_PATH
|
||||
- task: cleanup:package:artifacts
|
||||
vars:
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
|
||||
package:universal:
|
||||
summary: 打包 macOS 通用 .app
|
||||
internal: true
|
||||
cmds:
|
||||
- task: build:universal
|
||||
- task: create:app:bundle
|
||||
|
||||
create:app:bundle:
|
||||
summary: 生成 .app 包
|
||||
internal: true
|
||||
vars:
|
||||
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
|
||||
EXECUTABLE_NAME: '{{.EXECUTABLE_NAME | default .APP_NAME}}'
|
||||
cmds:
|
||||
- rm -rf "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
|
||||
- mkdir -p "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/MacOS"
|
||||
- mkdir -p "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/Resources"
|
||||
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/Resources"
|
||||
- cp "{{.BINARY_PATH}}" "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/MacOS/{{.EXECUTABLE_NAME}}"
|
||||
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents"
|
||||
- task: codesign:adhoc
|
||||
vars:
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
|
||||
codesign:adhoc:
|
||||
summary: 使用临时签名(开发测试)
|
||||
internal: true
|
||||
vars:
|
||||
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
|
||||
cmds:
|
||||
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
|
||||
|
||||
create:dmg:
|
||||
summary: 生成 macOS DMG 包(优先使用 create-dmg,不存在时回退 hdiutil)
|
||||
internal: true
|
||||
vars:
|
||||
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
|
||||
DMG_NAME: '{{.DMG_NAME | default (printf "%s.dmg" .APP_NAME)}}'
|
||||
STAGING_DIR: '{{.BIN_DIR}}/.dmg-{{.ARCH | default ARCH}}'
|
||||
cmds:
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
- mkdir -p "{{.STAGING_DIR}}"
|
||||
- cp -R "{{.BIN_DIR}}/{{.APP_BUNDLE}}" "{{.STAGING_DIR}}/{{.APP_NAME}}.app"
|
||||
- |
|
||||
if [ -f "build/dmg-extras/提示损坏?点我.command" ]; then
|
||||
cp "build/dmg-extras/提示损坏?点我.command" "{{.STAGING_DIR}}/提示损坏?点我.command"
|
||||
chmod +x "{{.STAGING_DIR}}/提示损坏?点我.command"
|
||||
fi
|
||||
- |
|
||||
if command -v create-dmg >/dev/null 2>&1; then
|
||||
rm -f "{{.BIN_DIR}}/{{.DMG_NAME}}"
|
||||
create-dmg --volname "{{.APP_NAME}}" --window-size 600 400 --icon-size 100 --app-drop-link 450 200 "{{.BIN_DIR}}/{{.DMG_NAME}}" "{{.STAGING_DIR}}"
|
||||
else
|
||||
hdiutil create -volname "{{.APP_NAME}}" -srcfolder "{{.STAGING_DIR}}" -ov -format UDZO "{{.BIN_DIR}}/{{.DMG_NAME}}"
|
||||
fi
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
|
||||
create:archive:
|
||||
summary: 生成 macOS updater tar.gz 归档
|
||||
internal: true
|
||||
vars:
|
||||
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
|
||||
ARCHIVE_PATH: '{{.ARCHIVE_PATH | default (printf "%s/%s.tar.gz" .BIN_DIR .APP_NAME)}}'
|
||||
cmds:
|
||||
- rm -f "{{.ARCHIVE_PATH}}"
|
||||
- env LC_ALL=C tar -C "{{.BIN_DIR}}" -czf "{{.ARCHIVE_PATH}}" "{{.APP_BUNDLE}}"
|
||||
|
||||
cleanup:package:artifacts:
|
||||
summary: 清理打包中间产物
|
||||
internal: true
|
||||
vars:
|
||||
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
|
||||
cmds:
|
||||
- rm -f "{{.BINARY_PATH}}"
|
||||
- rm -rf "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
|
||||
|
||||
run:
|
||||
summary: 运行本地开发包
|
||||
vars:
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
EXECUTABLE_NAME: '{{.EXECUTABLE_NAME | default .APP_NAME}}'
|
||||
cmds:
|
||||
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
|
||||
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||
- cp "{{.BIN_DIR}}/{{.BINARY_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.EXECUTABLE_NAME}}"
|
||||
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
|
||||
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||
- '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.EXECUTABLE_NAME}}'
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
APP_PATH="/Applications/Cursor助手.app"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "请先把 Cursor助手 拖到“应用程序”目录后再运行本脚本。"
|
||||
read -r -p "按回车退出..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "正在移除隔离属性: $APP_PATH"
|
||||
xattr -cr "$APP_PATH"
|
||||
echo "处理完成,现在可以正常打开 Cursor助手。"
|
||||
read -r -p "按回车退出..."
|
||||
@@ -0,0 +1,26 @@
|
||||
# Docker image for building this app's Linux amd64 binary from macOS/Linux hosts.
|
||||
|
||||
ARG BASE_IMAGE=debian:trixie
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG GO_TARBALL_URL=https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git nodejs yarnpkg pkg-config gcc libc6-dev \
|
||||
libgtk-3-dev libwebkit2gtk-4.1-dev \
|
||||
&& curl -fsSL "${GO_TARBALL_URL}" -o /tmp/go.tar.gz \
|
||||
&& rm -rf /usr/local/go \
|
||||
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
|
||||
&& ln -sf /usr/local/go/bin/go /usr/local/bin/go \
|
||||
&& ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt \
|
||||
&& ln -sf /usr/bin/yarnpkg /usr/local/bin/yarn \
|
||||
&& rm -f /tmp/go.tar.gz \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY build-linux.sh /usr/local/bin/build-linux.sh
|
||||
|
||||
RUN chmod +x /usr/local/bin/build-linux.sh
|
||||
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["/usr/local/bin/build-linux.sh"]
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
log() {
|
||||
printf '[linux-build] %s\n' "$*"
|
||||
}
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=amd64
|
||||
export CGO_ENABLED=1
|
||||
export CGO_CFLAGS="-w"
|
||||
|
||||
APP=${APP_NAME:-$(basename "$(pwd)")}
|
||||
BIN_PATH="bin/${APP}-linux-amd64"
|
||||
LOG_MODE=${GO_BUILD_LOG_MODE:-quiet}
|
||||
|
||||
case "$LOG_MODE" in
|
||||
quiet|heartbeat)
|
||||
;;
|
||||
verbose|trace)
|
||||
log "workspace: $(pwd)"
|
||||
log "target: ${APP} (${GOOS}/${GOARCH})"
|
||||
log "go version: $(go version)"
|
||||
log "log mode: ${LOG_MODE}"
|
||||
;;
|
||||
*)
|
||||
log "unknown GO_BUILD_LOG_MODE=${LOG_MODE}, falling back to quiet"
|
||||
LOG_MODE="quiet"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
|
||||
log "frontend/dist missing, building frontend assets in container"
|
||||
(
|
||||
cd frontend
|
||||
yarn install --frozen-lockfile
|
||||
yarn run build
|
||||
)
|
||||
fi
|
||||
|
||||
mkdir -p bin
|
||||
|
||||
LDFLAGS="-s -w"
|
||||
if [ -n "${EXTRA_LDFLAGS:-}" ]; then
|
||||
LDFLAGS="$LDFLAGS $EXTRA_LDFLAGS"
|
||||
fi
|
||||
|
||||
TAGS="production"
|
||||
if [ -n "${EXTRA_TAGS:-}" ]; then
|
||||
TAGS="${TAGS},${EXTRA_TAGS}"
|
||||
fi
|
||||
|
||||
set -- go build
|
||||
case "$LOG_MODE" in
|
||||
quiet)
|
||||
;;
|
||||
heartbeat)
|
||||
;;
|
||||
verbose)
|
||||
set -- "$@" -v
|
||||
;;
|
||||
trace)
|
||||
set -- "$@" -x -v
|
||||
;;
|
||||
esac
|
||||
|
||||
set -- "$@" -tags "$TAGS" -trimpath -buildvcs=false -ldflags "$LDFLAGS" -o "$BIN_PATH" .
|
||||
|
||||
if "$@"; then
|
||||
:
|
||||
else
|
||||
status="$?"
|
||||
log "go build failed with exit code ${status}"
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
case "$LOG_MODE" in
|
||||
verbose|trace)
|
||||
log "built: ${BIN_PATH}"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,9 @@
|
||||
//@ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
Object.freeze($Create.Events);
|
||||
@@ -0,0 +1,2 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
@@ -0,0 +1,192 @@
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
vars:
|
||||
CROSS_IMAGE: cursor-linux-amd64-cross
|
||||
PROJECT_ROOT:
|
||||
sh: 'cd "{{.TASKFILE_DIR}}/../.." && pwd'
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: 构建 Linux 程序
|
||||
internal: true
|
||||
vars:
|
||||
TARGET_ARCH: '{{.ARCH | default ARCH}}'
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
|
||||
HAS_CC:
|
||||
sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
|
||||
preconditions:
|
||||
- sh: '[ "{{.TARGET_ARCH}}" = "amd64" ]'
|
||||
msg: "当前仅支持 linux/amd64 构建。"
|
||||
cmds:
|
||||
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
|
||||
build:native:
|
||||
summary: 使用本机构建 Linux 程序
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:go:mod:tidy
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "linux" ]'
|
||||
msg: "Linux 原生构建仅支持在 Linux 主机上执行。"
|
||||
- sh: pkg-config --version >/dev/null 2>&1
|
||||
msg: "未检测到 pkg-config,请先安装。"
|
||||
- sh: 'command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1'
|
||||
msg: "未检测到 gcc 或 clang,请先安装 C 编译器。"
|
||||
- sh: pkg-config --exists gtk+-3.0 webkit2gtk-4.1 gdk-3.0 gio-unix-2.0
|
||||
msg: "缺少 Linux GUI 构建依赖,请安装 gtk+-3.0、webkit2gtk-4.1、gdk-3.0 和 gio-unix-2.0 开发包。"
|
||||
cmds:
|
||||
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
|
||||
vars:
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.BINARY_NAME}}'
|
||||
OUTPUT: '{{.OUTPUT | default .DEFAULT_OUTPUT}}'
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GOARCH: '{{.ARCH | default ARCH}}'
|
||||
|
||||
build:docker:
|
||||
summary: 使用 Docker 构建 Linux 程序
|
||||
internal: true
|
||||
dir: '{{.PROJECT_ROOT}}'
|
||||
deps:
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
|
||||
msg: "Docker Linux 构建当前仅支持在 macOS 或 Linux 主机上执行。"
|
||||
- sh: docker info >/dev/null 2>&1
|
||||
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
|
||||
- sh: docker image inspect {{.CROSS_IMAGE}} >/dev/null 2>&1
|
||||
msg: "未找到 Docker 镜像 '{{.CROSS_IMAGE}}',请先执行 task setup:docker。"
|
||||
cmds:
|
||||
- docker run --rm --platform "{{.DOCKER_PLATFORM}}" --entrypoint /bin/sh -v "{{.PROJECT_ROOT}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e GOPATH=/go -e GOMODCACHE=/go/pkg/mod -e APP_NAME="{{.APP_NAME}}" -e EXTRA_LDFLAGS="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}" -e GO_BUILD_LOG_MODE="{{.GO_BUILD_LOG_MODE}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" /app/build/docker/build-linux.sh
|
||||
- docker run --rm --platform "{{.DOCKER_PLATFORM}}" --entrypoint chown -v "{{.PROJECT_ROOT}}:/app" "{{.CROSS_IMAGE}}" -R $(id -u):$(id -g) /app/bin
|
||||
- mkdir -p "{{.BIN_DIR}}"
|
||||
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
|
||||
vars:
|
||||
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
|
||||
DOCKER_PLATFORM: 'linux/{{.DOCKER_ARCH}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
GO_BUILD_LOG_MODE: '{{.GO_BUILD_LOG_MODE | default "quiet"}}'
|
||||
OUTPUT: '{{.OUTPUT | default .DEFAULT_OUTPUT}}'
|
||||
GO_CACHE_MOUNT:
|
||||
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
|
||||
REPLACE_MOUNTS:
|
||||
sh: |
|
||||
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
|
||||
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
|
||||
if [ "${path#/}" = "$path" ]; then
|
||||
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
|
||||
fi
|
||||
if [ -d "$path" ]; then
|
||||
echo "-v $path:$path:ro"
|
||||
fi
|
||||
done | tr '\n' ' '
|
||||
|
||||
package:archive:
|
||||
summary: 打包 Linux updater 归档(.tar.gz)
|
||||
internal: true
|
||||
cmds:
|
||||
- task: build
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: create:archive
|
||||
vars:
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
ARCHIVE_BINARY_NAME:
|
||||
ref: .ARCHIVE_BINARY_NAME
|
||||
ARCHIVE_PATH:
|
||||
ref: .ARCHIVE_PATH
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
- task: cleanup:package:artifacts
|
||||
vars:
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
|
||||
create:archive:
|
||||
summary: 生成 Linux updater tar.gz 归档
|
||||
internal: true
|
||||
vars:
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
|
||||
ARCHIVE_BINARY_NAME: '{{.ARCHIVE_BINARY_NAME | default .APP_NAME}}'
|
||||
ARCHIVE_PATH: '{{.ARCHIVE_PATH | default (printf "%s/%s.tar.gz" .BIN_DIR .APP_NAME)}}'
|
||||
STAGING_DIR: '{{.BIN_DIR}}/.linux-archive-{{.ARCH | default ARCH}}'
|
||||
cmds:
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
- mkdir -p "{{.STAGING_DIR}}"
|
||||
- cp "{{.BINARY_PATH}}" "{{.STAGING_DIR}}/{{.ARCHIVE_BINARY_NAME}}"
|
||||
- chmod +x "{{.STAGING_DIR}}/{{.ARCHIVE_BINARY_NAME}}"
|
||||
- rm -f "{{.ARCHIVE_PATH}}"
|
||||
- env LC_ALL=C tar -C "{{.STAGING_DIR}}" -czf "{{.ARCHIVE_PATH}}" "{{.ARCHIVE_BINARY_NAME}}"
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
|
||||
cleanup:package:artifacts:
|
||||
summary: 清理 Linux 打包中间产物
|
||||
internal: true
|
||||
vars:
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
|
||||
cmds:
|
||||
- rm -f "{{.BINARY_PATH}}"
|
||||
|
||||
run:
|
||||
summary: 运行 Linux 可执行文件
|
||||
vars:
|
||||
OUTPUT: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .APP_NAME)}}'
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "linux" ]'
|
||||
msg: "Linux 运行任务仅支持在 Linux 主机上执行。"
|
||||
cmds:
|
||||
- '{{.OUTPUT}}'
|
||||
@@ -0,0 +1,13 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=Cursor助手
|
||||
Comment=Cursor助手
|
||||
# The Exec line includes %u to pass the URL to the application
|
||||
Exec=/usr/local/bin/Cursor助手 %u
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=Cursor助手
|
||||
Categories=Utility;
|
||||
StartupWMClass=Cursor助手
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Feel free to remove those if you don't want/need to use them.
|
||||
# Make sure to check the documentation at https://nfpm.goreleaser.com
|
||||
#
|
||||
# The lines below are called `modelines`. See `:help modeline`
|
||||
|
||||
name: "Cursor助手"
|
||||
arch: ${GOARCH}
|
||||
platform: "linux"
|
||||
version: "0.0.38"
|
||||
section: "default"
|
||||
priority: "extra"
|
||||
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||
description: "Cursor助手"
|
||||
vendor: "Cursor助手"
|
||||
homepage: "https://wails.io"
|
||||
license: "MIT"
|
||||
release: "1"
|
||||
|
||||
contents:
|
||||
- src: "./bin/Cursor助手"
|
||||
dst: "/usr/local/bin/Cursor助手"
|
||||
- src: "./build/appicon.png"
|
||||
dst: "/usr/share/icons/hicolor/128x128/apps/Cursor助手.png"
|
||||
- src: "./build/linux/Cursor助手.desktop"
|
||||
dst: "/usr/share/applications/Cursor助手.desktop"
|
||||
|
||||
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
|
||||
depends:
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
|
||||
# Distribution-specific overrides for different package formats and WebKit versions
|
||||
overrides:
|
||||
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
|
||||
rpm:
|
||||
depends:
|
||||
- gtk3
|
||||
- webkit2gtk4.1
|
||||
|
||||
# Arch Linux packages (WebKit 4.1)
|
||||
archlinux:
|
||||
depends:
|
||||
- gtk3
|
||||
- webkit2gtk-4.1
|
||||
|
||||
# scripts section to ensure desktop database is updated after install
|
||||
scripts:
|
||||
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
|
||||
# You can also add preremove, postremove if needed
|
||||
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
|
||||
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
|
||||
|
||||
# replaces:
|
||||
# - foobar
|
||||
# provides:
|
||||
# - bar
|
||||
# depends:
|
||||
# - gtk3
|
||||
# - libwebkit2gtk
|
||||
# recommends:
|
||||
# - whatever
|
||||
# suggests:
|
||||
# - something-else
|
||||
# conflicts:
|
||||
# - not-foo
|
||||
# - not-bar
|
||||
# changelog: "changelog.yaml"
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,156 @@
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: 构建 Windows 程序
|
||||
internal: true
|
||||
cmds:
|
||||
- task: build:native
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
|
||||
build:native:
|
||||
summary: 使用 Go 原生交叉编译构建 Windows 程序
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:go:mod:tidy
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: common:generate:icons
|
||||
cmds:
|
||||
- task: generate:syso
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
|
||||
- cmd: powershell Remove-item *.syso
|
||||
platforms: [windows]
|
||||
- cmd: rm -f *.syso
|
||||
platforms: [darwin, linux]
|
||||
vars:
|
||||
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
|
||||
env:
|
||||
GOOS: windows
|
||||
CGO_ENABLED: 0
|
||||
GOARCH: '{{.ARCH | default ARCH}}'
|
||||
|
||||
package:
|
||||
summary: 打包 Windows 安装程序
|
||||
internal: true
|
||||
cmds:
|
||||
- task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
|
||||
vars:
|
||||
FORMAT: '{{.FORMAT | default "nsis"}}'
|
||||
|
||||
generate:syso:
|
||||
summary: 生成 Windows 图标与版本信息资源
|
||||
internal: true
|
||||
dir: build
|
||||
cmds:
|
||||
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
|
||||
vars:
|
||||
ARCH: '{{.ARCH | default ARCH}}'
|
||||
|
||||
create:nsis:installer:
|
||||
summary: 生成 NSIS 安装包
|
||||
internal: true
|
||||
dir: build/windows/nsis
|
||||
deps:
|
||||
- task: build
|
||||
vars:
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
cmds:
|
||||
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
|
||||
- |
|
||||
{{if eq OS "windows"}}
|
||||
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
|
||||
{{else}}
|
||||
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
|
||||
{{end}}
|
||||
vars:
|
||||
ARCH: '{{.ARCH | default ARCH}}'
|
||||
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
|
||||
|
||||
create:msix:package:
|
||||
summary: 生成 MSIX 安装包
|
||||
internal: true
|
||||
deps:
|
||||
- task: build
|
||||
vars:
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
cmds:
|
||||
- |-
|
||||
wails3 tool msix \
|
||||
--config "{{.ROOT_DIR}}/wails.json" \
|
||||
--name "{{.APP_NAME}}" \
|
||||
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
|
||||
--arch "{{.ARCH}}" \
|
||||
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
|
||||
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
|
||||
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
|
||||
{{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
|
||||
vars:
|
||||
ARCH: '{{.ARCH | default ARCH}}'
|
||||
CERT_PATH: '{{.CERT_PATH | default ""}}'
|
||||
PUBLISHER: '{{.PUBLISHER | default ""}}'
|
||||
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
|
||||
|
||||
create:zip:
|
||||
summary: 生成 Windows ZIP 包(包含 exe 与 certs)
|
||||
internal: true
|
||||
vars:
|
||||
ARCH: '{{.ARCH | default ARCH}}'
|
||||
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
|
||||
ZIP_NAME: '{{.ZIP_NAME | default (printf "%s-windows-%s.zip" .APP_NAME .ARCH)}}'
|
||||
STAGING_DIR: '{{.BIN_DIR}}/.zip-{{.ARCH}}'
|
||||
cmds:
|
||||
- task: build
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
- mkdir -p "{{.STAGING_DIR}}"
|
||||
- cp "{{.OUTPUT}}" "{{.STAGING_DIR}}/"
|
||||
- (cd "{{.STAGING_DIR}}" && zip -qry "../{{.ZIP_NAME}}" .)
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
- rm -f "{{.OUTPUT}}"
|
||||
|
||||
run:
|
||||
summary: 运行 Windows 可执行文件(仅在 Windows 可用)
|
||||
vars:
|
||||
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
|
||||
cmds:
|
||||
- '{{.OUTPUT}}'
|
||||
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "0.0.38"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "0.0.38",
|
||||
"CompanyName": "Cursor助手",
|
||||
"FileDescription": "Cursor助手",
|
||||
"LegalCopyright": "© 2026, Cursor助手",
|
||||
"ProductName": "Cursor助手",
|
||||
"Comments": "Cursor助手"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
|
||||
IgnorableNamespaces="uap3">
|
||||
|
||||
<Identity
|
||||
Name="com.cursor.wuxianxubei"
|
||||
Publisher="CN=Cursor助手"
|
||||
Version="0.1.0.0"
|
||||
ProcessorArchitecture="x64" />
|
||||
|
||||
<Properties>
|
||||
<DisplayName>Cursor助手</DisplayName>
|
||||
<PublisherDisplayName>Cursor助手</PublisherDisplayName>
|
||||
<Description>Cursor助手</Description>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="zh-cn" />
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="com.cursor.wuxianxubei" Executable="Cursor助手" EntryPoint="Windows.FullTrustApplication">
|
||||
<uap:VisualElements
|
||||
DisplayName="Cursor助手"
|
||||
Description="本地 MITM 代理客户端,支持中转转发与托盘控制"
|
||||
BackgroundColor="transparent"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||
</uap:VisualElements>
|
||||
|
||||
<Extensions>
|
||||
<desktop:Extension Category="windows.fullTrustProcess" Executable="Cursor助手" />
|
||||
|
||||
|
||||
</Extensions>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
|
||||
</Capabilities>
|
||||
</Package>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<MsixPackagingToolTemplate
|
||||
xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
|
||||
<Settings
|
||||
AllowTelemetry="false"
|
||||
ApplyACLsToPackageFiles="true"
|
||||
GenerateCommandLineFile="true"
|
||||
AllowPromptForPassword="false">
|
||||
</Settings>
|
||||
<Installer
|
||||
Path="Cursor助手"
|
||||
Arguments=""
|
||||
InstallLocation="C:\Program Files\Cursor助手\Cursor助手">
|
||||
</Installer>
|
||||
<PackageInformation
|
||||
PackageName="Cursor助手"
|
||||
PackageDisplayName="Cursor助手"
|
||||
PublisherName="CN=Cursor助手"
|
||||
PublisherDisplayName="Cursor助手"
|
||||
Version="0.1.0.0"
|
||||
PackageDescription="Cursor助手">
|
||||
<Capabilities>
|
||||
<Capability Name="runFullTrust" />
|
||||
|
||||
</Capabilities>
|
||||
<Applications>
|
||||
<Application
|
||||
Id="com.cursor.wuxianxubei"
|
||||
Description="Cursor助手"
|
||||
DisplayName="Cursor助手"
|
||||
ExecutableName="Cursor助手"
|
||||
EntryPoint="Windows.FullTrustApplication">
|
||||
|
||||
</Application>
|
||||
</Applications>
|
||||
<Resources>
|
||||
<Resource Language="en-us" />
|
||||
</Resources>
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||
</Dependencies>
|
||||
<Properties>
|
||||
<Framework>false</Framework>
|
||||
<DisplayName>Cursor助手</DisplayName>
|
||||
<PublisherDisplayName>Cursor助手</PublisherDisplayName>
|
||||
<Description>Cursor助手</Description>
|
||||
<Logo>Assets\AppIcon.png</Logo>
|
||||
</Properties>
|
||||
</PackageInformation>
|
||||
<SaveLocation PackagePath="Cursor助手.msix" />
|
||||
<PackageIntegrity>
|
||||
<CertificatePath></CertificatePath>
|
||||
</PackageIntegrity>
|
||||
</MsixPackagingToolTemplate>
|
||||
@@ -0,0 +1,116 @@
|
||||
Unicode true
|
||||
|
||||
####
|
||||
## Please note: Template replacements don't work in this file. They are provided with default defines like
|
||||
## mentioned underneath.
|
||||
## If the keyword is not defined, "wails_tools.nsh" will populate them.
|
||||
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
|
||||
## from outside of Wails for debugging and development of the installer.
|
||||
##
|
||||
## For development first make a wails nsis build to populate the "wails_tools.nsh":
|
||||
## > wails build --target windows/amd64 --nsis
|
||||
## Then you can call makensis on this file with specifying the path to your binary:
|
||||
## For a AMD64 only installer:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
|
||||
## For a ARM64 only installer:
|
||||
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
|
||||
## For a installer with both architectures:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
|
||||
####
|
||||
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
|
||||
####
|
||||
## !define INFO_PROJECTNAME "my-project" # Default "cursorclient"
|
||||
## !define INFO_COMPANYNAME "My Company" # Default "My Company"
|
||||
## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product"
|
||||
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
|
||||
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company"
|
||||
###
|
||||
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
|
||||
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
####
|
||||
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||
####
|
||||
## Include the wails tools
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||
|
||||
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||
|
||||
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "MUI.nsh"
|
||||
|
||||
!define MUI_ICON "..\icon.ico"
|
||||
!define MUI_UNICON "..\icon.ico"
|
||||
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||
|
||||
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
|
||||
|
||||
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||
#!uninstfinalize 'signtool --file "%1"'
|
||||
#!finalize 'signtool --file "%1"'
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
Function .onInit
|
||||
!insertmacro wails.checkArchitecture
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
|
||||
!insertmacro wails.writeUninstaller
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||
|
||||
RMDir /r $INSTDIR
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
!insertmacro wails.unassociateFiles
|
||||
!insertmacro wails.unassociateCustomProtocols
|
||||
|
||||
!insertmacro wails.deleteUninstaller
|
||||
SectionEnd
|
||||
@@ -0,0 +1,236 @@
|
||||
# DO NOT EDIT - Generated automatically by `wails build`
|
||||
|
||||
!include "x64.nsh"
|
||||
!include "WinVer.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
|
||||
!ifndef INFO_PROJECTNAME
|
||||
!define INFO_PROJECTNAME "Cursor助手"
|
||||
!endif
|
||||
!ifndef INFO_COMPANYNAME
|
||||
!define INFO_COMPANYNAME "Cursor助手"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTNAME
|
||||
!define INFO_PRODUCTNAME "Cursor助手"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "0.0.38"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "© 2026, Cursor助手"
|
||||
!endif
|
||||
!ifndef PRODUCT_EXECUTABLE
|
||||
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||
!endif
|
||||
!ifndef UNINST_KEY_NAME
|
||||
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
!endif
|
||||
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||
|
||||
!ifndef REQUEST_EXECUTION_LEVEL
|
||||
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||
!endif
|
||||
|
||||
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
|
||||
!ifdef ARG_WAILS_AMD64_BINARY
|
||||
!define SUPPORTS_AMD64
|
||||
!endif
|
||||
|
||||
!ifdef ARG_WAILS_ARM64_BINARY
|
||||
!define SUPPORTS_ARM64
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_AMD64
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "amd64_arm64"
|
||||
!else
|
||||
!define ARCH "amd64"
|
||||
!endif
|
||||
!else
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "arm64"
|
||||
!else
|
||||
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
|
||||
!endif
|
||||
!endif
|
||||
|
||||
!macro wails.checkArchitecture
|
||||
!ifndef WAILS_WIN10_REQUIRED
|
||||
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||
!endif
|
||||
|
||||
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||
!endif
|
||||
|
||||
${If} ${AtLeastWin10}
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
IfSilent silentArch notSilentArch
|
||||
silentArch:
|
||||
SetErrorLevel 65
|
||||
Abort
|
||||
notSilentArch:
|
||||
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
|
||||
Quit
|
||||
${else}
|
||||
IfSilent silentWin notSilentWin
|
||||
silentWin:
|
||||
SetErrorLevel 64
|
||||
Abort
|
||||
notSilentWin:
|
||||
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
!macro wails.files
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
!macro wails.writeUninstaller
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!macroend
|
||||
|
||||
!macro wails.deleteUninstaller
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||
!macroend
|
||||
|
||||
!macro wails.setShellContext
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
|
||||
SetShellVarContext all
|
||||
${else}
|
||||
SetShellVarContext current
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
# Install webview2 by launching the bootstrapper
|
||||
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||
!macro wails.webview2runtime
|
||||
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||
!endif
|
||||
|
||||
SetRegView 64
|
||||
# If the admin key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
|
||||
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
SetDetailsPrint both
|
||||
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
|
||||
SetDetailsPrint listonly
|
||||
|
||||
InitPluginsDir
|
||||
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||
File "MicrosoftEdgeWebview2Setup.exe"
|
||||
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||
|
||||
SetDetailsPrint both
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
|
||||
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
|
||||
!macroend
|
||||
|
||||
!macro APP_UNASSOCIATE EXT FILECLASS
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
|
||||
|
||||
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
|
||||
!macroend
|
||||
|
||||
!macro wails.associateFiles
|
||||
; Create file associations
|
||||
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateFiles
|
||||
; Delete app associations
|
||||
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
!macroend
|
||||
|
||||
!macro wails.associateCustomProtocols
|
||||
; Create custom protocols associations
|
||||
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateCustomProtocols
|
||||
; Delete app custom protocol associations
|
||||
|
||||
!macroend
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.38" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
@@ -0,0 +1,2 @@
|
||||
cursor-tab-server-linux-amd64.tar
|
||||
*.tar
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM golang:1.25 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/cursor-tab-server .
|
||||
|
||||
FROM scratch
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /out/cursor-tab-server /app/cursor-tab-server
|
||||
COPY config.yaml /app/config.yaml
|
||||
|
||||
EXPOSE 8041
|
||||
|
||||
ENTRYPOINT ["/app/cursor-tab-server"]
|
||||
@@ -0,0 +1,13 @@
|
||||
### 如何获取 token?
|
||||
**macos**
|
||||
```bash
|
||||
sqlite3 "$HOME/Library/Application Support/Cursor/User/globalStorage/state.vscdb" \
|
||||
"SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';"
|
||||
|
||||
```
|
||||
**windows 获取方式**
|
||||
```bash
|
||||
sqlite3 "$env:APPDATA\Cursor\User\globalStorage\state.vscdb" "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';"
|
||||
|
||||
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
token: "eyJ..."
|
||||
@@ -0,0 +1,5 @@
|
||||
module cursor-tab-server
|
||||
|
||||
go 1.25
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfigPath = "./config.yaml"
|
||||
defaultListenAddr = ":8041"
|
||||
)
|
||||
|
||||
var hopByHopHeaders = map[string]struct{}{
|
||||
"connection": {},
|
||||
"proxy-connection": {},
|
||||
"keep-alive": {},
|
||||
"proxy-authenticate": {},
|
||||
"proxy-authorization": {},
|
||||
"te": {},
|
||||
"trailer": {},
|
||||
"transfer-encoding": {},
|
||||
"upgrade": {},
|
||||
}
|
||||
|
||||
var defaultUpstreamTargets = map[string]string{
|
||||
"/aiserver.v1.AiService/StreamCpp": "https://api4.cursor.sh:443/aiserver.v1.AiService/StreamCpp",
|
||||
"/aiserver.v1.AiService/StreamNextCursorPrediction": "https://api4.cursor.sh:443/aiserver.v1.AiService/StreamNextCursorPrediction",
|
||||
"/aiserver.v1.AiService/GetCppEditClassification": "https://api4.cursor.sh:443/aiserver.v1.AiService/GetCppEditClassification",
|
||||
"/aiserver.v1.AiService/RefreshTabContext": "https://api2.cursor.sh:443/aiserver.v1.AiService/RefreshTabContext",
|
||||
"/aiserver.v1.AiService/CppConfig": "https://api4.cursor.sh:443/aiserver.v1.AiService/CppConfig",
|
||||
"/aiserver.v1.AiService/CppEditHistoryStatus": "https://api2.cursor.sh:443/aiserver.v1.AiService/CppEditHistoryStatus",
|
||||
"/aiserver.v1.AiService/CppAppend": "https://api3.cursor.sh:443/aiserver.v1.AiService/CppAppend",
|
||||
"/aiserver.v1.AiService/CppEditHistoryAppend": "https://api3.cursor.sh:443/aiserver.v1.AiService/CppEditHistoryAppend",
|
||||
"/aiserver.v1.CppService/AvailableModels": "https://api3.cursor.sh:443/aiserver.v1.CppService/AvailableModels",
|
||||
"/aiserver.v1.CppService/RecordCppFate": "https://api2.cursor.sh:443/aiserver.v1.CppService/RecordCppFate",
|
||||
"/aiserver.v1.AiService/ReportAiCodeChangeMetrics": "https://api2.cursor.sh:443/aiserver.v1.AiService/ReportAiCodeChangeMetrics",
|
||||
"/aiserver.v1.AiService/WriteGitCommitMessage": "https://api2.cursor.sh:443/aiserver.v1.AiService/WriteGitCommitMessage",
|
||||
"/aiserver.v1.AiService/WriteGitBranchName": "https://api2.cursor.sh:443/aiserver.v1.AiService/WriteGitBranchName",
|
||||
"/aiserver.v1.FileSyncService/FSSyncFile": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSSyncFile",
|
||||
"/aiserver.v1.FileSyncService/FSIsEnabledForUser": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSIsEnabledForUser",
|
||||
"/aiserver.v1.FileSyncService/FSConfig": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSConfig",
|
||||
"/aiserver.v1.FileSyncService/FSUploadFile": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSUploadFile",
|
||||
"/aiserver.v1.DashboardService/GetEffectiveUserPlugins": "https://api2.cursor.sh:443/aiserver.v1.DashboardService/GetEffectiveUserPlugins",
|
||||
}
|
||||
|
||||
type appConfig struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
type serverApp struct {
|
||||
config appConfig
|
||||
client *http.Client
|
||||
upstreamTargets map[string]string
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := loadConfig(defaultConfigPath)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Printf("cursor-tab-server 启动 listen_addr=%s config_path=%s", defaultListenAddr, defaultConfigPath)
|
||||
server := &http.Server{
|
||||
Addr: defaultListenAddr,
|
||||
Handler: newServerApp(cfg, newHTTPClient(), defaultUpstreamTargets),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "监听失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newServerApp(cfg appConfig, client *http.Client, upstreamTargets map[string]string) http.Handler {
|
||||
app := &serverApp{
|
||||
config: cfg,
|
||||
client: client,
|
||||
upstreamTargets: cloneUpstreamTargets(upstreamTargets),
|
||||
}
|
||||
if app.client == nil {
|
||||
app.client = newHTTPClient()
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func (app *serverApp) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.handleProxy(writer, request); err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadGateway)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *serverApp) handleProxy(writer http.ResponseWriter, request *http.Request) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("服务实例为空")
|
||||
}
|
||||
rawTarget, ok := app.upstreamTargets[strings.TrimSpace(request.URL.Path)]
|
||||
if !ok {
|
||||
http.NotFound(writer, request)
|
||||
return nil
|
||||
}
|
||||
targetURL, err := url.Parse(rawTarget)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析上游地址失败: %w", err)
|
||||
}
|
||||
targetURL.RawQuery = request.URL.RawQuery
|
||||
|
||||
requestBody := []byte{}
|
||||
if shouldRequestCarryBody(request.Method) {
|
||||
requestBody, err = io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取请求体失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
upstreamRequest, err := http.NewRequestWithContext(request.Context(), request.Method, targetURL.String(), bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建上游请求失败: %w", err)
|
||||
}
|
||||
copyRequestHeaders(upstreamRequest.Header, request.Header)
|
||||
authorization := formatBearerAuthorization(app.config.Token)
|
||||
upstreamRequest.Header.Set("Authorization", authorization)
|
||||
upstreamRequest.Header.Set("x-cursor-checksum", buildCursorChecksum(authorization))
|
||||
if !shouldRequestCarryBody(request.Method) {
|
||||
upstreamRequest.Header.Del("content-length")
|
||||
} else {
|
||||
upstreamRequest.Header.Set("content-length", strconv.Itoa(len(requestBody)))
|
||||
}
|
||||
upstreamRequest.Host = targetURL.Host
|
||||
|
||||
response, err := app.client.Do(upstreamRequest)
|
||||
if err != nil {
|
||||
log.Printf("上游转发失败 method=%s path=%s target=%s err=%v", request.Method, request.URL.Path, targetURL.String(), err)
|
||||
return fmt.Errorf("上游请求失败: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
log.Printf("上游响应 method=%s path=%s target_host=%s status=%d", request.Method, request.URL.Path, targetURL.Host, response.StatusCode)
|
||||
|
||||
copyResponseHeaders(writer.Header(), response.Header)
|
||||
writer.WriteHeader(response.StatusCode)
|
||||
_, err = copyStream(writer, response.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadConfig(path string) (appConfig, error) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return appConfig{}, err
|
||||
}
|
||||
token, err := parseTokenYAML(contents)
|
||||
if err != nil {
|
||||
return appConfig{}, err
|
||||
}
|
||||
return appConfig{Token: token}, nil
|
||||
}
|
||||
|
||||
func parseTokenYAML(contents []byte) (string, error) {
|
||||
var cfg appConfig
|
||||
if err := yaml.Unmarshal(contents, &cfg); err != nil {
|
||||
return "", fmt.Errorf("解析配置失败: %w", err)
|
||||
}
|
||||
token := strings.TrimSpace(cfg.Token)
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("token 不能为空")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func copyRequestHeaders(target http.Header, source http.Header) {
|
||||
for key, values := range source {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if _, exists := hopByHopHeaders[lowerKey]; exists {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
target.Add(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyResponseHeaders(target http.Header, source http.Header) {
|
||||
for key, values := range source {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if _, exists := hopByHopHeaders[lowerKey]; exists {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
target.Add(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyStream(writer io.Writer, reader io.Reader) (int64, error) {
|
||||
buffer := make([]byte, 32*1024)
|
||||
var total int64
|
||||
for {
|
||||
readCount, readErr := reader.Read(buffer)
|
||||
if readCount > 0 {
|
||||
chunk := buffer[:readCount]
|
||||
written, writeErr := writer.Write(chunk)
|
||||
total += int64(written)
|
||||
if writeErr != nil {
|
||||
return total, writeErr
|
||||
}
|
||||
if written < len(chunk) {
|
||||
return total, io.ErrShortWrite
|
||||
}
|
||||
if flusher, ok := writer.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
return total, nil
|
||||
}
|
||||
return total, readErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRequestCarryBody(method string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(method)) {
|
||||
case http.MethodGet, http.MethodHead, http.MethodDelete:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func formatBearerAuthorization(raw string) string {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
|
||||
return value
|
||||
}
|
||||
return "Bearer " + value
|
||||
}
|
||||
|
||||
func buildCursorChecksum(authorization string) string {
|
||||
const (
|
||||
checksumTimestampDivisor = 1_000_000
|
||||
checksumInitialSeed = 165
|
||||
)
|
||||
timestamp := time.Now().UnixMilli() / checksumTimestampDivisor
|
||||
timestampBytes := make([]byte, 6)
|
||||
timestampBigInt := big.NewInt(timestamp)
|
||||
for index := 0; index < len(timestampBytes); index++ {
|
||||
shift := uint((len(timestampBytes) - 1 - index) * 8)
|
||||
timestampBytes[index] = byte(new(big.Int).Rsh(timestampBigInt, shift).Uint64() & 0xff)
|
||||
}
|
||||
seed := checksumInitialSeed
|
||||
for index := 0; index < len(timestampBytes); index++ {
|
||||
current := int(timestampBytes[index]^byte(seed)) + (index % 256)
|
||||
current &= 0xff
|
||||
timestampBytes[index] = byte(current)
|
||||
seed = current
|
||||
}
|
||||
prefix := strings.TrimRight(base64.StdEncoding.EncodeToString(timestampBytes), "=")
|
||||
hashBytes := sha256.Sum256([]byte(strings.TrimSpace(authorization)))
|
||||
hash := fmt.Sprintf("%x", hashBytes)
|
||||
return prefix + hash[:32]
|
||||
}
|
||||
|
||||
func newHTTPClient() *http.Client {
|
||||
return &http.Client{}
|
||||
}
|
||||
|
||||
func cloneUpstreamTargets(input map[string]string) map[string]string {
|
||||
output := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
registry "https://registry.npmmirror.com"
|
||||
network-timeout 120000
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Cursor助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@bindings/*": ["bindings/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:dev": "node ./scripts/run-vite-build.mjs --minify false --mode development",
|
||||
"build": "node ./scripts/run-vite-build.mjs --mode production",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4",
|
||||
"@iconify/json": "^2.2.447",
|
||||
"@wailsio/runtime": "latest",
|
||||
"chart.js": "^4.5.1",
|
||||
"copy-text-to-clipboard": "^3.2.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"code-inspector-plugin": "^1.4.3",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^7.1.11",
|
||||
"vite-plugin-top-level-await": "^1.6.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"Safari >= 13"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
import { createHash } from "crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import MagicString from "magic-string";
|
||||
import { parse as parseJavaScript } from "@babel/parser";
|
||||
import traverseModule from "@babel/traverse";
|
||||
import { parse as parseTemplate } from "@vue/compiler-dom";
|
||||
import { parse as parseSFC } from "@vue/compiler-sfc";
|
||||
import { normalizePath } from "vite";
|
||||
|
||||
const traverse = traverseModule.default ?? traverseModule;
|
||||
|
||||
const SOURCE_LANGUAGE = "zh-CN";
|
||||
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ja-JP"];
|
||||
const HAN_REGEX = /\p{Script=Han}/u;
|
||||
const JS_HELPERS = {
|
||||
localized: "__i18nLocalized",
|
||||
localizedTemplate: "__i18nLocalizedTemplate",
|
||||
};
|
||||
const TEMPLATE_HELPERS = {
|
||||
localized: "$ls",
|
||||
localizedTemplate: "$lt",
|
||||
};
|
||||
const RUNTIME_IMPORT = "@/i18n/runtime";
|
||||
const BABEL_PLUGINS = [
|
||||
"jsx",
|
||||
"typescript",
|
||||
"classProperties",
|
||||
"classPrivateProperties",
|
||||
"classPrivateMethods",
|
||||
"topLevelAwait",
|
||||
"importAttributes",
|
||||
];
|
||||
|
||||
function containsHan(value) {
|
||||
return typeof value === "string" && HAN_REGEX.test(value);
|
||||
}
|
||||
|
||||
function toJSONLiteral(value) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function toSingleQuotedLiteral(value) {
|
||||
return `'${String(value)
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029")}'`;
|
||||
}
|
||||
|
||||
function hashMessageID(message) {
|
||||
return createHash("sha256").update(message).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function stripQuery(id) {
|
||||
return id.split("?")[0];
|
||||
}
|
||||
|
||||
function isSourceFile(id) {
|
||||
const cleanID = stripQuery(id);
|
||||
return /\.(?:js|jsx|ts|tsx|vue)$/.test(cleanID);
|
||||
}
|
||||
|
||||
function isExcludedFile(rootDir, id) {
|
||||
const cleanID = normalizePath(stripQuery(id));
|
||||
if (cleanID.includes("/node_modules/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const relativePath = normalizePath(path.relative(rootDir, cleanID));
|
||||
if (!relativePath.startsWith("src/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return relativePath.startsWith("src/i18n/");
|
||||
}
|
||||
|
||||
function readJSONFile(filePath, fallback) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, "utf8").trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function ensureDirectory(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function writeJSONFile(filePath, payload) {
|
||||
ensureDirectory(filePath);
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function buildRef(filePath, rootDir, loc) {
|
||||
return {
|
||||
file: normalizePath(path.relative(rootDir, filePath)),
|
||||
line: loc?.line ?? 1,
|
||||
column: loc?.column ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessageRecord(filePath, rootDir, canonical, placeholders, loc) {
|
||||
const id = hashMessageID(canonical);
|
||||
return {
|
||||
id,
|
||||
source: canonical,
|
||||
kind: placeholders > 0 ? "template" : "text",
|
||||
placeholders,
|
||||
ref: buildRef(filePath, rootDir, loc),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeMessageRecords(records) {
|
||||
const entries = new Map();
|
||||
|
||||
for (const record of records) {
|
||||
const current = entries.get(record.id);
|
||||
if (!current) {
|
||||
entries.set(record.id, {
|
||||
source: record.source,
|
||||
kind: record.kind,
|
||||
placeholders: record.placeholders,
|
||||
refs: [record.ref],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.source !== record.source) {
|
||||
throw new Error(
|
||||
`[static-i18n] Message id collision for ${record.id}: ${current.source} <> ${record.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
current.refs.push(record.ref);
|
||||
}
|
||||
|
||||
const sortedEntries = {};
|
||||
for (const id of Array.from(entries.keys()).sort()) {
|
||||
const entry = entries.get(id);
|
||||
sortedEntries[id] = {
|
||||
source: entry.source,
|
||||
kind: entry.kind,
|
||||
placeholders: entry.placeholders,
|
||||
refs: entry.refs.sort((left, right) =>
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.column - right.column),
|
||||
};
|
||||
}
|
||||
|
||||
return { entries: sortedEntries };
|
||||
}
|
||||
|
||||
function mergeLocaleMessages(existingMessages, catalogEntries, locale) {
|
||||
const nextMessages = {};
|
||||
|
||||
for (const id of Object.keys(catalogEntries)) {
|
||||
if (locale === SOURCE_LANGUAGE) {
|
||||
nextMessages[id] = catalogEntries[id].source;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentValue = existingMessages?.[id];
|
||||
nextMessages[id] = typeof currentValue === "string" ? currentValue : "";
|
||||
}
|
||||
|
||||
return nextMessages;
|
||||
}
|
||||
|
||||
function walkSourceFiles(dirPath, visitor) {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const nextPath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules") {
|
||||
continue;
|
||||
}
|
||||
walkSourceFiles(nextPath, visitor);
|
||||
continue;
|
||||
}
|
||||
|
||||
visitor(nextPath);
|
||||
}
|
||||
}
|
||||
|
||||
function parseProgram(code, filename) {
|
||||
try {
|
||||
return parseJavaScript(code, {
|
||||
sourceType: "module",
|
||||
sourceFilename: filename,
|
||||
plugins: BABEL_PLUGINS,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`[static-i18n] Failed to parse ${filename}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createTemplateCanonical(node) {
|
||||
const parts = [];
|
||||
for (let index = 0; index < node.quasis.length; index += 1) {
|
||||
const quasi = node.quasis[index];
|
||||
parts.push(quasi.value.cooked ?? quasi.value.raw ?? "");
|
||||
if (index < node.expressions.length) {
|
||||
parts.push(`{${index}}`);
|
||||
}
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function shouldIgnoreStringLiteral(path) {
|
||||
const parent = path.parentPath;
|
||||
if (!parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
parent.isImportDeclaration() ||
|
||||
parent.isExportAllDeclaration() ||
|
||||
parent.isExportNamedDeclaration()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parent.isDirective()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(parent.isObjectProperty() || parent.isObjectMethod()) &&
|
||||
path.key === "key" &&
|
||||
parent.node.computed !== true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(parent.isMemberExpression() || parent.isOptionalMemberExpression?.()) &&
|
||||
path.key === "property" &&
|
||||
parent.node.computed !== true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(parent.isClassMethod?.() || parent.isClassProperty?.() || parent.isClassPrivateProperty?.()) &&
|
||||
path.key === "key"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldIgnoreTemplateLiteral(path) {
|
||||
return path.parentPath?.isTaggedTemplateExpression?.() === true;
|
||||
}
|
||||
|
||||
function createStringLiteralReplacement(path, helperNames, record, quoteLiteral) {
|
||||
if (path.parentPath?.isJSXAttribute?.() && path.key === "value") {
|
||||
return `{${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})}`;
|
||||
}
|
||||
|
||||
return `${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})`;
|
||||
}
|
||||
|
||||
function createTemplateLiteralReplacement(path, code, helperNames, record, quoteLiteral) {
|
||||
if (path.node.expressions.length === 0) {
|
||||
return `${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})`;
|
||||
}
|
||||
|
||||
const args = path.node.expressions.map((expression) => code.slice(expression.start, expression.end));
|
||||
const payload = `[${args.join(", ")}]`;
|
||||
return `${helperNames.localizedTemplate}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)}, ${payload})`;
|
||||
}
|
||||
|
||||
function collectJSReplacements(code, ast, filePath, rootDir, helperNames, options = {}) {
|
||||
const replacements = [];
|
||||
const records = [];
|
||||
const helperUsage = {
|
||||
localized: false,
|
||||
localizedTemplate: false,
|
||||
};
|
||||
const refLoc = options.refLoc ?? null;
|
||||
const quoteLiteral = options.quoteLiteral ?? toJSONLiteral;
|
||||
|
||||
function resolveLoc(node) {
|
||||
if (refLoc) {
|
||||
return refLoc;
|
||||
}
|
||||
|
||||
return node?.loc?.start
|
||||
? {
|
||||
line: node.loc.start.line,
|
||||
column: node.loc.start.column + 1,
|
||||
}
|
||||
: { line: 1, column: 1 };
|
||||
}
|
||||
|
||||
traverse(ast, {
|
||||
noScope: true,
|
||||
StringLiteral(path) {
|
||||
if (shouldIgnoreStringLiteral(path) || !containsHan(path.node.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = buildMessageRecord(filePath, rootDir, path.node.value, 0, resolveLoc(path.node));
|
||||
records.push(record);
|
||||
helperUsage.localized = true;
|
||||
replacements.push({
|
||||
start: path.node.start,
|
||||
end: path.node.end,
|
||||
text: createStringLiteralReplacement(path, helperNames, record, quoteLiteral),
|
||||
});
|
||||
},
|
||||
TemplateLiteral(path) {
|
||||
if (shouldIgnoreTemplateLiteral(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canonical = createTemplateCanonical(path.node);
|
||||
if (!containsHan(canonical)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = buildMessageRecord(
|
||||
filePath,
|
||||
rootDir,
|
||||
canonical,
|
||||
path.node.expressions.length,
|
||||
resolveLoc(path.node),
|
||||
);
|
||||
records.push(record);
|
||||
if (path.node.expressions.length === 0) {
|
||||
helperUsage.localized = true;
|
||||
} else {
|
||||
helperUsage.localizedTemplate = true;
|
||||
}
|
||||
replacements.push({
|
||||
start: path.node.start,
|
||||
end: path.node.end,
|
||||
text: createTemplateLiteralReplacement(path, code, helperNames, record, quoteLiteral),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
replacements,
|
||||
records,
|
||||
helperUsage,
|
||||
};
|
||||
}
|
||||
|
||||
function applyReplacements(code, replacements) {
|
||||
if (!replacements.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const magicString = new MagicString(code);
|
||||
const sortedReplacements = [...replacements].sort((left, right) => right.start - left.start);
|
||||
for (const replacement of sortedReplacements) {
|
||||
magicString.overwrite(replacement.start, replacement.end, replacement.text);
|
||||
}
|
||||
|
||||
return magicString;
|
||||
}
|
||||
|
||||
function ensureRuntimeImport(code, helperUsage) {
|
||||
if (!helperUsage.localized && !helperUsage.localizedTemplate) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const pieces = [];
|
||||
if (helperUsage.localized) {
|
||||
pieces.push(`localized as ${JS_HELPERS.localized}`);
|
||||
}
|
||||
if (helperUsage.localizedTemplate) {
|
||||
pieces.push(`localizedTemplate as ${JS_HELPERS.localizedTemplate}`);
|
||||
}
|
||||
|
||||
return `import { ${pieces.join(", ")} } from "${RUNTIME_IMPORT}";\n${code}`;
|
||||
}
|
||||
|
||||
function transformJavaScript(code, filePath, rootDir, options = {}) {
|
||||
const ast = parseProgram(code, filePath);
|
||||
const result = collectJSReplacements(
|
||||
code,
|
||||
ast,
|
||||
filePath,
|
||||
rootDir,
|
||||
options.helperNames ?? JS_HELPERS,
|
||||
{
|
||||
refLoc: options.refLoc,
|
||||
quoteLiteral: options.quoteLiteral,
|
||||
},
|
||||
);
|
||||
const magicString = applyReplacements(code, result.replacements);
|
||||
const transformedCode = options.injectImport === false
|
||||
? magicString?.toString() ?? code
|
||||
: ensureRuntimeImport(magicString?.toString() ?? code, result.helperUsage);
|
||||
|
||||
return {
|
||||
code: transformedCode,
|
||||
changed: transformedCode !== code,
|
||||
records: result.records,
|
||||
map: magicString
|
||||
? magicString.generateMap({
|
||||
source: filePath,
|
||||
hires: true,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function translateTemplateExpression(expression, filePath, rootDir, refLoc) {
|
||||
const wrappedCode = `(${expression})`;
|
||||
|
||||
try {
|
||||
const transformed = transformJavaScript(wrappedCode, filePath, rootDir, {
|
||||
helperNames: TEMPLATE_HELPERS,
|
||||
injectImport: false,
|
||||
refLoc,
|
||||
quoteLiteral: toSingleQuotedLiteral,
|
||||
});
|
||||
const nextCode = transformed.code.slice(1, -1);
|
||||
return {
|
||||
code: nextCode,
|
||||
changed: nextCode !== expression,
|
||||
records: transformed.records,
|
||||
};
|
||||
} catch (_error) {
|
||||
const transformed = transformJavaScript(expression, filePath, rootDir, {
|
||||
helperNames: TEMPLATE_HELPERS,
|
||||
injectImport: false,
|
||||
refLoc,
|
||||
quoteLiteral: toSingleQuotedLiteral,
|
||||
});
|
||||
return {
|
||||
code: transformed.code,
|
||||
changed: transformed.code !== expression,
|
||||
records: transformed.records,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createTextNodeReplacement(source, record) {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leadingLength = source.indexOf(trimmed);
|
||||
const leading = leadingLength > 0 ? source.slice(0, leadingLength) : "";
|
||||
const trailing = source.slice(leadingLength + trimmed.length);
|
||||
return `${leading}{{ $ls(${toSingleQuotedLiteral(record.id)}, ${toSingleQuotedLiteral(record.source)}) }}${trailing}`;
|
||||
}
|
||||
|
||||
function walkTemplateNode(node, visitor) {
|
||||
visitor(node);
|
||||
|
||||
if (Array.isArray(node.branches)) {
|
||||
node.branches.forEach((branch) => walkTemplateNode(branch, visitor));
|
||||
}
|
||||
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children.forEach((child) => walkTemplateNode(child, visitor));
|
||||
}
|
||||
|
||||
if (node.type === 1 && Array.isArray(node.props)) {
|
||||
for (const prop of node.props) {
|
||||
visitor(prop, node);
|
||||
if (prop.exp) {
|
||||
visitor(prop.exp, prop);
|
||||
}
|
||||
if (prop.arg) {
|
||||
visitor(prop.arg, prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 5 && node.content) {
|
||||
visitor(node.content, node);
|
||||
}
|
||||
}
|
||||
|
||||
function transformVueTemplate(templateCode, filePath, rootDir) {
|
||||
const ast = parseTemplate(templateCode, { comments: true });
|
||||
const replacements = [];
|
||||
const records = [];
|
||||
|
||||
walkTemplateNode(ast, (node, parent) => {
|
||||
if (node.type === 2 && containsHan(node.content)) {
|
||||
const record = buildMessageRecord(
|
||||
filePath,
|
||||
rootDir,
|
||||
node.content.trim(),
|
||||
0,
|
||||
{
|
||||
line: node.loc.start.line,
|
||||
column: node.loc.start.column + 1,
|
||||
},
|
||||
);
|
||||
const replacement = createTextNodeReplacement(node.loc.source, record);
|
||||
if (!replacement) {
|
||||
return;
|
||||
}
|
||||
|
||||
records.push(record);
|
||||
replacements.push({
|
||||
start: node.loc.start.offset,
|
||||
end: node.loc.end.offset,
|
||||
text: replacement,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 6 && node.value && containsHan(node.value.content)) {
|
||||
const record = buildMessageRecord(
|
||||
filePath,
|
||||
rootDir,
|
||||
node.value.content,
|
||||
0,
|
||||
{
|
||||
line: node.loc.start.line,
|
||||
column: node.loc.start.column + 1,
|
||||
},
|
||||
);
|
||||
records.push(record);
|
||||
replacements.push({
|
||||
start: node.loc.start.offset,
|
||||
end: node.loc.end.offset,
|
||||
text: `:${node.name}="$ls(${toSingleQuotedLiteral(record.id)}, ${toSingleQuotedLiteral(record.source)})"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
node.type === 4 &&
|
||||
typeof node.content === "string" &&
|
||||
containsHan(node.content) &&
|
||||
parent &&
|
||||
((parent.type === 5) || (parent.type === 7 && parent.exp === node))
|
||||
) {
|
||||
const transformed = translateTemplateExpression(
|
||||
node.content,
|
||||
filePath,
|
||||
rootDir,
|
||||
{
|
||||
line: node.loc.start.line,
|
||||
column: node.loc.start.column + 1,
|
||||
},
|
||||
);
|
||||
if (!transformed.changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
records.push(...transformed.records);
|
||||
replacements.push({
|
||||
start: node.loc.start.offset,
|
||||
end: node.loc.end.offset,
|
||||
text: transformed.code,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const magicString = applyReplacements(templateCode, replacements);
|
||||
return {
|
||||
code: magicString?.toString() ?? templateCode,
|
||||
changed: Boolean(magicString),
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
function transformVueSFC(code, filePath, rootDir) {
|
||||
const { descriptor } = parseSFC(code, { filename: filePath });
|
||||
const magicString = new MagicString(code);
|
||||
const records = [];
|
||||
let changed = false;
|
||||
|
||||
if (descriptor.template) {
|
||||
const templateResult = transformVueTemplate(descriptor.template.content, filePath, rootDir);
|
||||
records.push(...templateResult.records);
|
||||
if (templateResult.changed) {
|
||||
changed = true;
|
||||
magicString.overwrite(
|
||||
descriptor.template.loc.start.offset,
|
||||
descriptor.template.loc.end.offset,
|
||||
templateResult.code,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of [descriptor.script, descriptor.scriptSetup].filter(Boolean)) {
|
||||
const scriptResult = transformJavaScript(block.content, filePath, rootDir, {
|
||||
helperNames: JS_HELPERS,
|
||||
injectImport: true,
|
||||
});
|
||||
records.push(...scriptResult.records);
|
||||
if (scriptResult.changed) {
|
||||
changed = true;
|
||||
magicString.overwrite(block.loc.start.offset, block.loc.end.offset, scriptResult.code);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: changed ? magicString.toString() : code,
|
||||
changed,
|
||||
records,
|
||||
map: changed
|
||||
? magicString.generateMap({
|
||||
source: filePath,
|
||||
hires: true,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function transformSourceCode(code, filePath, rootDir) {
|
||||
if (filePath.endsWith(".vue")) {
|
||||
return transformVueSFC(code, filePath, rootDir);
|
||||
}
|
||||
|
||||
return transformJavaScript(code, filePath, rootDir, {
|
||||
helperNames: JS_HELPERS,
|
||||
injectImport: true,
|
||||
});
|
||||
}
|
||||
|
||||
function collectCatalogRecords(rootDir) {
|
||||
const srcDir = path.join(rootDir, "src");
|
||||
const records = [];
|
||||
|
||||
walkSourceFiles(srcDir, (filePath) => {
|
||||
const cleanPath = normalizePath(filePath);
|
||||
if (!isSourceFile(cleanPath) || isExcludedFile(rootDir, cleanPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = fs.readFileSync(cleanPath, "utf8");
|
||||
const result = transformSourceCode(code, cleanPath, rootDir);
|
||||
records.push(...result.records);
|
||||
});
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function syncCatalogFiles(rootDir) {
|
||||
const records = collectCatalogRecords(rootDir);
|
||||
const catalog = mergeMessageRecords(records);
|
||||
const generatedDir = path.join(rootDir, "src/i18n/generated");
|
||||
const localesDir = path.join(rootDir, "src/i18n/locales");
|
||||
|
||||
writeJSONFile(path.join(generatedDir, "catalog.json"), catalog);
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localePath = path.join(localesDir, `${locale}.json`);
|
||||
const previousMessages = readJSONFile(localePath, {});
|
||||
const nextMessages = mergeLocaleMessages(previousMessages, catalog.entries, locale);
|
||||
writeJSONFile(localePath, nextMessages);
|
||||
}
|
||||
}
|
||||
|
||||
export function staticI18nPlugin() {
|
||||
let rootDir = process.cwd();
|
||||
const shouldScan = process.argv.includes("--scan") || process.env.STATIC_I18N_SCAN === "true";
|
||||
|
||||
return {
|
||||
name: "cursor-static-i18n",
|
||||
enforce: "pre",
|
||||
configResolved(config) {
|
||||
rootDir = config.root;
|
||||
},
|
||||
buildStart() {
|
||||
if (!shouldScan) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncCatalogFiles(rootDir);
|
||||
},
|
||||
transform(code, id) {
|
||||
if (!isSourceFile(id) || isExcludedFile(rootDir, id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = stripQuery(id);
|
||||
const result = transformSourceCode(code, filePath, rootDir);
|
||||
if (!result.changed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: result.code,
|
||||
map: result.map,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>
|
||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1,281 @@
|
||||
:root {
|
||||
--bg: #f4f5f7;
|
||||
--card: #ffffff;
|
||||
--text: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--line: #e5e7eb;
|
||||
--btn: #111827;
|
||||
--btn-text: #ffffff;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 10px;
|
||||
background: var(--card);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.window-header {
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #f8f8f8;
|
||||
color: #6b7280;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body.os-windows .window-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-spacer {
|
||||
display: none;
|
||||
width: 48px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
body.os-windows .header-spacer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.window-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
body.os-windows .window-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.window-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.window-btn img {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.window-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.window-btn:hover img {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.window-btn.close-btn:hover {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.window-btn.close-btn:hover img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-top,
|
||||
.panel-bottom {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 74px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.windows-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.os-windows .windows-only {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 56px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
input[readonly] {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--btn);
|
||||
color: var(--btn-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.toggle-btn.waiting-init {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
.init-btn {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.init-btn:hover:not(:disabled) {
|
||||
border-color: #9ca3af;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.query-btn {
|
||||
height: 30px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.query-btn:disabled,
|
||||
.init-btn:disabled,
|
||||
.toggle-btn:disabled,
|
||||
.window-btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.expire-inline {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.init-status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.init-status {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.init-status[data-state="idle"] {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.init-status[data-state="checking"] {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.init-status[data-state="ready"] {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.init-status[data-state="pending"] {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.init-status[data-state="error"] {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,27 @@
|
||||
import { spawn } from "child_process";
|
||||
import { createRequire } from "module";
|
||||
import path from "path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const vitePackagePath = require.resolve("vite/package.json");
|
||||
const viteBin = path.join(path.dirname(vitePackagePath), "bin", "vite.js");
|
||||
const extraArgs = process.argv.slice(2);
|
||||
const shouldScan = extraArgs.includes("--scan");
|
||||
const forwardedArgs = extraArgs.filter((arg) => arg !== "--scan");
|
||||
|
||||
const child = spawn(process.execPath, [viteBin, "build", ...forwardedArgs], {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
STATIC_I18N_SCAN: shouldScan ? "true" : process.env.STATIC_I18N_SCAN || "false",
|
||||
},
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<MainLayout />
|
||||
<MessageProvider />
|
||||
<AdModelProvider v-if="isMainWindow" />
|
||||
<Modal
|
||||
|
||||
:visible="modalState.visible"
|
||||
:title="modalState.title"
|
||||
:content="modalState.content"
|
||||
:confirm-text="modalState.confirmText"
|
||||
:cancel-text="modalState.cancelText"
|
||||
:show-cancel="modalState.showCancel"
|
||||
:confirm-disabled="modalState.confirmDisabled"
|
||||
@confirm="resolveModal(true)"
|
||||
@cancel="resolveModal(false)"
|
||||
/>
|
||||
<Modal
|
||||
v-if="isMainWindow"
|
||||
:visible="appState.updatePromptVisible"
|
||||
:title="updateViewState.promptTitle"
|
||||
:content="updateViewState.promptContent"
|
||||
:confirm-text="updateViewState.promptConfirmText"
|
||||
:cancel-text="updateViewState.promptCancelText"
|
||||
:show-cancel="updateViewState.promptShowCancel"
|
||||
:confirm-disabled="appState.updatePromptBusy"
|
||||
@confirm="confirmUpdatePrompt"
|
||||
@cancel="dismissUpdatePrompt"
|
||||
/>
|
||||
<InputModal
|
||||
:visible="inputModalState.visible"
|
||||
:title="inputModalState.title"
|
||||
:content="inputModalState.content"
|
||||
:placeholder="inputModalState.placeholder"
|
||||
:model-value="inputModalState.value"
|
||||
@update:model-value="inputModalState.value = $event"
|
||||
@confirm="resolveInputModal(true)"
|
||||
@cancel="resolveInputModal(false)"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import MainLayout from "@/layouts/MainLayout.vue";
|
||||
import AdModelProvider from "@/components/AdModelProvider.vue";
|
||||
import Modal from "@/components/ui/Modal.vue";
|
||||
import MessageProvider from "@/components/ui/MessageProvider.vue";
|
||||
import { modalState, resolveModal } from "@/composables/useModal";
|
||||
|
||||
import InputModal from "@/components/ui/InputModal.vue";
|
||||
import { inputModalState, resolveInputModal } from "@/composables/useInputModal";
|
||||
import { appState, confirmUpdatePrompt, dismissUpdatePrompt, updateViewState } from "@/state/appState";
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const isMainWindow = computed(() => route.path === "/");
|
||||
</script>
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,237 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { getAdRuntime, openAdExternalURL } from "@/services/clientApi";
|
||||
|
||||
const OPEN_AD_EVENT = "cursor:open-ad";
|
||||
const BRIDGE_SOURCE = "cursor-ad";
|
||||
|
||||
const visible = ref(false);
|
||||
const runtimeState = ref(null);
|
||||
const iframeSrc = ref("");
|
||||
const viewport = ref({
|
||||
width: typeof window === "undefined" ? 1024 : window.innerWidth,
|
||||
height: typeof window === "undefined" ? 768 : window.innerHeight,
|
||||
});
|
||||
|
||||
const showingHashes = new Set();
|
||||
let refreshPending = false;
|
||||
let hideTimer = 0;
|
||||
|
||||
const frameStyle = computed(() => {
|
||||
const win = runtimeState.value?.window ?? {};
|
||||
const maxWidth = Math.max(220, viewport.value.width - 32);
|
||||
const maxHeight = Math.max(160, viewport.value.height - 32);
|
||||
const width = Math.min(clampNumber(win.width, 280, 1200, 640), maxWidth);
|
||||
const height = Math.min(clampNumber(win.height, 180, 900, 420), maxHeight);
|
||||
return {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
maxWidth: "calc(100vw - 32px)",
|
||||
maxHeight: "calc(100vh - 32px)",
|
||||
};
|
||||
});
|
||||
|
||||
function asString(value) {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function asBoolean(value) {
|
||||
return value === true || value === "true" || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
function asNumber(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function clampNumber(value, min, max, fallback) {
|
||||
const parsed = asNumber(value, fallback);
|
||||
return Math.min(max, Math.max(min, parsed || fallback));
|
||||
}
|
||||
|
||||
function normalizeRuntime(source, preferredSlotId = "") {
|
||||
const raw = source && typeof source === "object" ? source : {};
|
||||
const slots = Array.isArray(raw.slots) ? raw.slots : [];
|
||||
const selectedSlot =
|
||||
slots.find((slot) => asString(slot?.id) === asString(preferredSlotId)) ||
|
||||
slots[0] ||
|
||||
raw;
|
||||
const slot = selectedSlot && typeof selectedSlot === "object" ? selectedSlot : {};
|
||||
const win = raw.window && typeof raw.window === "object" ? raw.window : {};
|
||||
const slotWin = slot.window && typeof slot.window === "object" ? slot.window : win;
|
||||
return {
|
||||
id: asString(slot.id) || asString(preferredSlotId) || "1",
|
||||
available: asBoolean(slot.available),
|
||||
enabled: asBoolean(slot.enabled),
|
||||
packageHash: asString(slot.packageHash),
|
||||
assetBaseURL: asString(slot.assetBaseURL).replace(/\/+$/, ""),
|
||||
indexURL: asString(slot.indexURL),
|
||||
window: {
|
||||
width: Math.round(asNumber(slotWin.width, 640)),
|
||||
height: Math.round(asNumber(slotWin.height, 420)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectedAdOrigin() {
|
||||
const baseURL = runtimeState.value?.assetBaseURL;
|
||||
if (!baseURL) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return new URL(baseURL).origin;
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function canOpen(runtime) {
|
||||
if (!runtime?.available || !runtime.enabled) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(runtime.packageHash && runtime.assetBaseURL);
|
||||
}
|
||||
|
||||
async function openCurrentAd(slotId = "") {
|
||||
if (visible.value || refreshPending) {
|
||||
return;
|
||||
}
|
||||
refreshPending = true;
|
||||
try {
|
||||
const nextRuntime = normalizeRuntime(await getAdRuntime(), slotId);
|
||||
runtimeState.value = nextRuntime;
|
||||
if (canOpen(nextRuntime)) {
|
||||
await showAd(nextRuntime);
|
||||
}
|
||||
} catch (_error) {
|
||||
// 广告入口失败不影响主界面。
|
||||
} finally {
|
||||
refreshPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function showAd(runtime) {
|
||||
const hash = runtime.packageHash;
|
||||
if (showingHashes.has(hash)) {
|
||||
return;
|
||||
}
|
||||
showingHashes.add(hash);
|
||||
try {
|
||||
const indexURL = runtime.indexURL || `${runtime.assetBaseURL}/index.html`;
|
||||
const separator = indexURL.includes("?") ? "&" : "?";
|
||||
iframeSrc.value = `${indexURL}${separator}hash=${encodeURIComponent(hash)}&ts=${Date.now()}`;
|
||||
visible.value = true;
|
||||
} finally {
|
||||
showingHashes.delete(hash);
|
||||
}
|
||||
}
|
||||
|
||||
function closeAd() {
|
||||
visible.value = false;
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer);
|
||||
}
|
||||
hideTimer = window.setTimeout(() => {
|
||||
iframeSrc.value = "";
|
||||
}, 260);
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const origin = expectedAdOrigin();
|
||||
if (origin && event.origin !== origin) {
|
||||
return;
|
||||
}
|
||||
const data = event.data && typeof event.data === "object" ? event.data : {};
|
||||
if (data.source !== BRIDGE_SOURCE) {
|
||||
return;
|
||||
}
|
||||
if (data.type === "close") {
|
||||
closeAd();
|
||||
return;
|
||||
}
|
||||
if (data.type === "openExternal") {
|
||||
const targetURL = asString(data.url);
|
||||
if (targetURL) {
|
||||
void openAdExternalURL(targetURL).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenRequested(event) {
|
||||
void openCurrentAd(asString(event?.detail?.slotId));
|
||||
}
|
||||
|
||||
function updateViewport() {
|
||||
viewport.value = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.addEventListener(OPEN_AD_EVENT, handleOpenRequested);
|
||||
window.addEventListener("resize", updateViewport);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer);
|
||||
}
|
||||
window.removeEventListener("message", handleMessage);
|
||||
window.removeEventListener(OPEN_AD_EVENT, handleOpenRequested);
|
||||
window.removeEventListener("resize", updateViewport);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-mask">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
|
||||
>
|
||||
<Transition name="ad-frame">
|
||||
<iframe
|
||||
v-show="visible && iframeSrc"
|
||||
:src="iframeSrc"
|
||||
:style="frameStyle"
|
||||
class="block overflow-hidden rounded-none border-none bg-transparent shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
|
||||
sandbox="allow-scripts allow-forms allow-same-origin"
|
||||
title="Advertisement"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-mask-enter-active,
|
||||
.modal-mask-leave-active {
|
||||
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-mask-enter-from,
|
||||
.modal-mask-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
|
||||
.ad-frame-enter-active,
|
||||
.ad-frame-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.ad-frame-enter-from,
|
||||
.ad-frame-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,403 @@
|
||||
<script setup>
|
||||
import CacheHitRateChart from "@/components/charts/CacheHitRateChart.vue";
|
||||
import Switch from "@/components/ui/Switch.vue";
|
||||
import Tooltip from "@/components/ui/Tooltip.vue";
|
||||
import { appState, saveIncludeCacheWriteInHitRate } from "@/state/appState";
|
||||
import { formatCompactInteger, formatInteger } from "@/utils/numberFormat";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const emit = defineEmits(["refresh", "open-ad"]);
|
||||
|
||||
const TOKEN_PRICE_PER_MILLION = {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
metrics: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
homeAd: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
homeAds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const homeMetricsConfigSaving = ref(false);
|
||||
const homeMetricsConfigError = ref("");
|
||||
|
||||
function normalizeNumber(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(number);
|
||||
}
|
||||
|
||||
function formatMetricValue(value) {
|
||||
const full = formatInteger(value);
|
||||
const compact = formatCompactInteger(value);
|
||||
return full === compact ? full : `${full} (${compact})`;
|
||||
}
|
||||
|
||||
function formatRateLabel(value) {
|
||||
const rate = Number(value);
|
||||
if (!Number.isFinite(rate)) {
|
||||
return "暂无数据";
|
||||
}
|
||||
return `${(Math.max(0, Math.min(1, rate)) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function calculateRate(numerator, denominator) {
|
||||
const top = normalizeNumber(numerator);
|
||||
const bottom = normalizeNumber(denominator);
|
||||
if (bottom <= 0) {
|
||||
return null;
|
||||
}
|
||||
return top / bottom;
|
||||
}
|
||||
|
||||
function priceTokens(tokens, pricePerMillion) {
|
||||
return (normalizeNumber(tokens) / 1_000_000) * pricePerMillion;
|
||||
}
|
||||
|
||||
function formatUSD(value) {
|
||||
const amount = Number(value);
|
||||
if (!Number.isFinite(amount)) {
|
||||
return "$0.00";
|
||||
}
|
||||
if (amount > 0 && amount < 0.01) {
|
||||
return "<$0.01";
|
||||
}
|
||||
return `$${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
const cacheReadTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheReadTokens));
|
||||
const cacheWriteTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheWriteTokens));
|
||||
|
||||
const inputTokensTotal = computed(() => {
|
||||
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
|
||||
return Math.max(0, promptTokensTotal - cacheReadTokensTotal.value - cacheWriteTokensTotal.value);
|
||||
});
|
||||
|
||||
const defaultCacheHitRate = computed(() =>
|
||||
calculateRate(cacheReadTokensTotal.value, cacheReadTokensTotal.value + inputTokensTotal.value),
|
||||
);
|
||||
|
||||
const cacheReuseRate = computed(() =>
|
||||
calculateRate(
|
||||
cacheReadTokensTotal.value,
|
||||
cacheReadTokensTotal.value + cacheWriteTokensTotal.value + inputTokensTotal.value,
|
||||
),
|
||||
);
|
||||
|
||||
const includeCacheWriteInHitRate = computed(() => appState.includeCacheWriteInHitRate);
|
||||
|
||||
const selectedCacheHitRate = computed(() =>
|
||||
includeCacheWriteInHitRate.value ? cacheReuseRate.value : defaultCacheHitRate.value,
|
||||
);
|
||||
|
||||
const selectedCacheRateModeLabel = computed(() =>
|
||||
includeCacheWriteInHitRate.value ? "计入缓存创建" : "默认口径",
|
||||
);
|
||||
|
||||
const validTurnsRate = computed(() => {
|
||||
const turnsTotal = normalizeNumber(props.metrics?.turnsTotal);
|
||||
if (turnsTotal <= 0) {
|
||||
return null;
|
||||
}
|
||||
return normalizeNumber(props.metrics?.validTurnsTotal) / turnsTotal;
|
||||
});
|
||||
|
||||
const completionTokensTotal = computed(() => {
|
||||
const requestTokensTotal = normalizeNumber(props.metrics?.requestTokensTotal);
|
||||
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
|
||||
return Math.max(0, requestTokensTotal - promptTokensTotal);
|
||||
});
|
||||
|
||||
const estimatedTokenCost = computed(() => {
|
||||
const input = priceTokens(inputTokensTotal.value, TOKEN_PRICE_PER_MILLION.input);
|
||||
const output = priceTokens(completionTokensTotal.value, TOKEN_PRICE_PER_MILLION.output);
|
||||
const cacheRead = priceTokens(cacheReadTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheRead);
|
||||
const cacheWrite = priceTokens(cacheWriteTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheWrite);
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
total: input + output + cacheRead + cacheWrite,
|
||||
};
|
||||
});
|
||||
|
||||
const cacheTooltipContent = computed(() => {
|
||||
const formula = includeCacheWriteInHitRate.value
|
||||
? "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)"
|
||||
: "缓存读取 /(缓存读取 + 非缓存输入)";
|
||||
return [
|
||||
`当前:${formatRateLabel(selectedCacheHitRate.value)}`,
|
||||
`公式:${formula}`,
|
||||
`默认 ${formatRateLabel(defaultCacheHitRate.value)} / 计入创建 ${formatRateLabel(cacheReuseRate.value)}`,
|
||||
].join("\n");
|
||||
});
|
||||
|
||||
const turnsTooltipContent = computed(() =>
|
||||
[
|
||||
"按历史记录里扫描到的回合 summary 汇总。",
|
||||
"",
|
||||
`总轮次:${formatMetricValue(props.metrics?.turnsTotal)}`,
|
||||
`有效轮次:${formatMetricValue(props.metrics?.validTurnsTotal)}`,
|
||||
`异常轮次:${formatMetricValue(props.metrics?.invalidTurnsTotal)}`,
|
||||
`有效占比:${formatRateLabel(validTurnsRate.value)}`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const tokensTooltipContent = computed(() =>
|
||||
[
|
||||
"总请求 Token 包含 Prompt 和模型输出。",
|
||||
"",
|
||||
`总请求:${formatMetricValue(props.metrics?.requestTokensTotal)}`,
|
||||
`Prompt:${formatMetricValue(props.metrics?.promptTokensTotal)}`,
|
||||
`输出推算:${formatMetricValue(completionTokensTotal.value)}`,
|
||||
`非缓存输入:${formatMetricValue(inputTokensTotal.value)}`,
|
||||
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)}`,
|
||||
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)}`,
|
||||
"",
|
||||
"缓存读写已计入 Prompt 侧统计。",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const costTooltipContent = computed(() =>
|
||||
[
|
||||
"按 Claude Opus 4.7 价格估算。",
|
||||
`缓存统计策略:${selectedCacheRateModeLabel.value}(${formatRateLabel(selectedCacheHitRate.value)})`,
|
||||
"",
|
||||
`普通输入:${formatMetricValue(inputTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.input}/1M = ${formatUSD(estimatedTokenCost.value.input)}`,
|
||||
`模型输出:${formatMetricValue(completionTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.output}/1M = ${formatUSD(estimatedTokenCost.value.output)}`,
|
||||
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheRead}/1M = ${formatUSD(estimatedTokenCost.value.cacheRead)}`,
|
||||
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheWrite}/1M = ${formatUSD(estimatedTokenCost.value.cacheWrite)}`,
|
||||
"",
|
||||
`合计:${formatUSD(estimatedTokenCost.value.total)}`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
function normalizeHomeAd(item, index) {
|
||||
const source = item && typeof item === "object" ? item : {};
|
||||
const title = typeof source.title === "string" ? source.title.trim() : "";
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: typeof source.id === "string" && source.id.trim() ? source.id.trim() : String(index + 1),
|
||||
title,
|
||||
subtitle: typeof source.subtitle === "string" ? source.subtitle.trim() : "",
|
||||
};
|
||||
}
|
||||
|
||||
async function toggleIncludeCacheWriteInHitRate(value) {
|
||||
const nextValue = Boolean(value);
|
||||
homeMetricsConfigSaving.value = true;
|
||||
homeMetricsConfigError.value = "";
|
||||
try {
|
||||
const result = await saveIncludeCacheWriteInHitRate(nextValue);
|
||||
if (!result?.ok) {
|
||||
homeMetricsConfigError.value = result?.error || "保存失败";
|
||||
}
|
||||
} catch (error) {
|
||||
homeMetricsConfigError.value = error?.message || "保存失败";
|
||||
} finally {
|
||||
homeMetricsConfigSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedHomeAds = computed(() => {
|
||||
const list = Array.isArray(props.homeAds) && props.homeAds.length > 0 ? props.homeAds : [props.homeAd];
|
||||
return list.map(normalizeHomeAd).filter(Boolean);
|
||||
});
|
||||
|
||||
const hasHomeAd = computed(() => normalizedHomeAds.value.length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-4 h-[42px]">
|
||||
<div v-if="!hasHomeAd" class="flex flex-col gap-1 w-[200px] shrink-0">
|
||||
<h2 class="text-[14px] font-medium text-white/80">会话统计</h2>
|
||||
</div>
|
||||
<div v-else class="grid min-w-0 grid-cols-3 gap-2 shrink-0">
|
||||
<div
|
||||
v-for="ad in normalizedHomeAds"
|
||||
:key="ad.id"
|
||||
style="font-family: var(--font-num)"
|
||||
class="center-row h-[42px] min-w-0 cursor-pointer gap-[8px] rounded-[6px] border border-[#343434] bg-[#242424] px-[8px] pr-[10px] text-left transition-colors duration-150 hover:border-[#4a4a4a] hover:bg-[#2a2a2a] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="ad.subtitle ? `${ad.title}\n${ad.subtitle}` : ad.title"
|
||||
@click="emit('open-ad', ad.id)"
|
||||
@keydown.enter.prevent="emit('open-ad', ad.id)"
|
||||
@keydown.space.prevent="emit('open-ad', ad.id)"
|
||||
>
|
||||
<div
|
||||
class="center-row h-[20px] w-[20px] shrink-0 justify-center text-[20px] text-amber-400"
|
||||
>
|
||||
<span class="icon-[cil--badge]"></span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[13px] font-medium leading-[16px] text-white">
|
||||
{{ ad.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="ad.subtitle"
|
||||
class="mt-[2px] center-row min-w-0 gap-[2px] text-[11px] leading-[12px] text-[#8A8A8A]"
|
||||
>
|
||||
<span class="truncate">{{ ad.subtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 center-row justify-end shrink-0 gap-2 text-xs text-[#6f6f6f] pr-4 w-[200px]"
|
||||
>
|
||||
<span>刷新统计</span>
|
||||
<button
|
||||
type="button"
|
||||
class="center-row justify-center h-[24px] w-[24px] rounded-[6px] border border-[#3b3b3b] bg-[#242424] text-[#9d9d9d] transition-colors duration-150 hover:border-[#4c4c4c] hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="loading"
|
||||
:title="loading ? '刷新中' : '刷新统计'"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<span
|
||||
class="icon-[mdi--refresh] text-[14px]"
|
||||
:class="{ '!animate-spin': loading }"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-[-4px] grid grid-cols-4 gap-0 overflow-hidden rounded-[8px] border border-[#343434] bg-[#242424] h-[130px]"
|
||||
>
|
||||
<div class="min-w-0 px-4 py-4 flex flex-col justify-between">
|
||||
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
|
||||
<span>缓存命中率</span>
|
||||
<Tooltip>
|
||||
<div class="w-[280px] space-y-3">
|
||||
<div class="border-b border-[#343434] pb-3">
|
||||
<Switch
|
||||
compact
|
||||
label="计入缓存创建"
|
||||
description="开启后把缓存创建纳入分母"
|
||||
enabled-text="当前按复用率口径显示"
|
||||
disabled-text="当前按默认命中率口径显示"
|
||||
:enabled="includeCacheWriteInHitRate"
|
||||
:busy="homeMetricsConfigSaving"
|
||||
:disabled="homeMetricsConfigSaving"
|
||||
@change="toggleIncludeCacheWriteInHitRate"
|
||||
/>
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap">{{ cacheTooltipContent }}</div>
|
||||
<div v-if="homeMetricsConfigError" class="text-[11px] text-[#f87171]">
|
||||
{{ homeMetricsConfigError }}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<CacheHitRateChart :rate="selectedCacheHitRate" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
|
||||
>
|
||||
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
|
||||
<span>对话轮次</span>
|
||||
<Tooltip :content="turnsTooltipContent" />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-[30px] leading-none text-white"
|
||||
style="font-family: var(--font-num)"
|
||||
:title="formatInteger(metrics.turnsTotal)"
|
||||
>
|
||||
{{ formatCompactInteger(metrics.turnsTotal) }}
|
||||
</div>
|
||||
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
|
||||
有效
|
||||
<span :title="formatInteger(metrics.validTurnsTotal)">
|
||||
{{ formatCompactInteger(metrics.validTurnsTotal) }}
|
||||
</span>
|
||||
/ 异常
|
||||
<span :title="formatInteger(metrics.invalidTurnsTotal)">
|
||||
{{ formatCompactInteger(metrics.invalidTurnsTotal) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
|
||||
>
|
||||
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
|
||||
<span>Token 消耗</span>
|
||||
<Tooltip :content="tokensTooltipContent" />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="truncate text-[30px] leading-none text-white"
|
||||
style="font-family: var(--font-num)"
|
||||
:title="formatInteger(metrics.requestTokensTotal)"
|
||||
>
|
||||
{{ formatCompactInteger(metrics.requestTokensTotal) }}
|
||||
</div>
|
||||
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
|
||||
Prompt
|
||||
<span :title="formatInteger(metrics.promptTokensTotal)">
|
||||
{{ formatCompactInteger(metrics.promptTokensTotal) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
|
||||
>
|
||||
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
|
||||
<span>价值估算</span>
|
||||
<Tooltip :content="costTooltipContent" />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="truncate text-[30px] leading-none text-white"
|
||||
style="font-family: var(--font-num)"
|
||||
:title="formatUSD(estimatedTokenCost.total)"
|
||||
>
|
||||
{{ formatUSD(estimatedTokenCost.total) }}
|
||||
</div>
|
||||
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
|
||||
缓存读写
|
||||
<span :title="formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite)">
|
||||
{{ formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import Select from "@/components/ui/Select.vue";
|
||||
import { useLocale } from "@/i18n/runtime";
|
||||
|
||||
const props = defineProps({
|
||||
border: { type: Boolean, default: true },
|
||||
ariaLabel: { type: String, default: "界面语言" },
|
||||
buttonClass: { type: String, default: "" },
|
||||
menuClass: { type: String, default: "" },
|
||||
wrapperClass: { type: String, default: "w-[180px] max-w-full" },
|
||||
placeholder: { type: String, default: "选择语言" },
|
||||
});
|
||||
|
||||
const { locale, localeOptions, setLocale } = useLocale();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<Select
|
||||
:model-value="locale"
|
||||
:options="localeOptions"
|
||||
:border="border"
|
||||
:aria-label="ariaLabel"
|
||||
:button-class="buttonClass"
|
||||
:menu-class="menuClass"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="setLocale"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script setup>
|
||||
import Button from "@/components/ui/Button.vue";
|
||||
import Select from "@/components/ui/Select.vue";
|
||||
import Tooltip from "@/components/ui/Tooltip.vue";
|
||||
import {
|
||||
ANTHROPIC_THINKING_EFFORT_DEFAULT,
|
||||
createEmptyModelAdapter,
|
||||
normalizeModelAdapter,
|
||||
OPENAI_ENDPOINT_CHAT_COMPLETIONS,
|
||||
OPENAI_ENDPOINT_RESPONSES,
|
||||
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
|
||||
} from "@/state/appState";
|
||||
import { computed, reactive, watch } from "vue";
|
||||
|
||||
const modelTypeOptions = [
|
||||
{ label: "openai", value: "openai", icon: "icon-[bxl--openai]" },
|
||||
{ label: "anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
|
||||
];
|
||||
|
||||
const reasoningEffortOptions = [
|
||||
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
|
||||
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
|
||||
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
|
||||
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
|
||||
];
|
||||
|
||||
const anthropicThinkingEffortOptions = [
|
||||
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
|
||||
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
|
||||
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
|
||||
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
|
||||
{ label: "最大", value: "max", icon: "icon-[mdi--brain]" },
|
||||
];
|
||||
|
||||
const openAIEndpointOptions = [
|
||||
{ label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" },
|
||||
{ label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" },
|
||||
];
|
||||
|
||||
const fieldTips = {
|
||||
openAIExtraParams: "开启后会把 JSON 对象合并到 OpenAI 请求体。OpenAI service_tier 支持 auto、default、flex、scale、priority;priority 可用于高优先级/Fast 类场景。",
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
title: { type: String, default: "模型配置" },
|
||||
adapter: {
|
||||
type: Object,
|
||||
default: () => createEmptyModelAdapter(),
|
||||
},
|
||||
errorMessage: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["cancel", "save"]);
|
||||
|
||||
const draft = reactive(createEmptyModelAdapter());
|
||||
|
||||
function createOptionalPositiveIntegerModel(key) {
|
||||
return computed({
|
||||
get() {
|
||||
return draft[key] > 0 ? String(draft[key]) : "";
|
||||
},
|
||||
set(value) {
|
||||
const text = String(value || "").trim();
|
||||
draft[key] = /^\d+$/.test(text) && Number(text) > 0 ? Number(text) : 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const maxCompletionTokensInput = createOptionalPositiveIntegerModel("maxCompletionTokens");
|
||||
const anthropicMaxTokensInput = createOptionalPositiveIntegerModel("anthropicMaxTokens");
|
||||
const contextWindowTokensInput = createOptionalPositiveIntegerModel("contextWindowTokens");
|
||||
|
||||
function ensureOpenAIExtraParamsJSON() {
|
||||
if (!String(draft.openAIExtraParamsJSON || "").trim()) {
|
||||
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAnthropicThinkingEffort() {
|
||||
if (!String(draft.anthropicThinkingEffort || "").trim()) {
|
||||
draft.anthropicThinkingEffort = ANTHROPIC_THINKING_EFFORT_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
function syncDraft() {
|
||||
Object.assign(draft, normalizeModelAdapter(props.adapter));
|
||||
if (!draft.type) {
|
||||
draft.type = "openai";
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible) {
|
||||
syncDraft();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.adapter, () => {
|
||||
if (props.visible) {
|
||||
syncDraft();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => draft.type, (type) => {
|
||||
if (type === "openai" && !draft.openAIEndpoint) {
|
||||
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
|
||||
} else if (type === "anthropic") {
|
||||
ensureAnthropicThinkingEffort();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => draft.openAIExtraParamsEnabled, (enabled) => {
|
||||
if (enabled) {
|
||||
ensureOpenAIExtraParamsJSON();
|
||||
}
|
||||
});
|
||||
|
||||
function handleCancel() {
|
||||
emit("cancel");
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
emit("save", normalizeModelAdapter(draft));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-mask">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
|
||||
@click.self="handleCancel"
|
||||
>
|
||||
<Transition name="modal-content">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="relative z-10 w-full max-w-[560px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
|
||||
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
|
||||
@click.stop
|
||||
>
|
||||
<div class="rounded-[7px] bg-[#292929] p-5">
|
||||
<h3 class="mb-4 text-base font-medium text-white">{{ title }}</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">显示名称</span>
|
||||
<input
|
||||
v-model="draft.displayName"
|
||||
type="text"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">ModelID</span>
|
||||
<input
|
||||
v-model="draft.modelID"
|
||||
type="text"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">类型</span>
|
||||
<Select
|
||||
v-model="draft.type"
|
||||
:options="modelTypeOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">API Key</span>
|
||||
<input
|
||||
v-model="draft.apiKey"
|
||||
type="text"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="mt-3 flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">baseURL</span>
|
||||
<input
|
||||
v-model="draft.baseURL"
|
||||
type="text"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="mt-3 flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">context_window_tokens</span>
|
||||
<input
|
||||
v-model="contextWindowTokensInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="留空时默认 200000"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="draft.type === 'openai'" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">reasoning_effort</span>
|
||||
<Select
|
||||
v-model="draft.reasoningEffort"
|
||||
:options="reasoningEffortOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">max token</span>
|
||||
<input
|
||||
v-model="maxCompletionTokensInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="留空时默认 65536"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">endpoint</span>
|
||||
<Select
|
||||
v-model="draft.openAIEndpoint"
|
||||
:options="openAIEndpointOptions"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="draft.type === 'openai'" class="mt-3 rounded-[8px] border border-[#343434] bg-[#252525] p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="fieldTips.openAIExtraParams" />
|
||||
<span>额外参数 JSON</span>
|
||||
</span>
|
||||
<label class="flex items-center gap-2 text-xs text-[#d4d4d4]">
|
||||
<input
|
||||
v-model="draft.openAIExtraParamsEnabled"
|
||||
type="checkbox"
|
||||
class="size-4 accent-[#10AD5D]"
|
||||
/>
|
||||
<span>启用</span>
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
v-if="draft.openAIExtraParamsEnabled"
|
||||
v-model="draft.openAIExtraParamsJSON"
|
||||
rows="5"
|
||||
spellcheck="false"
|
||||
class="mt-3 min-h-[120px] w-full resize-none rounded-[6px] border border-[#3f3f3f] bg-[#1f1f1f] px-3 py-2 font-mono text-xs text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="draft.type === 'anthropic'" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">max_tokens</span>
|
||||
<input
|
||||
v-model="anthropicMaxTokensInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="留空时默认 65536"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">thinking effort</span>
|
||||
<Select
|
||||
v-model="draft.anthropicThinkingEffort"
|
||||
:options="anthropicThinkingEffortOptions"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="mt-3 flex flex-col gap-1">
|
||||
<span class="text-sm text-[#d4d4d4]">tooltipData</span>
|
||||
<textarea
|
||||
v-model="draft.tooltipData"
|
||||
rows="5"
|
||||
class="min-h-[120px] resize-none rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 py-2 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="mt-4 rounded-[8px] border border-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<Button variant="default" @click="handleCancel">取消</Button>
|
||||
<Button variant="primary" @click="handleSave">保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-mask-enter-active,
|
||||
.modal-mask-leave-active {
|
||||
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||
}
|
||||
.modal-mask-enter-from,
|
||||
.modal-mask-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
|
||||
.modal-content-enter-active,
|
||||
.modal-content-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.modal-content-enter-from,
|
||||
.modal-content-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import Tooltip from "@/components/ui/Tooltip.vue";
|
||||
import { formatDuration } from "@/state/appState";
|
||||
|
||||
const props = defineProps({
|
||||
result: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
stale: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showMetrics: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "模型测试",
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: "尚未测试",
|
||||
},
|
||||
});
|
||||
|
||||
const normalizedStatus = computed(() => {
|
||||
const status = String(props.result?.status || "").trim().toLowerCase();
|
||||
return ["running", "success", "error"].includes(status) ? status : "idle";
|
||||
});
|
||||
|
||||
const summaryText = computed(() => {
|
||||
const text = String(props.result?.summaryText || "").trim();
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
if (normalizedStatus.value === "running") {
|
||||
return "测试中...";
|
||||
}
|
||||
if (normalizedStatus.value === "error") {
|
||||
return "测试失败";
|
||||
}
|
||||
return props.emptyText;
|
||||
});
|
||||
|
||||
const rawResponseText = computed(() => {
|
||||
const raw = String(props.result?.rawResponse || "").trim();
|
||||
if (raw) {
|
||||
return raw;
|
||||
}
|
||||
if (normalizedStatus.value === "error") {
|
||||
return String(props.result?.error || "").trim();
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const panelClass = computed(() => {
|
||||
if (props.stale) {
|
||||
return "border-[#6b5b1e] bg-[#2c2612]";
|
||||
}
|
||||
if (normalizedStatus.value === "running") {
|
||||
return "border-[#164e63] bg-[#0b2530]";
|
||||
}
|
||||
if (normalizedStatus.value === "error") {
|
||||
return "border-[#4b1d1d] bg-[#2a1313]";
|
||||
}
|
||||
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
|
||||
return "border-[#5a4314] bg-[#2f2612]";
|
||||
}
|
||||
if (normalizedStatus.value === "success") {
|
||||
return "border-[#14532d] bg-[#102418]";
|
||||
}
|
||||
return "border-[#343434] bg-[#232323]";
|
||||
});
|
||||
|
||||
const summaryClass = computed(() => {
|
||||
if (props.stale) {
|
||||
return "text-[#f6d77a]";
|
||||
}
|
||||
if (normalizedStatus.value === "running") {
|
||||
return "text-[#67e8f9]";
|
||||
}
|
||||
if (normalizedStatus.value === "error") {
|
||||
return "text-[#fca5a5]";
|
||||
}
|
||||
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
|
||||
return "text-[#fcd34d]";
|
||||
}
|
||||
if (normalizedStatus.value === "success") {
|
||||
return "text-[#86efac]";
|
||||
}
|
||||
return "text-[#a3a3a3]";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-[8px] border px-3 py-3" :class="panelClass">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-[#666]' : 'text-sm font-medium text-white'"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-[#8f8f8f]">
|
||||
<span>原始返回</span>
|
||||
<Tooltip :content="rawResponseText" copyable />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 text-sm leading-relaxed" :class="summaryClass">
|
||||
{{ summaryText }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="stale"
|
||||
class="shrink-0 rounded-[999px] border border-[#8a6d1a] px-2 py-1 text-xs text-[#f6d77a]"
|
||||
>
|
||||
需重测
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="stale" class="mt-2 text-xs text-[#f6d77a]">
|
||||
配置已变更,请重新测试
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showMetrics && normalizedStatus === 'success'"
|
||||
class="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2"
|
||||
>
|
||||
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
|
||||
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">总耗时</div>
|
||||
<div class="mt-1 text-sm text-[#d4d4d4]">{{ formatDuration(result?.totalDurationMS) }}</div>
|
||||
</div>
|
||||
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
|
||||
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">输出 Token</div>
|
||||
<div class="mt-1 text-sm text-[#d4d4d4]">{{ result?.outputTokens ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="normalizedStatus === 'success' && result?.tokensEstimated"
|
||||
class="mt-2 text-xs text-[#8f8f8f]"
|
||||
>
|
||||
输出 Token 为估算值
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import {
|
||||
ArcElement,
|
||||
Chart as ChartJS,
|
||||
Tooltip,
|
||||
} from "chart.js";
|
||||
import { computed } from "vue";
|
||||
import { Doughnut } from "vue-chartjs";
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip);
|
||||
|
||||
const props = defineProps({
|
||||
rate: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const percentage = computed(() => {
|
||||
const rate = Number(props.rate);
|
||||
if (!Number.isFinite(rate)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(100, rate * 100));
|
||||
});
|
||||
|
||||
const label = computed(() => {
|
||||
const rate = Number(props.rate);
|
||||
if (!Number.isFinite(rate)) {
|
||||
return "--";
|
||||
}
|
||||
return `${percentage.value.toFixed(2)}%`;
|
||||
});
|
||||
|
||||
function getSegmentBorderRadius(dataIndex) {
|
||||
const radius = 5;
|
||||
|
||||
if (percentage.value <= 0) {
|
||||
return dataIndex === 1
|
||||
? {
|
||||
outerStart: radius,
|
||||
outerEnd: radius,
|
||||
innerStart: radius,
|
||||
innerEnd: radius,
|
||||
}
|
||||
: 0;
|
||||
}
|
||||
|
||||
if (percentage.value >= 100) {
|
||||
return dataIndex === 0
|
||||
? {
|
||||
outerStart: radius,
|
||||
outerEnd: radius,
|
||||
innerStart: radius,
|
||||
innerEnd: radius,
|
||||
}
|
||||
: 0;
|
||||
}
|
||||
|
||||
return dataIndex === 0
|
||||
? {
|
||||
outerStart: radius,
|
||||
outerEnd: 0,
|
||||
innerStart: radius,
|
||||
innerEnd: 0,
|
||||
}
|
||||
: {
|
||||
outerStart: 0,
|
||||
outerEnd: radius,
|
||||
innerStart: 0,
|
||||
innerEnd: radius,
|
||||
};
|
||||
}
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: ["命中", "未命中"],
|
||||
datasets: [
|
||||
{
|
||||
data: [percentage.value, Math.max(0, 100 - percentage.value)],
|
||||
backgroundColor: ["#4ade80", "#373737"],
|
||||
borderWidth: 0,
|
||||
hoverBorderWidth: 0,
|
||||
selfJoin: false,
|
||||
borderRadius: ({ dataIndex }) => getSegmentBorderRadius(dataIndex),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "82%",
|
||||
rotation: -90,
|
||||
circumference: 180,
|
||||
animation: {
|
||||
duration: 450,
|
||||
},
|
||||
events: [],
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div
|
||||
class="relative h-[82px] w-[132px] shrink-0"
|
||||
role="img"
|
||||
:aria-label="`缓存命中率 ${label}`"
|
||||
>
|
||||
<Doughnut class="h-full w-full" :data="chartData" :options="chartOptions" />
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-[10px] flex justify-center">
|
||||
<div
|
||||
class="text-[20px] leading-none text-white"
|
||||
style="font-family: var(--font-num)"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (v) => ["default", "primary", "text"].includes(v),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="variant === 'text'"
|
||||
type="button"
|
||||
class="!whitespace-nowrap shrink-0 cursor-pointer text-sm text-[#a3a3a3] transition-colors duration-150 active:text-[#10AD5D] hover:text-[#10ad5cd9]"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="!whitespace-nowrap relative cursor-pointer overflow-hidden center-row min-h-[24px] gap-[2px] rounded-[6px] text-sm transition-transform duration-150 active:scale-105"
|
||||
:class="{
|
||||
'bg-[linear-gradient(to_bottom,#656565_0%,#3A3A3A_10px,#3A3A3A_100%)]': variant === 'default',
|
||||
'bg-gradient-to-b from-[#1D8010] to-[#25B433]': variant === 'primary',
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="relative center-row z-10 w-full justify-center rounded-[5px] !px-[7px] py-[3px] text-white transition-colors"
|
||||
:class="{
|
||||
'bg-gradient-to-b from-[#2a2a2a] to-[#1f1f1f] ': variant === 'default',
|
||||
'font-medium bg-gradient-to-b from-[#10AD5D] to-[#0F8A4C] ': variant === 'primary',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup></script>
|
||||
<template>
|
||||
<div
|
||||
class="rounded-[8px] p-[1px]"
|
||||
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
|
||||
>
|
||||
<div class="rounded-[7px] bg-[#292929] p-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { computed, ref, useAttrs, watch } from "vue";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
type: { type: String, default: "text" },
|
||||
placeholder: { type: String, default: "" },
|
||||
disabled: { type: Boolean, default: false },
|
||||
allowVisibilityToggle: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
const attrs = useAttrs();
|
||||
const isPasswordVisible = ref(false);
|
||||
|
||||
const canToggleVisibility = computed(() => props.type === "password" && props.allowVisibilityToggle);
|
||||
const inputType = computed(() => {
|
||||
if (!canToggleVisibility.value) {
|
||||
return props.type;
|
||||
}
|
||||
return isPasswordVisible.value ? "text" : "password";
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.type, props.allowVisibilityToggle],
|
||||
([type, allowVisibilityToggle]) => {
|
||||
if (type !== "password" || !allowVisibilityToggle) {
|
||||
isPasswordVisible.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function handleInput(event) {
|
||||
emit("update:modelValue", event?.target?.value ?? "");
|
||||
}
|
||||
|
||||
function toggleVisibility() {
|
||||
if (!canToggleVisibility.value || props.disabled) {
|
||||
return;
|
||||
}
|
||||
isPasswordVisible.value = !isPasswordVisible.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<input
|
||||
v-bind="attrs"
|
||||
:value="modelValue"
|
||||
:type="inputType"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
class="h-9 w-full rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none transition-colors focus:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="canToggleVisibility ? 'pr-10' : ''"
|
||||
@input="handleInput"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="canToggleVisibility"
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 center-row px-3 text-[#8f8f8f] transition-colors hover:text-[#d4d4d4] focus:text-[#d4d4d4] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:aria-label="isPasswordVisible ? '隐藏访问密钥' : '显示访问密钥'"
|
||||
:aria-pressed="isPasswordVisible"
|
||||
:disabled="disabled"
|
||||
@click="toggleVisibility"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
isPasswordVisible ? 'icon-[mdi--eye-off-outline]' : 'icon-[mdi--eye-outline]',
|
||||
'text-[18px]',
|
||||
]"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
import Button from "@/components/ui/Button.vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
title: { type: String, default: "提示" },
|
||||
content: { type: String, default: "" },
|
||||
placeholder: { type: String, default: "" },
|
||||
modelValue: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "update:modelValue", "confirm", "cancel"]);
|
||||
|
||||
function handleConfirm() {
|
||||
emit("confirm");
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("cancel");
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function onMaskClick() {
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
function onInput(event) {
|
||||
emit("update:modelValue", event?.target?.value ?? "");
|
||||
}
|
||||
|
||||
function onEnter(event) {
|
||||
event.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-mask">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
|
||||
@click.self="onMaskClick"
|
||||
>
|
||||
<Transition name="modal-content">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="relative z-10 w-full max-w-[380px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
|
||||
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
|
||||
@click.stop
|
||||
>
|
||||
<div class="rounded-[7px] bg-[#292929] p-5">
|
||||
<h3 class="mb-3 text-base font-medium text-white">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="mb-3 text-sm leading-relaxed text-[#a3a3a3]">
|
||||
{{ content }}
|
||||
</p>
|
||||
<input
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
type="text"
|
||||
class="mb-5 h-9 w-full rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
@input="onInput"
|
||||
@keydown.enter="onEnter"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="default" @click="handleCancel">取消</Button>
|
||||
<Button variant="primary" @click="handleConfirm">确定</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-mask-enter-active,
|
||||
.modal-mask-leave-active {
|
||||
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||
}
|
||||
.modal-mask-enter-from,
|
||||
.modal-mask-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
|
||||
.modal-content-enter-active,
|
||||
.modal-content-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.modal-content-enter-from,
|
||||
.modal-content-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import { messageState, provideMessage } from "@/composables/useMessage";
|
||||
|
||||
provideMessage();
|
||||
|
||||
const MESSAGE_THEME = {
|
||||
success: {
|
||||
containerClass: "bg-[#10AD5D] text-white",
|
||||
iconClass: "icon-[dashicons--yes]",
|
||||
iconExtraClass: "",
|
||||
},
|
||||
error: {
|
||||
containerClass: "bg-[#D84C4C] text-white",
|
||||
iconClass: "",
|
||||
iconExtraClass: "",
|
||||
},
|
||||
info: {
|
||||
containerClass: "bg-[#F08A24] text-white",
|
||||
iconClass: "",
|
||||
iconExtraClass: "",
|
||||
},
|
||||
loading: {
|
||||
containerClass: "bg-[#3a3a3a] text-white",
|
||||
iconClass: "icon-[mingcute--loading-fill]",
|
||||
iconExtraClass: "animate-spin",
|
||||
},
|
||||
};
|
||||
|
||||
function resolveTheme(type) {
|
||||
return MESSAGE_THEME[type] || MESSAGE_THEME.info;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-none fixed inset-x-0 top-4 z-[1000] flex justify-center px-4">
|
||||
<Transition name="message-slide" mode="out-in">
|
||||
<div
|
||||
v-if="messageState.current"
|
||||
:key="messageState.current.id"
|
||||
class="pointer-events-auto inline-flex max-w-full items-center gap-2 rounded-full px-4 py-2 text-sm shadow-[0_8px_24px_rgba(0,0,0,0.28)]"
|
||||
:class="resolveTheme(messageState.current.type).containerClass"
|
||||
>
|
||||
<span
|
||||
v-if="resolveTheme(messageState.current.type).iconClass"
|
||||
class="text-[14px]"
|
||||
:class="[
|
||||
resolveTheme(messageState.current.type).iconClass,
|
||||
resolveTheme(messageState.current.type).iconExtraClass,
|
||||
]"
|
||||
/>
|
||||
<span class="leading-none whitespace-nowrap">{{ messageState.current.content }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-slide-enter-active,
|
||||
.message-slide-leave-active {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.message-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
|
||||
.message-slide-enter-to,
|
||||
.message-slide-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.message-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import Button from "@/components/ui/Button.vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
title: { type: String, default: "提示" },
|
||||
content: { type: String, default: "" },
|
||||
confirmText: { type: String, default: "确定" },
|
||||
cancelText: { type: String, default: "取消" },
|
||||
showCancel: { type: Boolean, default: true },
|
||||
confirmDisabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "confirm", "cancel"]);
|
||||
|
||||
function handleConfirm() {
|
||||
emit("confirm");
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("cancel");
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function onMaskClick() {
|
||||
handleCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-mask">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4 "
|
||||
@click.self="onMaskClick"
|
||||
>
|
||||
<Transition name="modal-content">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
|
||||
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
|
||||
@click.stop
|
||||
>
|
||||
<div class="rounded-[7px] bg-[#292929] p-5">
|
||||
<h3 class="mb-3 text-base font-medium text-white">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="mb-5 max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-[#a3a3a3]">
|
||||
{{ content }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button v-if="showCancel" variant="default" @click="handleCancel">{{ cancelText }}</Button>
|
||||
<Button variant="primary" :disabled="confirmDisabled" @click="handleConfirm">{{ confirmText }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-mask-enter-active,
|
||||
.modal-mask-leave-active {
|
||||
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||
}
|
||||
.modal-mask-enter-from,
|
||||
.modal-mask-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
|
||||
.modal-content-enter-active,
|
||||
.modal-content-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.modal-content-enter-from,
|
||||
.modal-content-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<script setup>
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch, watchPostEffect } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
placeholder: { type: String, default: "请选择" },
|
||||
disabled: { type: Boolean, default: false },
|
||||
border: { type: Boolean, default: true },
|
||||
ariaLabel: { type: String, default: "" },
|
||||
buttonClass: { type: String, default: "" },
|
||||
menuClass: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "change", "blur"]);
|
||||
|
||||
const rootRef = ref(null);
|
||||
const buttonRef = ref(null);
|
||||
const menuRef = ref(null);
|
||||
const optionRefs = ref([]);
|
||||
const isOpen = ref(false);
|
||||
const activeIndex = ref(-1);
|
||||
const menuStyle = ref({});
|
||||
|
||||
const normalizedOptions = computed(() => props.options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { label: option, value: option };
|
||||
}
|
||||
|
||||
return {
|
||||
label: option?.label ?? option?.value ?? "",
|
||||
value: option?.value ?? "",
|
||||
icon: option?.icon ?? option?.iconClass ?? "",
|
||||
};
|
||||
}));
|
||||
|
||||
const selectedOption = computed(() => normalizedOptions.value.find((option) => option.value === props.modelValue) ?? null);
|
||||
const selectedLabel = computed(() => selectedOption.value?.label || props.placeholder);
|
||||
|
||||
function setOptionRef(el, index) {
|
||||
if (el) {
|
||||
optionRefs.value[index] = el;
|
||||
return;
|
||||
}
|
||||
|
||||
delete optionRefs.value[index];
|
||||
}
|
||||
|
||||
function focusActiveOption() {
|
||||
nextTick(() => {
|
||||
const option = optionRefs.value[activeIndex.value];
|
||||
option?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
if (props.disabled || isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = true;
|
||||
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
|
||||
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
nextTick(() => {
|
||||
updatePosition();
|
||||
focusActiveOption();
|
||||
});
|
||||
}
|
||||
|
||||
function closeMenu({ restoreFocus = false } = {}) {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = false;
|
||||
activeIndex.value = -1;
|
||||
optionRefs.value = [];
|
||||
menuStyle.value = {};
|
||||
|
||||
if (restoreFocus) {
|
||||
nextTick(() => buttonRef.value?.focus());
|
||||
}
|
||||
|
||||
emit("blur");
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
if (isOpen.value) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
openMenu();
|
||||
}
|
||||
|
||||
function selectOption(option) {
|
||||
if (!option || option.value === props.modelValue) {
|
||||
closeMenu({ restoreFocus: true });
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update:modelValue", option.value);
|
||||
emit("change", option.value);
|
||||
closeMenu({ restoreFocus: true });
|
||||
}
|
||||
|
||||
function moveActiveIndex(step) {
|
||||
if (!normalizedOptions.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen.value) {
|
||||
openMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
const total = normalizedOptions.value.length;
|
||||
const current = activeIndex.value >= 0 ? activeIndex.value : 0;
|
||||
activeIndex.value = (current + step + total) % total;
|
||||
focusActiveOption();
|
||||
}
|
||||
|
||||
function handleButtonKeydown(event) {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
moveActiveIndex(1);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
moveActiveIndex(-1);
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
toggleMenu();
|
||||
break;
|
||||
case "Escape":
|
||||
if (isOpen.value) {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOptionKeydown(event, option, index) {
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
activeIndex.value = index;
|
||||
moveActiveIndex(1);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
activeIndex.value = index;
|
||||
moveActiveIndex(-1);
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
selectOption(option);
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
closeMenu({ restoreFocus: true });
|
||||
break;
|
||||
case "Tab":
|
||||
closeMenu();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(event) {
|
||||
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
if (!buttonRef.value || !menuRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
computePosition(buttonRef.value, menuRef.value, {
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
offset(6),
|
||||
flip({ padding: 12 }),
|
||||
shift({ padding: 12 }),
|
||||
size({
|
||||
apply({ rects, elements, availableHeight }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
minWidth: `${rects.reference.width}px`,
|
||||
maxHeight: `${Math.max(availableHeight, 160)}px`,
|
||||
});
|
||||
},
|
||||
padding: 12,
|
||||
}),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
menuStyle.value = {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
watchPostEffect((cleanup) => {
|
||||
if (!isOpen.value || !buttonRef.value || !menuRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopAutoUpdate = autoUpdate(buttonRef.value, menuRef.value, updatePosition);
|
||||
|
||||
cleanup(() => {
|
||||
stopAutoUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
|
||||
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
});
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return;
|
||||
}
|
||||
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="relative">
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
class="flex h-9 items-center rounded-[6px] bg-[#232323] px-3 text-left text-sm text-[#e5e5e5] outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="[
|
||||
border
|
||||
? 'w-full justify-between gap-2 border border-[#3f3f3f] focus:border-[#10AD5D]'
|
||||
: 'w-auto justify-start gap-2 border border-transparent focus-visible:ring-2 focus-visible:ring-[#10AD5D]/35',
|
||||
buttonClass,
|
||||
]"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-label="ariaLabel || undefined"
|
||||
aria-haspopup="listbox"
|
||||
@click="toggleMenu"
|
||||
@keydown="handleButtonKeydown"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
:class="[
|
||||
border ? 'flex-1' : 'shrink-0',
|
||||
selectedOption
|
||||
? (border ? 'text-[#e5e5e5]' : 'text-current')
|
||||
: 'text-[#7b7b7b]',
|
||||
]"
|
||||
>
|
||||
<span v-if="selectedOption?.icon" :class="[selectedOption.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
|
||||
<span class="truncate">{{ selectedLabel }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="pointer-events-none center-row transition-transform duration-200"
|
||||
:class="[border ? 'text-[#8f8f8f]' : 'text-current', isOpen ? 'rotate-180' : '']"
|
||||
>
|
||||
<span class="icon-[mdi--chevron-down] text-[18px]"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="translate-y-1 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-1 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
class="fixed z-[999] overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
|
||||
:class="menuClass"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<ul role="listbox" class="overflow-y-auto py-1">
|
||||
<li v-for="(option, index) in normalizedOptions" :key="option.value">
|
||||
<button
|
||||
:ref="(el) => setOptionRef(el, index)"
|
||||
type="button"
|
||||
role="option"
|
||||
class="flex w-full items-center rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
|
||||
:class="[
|
||||
option.value === modelValue
|
||||
? 'bg-[#10AD5D]/15 text-[#10d06f]'
|
||||
: 'text-[#e5e5e5] hover:bg-[#303030]',
|
||||
activeIndex === index ? 'bg-[#303030]' : '',
|
||||
]"
|
||||
:aria-selected="option.value === modelValue"
|
||||
tabindex="0"
|
||||
@click="selectOption(option)"
|
||||
@mouseenter="activeIndex = index"
|
||||
@keydown="handleOptionKeydown($event, option, index)"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span v-if="option.icon" :class="[option.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
enabled: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
busy: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
label: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
enabledText: { type: String, default: "已开启" },
|
||||
disabledText: { type: String, default: "已关闭" },
|
||||
busyText: { type: String, default: "切换中..." },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["change"]);
|
||||
|
||||
function handleToggle() {
|
||||
if (props.disabled || props.busy) {
|
||||
return;
|
||||
}
|
||||
emit("change", !props.enabled);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4"
|
||||
:class="compact ? 'py-0' : 'py-1'"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col" :class="compact ? 'gap-[2px]' : 'gap-1'">
|
||||
<div :class="compact ? 'text-[12px]' : 'text-sm'" class="font-medium text-white">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="description"
|
||||
:class="compact ? 'text-[11px] leading-[16px]' : 'text-xs'"
|
||||
class="text-[#a3a3a3]"
|
||||
>
|
||||
{{ description }}
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
compact ? 'text-[11px] leading-[16px]' : 'text-xs',
|
||||
enabled ? 'text-[#10AD5D]' : 'text-[#a3a3a3]',
|
||||
]"
|
||||
>
|
||||
{{ busy ? busyText : enabled ? enabledText : disabledText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="enabled"
|
||||
:disabled="disabled || busy"
|
||||
class="relative inline-flex h-[22px] w-[40px] shrink-0 cursor-pointer rounded-full outline-none transition-all duration-200 ease-out disabled:cursor-not-allowed disabled:opacity-55 focus-visible:ring-2 focus-visible:ring-[#10AD5D]/35"
|
||||
:class="enabled ? 'bg-[#10AD5D]' : 'bg-[rgba(255,255,255,0.22)]'"
|
||||
@click="handleToggle"
|
||||
>
|
||||
<span
|
||||
class="absolute left-[2px] top-[2px] inline-flex h-[18px] w-[18px] rounded-full bg-white shadow-[0_2px_5px_rgba(0,0,0,0.22)] transition-all duration-200 ease-out"
|
||||
:class="enabled ? 'translate-x-[18px]' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
|
||||
import copyTextToClipboard from "copy-text-to-clipboard";
|
||||
import { computed, nextTick, onBeforeUnmount, ref, useSlots, watchPostEffect } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
content: { type: String, default: "" },
|
||||
copyable: { type: Boolean, default: false },
|
||||
copyText: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const HIDE_DELAY_MS = 300;
|
||||
const COPY_RESET_DELAY_MS = 1500;
|
||||
const triggerRef = ref(null);
|
||||
const tooltipRef = ref(null);
|
||||
const isOpen = ref(false);
|
||||
const tooltipStyle = ref({});
|
||||
const copied = ref(false);
|
||||
let hideTimer = null;
|
||||
let copyResetTimer = null;
|
||||
|
||||
const copyValue = computed(() => String(props.copyText || props.content || "").trim());
|
||||
const hasContent = computed(() => !!props.content || !!slots.default);
|
||||
const showCopyButton = computed(() => props.copyable && !!copyValue.value);
|
||||
|
||||
function showTooltip() {
|
||||
if (!hasContent.value) {
|
||||
return;
|
||||
}
|
||||
clearHideTimer();
|
||||
isOpen.value = true;
|
||||
nextTick(() => {
|
||||
updatePosition();
|
||||
});
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function clearHideTimer() {
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCopyResetTimer() {
|
||||
if (copyResetTimer) {
|
||||
window.clearTimeout(copyResetTimer);
|
||||
copyResetTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHideTooltip() {
|
||||
clearHideTimer();
|
||||
hideTimer = window.setTimeout(() => {
|
||||
hideTooltip();
|
||||
hideTimer = null;
|
||||
}, HIDE_DELAY_MS);
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value || !tooltipRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
computePosition(triggerRef.value, tooltipRef.value, {
|
||||
placement: "top",
|
||||
middleware: [
|
||||
offset(10),
|
||||
flip({ padding: 12 }),
|
||||
shift({ padding: 12 }),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
tooltipStyle.value = {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
if (!copyValue.value) {
|
||||
return;
|
||||
}
|
||||
copyTextToClipboard(copyValue.value);
|
||||
copied.value = true;
|
||||
clearCopyResetTimer();
|
||||
copyResetTimer = window.setTimeout(() => {
|
||||
copied.value = false;
|
||||
copyResetTimer = null;
|
||||
}, COPY_RESET_DELAY_MS);
|
||||
}
|
||||
|
||||
watchPostEffect((cleanup) => {
|
||||
if (!isOpen.value || !triggerRef.value || !tooltipRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stop = autoUpdate(triggerRef.value, tooltipRef.value, updatePosition);
|
||||
cleanup(() => {
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHideTimer();
|
||||
clearCopyResetTimer();
|
||||
hideTooltip();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-[#727272] transition-colors duration-150 hover:text-[#cfcfcf]"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="scheduleHideTooltip"
|
||||
@focus="showTooltip"
|
||||
@blur="scheduleHideTooltip"
|
||||
>
|
||||
<span class="icon-[mdi--information-outline] text-[14px]"></span>
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="tooltipRef"
|
||||
class="fixed z-[10000] flex max-h-[320px] max-w-[420px] flex-col overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#202020] px-3 py-2 text-left text-[12px] leading-relaxed text-[#d4d4d4] shadow-[0_12px_32px_rgba(0,0,0,0.45)]"
|
||||
:style="tooltipStyle"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="scheduleHideTooltip"
|
||||
>
|
||||
<div v-if="showCopyButton" class="mb-2 flex shrink-0 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="center-row gap-1 rounded-[6px] border border-[#3f3f3f] bg-[#272727] px-2 py-1 text-[11px] text-[#d4d4d4] transition-colors duration-150 hover:border-[#4c4c4c] hover:bg-[#2f2f2f]"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<span :class="copied ? 'icon-[mdi--check]' : 'icon-[mdi--content-copy]'" class="text-[13px]"></span>
|
||||
<span>{{ copied ? "已复制" : "拷贝" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="min-h-0 overflow-auto break-words">
|
||||
<slot>
|
||||
<div class="whitespace-pre-wrap">{{ content }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
export const inputModalState = reactive({
|
||||
visible: false,
|
||||
title: "提示",
|
||||
content: "",
|
||||
placeholder: "",
|
||||
value: "",
|
||||
_resolve: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* 显示输入弹窗,返回 Promise<string|null>
|
||||
* @param {Object} options - { title, content, placeholder, defaultValue }
|
||||
* @returns {Promise<string|null>} - string=确定后的输入值, null=取消
|
||||
*/
|
||||
export function showInputModal(options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
inputModalState.visible = true;
|
||||
inputModalState.title = options.title ?? "提示";
|
||||
inputModalState.content = options.content ?? "";
|
||||
inputModalState.placeholder = options.placeholder ?? "";
|
||||
inputModalState.value = String(options.defaultValue ?? "");
|
||||
inputModalState._resolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveInputModal(ok) {
|
||||
const value = String(inputModalState.value ?? "").trim();
|
||||
inputModalState.visible = false;
|
||||
inputModalState._resolve?.(ok ? value : null);
|
||||
inputModalState._resolve = null;
|
||||
if (!ok) {
|
||||
inputModalState.value = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { inject, provide, reactive } from "vue";
|
||||
|
||||
const MESSAGE_API_SYMBOL = Symbol("message-api");
|
||||
const MIN_VISIBLE_MS = 300;
|
||||
let messageSeed = 0;
|
||||
|
||||
const messageState = reactive({
|
||||
current: null,
|
||||
});
|
||||
|
||||
function clearMessageTimer(item) {
|
||||
if (item?.timer) {
|
||||
clearTimeout(item.timer);
|
||||
item.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeMessage(id, options = {}) {
|
||||
if (!messageState.current || messageState.current.id !== id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = messageState.current;
|
||||
const elapsed = Date.now() - current.shownAt;
|
||||
const force = options.force === true;
|
||||
if (!force && elapsed < MIN_VISIBLE_MS) {
|
||||
clearMessageTimer(current);
|
||||
current.timer = window.setTimeout(() => {
|
||||
removeMessage(id, { force: true });
|
||||
}, MIN_VISIBLE_MS - elapsed);
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessageTimer(current);
|
||||
messageState.current = null;
|
||||
}
|
||||
|
||||
function showMessage(options = {}) {
|
||||
const type = typeof options.type === "string" ? options.type : "info";
|
||||
const content = String(options.content || "").trim();
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (messageState.current) {
|
||||
clearMessageTimer(messageState.current);
|
||||
}
|
||||
|
||||
const duration = Number.isFinite(options.duration)
|
||||
? Math.max(0, options.duration)
|
||||
: type === "loading"
|
||||
? 0
|
||||
: 2400;
|
||||
const id = `message-${Date.now()}-${messageSeed += 1}`;
|
||||
const item = {
|
||||
id,
|
||||
type,
|
||||
content,
|
||||
shownAt: Date.now(),
|
||||
timer: null,
|
||||
};
|
||||
|
||||
if (duration > 0) {
|
||||
item.timer = window.setTimeout(() => {
|
||||
removeMessage(id);
|
||||
}, Math.max(duration, MIN_VISIBLE_MS));
|
||||
}
|
||||
|
||||
messageState.current = item;
|
||||
return id;
|
||||
}
|
||||
|
||||
export function createMessageApi() {
|
||||
return {
|
||||
state: messageState,
|
||||
show: showMessage,
|
||||
success(content, options = {}) {
|
||||
return showMessage({ ...options, type: "success", content });
|
||||
},
|
||||
error(content, options = {}) {
|
||||
return showMessage({ ...options, type: "error", content });
|
||||
},
|
||||
info(content, options = {}) {
|
||||
return showMessage({ ...options, type: "info", content });
|
||||
},
|
||||
loading(content, options = {}) {
|
||||
return showMessage({ ...options, type: "loading", content });
|
||||
},
|
||||
remove: removeMessage,
|
||||
clear() {
|
||||
if (messageState.current) {
|
||||
removeMessage(messageState.current.id, { force: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const defaultMessageApi = createMessageApi();
|
||||
|
||||
export function provideMessage() {
|
||||
provide(MESSAGE_API_SYMBOL, defaultMessageApi);
|
||||
return defaultMessageApi;
|
||||
}
|
||||
|
||||
export function useMessage() {
|
||||
return inject(MESSAGE_API_SYMBOL, defaultMessageApi);
|
||||
}
|
||||
|
||||
export { messageState, showMessage, removeMessage };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
export const modalState = reactive({
|
||||
visible: false,
|
||||
title: "提示",
|
||||
content: "",
|
||||
confirmText: "确定",
|
||||
cancelText: "取消",
|
||||
showCancel: true,
|
||||
confirmDisabled: false,
|
||||
_resolve: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* 显示确认弹窗,返回 Promise<boolean>
|
||||
* @param {Object} options - { title, content }
|
||||
* @returns {Promise<boolean>} - true=确定, false=取消
|
||||
*/
|
||||
export function showModal(options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
modalState.visible = true;
|
||||
modalState.title = options.title ?? "提示";
|
||||
modalState.content = options.content ?? "";
|
||||
modalState.confirmText = options.confirmText ?? "确定";
|
||||
modalState.cancelText = options.cancelText ?? "取消";
|
||||
modalState.showCancel = options.showCancel ?? true;
|
||||
modalState.confirmDisabled = options.confirmDisabled ?? false;
|
||||
modalState._resolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveModal(ok) {
|
||||
modalState.visible = false;
|
||||
const resolve = modalState._resolve;
|
||||
modalState._resolve = null;
|
||||
resolve?.(ok);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const LOCALE_STORAGE_KEY = "cursor-client:locale:v1";
|
||||
export const LOCALE_STORAGE_SOURCE_KEY = "cursor-client:locale-source:v1";
|
||||
export const SOURCE_LOCALE = "zh-CN";
|
||||
export const DEFAULT_LOCALE = "en-US";
|
||||
export const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ja-JP"];
|
||||
export const LOCALE_OPTIONS = [
|
||||
{ label: "简体中文", value: "zh-CN" },
|
||||
{ label: "English", value: "en-US" },
|
||||
{ label: "日本語", value: "ja-JP" },
|
||||
];
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"02216368edc68816": "No release notes",
|
||||
"02bc2e95bf49e587": "No",
|
||||
"03b11112dc970014": "Base URL",
|
||||
"045261cc748d4300": "Token budget allowed during Anthropic's thinking phase. Leave blank to use the default.",
|
||||
"047ec6b71d0cec08": "Control whether requests on the whitelist main path go through the local service or return to the original Cursor upstream endpoint",
|
||||
"04f632dd4f034d5e": "{0} context window must be a positive integer",
|
||||
"051836569928a9f9": "Edit",
|
||||
"054d763265603305": "e.g. 65536 (leave blank to use the default)",
|
||||
"05c8a9238c702efa": "Direct Cursor Mode",
|
||||
"0647728439b5da2e": "You can configure the routing mode and model channels. Runtime logs are stored in",
|
||||
"092b520558eff5f2": "Not tested",
|
||||
"0b0e7478e41fe677": "{0} tooltip text cannot be empty",
|
||||
"0c3b4cf7aa259edb": "Operation failed",
|
||||
"0dde813d719dbd01": "Failed to open homepage",
|
||||
"0e40a09ab4e664ab": "Duplicate model channel detected. Check the combination of url, modelID, apiKey, and displayName",
|
||||
"1117a2f86030d03b": "Cache reads and writes are included in Prompt-side statistics.",
|
||||
"11afd2a534395b18": "Valid",
|
||||
"124be3f86f197802": "Token Usage",
|
||||
"13b61c5f697b6700": "Cache Hit Rate",
|
||||
"15d124b200ddabed": "Maximum number of context tokens the model can accept in a single request. Leave blank to use the default.",
|
||||
"18b7312022cd1840": "Start Service",
|
||||
"1af38868896cf53d": "Routing mode only supports local or upstream",
|
||||
"1b7d24b212e52c54": "{0} reasoning effort only supports low, medium, high, and xhigh",
|
||||
"1bc77f5ab979f4c1": "Add Model Settings",
|
||||
"1e238093b79b3165": "Uses 65536 by default when left blank",
|
||||
"24343a2096988d42": "Failed to open",
|
||||
"253d4a3428c648fb": "Cache write tokens: {0}",
|
||||
"258e4620e2108793": "Uses 4096 by default when left blank",
|
||||
"26a3855aed1d8d17": "Service not running",
|
||||
"281eb6d08c9960d0": "{0} thinking budget token must be a positive integer",
|
||||
"28aeffc70ceb4267": "Change the display language for this interface. The setting takes effect immediately and is saved on this device.",
|
||||
"2caeaec539e78898": "Thinking Budget Token",
|
||||
"2cd0f3be8738a86c": "Cancel",
|
||||
"2d706f7981b45a7b": "Local settings saved",
|
||||
"2f9daa828907b93f": "Delete",
|
||||
"30bb57a50caedc38": "e.g. For everyday code completion and Q&A",
|
||||
"32b3c9a50003f77a": "Output tokens are estimated",
|
||||
"33d2d273e2bd5f88": "Failed to open user guide",
|
||||
"3468b57e3edbc599": "Aggregated from turn summaries scanned from the history.",
|
||||
"35076178fe79a210": "Configuration changed. Please test again.",
|
||||
"36c149a9b3e8dca0": "models configured yet.",
|
||||
"37d23612f78a2e63": "Restart Now to Update",
|
||||
"392d0dceb45998d3": "Extreme",
|
||||
"393df9bb13ea4900": "Hit",
|
||||
"3af7e5489e61ea51": "Refreshing",
|
||||
"3bf8512aa520ed21": "Local Service Mode",
|
||||
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
|
||||
"3d13868593ae4eeb": "Interface Language",
|
||||
"3ea83f9f55062582": "Release date: {0}",
|
||||
"3edda85621fd03b2": "model adapters",
|
||||
"42aa8e01e98c0d8c": "Total Duration",
|
||||
"468adaa418ee1475": "e.g. https://api.openai.com/v1",
|
||||
"4923eeb7bd75cccd": "{0} model ID cannot be empty",
|
||||
"497c85690c4cc0fc": "No data",
|
||||
"4b8d11bf235e9213": "Current hit rate: {0}",
|
||||
"4d8c1c5b42830791": "Unknown",
|
||||
"51194c3ad014fb29": "Retest required",
|
||||
"5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.",
|
||||
"56627c94a9decee6": "Max Output Tokens",
|
||||
"5beb1206c532729f": "Maximum number of tokens allowed in a single response. Leave blank to use the default.",
|
||||
"5d1687a4a41883fd": "Stopping...",
|
||||
"6106f0a12583a334": "Refresh failed",
|
||||
"62873083fcaed27d": "Session Statistics",
|
||||
"6309a3bb5ba4c714": "Save failed",
|
||||
"636e3deffc1e960a": "Model {0}",
|
||||
"63d90d977348ab1f": "Duplicate",
|
||||
"64d2730f2ae37997": "Raw Response",
|
||||
"65cc5fd2e6ce6e75": "Backend started, proxy not started",
|
||||
"66af574b8948fe83": "{0} API key cannot be empty",
|
||||
"675109292da4eb36": "Not tested yet",
|
||||
"699fe7ade5407687": "Direct Mode",
|
||||
"6a7b96f399e58138": "e.g. sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "Test",
|
||||
"6ae23d6d7cb18592": "Service error",
|
||||
"6e584e3d5ce64aa0": "Save Settings",
|
||||
"737225e2904673fc": "Estimated output tokens: {0}",
|
||||
"7520bd50a5ee5471": "Stop testing {0}/{1}",
|
||||
"753d8bb0da9913ce": "Duplication failed",
|
||||
"77c9e582e85583af": "Test failed",
|
||||
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
|
||||
"7b6187c41e88b70c": "Testing...",
|
||||
"7bf8e2c07e084d09": "Model Editor",
|
||||
"7e9e334aeb0bdc07": "Service operation failed",
|
||||
"7f68ebad19ba6bcd": "Check for Updates",
|
||||
"81123c56d5d880d0": "API Key",
|
||||
"86df7ec743047234": "Service running",
|
||||
"87ed126f7bd1121e": "Routing Mode",
|
||||
"899add6275682210": "Uses 200000 by default when left blank",
|
||||
"8c0d84831a3c3d5b": "Currently in Local Service Mode",
|
||||
"8c1935935600e336": "Model Test",
|
||||
"8cbcf741e727dbf7": "Model Settings",
|
||||
"8d1de152be6360ce": "Valid ratio: {0}",
|
||||
"8e2dc7b0d2e8f6f8": "e.g. OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "Copied",
|
||||
"8faa670b512b6b9b": "Open Model Settings",
|
||||
"917b1c1f18d0276b": "Saving...",
|
||||
"9196835e388d2550": "Test All",
|
||||
"91cba5c107a51892": "/ Invalid",
|
||||
"92059fe6cd713db4": "The model name actually sent to the server, for example gpt-4.1 or claude-sonnet.",
|
||||
"93e08803675e378b": "Model ID",
|
||||
"942ff2d88baca0c6": "Checking for updates...",
|
||||
"986678eccf56dc28": "Service status is being updated. Please try again later.",
|
||||
"991e374fce0f4492": "Cursor Assistant",
|
||||
"9970736b36ff2b68": "The base URL of the model service, usually an OpenAI- or Anthropic-compatible endpoint.",
|
||||
"9b17fa889b307f7f": "Valid turns: {0}",
|
||||
"9c38b6e9bf94abec": "Switched to Direct Cursor Mode",
|
||||
"9cd4ac17428b86e4": "Cache hit rate = cache read tokens / prompt tokens",
|
||||
"9d2ca261281a158a": "Later",
|
||||
"9dc0825fba5422e4": "Loading...",
|
||||
"a026f37e613cf48b": "Output Tokens",
|
||||
"a1a038dfa16c3ede": "You're already on the latest version (v{0}).",
|
||||
"a3030bf8f16dc63c": "Save",
|
||||
"a325d25c69e7256d": "Model settings not found; cannot duplicate",
|
||||
"a4dd8bb7e8b6eb31": "Show API Key",
|
||||
"a55a88237df85d98": "Currently in Direct Mode",
|
||||
"a567bdaa11367f26": "Medium",
|
||||
"a5f1bd344c92e195": "The API key required to call this model service.",
|
||||
"a693d69af48bfe48": "Save and Test",
|
||||
"a98585871c5313ff": "Display Name",
|
||||
"aa9e366f68d3d097": "Low",
|
||||
"ac217e4d1ca410f1": "New version available",
|
||||
"ad79540418be700a": "Open the settings folder, or manage model settings separately",
|
||||
"ae5a738238463a92": "Hide API Key",
|
||||
"aed55419ce62f08e": "Switching...",
|
||||
"b1c27820fec23edb": "High",
|
||||
"b42049dcf8a05ef7": "Switched to Local Service Mode",
|
||||
"b571037dc396a00c": "Total request tokens include both prompt and model output.",
|
||||
"b765005f69fa971f": "e.g. gpt-4.1",
|
||||
"b90a8ac9c488ce46": "Select language",
|
||||
"ba40014ff496f64e": "Type",
|
||||
"bb074b86a98f6911": "Context Window",
|
||||
"bbacfde55a92869f": "A higher hit rate means more repeated context is being reused.",
|
||||
"bc87a4121a0873b3": "Refresh Stats",
|
||||
"bd4464ea88d3f24a": "Total turns: {0}",
|
||||
"bef280f9eb392495": "Conversation Turns",
|
||||
"c228558cf257fc49": "Delete failed",
|
||||
"c3e9c3c60020b8b7": "Select Mode",
|
||||
"c69f5bce63b9f14c": "Settings Folder",
|
||||
"c6f743953145f40b": "e.g. 4096 (leave blank to use the default)",
|
||||
"c8c14507b2d37395": "Reasoning Effort",
|
||||
"c98e118e0a43f078": "Model",
|
||||
"ca00a39fcea70dc6": "Starting...",
|
||||
"ca1d1059408b3837": "Invalid turns: {0}",
|
||||
"ce46f23cea3bf3c5": "When enabled, Cursor connects directly to the official service. Do not enable this.",
|
||||
"d0325067fed88e5a": "Cache hit rate {0}",
|
||||
"d08fd4224abcd69d": "Switch failed",
|
||||
"d1bde4a4e057b2c7": "[MainLayout] Failed to load author info",
|
||||
"d20ab96566d33f25": "{0} display name cannot be empty",
|
||||
"d2243e1d44b2a94e": "Edit Model Settings",
|
||||
"d3209b935ae86797": "Model settings not found; cannot delete",
|
||||
"d373809ab86ba93b": "Copy",
|
||||
"d3b1da3088ddd334": "Model test failed",
|
||||
"d7da2aabd35772ec": "e.g. 200000 (leave blank to use the default)",
|
||||
"da590a8fe3ce4de0": "Please select",
|
||||
"daede9881787abe7": "Notes",
|
||||
"dbee6e7139243362": "{0} base URL cannot be empty",
|
||||
"dc82c5e8fb2ab777": "Version: v{0}",
|
||||
"de8184da1ef88d03": "Configured",
|
||||
"e14c41ef2b7253c9": "Total request tokens: {0}",
|
||||
"e406825e0a72d2c2": "Local Settings",
|
||||
"e552c2accdbf5178": "Add Model",
|
||||
"e6faccfddce722e8": "Cache read tokens: {0}",
|
||||
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
|
||||
"ec3b17a75db49e24": "{0} t/s | First token {1}",
|
||||
"ec99e5c45d648fd6": "Update failed",
|
||||
"f1e0fc261d42fe29": "Notes shown when hovering over the model list.",
|
||||
"f363622480699c52": "Reasoning effort only applies to some models that support reasoning_effort. Not all models do. Higher values are usually more stable, but may also be slower.",
|
||||
"f3a76d896853c1df": "Miss",
|
||||
"f474a4108aba4c4c": "Stop Service",
|
||||
"f56c6c82203b33f6": "Notice",
|
||||
"f61e03f047b786d5": "{0} max output tokens must be a positive integer",
|
||||
"fac2a67ad87807c4": "OK",
|
||||
"fb7a4c81729ed0ca": "Stopping...",
|
||||
"fec45092945f8790": "User Guide"
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"02216368edc68816": "更新内容はありません",
|
||||
"02bc2e95bf49e587": "まだ",
|
||||
"03b11112dc970014": "ベース URL",
|
||||
"045261cc748d4300": "Anthropic の思考フェーズで使用できる予算 Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"047ec6b71d0cec08": "ホワイトリストのメイン経路のリクエストをローカルサービス経由にするか、元の Cursor 上流アドレスに戻すかを制御します",
|
||||
"04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります",
|
||||
"051836569928a9f9": "編集",
|
||||
"054d763265603305": "例: 65536(空欄でデフォルト値)",
|
||||
"05c8a9238c702efa": "Cursor 直結モード",
|
||||
"0647728439b5da2e": "ルーティングモードとモデルチャネルを設定できます。実行ログは次にあります",
|
||||
"092b520558eff5f2": "未テスト",
|
||||
"0b0e7478e41fe677": "{0} のツールチップは必須です",
|
||||
"0c3b4cf7aa259edb": "操作に失敗しました",
|
||||
"0dde813d719dbd01": "ホームページを開けませんでした",
|
||||
"0e40a09ab4e664ab": "モデルチャネルが重複しています。url、modelID、apiKey、displayName の組み合わせを確認してください",
|
||||
"1117a2f86030d03b": "キャッシュの読み書きは Prompt 側の統計に含まれます。",
|
||||
"11afd2a534395b18": "有効",
|
||||
"124be3f86f197802": "Token 使用量",
|
||||
"13b61c5f697b6700": "キャッシュヒット率",
|
||||
"15d124b200ddabed": "モデルが1回のリクエストで受け取れる最大コンテキスト Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"18b7312022cd1840": "サービスを開始",
|
||||
"1af38868896cf53d": "ルーティングモードは local または upstream のみサポートします",
|
||||
"1b7d24b212e52c54": "{0} の推論強度は low、medium、high、xhigh のみサポートします",
|
||||
"1bc77f5ab979f4c1": "モデル設定を追加",
|
||||
"1e238093b79b3165": "空欄で 65536",
|
||||
"24343a2096988d42": "開けませんでした",
|
||||
"253d4a3428c648fb": "キャッシュ書き込み Token: {0}",
|
||||
"258e4620e2108793": "空欄で 4096",
|
||||
"26a3855aed1d8d17": "サービスは起動していません",
|
||||
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
|
||||
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
|
||||
"2caeaec539e78898": "思考予算 Token",
|
||||
"2cd0f3be8738a86c": "キャンセル",
|
||||
"2d706f7981b45a7b": "ローカル設定を保存しました",
|
||||
"2f9daa828907b93f": "削除",
|
||||
"30bb57a50caedc38": "例: 日常的なコード補完や Q&A 用",
|
||||
"32b3c9a50003f77a": "出力 Token は推定値です",
|
||||
"33d2d273e2bd5f88": "ユーザーガイドを開けませんでした",
|
||||
"3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。",
|
||||
"35076178fe79a210": "設定が変更されました。再テストしてください",
|
||||
"36c149a9b3e8dca0": "モデルは設定されていません。",
|
||||
"37d23612f78a2e63": "今すぐ再起動して更新",
|
||||
"392d0dceb45998d3": "最高",
|
||||
"393df9bb13ea4900": "ヒット",
|
||||
"3af7e5489e61ea51": "更新中",
|
||||
"3bf8512aa520ed21": "ローカルサービスモード",
|
||||
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
|
||||
"3d13868593ae4eeb": "表示言語",
|
||||
"3ea83f9f55062582": "公開日時: {0}",
|
||||
"3edda85621fd03b2": "件のモデルアダプター",
|
||||
"42aa8e01e98c0d8c": "総所要時間",
|
||||
"468adaa418ee1475": "例: https://api.openai.com/v1",
|
||||
"4923eeb7bd75cccd": "{0} のモデル ID は必須です",
|
||||
"497c85690c4cc0fc": "データなし",
|
||||
"4b8d11bf235e9213": "現在のヒット率: {0}",
|
||||
"4d8c1c5b42830791": "不明",
|
||||
"51194c3ad014fb29": "再テストが必要",
|
||||
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"56627c94a9decee6": "最大出力 Token",
|
||||
"5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"5d1687a4a41883fd": "停止中...",
|
||||
"6106f0a12583a334": "再読み込みに失敗しました",
|
||||
"62873083fcaed27d": "セッション統計",
|
||||
"6309a3bb5ba4c714": "保存に失敗しました",
|
||||
"636e3deffc1e960a": "モデル {0}",
|
||||
"63d90d977348ab1f": "複製",
|
||||
"64d2730f2ae37997": "生のレスポンス",
|
||||
"65cc5fd2e6ce6e75": "バックエンドは起動済み、プロキシは未起動です",
|
||||
"66af574b8948fe83": "{0} の API キーは必須です",
|
||||
"675109292da4eb36": "まだテストしていません",
|
||||
"699fe7ade5407687": "直結モード",
|
||||
"6a7b96f399e58138": "例: sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "テスト",
|
||||
"6ae23d6d7cb18592": "サービスエラー",
|
||||
"6e584e3d5ce64aa0": "設定を保存",
|
||||
"737225e2904673fc": "推定出力 Token: {0}",
|
||||
"7520bd50a5ee5471": "テスト停止 {0}/{1}",
|
||||
"753d8bb0da9913ce": "複製に失敗しました",
|
||||
"77c9e582e85583af": "テスト失敗",
|
||||
"7a26bf794e9fb6bf": "UI 上の表示専用で、異なるモデルを見分けやすくします。",
|
||||
"7b6187c41e88b70c": "テスト中...",
|
||||
"7bf8e2c07e084d09": "モデル編集",
|
||||
"7e9e334aeb0bdc07": "サービス操作に失敗しました",
|
||||
"7f68ebad19ba6bcd": "アップデートを確認",
|
||||
"81123c56d5d880d0": "API キー",
|
||||
"86df7ec743047234": "サービス稼働中",
|
||||
"87ed126f7bd1121e": "ルーティングモード",
|
||||
"899add6275682210": "空欄で 200000",
|
||||
"8c0d84831a3c3d5b": "現在はローカルサービスモードです",
|
||||
"8c1935935600e336": "モデルテスト",
|
||||
"8cbcf741e727dbf7": "モデル設定",
|
||||
"8d1de152be6360ce": "有効率: {0}",
|
||||
"8e2dc7b0d2e8f6f8": "例: OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "コピーしました",
|
||||
"8faa670b512b6b9b": "モデル設定を開く",
|
||||
"917b1c1f18d0276b": "保存中...",
|
||||
"9196835e388d2550": "すべてテスト",
|
||||
"91cba5c107a51892": "/ 異常",
|
||||
"92059fe6cd713db4": "実際にサーバーへ送信されるモデル名です。例: gpt-4.1 または claude-sonnet。",
|
||||
"93e08803675e378b": "モデル ID",
|
||||
"942ff2d88baca0c6": "アップデートを確認中...",
|
||||
"986678eccf56dc28": "サービス状態を更新中です。しばらくしてからもう一度お試しください",
|
||||
"991e374fce0f4492": "Cursorアシスタント",
|
||||
"9970736b36ff2b68": "モデルサービスの API ルート URL。通常は OpenAI または Anthropic 互換のエンドポイントです。",
|
||||
"9b17fa889b307f7f": "有効ターン: {0}",
|
||||
"9c38b6e9bf94abec": "Cursor 直結モードに切り替えました",
|
||||
"9cd4ac17428b86e4": "キャッシュヒット率 = キャッシュ読込 Token / Prompt Token",
|
||||
"9d2ca261281a158a": "後で",
|
||||
"9dc0825fba5422e4": "読み込み中...",
|
||||
"a026f37e613cf48b": "出力 Token",
|
||||
"a1a038dfa16c3ede": "すでに最新バージョンです(v{0})。",
|
||||
"a3030bf8f16dc63c": "保存",
|
||||
"a325d25c69e7256d": "モデル設定が存在しないため複製できません",
|
||||
"a4dd8bb7e8b6eb31": "API キーを表示",
|
||||
"a55a88237df85d98": "現在は直結モードです",
|
||||
"a567bdaa11367f26": "中",
|
||||
"a5f1bd344c92e195": "このモデルサービスを呼び出すために必要な API キーです。",
|
||||
"a693d69af48bfe48": "保存してテスト",
|
||||
"a98585871c5313ff": "表示名",
|
||||
"aa9e366f68d3d097": "低",
|
||||
"ac217e4d1ca410f1": "新しいバージョンがあります",
|
||||
"ad79540418be700a": "設定フォルダーを開くか、モデル設定を個別に管理できます",
|
||||
"ae5a738238463a92": "API キーを隠す",
|
||||
"aed55419ce62f08e": "切替中...",
|
||||
"b1c27820fec23edb": "高",
|
||||
"b42049dcf8a05ef7": "ローカルサービスモードに切り替えました",
|
||||
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
|
||||
"b765005f69fa971f": "例: gpt-4.1",
|
||||
"b90a8ac9c488ce46": "言語を選択",
|
||||
"ba40014ff496f64e": "タイプ",
|
||||
"bb074b86a98f6911": "コンテキストウィンドウ",
|
||||
"bbacfde55a92869f": "ヒット率が高いほど、重複するコンテキストがより多く再利用されていることを示します。",
|
||||
"bc87a4121a0873b3": "統計を更新",
|
||||
"bd4464ea88d3f24a": "総ターン: {0}",
|
||||
"bef280f9eb392495": "会話ターン",
|
||||
"c228558cf257fc49": "削除に失敗しました",
|
||||
"c3e9c3c60020b8b7": "モードを選択",
|
||||
"c69f5bce63b9f14c": "設定フォルダー",
|
||||
"c6f743953145f40b": "例: 4096(空欄でデフォルト値)",
|
||||
"c8c14507b2d37395": "推論強度",
|
||||
"c98e118e0a43f078": "モデル",
|
||||
"ca00a39fcea70dc6": "起動中...",
|
||||
"ca1d1059408b3837": "異常ターン: {0}",
|
||||
"ce46f23cea3bf3c5": "有効にすると、Cursor は公式サービスへ直接接続します。オンにしないでください",
|
||||
"d0325067fed88e5a": "キャッシュヒット率 {0}",
|
||||
"d08fd4224abcd69d": "切替に失敗しました",
|
||||
"d1bde4a4e057b2c7": "[MainLayout] 作者情報の読み込みに失敗しました",
|
||||
"d20ab96566d33f25": "{0} の表示名は必須です",
|
||||
"d2243e1d44b2a94e": "モデル設定を編集",
|
||||
"d3209b935ae86797": "モデル設定が存在しないため削除できません",
|
||||
"d373809ab86ba93b": "コピー",
|
||||
"d3b1da3088ddd334": "モデルテストに失敗しました",
|
||||
"d7da2aabd35772ec": "例: 200000(空欄でデフォルト値)",
|
||||
"da590a8fe3ce4de0": "選択してください",
|
||||
"daede9881787abe7": "メモ",
|
||||
"dbee6e7139243362": "{0} のベース URL は必須です",
|
||||
"dc82c5e8fb2ab777": "バージョン: v{0}",
|
||||
"de8184da1ef88d03": "設定済み",
|
||||
"e14c41ef2b7253c9": "総リクエスト Token: {0}",
|
||||
"e406825e0a72d2c2": "ローカル設定",
|
||||
"e552c2accdbf5178": "モデルを追加",
|
||||
"e6faccfddce722e8": "キャッシュ読込 Token: {0}",
|
||||
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
|
||||
"ec3b17a75db49e24": "{0} t/s | 初回 Token {1}",
|
||||
"ec99e5c45d648fd6": "アップデートに失敗しました",
|
||||
"f1e0fc261d42fe29": "モデル一覧にホバーしたときに表示されるメモです。",
|
||||
"f363622480699c52": "推論強度は reasoning_effort をサポートする一部のモデルでのみ有効です。すべてのモデルが対応しているわけではありません。値が高いほど安定しやすい反面、遅くなることがあります。",
|
||||
"f3a76d896853c1df": "ミス",
|
||||
"f474a4108aba4c4c": "サービスを停止",
|
||||
"f56c6c82203b33f6": "お知らせ",
|
||||
"f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります",
|
||||
"fac2a67ad87807c4": "OK",
|
||||
"fb7a4c81729ed0ca": "停止中...",
|
||||
"fec45092945f8790": "ユーザーガイド"
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"02216368edc68816": "无更新说明",
|
||||
"02bc2e95bf49e587": "当前还没有配置任何",
|
||||
"03b11112dc970014": "接口地址",
|
||||
"045261cc748d4300": "Anthropic 思考阶段允许消耗的预算 Token 数。留空时使用默认值。",
|
||||
"047ec6b71d0cec08": "控制白名单主链路请求走本地服务,还是回到原始 Cursor 上游地址",
|
||||
"04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数",
|
||||
"051836569928a9f9": "编辑",
|
||||
"054d763265603305": "例如:65536(留空用默认值)",
|
||||
"05c8a9238c702efa": "直连 Cursor 模式",
|
||||
"0647728439b5da2e": "可配置运行模式和模型渠道;运行日志位于",
|
||||
"092b520558eff5f2": "未测试",
|
||||
"0b0e7478e41fe677": "{0} 的悬停提示不能为空",
|
||||
"0c3b4cf7aa259edb": "操作失败",
|
||||
"0dde813d719dbd01": "打开主页失败",
|
||||
"0e40a09ab4e664ab": "模型渠道重复,请检查 url、modelID、apiKey、displayName 组合",
|
||||
"1117a2f86030d03b": "缓存读写已计入 Prompt 侧统计。",
|
||||
"11afd2a534395b18": "有效",
|
||||
"124be3f86f197802": "Token 消耗",
|
||||
"13b61c5f697b6700": "缓存命中率",
|
||||
"15d124b200ddabed": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
||||
"18b7312022cd1840": "启动服务",
|
||||
"1af38868896cf53d": "运行模式仅支持 local 或 upstream",
|
||||
"1b7d24b212e52c54": "{0} 的推理强度仅支持 low、medium、high、xhigh",
|
||||
"1bc77f5ab979f4c1": "新增模型配置",
|
||||
"1e238093b79b3165": "留空时默认 65536",
|
||||
"24343a2096988d42": "打开失败",
|
||||
"253d4a3428c648fb": "缓存写入:{0}",
|
||||
"258e4620e2108793": "留空时默认 4096",
|
||||
"26a3855aed1d8d17": "服务未启动",
|
||||
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
|
||||
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
|
||||
"2caeaec539e78898": "思考预算 Token",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2d706f7981b45a7b": "本地配置已保存",
|
||||
"2f9daa828907b93f": "删除",
|
||||
"30bb57a50caedc38": "例如:用于日常代码补全与问答",
|
||||
"32b3c9a50003f77a": "输出 Token 为估算值",
|
||||
"33d2d273e2bd5f88": "打开使用教程失败",
|
||||
"3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。",
|
||||
"35076178fe79a210": "配置已变更,请重新测试",
|
||||
"36c149a9b3e8dca0": "模型。",
|
||||
"37d23612f78a2e63": "立即重启更新",
|
||||
"392d0dceb45998d3": "极高",
|
||||
"393df9bb13ea4900": "命中",
|
||||
"3af7e5489e61ea51": "刷新中",
|
||||
"3bf8512aa520ed21": "本地服务模式",
|
||||
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
|
||||
"3d13868593ae4eeb": "界面语言",
|
||||
"3ea83f9f55062582": "发布时间:{0}",
|
||||
"3edda85621fd03b2": "个模型适配器",
|
||||
"42aa8e01e98c0d8c": "总耗时",
|
||||
"468adaa418ee1475": "例如:https://api.openai.com/v1",
|
||||
"4923eeb7bd75cccd": "{0} 的模型标识不能为空",
|
||||
"497c85690c4cc0fc": "暂无数据",
|
||||
"4b8d11bf235e9213": "当前命中率:{0}",
|
||||
"4d8c1c5b42830791": "未知",
|
||||
"51194c3ad014fb29": "需重测",
|
||||
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||
"56627c94a9decee6": "最大输出 Token",
|
||||
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||
"5d1687a4a41883fd": "停止中...",
|
||||
"6106f0a12583a334": "刷新失败",
|
||||
"62873083fcaed27d": "会话统计",
|
||||
"6309a3bb5ba4c714": "保存失败",
|
||||
"636e3deffc1e960a": "模型 {0}",
|
||||
"63d90d977348ab1f": "复制",
|
||||
"64d2730f2ae37997": "原始返回",
|
||||
"65cc5fd2e6ce6e75": "后端已启动,代理未启动",
|
||||
"66af574b8948fe83": "{0} 的访问密钥不能为空",
|
||||
"675109292da4eb36": "尚未测试",
|
||||
"699fe7ade5407687": "直连模式",
|
||||
"6a7b96f399e58138": "例如:sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "测试",
|
||||
"6ae23d6d7cb18592": "服务错误",
|
||||
"6e584e3d5ce64aa0": "保存配置",
|
||||
"737225e2904673fc": "输出推算:{0}",
|
||||
"7520bd50a5ee5471": "停止测试 {0}/{1}",
|
||||
"753d8bb0da9913ce": "复制失败",
|
||||
"77c9e582e85583af": "测试失败",
|
||||
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
|
||||
"7b6187c41e88b70c": "测试中...",
|
||||
"7bf8e2c07e084d09": "模型编辑",
|
||||
"7e9e334aeb0bdc07": "服务操作失败",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
"81123c56d5d880d0": "访问密钥",
|
||||
"86df7ec743047234": "服务运行中",
|
||||
"87ed126f7bd1121e": "运行模式",
|
||||
"899add6275682210": "留空时默认 200000",
|
||||
"8c0d84831a3c3d5b": "当前为本地服务模式",
|
||||
"8c1935935600e336": "模型测试",
|
||||
"8cbcf741e727dbf7": "模型配置",
|
||||
"8d1de152be6360ce": "有效占比:{0}",
|
||||
"8e2dc7b0d2e8f6f8": "例如:OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "已复制",
|
||||
"8faa670b512b6b9b": "打开模型配置",
|
||||
"917b1c1f18d0276b": "保存中...",
|
||||
"9196835e388d2550": "测试全部",
|
||||
"91cba5c107a51892": "/ 异常",
|
||||
"92059fe6cd713db4": "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
|
||||
"93e08803675e378b": "模型标识",
|
||||
"942ff2d88baca0c6": "检查更新中...",
|
||||
"986678eccf56dc28": "服务状态更新中,请稍后再试",
|
||||
"991e374fce0f4492": "Cursor助手",
|
||||
"9970736b36ff2b68": "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
|
||||
"9b17fa889b307f7f": "有效轮次:{0}",
|
||||
"9c38b6e9bf94abec": "已切换到直连 Cursor 模式",
|
||||
"9cd4ac17428b86e4": "缓存命中率 = 缓存读取 Token / Prompt Token",
|
||||
"9d2ca261281a158a": "稍后",
|
||||
"9dc0825fba5422e4": "加载中...",
|
||||
"a026f37e613cf48b": "输出 Token",
|
||||
"a1a038dfa16c3ede": "当前已是最新版本(v{0})。",
|
||||
"a3030bf8f16dc63c": "保存",
|
||||
"a325d25c69e7256d": "模型配置不存在,无法复制",
|
||||
"a4dd8bb7e8b6eb31": "显示访问密钥",
|
||||
"a55a88237df85d98": "当前为直连模式",
|
||||
"a567bdaa11367f26": "中",
|
||||
"a5f1bd344c92e195": "调用该模型服务需要使用的访问密钥。",
|
||||
"a693d69af48bfe48": "保存并测试",
|
||||
"a98585871c5313ff": "显示名称",
|
||||
"aa9e366f68d3d097": "低",
|
||||
"ac217e4d1ca410f1": "发现新版本",
|
||||
"ad79540418be700a": "打开设置目录,或单独管理模型配置",
|
||||
"ae5a738238463a92": "隐藏访问密钥",
|
||||
"aed55419ce62f08e": "切换中...",
|
||||
"b1c27820fec23edb": "高",
|
||||
"b42049dcf8a05ef7": "已切换到本地服务模式",
|
||||
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
|
||||
"b765005f69fa971f": "例如:gpt-4.1",
|
||||
"b90a8ac9c488ce46": "选择语言",
|
||||
"ba40014ff496f64e": "类型",
|
||||
"bb074b86a98f6911": "上下文窗口",
|
||||
"bbacfde55a92869f": "命中率越高,说明重复上下文复用得越多。",
|
||||
"bc87a4121a0873b3": "刷新统计",
|
||||
"bd4464ea88d3f24a": "总轮次:{0}",
|
||||
"bef280f9eb392495": "对话轮次",
|
||||
"c228558cf257fc49": "删除失败",
|
||||
"c3e9c3c60020b8b7": "选择模式",
|
||||
"c69f5bce63b9f14c": "设置文件夹",
|
||||
"c6f743953145f40b": "例如:4096(留空用默认值)",
|
||||
"c8c14507b2d37395": "推理强度",
|
||||
"c98e118e0a43f078": "模型",
|
||||
"ca00a39fcea70dc6": "启动中...",
|
||||
"ca1d1059408b3837": "异常轮次:{0}",
|
||||
"ce46f23cea3bf3c5": "开启后,Cursor将直接接通官方,请勿开启",
|
||||
"d0325067fed88e5a": "缓存命中率 {0}",
|
||||
"d08fd4224abcd69d": "切换失败",
|
||||
"d1bde4a4e057b2c7": "[MainLayout] 加载作者信息失败",
|
||||
"d20ab96566d33f25": "{0} 的显示名称不能为空",
|
||||
"d2243e1d44b2a94e": "编辑模型配置",
|
||||
"d3209b935ae86797": "模型配置不存在,无法删除",
|
||||
"d373809ab86ba93b": "拷贝",
|
||||
"d3b1da3088ddd334": "模型测试失败",
|
||||
"d7da2aabd35772ec": "例如:200000(留空用默认值)",
|
||||
"da590a8fe3ce4de0": "请选择",
|
||||
"daede9881787abe7": "备注",
|
||||
"dbee6e7139243362": "{0} 的接口地址不能为空",
|
||||
"dc82c5e8fb2ab777": "版本:v{0}",
|
||||
"de8184da1ef88d03": "已配置",
|
||||
"e14c41ef2b7253c9": "总请求:{0}",
|
||||
"e406825e0a72d2c2": "本地配置",
|
||||
"e552c2accdbf5178": "新增模型",
|
||||
"e6faccfddce722e8": "缓存读取:{0}",
|
||||
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
|
||||
"ec3b17a75db49e24": "{0} t/s | 首字 {1}",
|
||||
"ec99e5c45d648fd6": "更新失败",
|
||||
"f1e0fc261d42fe29": "模型列表 hover 时显示的备注说明。",
|
||||
"f363622480699c52": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
|
||||
"f3a76d896853c1df": "未命中",
|
||||
"f474a4108aba4c4c": "关闭服务",
|
||||
"f56c6c82203b33f6": "提示",
|
||||
"f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数",
|
||||
"fac2a67ad87807c4": "确定",
|
||||
"fb7a4c81729ed0ca": "关闭中...",
|
||||
"fec45092945f8790": "使用教程"
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_OPTIONS,
|
||||
LOCALE_STORAGE_KEY,
|
||||
LOCALE_STORAGE_SOURCE_KEY,
|
||||
SOURCE_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "@/i18n/config";
|
||||
import zhCNMessages from "@/i18n/locales/zh-CN.json";
|
||||
import enUSMessages from "@/i18n/locales/en-US.json";
|
||||
import jaJPMessages from "@/i18n/locales/ja-JP.json";
|
||||
|
||||
const localeMessages = {
|
||||
"zh-CN": zhCNMessages,
|
||||
"en-US": enUSMessages,
|
||||
"ja-JP": jaJPMessages,
|
||||
};
|
||||
|
||||
const languageLocaleMap = {
|
||||
zh: "zh-CN",
|
||||
en: "en-US",
|
||||
ja: "ja-JP",
|
||||
};
|
||||
|
||||
function isSupportedLocale(locale) {
|
||||
return SUPPORTED_LOCALES.includes(locale);
|
||||
}
|
||||
|
||||
function matchSupportedLocale(locale) {
|
||||
const normalized = String(locale || "").trim().replace(/_/g, "-");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lowered = normalized.toLowerCase();
|
||||
const exactMatch = SUPPORTED_LOCALES.find((supportedLocale) => supportedLocale.toLowerCase() === lowered);
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
const primaryLanguage = lowered.split("-")[0];
|
||||
return languageLocaleMap[primaryLanguage] || "";
|
||||
}
|
||||
|
||||
function getSystemLocaleCandidates() {
|
||||
const candidates = [];
|
||||
|
||||
if (typeof navigator !== "undefined") {
|
||||
if (Array.isArray(navigator.languages)) {
|
||||
candidates.push(...navigator.languages);
|
||||
}
|
||||
candidates.push(navigator.language);
|
||||
}
|
||||
|
||||
if (typeof Intl !== "undefined" && typeof Intl.DateTimeFormat === "function") {
|
||||
candidates.push(Intl.DateTimeFormat().resolvedOptions()?.locale);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolveSystemLocale() {
|
||||
for (const candidate of getSystemLocaleCandidates()) {
|
||||
const matchedLocale = matchSupportedLocale(candidate);
|
||||
if (matchedLocale) {
|
||||
return matchedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
function resolveInitialLocale() {
|
||||
if (typeof window === "undefined" || typeof window.localStorage === "undefined") {
|
||||
return resolveSystemLocale();
|
||||
}
|
||||
|
||||
const storedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
const storedSource = window.localStorage.getItem(LOCALE_STORAGE_SOURCE_KEY);
|
||||
if (storedSource === "manual") {
|
||||
return matchSupportedLocale(storedLocale) || resolveSystemLocale();
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(LOCALE_STORAGE_KEY);
|
||||
window.localStorage.removeItem(LOCALE_STORAGE_SOURCE_KEY);
|
||||
return resolveSystemLocale();
|
||||
}
|
||||
|
||||
function applyLocaleToDocument(locale) {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
}
|
||||
|
||||
function persistManualLocale(locale) {
|
||||
if (typeof window === "undefined" || typeof window.localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
window.localStorage.setItem(LOCALE_STORAGE_SOURCE_KEY, "manual");
|
||||
}
|
||||
|
||||
function resolveMessage(id, fallback) {
|
||||
const activeMessages = localeMessages[currentLocale.value] || {};
|
||||
const sourceMessages = localeMessages[SOURCE_LOCALE] || {};
|
||||
return activeMessages[id] || sourceMessages[id] || fallback || "";
|
||||
}
|
||||
|
||||
function interpolateMessage(template, args = []) {
|
||||
return template.replace(/\{(\d+)\}/g, (_match, index) => {
|
||||
const value = args[Number(index)];
|
||||
return value == null ? "" : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
class LocalizedText extends String {
|
||||
constructor(id, fallback, args = null) {
|
||||
super(fallback);
|
||||
this.id = id;
|
||||
this.fallback = fallback;
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
toString() {
|
||||
const text = resolveMessage(this.id, this.fallback);
|
||||
return Array.isArray(this.args) ? interpolateMessage(text, this.args) : text;
|
||||
}
|
||||
|
||||
valueOf() {
|
||||
return this.toString();
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.toString();
|
||||
}
|
||||
|
||||
[Symbol.toPrimitive]() {
|
||||
return this.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const currentLocale = ref(resolveInitialLocale());
|
||||
applyLocaleToDocument(currentLocale.value);
|
||||
|
||||
const localizedCache = new Map();
|
||||
|
||||
export function getLocale() {
|
||||
return currentLocale.value;
|
||||
}
|
||||
|
||||
export function setLocale(locale) {
|
||||
const nextLocale = matchSupportedLocale(locale) || DEFAULT_LOCALE;
|
||||
currentLocale.value = nextLocale;
|
||||
persistManualLocale(nextLocale);
|
||||
applyLocaleToDocument(nextLocale);
|
||||
return nextLocale;
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
return {
|
||||
locale: currentLocale,
|
||||
localeOptions: LOCALE_OPTIONS,
|
||||
currentLocale: computed(() => currentLocale.value),
|
||||
setLocale,
|
||||
};
|
||||
}
|
||||
|
||||
export function localized(id, fallback) {
|
||||
const cacheKey = `${id}:${fallback}`;
|
||||
if (!localizedCache.has(cacheKey)) {
|
||||
localizedCache.set(cacheKey, new LocalizedText(id, fallback));
|
||||
}
|
||||
return localizedCache.get(cacheKey);
|
||||
}
|
||||
|
||||
export function localizedTemplate(id, fallback, args = []) {
|
||||
return new LocalizedText(id, fallback, args);
|
||||
}
|
||||
|
||||
export function installI18nRuntime(app) {
|
||||
app.config.globalProperties.$ls = localized;
|
||||
app.config.globalProperties.$lt = localizedTemplate;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
<script setup>
|
||||
import { Browser, Window } from "@wailsio/runtime";
|
||||
import LocaleSelect from "@/components/LocaleSelect.vue";
|
||||
import { useMessage } from "@/composables/useMessage";
|
||||
import { showModal } from "@/composables/useModal";
|
||||
import {
|
||||
getFooterAuthorInfo,
|
||||
openFooterAuthorHome,
|
||||
} from "@/services/clientApi";
|
||||
import {
|
||||
appState,
|
||||
checkForAppUpdates,
|
||||
syncServiceState,
|
||||
updateViewState,
|
||||
} from "@/state/appState";
|
||||
import { isWindows } from "@/utils/isWindows";
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import Logo from "@/assets/logo.png";
|
||||
|
||||
const route = useRoute();
|
||||
const message = useMessage();
|
||||
const showIcon = computed(() => route.meta.showIcon !== false);
|
||||
const title = computed(() => route.meta.title ?? "Cursor助手|永久免费|自定义API");
|
||||
const directlyClose = computed(() => route.meta.directlyClose === true);
|
||||
const showFooter = computed(() => route.path === "/");
|
||||
const footerAuthorInfo = ref(null);
|
||||
const usageDocsURL = "https://docs.leokun.cn";
|
||||
let proxyStateTimer = null;
|
||||
const proxyStatePollIntervalMs = 10000;
|
||||
const netProxyEndpoint = computed(
|
||||
() => appState.netProxyHttps || appState.netProxyHttp || "",
|
||||
);
|
||||
const proxyBadgeText = computed(() => {
|
||||
if (appState.netProxyUsingSystem) {
|
||||
return "已识别系统代理";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const proxyBadgeTitle = computed(() => {
|
||||
if (appState.netProxyUsingSystem) {
|
||||
return netProxyEndpoint.value
|
||||
? `当前出站请求使用系统代理:${netProxyEndpoint.value}`
|
||||
: "当前出站请求使用系统代理";
|
||||
}
|
||||
if (appState.netProxyUsingEnv) {
|
||||
return netProxyEndpoint.value
|
||||
? `当前出站请求使用环境变量代理:${netProxyEndpoint.value}`
|
||||
: "当前出站请求使用环境变量代理";
|
||||
}
|
||||
if (appState.netProxyPacIgnored) {
|
||||
return "检测到系统 PAC/自动代理,当前版本按直连处理";
|
||||
}
|
||||
return "当前出站请求未使用系统代理";
|
||||
});
|
||||
|
||||
async function minimizeWindow() {
|
||||
await Window.Minimise();
|
||||
}
|
||||
|
||||
async function closeWindow() {
|
||||
if (directlyClose.value) {
|
||||
await Window.Close();
|
||||
return;
|
||||
}
|
||||
// const confirmed = await showModal({
|
||||
// title: "确认关闭",
|
||||
// content: "程序将会最小化到托盘,彻底关闭请在托盘退出,关闭后无法使用Cursor",
|
||||
// });
|
||||
// if (!confirmed) {
|
||||
// return;
|
||||
// }
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await Window.Hide();
|
||||
}
|
||||
|
||||
async function handleCheckForUpdates() {
|
||||
if (updateViewState.footerBusy || updateViewState.footerDownloading) {
|
||||
return;
|
||||
}
|
||||
const loadingMessageID = message.loading("检查更新中...");
|
||||
try {
|
||||
await checkForAppUpdates();
|
||||
} finally {
|
||||
if (loadingMessageID) {
|
||||
message.remove(loadingMessageID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFooterAuthorInfo() {
|
||||
try {
|
||||
footerAuthorInfo.value = await getFooterAuthorInfo();
|
||||
} catch (error) {
|
||||
console.error("[MainLayout] 加载作者信息失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function showActionError(title, error) {
|
||||
await showModal({
|
||||
title,
|
||||
content: String(error || "操作失败").trim() || "操作失败",
|
||||
confirmText: "确定",
|
||||
showCancel: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOpenAuthorHome() {
|
||||
if (!footerAuthorInfo.value) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await showModal({
|
||||
title: footerAuthorInfo.value.dialogTitle,
|
||||
content: footerAuthorInfo.value.dialogContent,
|
||||
confirmText: footerAuthorInfo.value.dialogConfirmText,
|
||||
cancelText: footerAuthorInfo.value.dialogCancelText,
|
||||
showCancel: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openFooterAuthorHome();
|
||||
} catch (error) {
|
||||
await showActionError("打开主页失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenUsageDocs() {
|
||||
try {
|
||||
await Browser.OpenURL(usageDocsURL);
|
||||
} catch (error) {
|
||||
await showActionError("打开使用教程失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadFooterAuthorInfo();
|
||||
proxyStateTimer = window.setInterval(() => {
|
||||
if (showFooter.value) {
|
||||
void syncServiceState().catch(() => {});
|
||||
}
|
||||
}, proxyStatePollIntervalMs);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (proxyStateTimer) {
|
||||
window.clearInterval(proxyStateTimer);
|
||||
proxyStateTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen w-screen overflow-hidden flex-col">
|
||||
<div
|
||||
class="fixed top-0 w-screen h-[40px] z-9999 w-full"
|
||||
style="--wails-draggable: drag"
|
||||
></div>
|
||||
|
||||
<header
|
||||
class="flex h-[40px] center-row px-[20px] w-full min-h-0 shrink-0 justify-between relative"
|
||||
style="--wails-draggable: drag"
|
||||
:class="{ '!justify-center': !isWindows }"
|
||||
>
|
||||
<div class="center-row gap-2" style="font-family: var(--font-num);">
|
||||
<img v-if="showIcon" :src="Logo" class="w-[18px] h-[18px]" />
|
||||
<div>{{ title }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isWindows"
|
||||
class="absolute right-[10px] top-[8px] z-99999 center-row gap-[1px]"
|
||||
>
|
||||
<button
|
||||
class="text-[20px] center-row justify-center w-[30px] h-[23px] rounded-[4px] text-[#777] hover:bg-[#333] hover:text-[#ddd] cursor-pointer"
|
||||
@click="minimizeWindow"
|
||||
>
|
||||
<span class="icon-[ic--round-minus]"></span>
|
||||
</button>
|
||||
<button
|
||||
class="text-[20px] center-row justify-center w-[30px] h-[23px] rounded-[4px] text-[#777] hover:bg-[#333] hover:text-[#ddd] cursor-pointer"
|
||||
@click="closeWindow"
|
||||
>
|
||||
<span class="icon-[ic--round-close]"></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 min-h-0 overflow-hidden flex flex-col w-full">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<footer
|
||||
v-if="showFooter"
|
||||
class="flex !pr-1 h-[30px] shrink-0 items-center gap-[8px] border-t border-[#242424] px-[14px] text-[12px] text-[#8f8f8f]"
|
||||
>
|
||||
<div
|
||||
v-if="proxyBadgeText"
|
||||
class="center-row border-none gap-[2px] border-none px-[0px] py-[3px] leading-none "
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="icon-[mdi--wifi] text-[15px]"></span>
|
||||
<span class="truncate">{{ proxyBadgeText }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!updateViewState.footerDownloading"
|
||||
type="button"
|
||||
class="center-row shrink-0 gap-[6px] cursor-pointer rounded-[6px] px-[6px] py-[3px] transition-colors duration-150 hover:bg-[#1f1f1f] hover:text-[#e5e5e5]"
|
||||
:disabled="updateViewState.footerBusy"
|
||||
@click="handleCheckForUpdates"
|
||||
>
|
||||
<span>{{ updateViewState.footerVersionLabel }}</span>
|
||||
<span>检查更新</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="center-row shrink-0 gap-[2px] cursor-pointer rounded-[6px] px-[6px] py-[3px] transition-colors duration-150 hover:bg-[#1f1f1f] hover:text-[#e5e5e5]"
|
||||
@click="handleOpenUsageDocs"
|
||||
>
|
||||
<span class="icon-[mdi--file-document-outline] text-[15px]"></span>
|
||||
<span>使用教程</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="footerAuthorInfo"
|
||||
type="button"
|
||||
class="center-row shrink-0 gap-[6px] cursor-pointer rounded-[6px] px-[6px] py-[3px] transition-colors duration-150 hover:bg-[#1f1f1f] hover:text-[#e5e5e5]"
|
||||
@click="handleOpenAuthorHome"
|
||||
>
|
||||
<span class="icon-[ant-design--bilibili-outlined] text-[14px]"></span>
|
||||
<span>{{ footerAuthorInfo.buttonText }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="updateViewState.footerDownloading"
|
||||
class="flex min-w-0 flex-1 items-center gap-[10px]"
|
||||
>
|
||||
<span class="shrink-0">{{ updateViewState.footerVersionLabel }}</span>
|
||||
<div class="center-row min-w-0 gap-[8px]">
|
||||
<div
|
||||
class="h-[6px] w-[120px] overflow-hidden rounded-full bg-[#1f1f1f]"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-[#10AD5D] to-[#29c776]"
|
||||
:style="updateViewState.footerProgressStyle"
|
||||
></div>
|
||||
</div>
|
||||
<span class="shrink-0 text-[#d4d4d4]">{{
|
||||
updateViewState.footerProgressText
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-[8px]">
|
||||
<LocaleSelect
|
||||
:border="false"
|
||||
aria-label="界面语言"
|
||||
wrapper-class="w-auto"
|
||||
button-class="h-[24px] bg-transparent px-1.5 text-[12px] !text-[#8f8f8f] !hover:text-[#e5e5e5]"
|
||||
menu-class="text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createApp } from "vue";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import App from "@/App.vue";
|
||||
import { installI18nRuntime } from "@/i18n/runtime";
|
||||
import router from "@/router";
|
||||
import { bootstrapAppState } from "@/state/appState";
|
||||
import "@/style/global.css";
|
||||
import "@/style/tailwind.css";
|
||||
|
||||
if (typeof window !== "undefined" && typeof window.ResizeObserver === "undefined") {
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
}
|
||||
|
||||
function updateFlexGapSupportClass() {
|
||||
if (typeof document === "undefined" || !document.body) {
|
||||
return;
|
||||
}
|
||||
const flex = document.createElement("div");
|
||||
flex.style.position = "absolute";
|
||||
flex.style.visibility = "hidden";
|
||||
flex.style.display = "flex";
|
||||
flex.style.flexDirection = "column";
|
||||
flex.style.rowGap = "1px";
|
||||
flex.appendChild(document.createElement("div"));
|
||||
flex.appendChild(document.createElement("div"));
|
||||
document.body.appendChild(flex);
|
||||
document.documentElement.classList.toggle("no-flex-gap", flex.scrollHeight !== 1);
|
||||
flex.parentNode?.removeChild(flex);
|
||||
}
|
||||
|
||||
updateFlexGapSupportClass();
|
||||
|
||||
const app = createApp(App);
|
||||
installI18nRuntime(app);
|
||||
app.use(router);
|
||||
app.mount("#root");
|
||||
|
||||
bootstrapAppState().catch(() => {
|
||||
// 启动阶段失败时保持界面可用,错误在业务交互中再提示。
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import Home from "@/views/Home.vue";
|
||||
import ModelConfig from "@/views/ModelConfig.vue";
|
||||
import ModelEditor from "@/views/ModelEditor.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
component: Home,
|
||||
meta: { showIcon: true, title: "Cursor助手|永久免费|自定义API", directlyClose: false },
|
||||
},
|
||||
{
|
||||
path: "/model-config",
|
||||
component: ModelConfig,
|
||||
meta: { showIcon: false, title: "模型配置", directlyClose: true },
|
||||
},
|
||||
{
|
||||
path: "/model-editor",
|
||||
component: ModelEditor,
|
||||
meta: { showIcon: false, title: "模型编辑", directlyClose: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
GetState,
|
||||
LoadUserConfig,
|
||||
SaveUserConfig,
|
||||
StartProxy,
|
||||
StopProxy,
|
||||
} from "@bindings/cursor/internal/bridge/proxyservice.js";
|
||||
import {
|
||||
GetAdRuntime,
|
||||
OpenExternalURL as OpenAdExternalURL,
|
||||
} from "@bindings/cursor/internal/bridge/adservice.js";
|
||||
import { GetHomeMetricsSummary } from "@bindings/cursor/internal/bridge/metricsservice.js";
|
||||
import {
|
||||
CheckForUpdates,
|
||||
GetAppVersion,
|
||||
GetFooterAuthorInfo,
|
||||
InstallReadyUpdate,
|
||||
GetModelEditorContext,
|
||||
OpenConfigWindow,
|
||||
OpenFooterAuthorHome,
|
||||
OpenHistoryWindow,
|
||||
OpenModelConfigWindow,
|
||||
OpenModelEditorWindow,
|
||||
} from "@bindings/cursor/internal/bridge/windowservice.js";
|
||||
import { Call } from "@wailsio/runtime";
|
||||
|
||||
const API_LOG_PREFIX = "[clientApi]";
|
||||
const PROXY_SERVICE_NAME = "cursor/internal/bridge.ProxyService";
|
||||
|
||||
function logSuccess(name, payload, result) {
|
||||
console.log(`${API_LOG_PREFIX} ${name} response`, {
|
||||
payload,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
function logError(name, payload, error) {
|
||||
console.error(`${API_LOG_PREFIX} ${name} error`, {
|
||||
payload,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
function withApiLogging(name, payload, runner) {
|
||||
return Promise.resolve()
|
||||
.then(() => runner())
|
||||
.then((result) => {
|
||||
logSuccess(name, payload, result);
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(name, payload, error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
export function loadUserConfig() {
|
||||
return withApiLogging("LoadUserConfig", undefined, () => LoadUserConfig());
|
||||
}
|
||||
|
||||
export function saveUserConfig(payload) {
|
||||
return withApiLogging("SaveUserConfig", payload, () => SaveUserConfig(payload));
|
||||
}
|
||||
|
||||
export function getProxyState() {
|
||||
return withApiLogging("GetState", undefined, () => GetState());
|
||||
}
|
||||
|
||||
export function getHomeMetricsSummary() {
|
||||
return withApiLogging("GetHomeMetricsSummary", undefined, () => GetHomeMetricsSummary());
|
||||
}
|
||||
|
||||
export function getAdRuntime() {
|
||||
return GetAdRuntime();
|
||||
}
|
||||
|
||||
export function openAdExternalURL(url) {
|
||||
return OpenAdExternalURL(url);
|
||||
}
|
||||
|
||||
export function startProxyService() {
|
||||
return withApiLogging("StartProxy", undefined, () => StartProxy());
|
||||
}
|
||||
|
||||
export function stopProxyService() {
|
||||
return withApiLogging("StopProxy", undefined, () => StopProxy());
|
||||
}
|
||||
|
||||
export function openLogsDirectory() {
|
||||
return withApiLogging("OpenHistoryWindow", undefined, () => OpenHistoryWindow());
|
||||
}
|
||||
|
||||
export function openConfigWindow() {
|
||||
return withApiLogging("OpenConfigWindow", undefined, () => OpenConfigWindow());
|
||||
}
|
||||
|
||||
export function getAppVersion() {
|
||||
return withApiLogging("GetAppVersion", undefined, () => GetAppVersion());
|
||||
}
|
||||
|
||||
export function getFooterAuthorInfo() {
|
||||
return withApiLogging("GetFooterAuthorInfo", undefined, () => GetFooterAuthorInfo());
|
||||
}
|
||||
|
||||
export function checkForUpdates() {
|
||||
return withApiLogging("CheckForUpdates", undefined, () => CheckForUpdates());
|
||||
}
|
||||
|
||||
export function installReadyUpdate() {
|
||||
return withApiLogging("InstallReadyUpdate", undefined, () => InstallReadyUpdate());
|
||||
}
|
||||
|
||||
export function openFooterAuthorHome() {
|
||||
return withApiLogging("OpenFooterAuthorHome", undefined, () => OpenFooterAuthorHome());
|
||||
}
|
||||
|
||||
export function openModelConfig() {
|
||||
return withApiLogging("OpenModelConfigWindow", undefined, () => OpenModelConfigWindow());
|
||||
}
|
||||
|
||||
export function openModelEditor(index, adapterJSON) {
|
||||
return withApiLogging("OpenModelEditorWindow", { index, adapterJSON }, () =>
|
||||
OpenModelEditorWindow(index, adapterJSON),
|
||||
);
|
||||
}
|
||||
|
||||
export function getModelEditorContext() {
|
||||
return withApiLogging("GetModelEditorContext", undefined, () => GetModelEditorContext());
|
||||
}
|
||||
|
||||
export function testModelAdapter(adapter) {
|
||||
return Call.ByName(`${PROXY_SERVICE_NAME}.TestModelAdapter`, adapter).then(
|
||||
(result) => {
|
||||
logSuccess("TestModelAdapter", adapter, result);
|
||||
return result;
|
||||
},
|
||||
(error) => {
|
||||
logError("TestModelAdapter", adapter, error);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getModelAdapterTestResults() {
|
||||
return withApiLogging("GetModelAdapterTestResults", undefined, () =>
|
||||
Call.ByName(`${PROXY_SERVICE_NAME}.GetModelAdapterTestResults`),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
@font-face {
|
||||
font-family: "PingFang-Medium";
|
||||
src: url("./fonts/PingFang-Medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background: #191919;
|
||||
font-family: "PingFang-Medium", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: #F7F7F7;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Global native scrollbar style: thin thumb, transparent track */
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(100, 100, 100, 0.8) transparent;
|
||||
}
|
||||
:root{
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* WebKit/Blink (Chrome, Safari, Edge, Opera) */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track,
|
||||
::-webkit-scrollbar-track-piece,
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(100, 100, 100, 0.8);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(120, 120, 120, 0.9);
|
||||
}
|
||||
|
||||
/* macOS overlay scrollbar style */
|
||||
@supports (scrollbar-width: thin) {
|
||||
:root {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent layout shift when scrollbar appears */
|
||||
.overflow-auto,
|
||||
.overflow-scroll {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-1:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-1:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-1 > * + * {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-1 > * + *,
|
||||
.no-flex-gap .center-col.gap-1 > * + * {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-1\.5:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-1\.5:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-1\.5 > * + * {
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-1\.5 > * + *,
|
||||
.no-flex-gap .center-col.gap-1\.5 > * + * {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-2:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-2:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-2 > * + * {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-2 > * + *,
|
||||
.no-flex-gap .center-col.gap-2 > * + * {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-2\.5:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-2\.5:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-2\.5 > * + * {
|
||||
margin-left: 0.625rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-2\.5 > * + *,
|
||||
.no-flex-gap .center-col.gap-2\.5 > * + * {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-3:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-3:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-3 > * + * {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-3 > * + *,
|
||||
.no-flex-gap .center-col.gap-3 > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-4:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-4:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-4 > * + * {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-4 > * + *,
|
||||
.no-flex-gap .center-col.gap-4 > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-\[1px\]:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-\[1px\]:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-\[1px\] > * + * {
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-\[1px\] > * + *,
|
||||
.no-flex-gap .center-col.gap-\[1px\] > * + * {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-\[2px\]:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-\[2px\]:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-\[2px\] > * + * {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-\[2px\] > * + *,
|
||||
.no-flex-gap .center-col.gap-\[2px\] > * + * {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-\[6px\]:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-\[6px\]:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-\[6px\] > * + * {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-\[6px\] > * + *,
|
||||
.no-flex-gap .center-col.gap-\[6px\] > * + * {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-\[8px\]:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-\[8px\]:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-\[8px\] > * + * {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-\[8px\] > * + *,
|
||||
.no-flex-gap .center-col.gap-\[8px\] > * + * {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.gap-\[10px\]:not(.flex-col) > * + *,
|
||||
.no-flex-gap .inline-flex.gap-\[10px\]:not(.flex-col) > * + *,
|
||||
.no-flex-gap .center-row.gap-\[10px\] > * + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.no-flex-gap .flex.flex-col.gap-\[10px\] > * + *,
|
||||
.no-flex-gap .center-col.gap-\[10px\] > * + * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "HFKos";
|
||||
src: url("./fonts/HFKos-R.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--font-num: "HFKos", "PingFang-Medium", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.center-row {
|
||||
@apply flex flex-row items-center;
|
||||
}
|
||||
|
||||
.center-col {
|
||||
@apply flex flex-col items-center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { IsWindows } from "@bindings/cursor/internal/bridge/proxyservice.js";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const isWindows = ref(Boolean(await IsWindows()));
|
||||