diff --git a/.gitignore b/.gitignore
index 4160736..5cab36d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,7 @@ node_modules
.vscode-test/
*.vsix
package-lock.json
-
+.env
# Reference code (not included in build)
ref/
diff --git a/README.md b/README.md
index 5449f62..07ee167 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,9 @@ RT-Thread VSCode扩展是一款专为RT-Thread及RT-Thread Smart (版本>5.2.0)
### 版本说明
+**v0.4.13**
+- 添加aichat界面
+
**v0.4.12**
- 加入RT-Thread ELF文件符号分析的功能;
- 调整界面,web页面颜色样式可以跟随vscode的浅色/深色自动进行切换;
diff --git a/package.json b/package.json
index 9e72e05..1513f44 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,10 @@
"command": "extension.showCreateProject",
"title": "Create RT-Thread Project"
},
+ {
+ "command": "extension.showChat",
+ "title": "RT-Thread: AI Chat"
+ },
{
"command": "extension.buildProject",
"title": "Build RT-Thread",
@@ -177,6 +181,31 @@
"type": "object",
"title": "RT-Thread Smart",
"properties": {
+ "smart.aiChatBaseUrl": {
+ "type": "string",
+ "default": "https://ai.rt-thread.org",
+ "description": "AI Chat 后端地址(用于 RT-Thread 登录 code 交换 sid、以及 VSCode Chat 请求 /api/*)。",
+ "markdownDescription": "AI Chat 后端地址(用于 RT-Thread 登录 code 交换 sid、以及 VSCode Chat 请求 `/api/*`)。\n\n**示例:**\n- `https://ai.rt-thread.org`\n- `http://127.0.0.1:3000`(本地启动 AiChat)",
+ "scope": "resource",
+ "order": 2
+ },
+ "smart.aiChatRequestTimeoutMs": {
+ "type": "number",
+ "default": 30000,
+ "minimum": 1000,
+ "description": "AI Chat 请求超时(毫秒)。网络不通时可避免长时间卡住。",
+ "markdownDescription": "AI Chat 请求超时(毫秒)。网络不通时可避免长时间卡住。\n\n**示例:**\n- `10000`: 10 秒\n- `30000`: 30 秒(默认)",
+ "scope": "resource",
+ "order": 3
+ },
+ "smart.authOpenInVscode": {
+ "type": "boolean",
+ "default": false,
+ "description": "RT-Thread 登录是否在 VSCode 内置浏览器中打开(登录完成后可自动关闭)。",
+ "markdownDescription": "RT-Thread 登录是否在 VSCode 内置浏览器中打开(登录完成后可自动关闭)。\n\n开启后会优先使用 VSCode 的 Simple Browser;若不可用则回退到系统默认浏览器。",
+ "scope": "resource",
+ "order": 4
+ },
"smart.menuCommands": {
"type": "array",
"items": {
@@ -186,7 +215,7 @@
"description": "自定义构建菜单命令列表,使用 Ctrl+Shift+M 快捷键打开菜单。",
"markdownDescription": "自定义构建菜单命令列表,使用 `Ctrl+Shift+M` 快捷键打开菜单。\n\n**示例:**\n```json\n[\n \"scons -c\",\n \"scons --dist\",\n \"scons --target=mdk5\"\n]\n```",
"scope": "resource",
- "order": 0
+ "order": 10
},
"smart.parallelBuidNumber": {
"type": "number",
@@ -195,7 +224,7 @@
"description": "并行编译使用的 CPU 核心数(默认为 1)。设置大于 1 的值可以加快编译速度。",
"markdownDescription": "并行编译使用的 CPU 核心数(默认为 1)。\n\n设置大于 1 的值可以加快编译速度,例如:\n- `1`: 单核编译\n- `4`: 使用 4 个核心并行编译\n- `8`: 使用 8 个核心并行编译",
"scope": "resource",
- "order": 1
+ "order": 11
}
}
},
diff --git a/resources/chat-ui/app.css b/resources/chat-ui/app.css
new file mode 100644
index 0000000..af172af
--- /dev/null
+++ b/resources/chat-ui/app.css
@@ -0,0 +1,1511 @@
+:root {
+ --ink: #0b1f26;
+ --ink-soft: #3b5660;
+ --paper: #f1f6f8;
+ --paper-2: #e2edf1;
+ --accent: #1aa6b7;
+ --accent-2: #0f6f81;
+ --accent-3: #6ad1dc;
+ --shadow: rgba(9, 24, 29, 0.18);
+ --glow: rgba(26, 166, 183, 0.35);
+ --font-display: "Bebas Neue", "IBM Plex Sans", "Noto Sans SC", sans-serif;
+ --font-body: "IBM Plex Sans", "Noto Sans SC", sans-serif;
+ --font-mono: "IBM Plex Mono", "Liberation Mono", "Noto Sans SC", monospace;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ font-family: var(--font-body);
+ color: var(--ink);
+ background: radial-gradient(circle at top right, #d7f2f5 0%, transparent 55%),
+ radial-gradient(circle at 20% 20%, #c9e4ea 0%, transparent 45%),
+ linear-gradient(135deg, var(--paper), var(--paper-2));
+ position: relative;
+ overflow-x: hidden;
+}
+
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ background: repeating-linear-gradient(
+ -45deg,
+ rgba(0, 0, 0, 0.03),
+ rgba(0, 0, 0, 0.03) 2px,
+ transparent 2px,
+ transparent 7px
+ ),
+ radial-gradient(circle at 10% 80%, rgba(15, 111, 129, 0.08), transparent 50%),
+ radial-gradient(circle at 85% 10%, rgba(26, 166, 183, 0.08), transparent 55%);
+ pointer-events: none;
+ z-index: -1;
+}
+
+.page {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ padding: 32px;
+ gap: 32px;
+}
+
+.page.page-chat {
+ height: 100vh;
+ height: 100dvh;
+ overflow: hidden;
+}
+
+.page.page-chat .chat-shell {
+ min-height: 0;
+}
+
+.page.page-chat .chat-main,
+.page.page-chat .chat-panel,
+.page.page-chat .conversation-panel {
+ min-height: 0;
+}
+
+.page.page-chat .chat-log,
+.page.page-chat .conversation-list {
+ min-height: 0;
+}
+
+.brand {
+ font-family: var(--font-display);
+ letter-spacing: 2px;
+ text-transform: none;
+ font-size: 22px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: var(--accent);
+}
+
+.brand-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 20px var(--glow);
+}
+
+.hero {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 32px;
+ align-items: start;
+ justify-items: center;
+ align-content: start;
+ flex: 1;
+ width: min(980px, 100%);
+ margin: 0 auto;
+ padding-top: clamp(24px, 8vh, 120px);
+ min-height: calc(100vh - 180px);
+}
+
+.hero-copy h1 {
+ font-family: var(--font-display);
+ font-size: clamp(42px, 6vw, 88px);
+ margin: 0 0 12px;
+ letter-spacing: 1px;
+}
+
+.hero-copy p {
+ margin: 0 0 20px;
+ font-size: 18px;
+ line-height: 1.6;
+ color: var(--ink-soft);
+}
+
+.hero-card {
+ background: rgba(241, 246, 248, 0.92);
+ padding: 32px 36px;
+ border-radius: 24px;
+ border: 1px solid rgba(15, 111, 129, 0.2);
+ box-shadow: 0 18px 45px var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ align-items: center;
+ text-align: center;
+ position: relative;
+ overflow: hidden;
+ animation: float-in 0.7s ease forwards;
+ width: 100%;
+ max-width: 720px;
+}
+
+.hero-card::after {
+ content: "";
+ position: absolute;
+ inset: auto -30% -40% auto;
+ width: 200px;
+ height: 200px;
+ background: radial-gradient(circle, rgba(15, 111, 129, 0.3), transparent 70%);
+ transform: rotate(25deg);
+}
+
+.hero-card h2 {
+ font-family: var(--font-display);
+ margin: 0;
+ font-size: 30px;
+}
+
+.hero-card h1 {
+ font-family: var(--font-display);
+ margin: 0;
+ font-size: clamp(36px, 4vw, 64px);
+ letter-spacing: 1px;
+ line-height: 1.05;
+}
+
+.hero-card p {
+ margin: 0;
+ line-height: 1.6;
+ color: var(--ink-soft);
+ max-width: 520px;
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 14px 22px;
+ border-radius: 999px;
+ background: var(--accent);
+ color: #fff;
+ text-decoration: none;
+ font-weight: 600;
+ border: none;
+ cursor: pointer;
+ font-family: var(--font-body);
+ box-shadow: 0 12px 25px rgba(26, 166, 183, 0.35);
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 16px 30px rgba(26, 166, 183, 0.4);
+}
+
+.note {
+ font-size: 13px;
+ color: var(--ink-soft);
+}
+
+.button.button-small {
+ padding: 8px 14px;
+ font-size: 13px;
+}
+
+.info-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 18px;
+}
+
+.info-card {
+ background: rgba(255, 255, 255, 0.7);
+ border-radius: 18px;
+ padding: 18px;
+ border: 1px solid rgba(15, 111, 129, 0.12);
+ box-shadow: 0 14px 30px rgba(25, 16, 11, 0.12);
+ min-height: 120px;
+ animation: float-in 0.6s ease forwards;
+}
+
+.info-card:nth-child(2) {
+ animation-delay: 0.1s;
+}
+
+.info-card:nth-child(3) {
+ animation-delay: 0.2s;
+}
+
+.info-card h3 {
+ margin: 0 0 8px;
+ font-family: var(--font-display);
+ letter-spacing: 1px;
+}
+
+.chat-shell {
+ display: grid;
+ grid-template-columns: minmax(240px, 320px) 1fr;
+ grid-template-rows: minmax(0, 1fr);
+ gap: 24px;
+ flex: 1;
+ min-height: 0;
+}
+
+.chat-main {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ min-width: 0;
+}
+
+.profile-shell {
+ width: min(980px, 100%);
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.profile-status {
+ border-radius: 12px;
+ padding: 10px 14px;
+ font-size: 14px;
+ margin-top: 12px;
+}
+
+.profile-status.success {
+ background: rgba(26, 166, 183, 0.15);
+ border: 1px solid rgba(26, 166, 183, 0.35);
+ color: var(--accent-2);
+}
+
+.profile-status.error {
+ background: rgba(209, 73, 91, 0.15);
+ border: 1px solid rgba(209, 73, 91, 0.4);
+ color: #8d1f2a;
+}
+
+.profile-status[hidden] {
+ display: none;
+}
+
+.profile-card {
+ background: rgba(255, 255, 255, 0.8);
+ border-radius: 22px;
+ padding: 20px;
+ box-shadow: 0 18px 40px var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.profile-card.profile-compact {
+ padding: 18px;
+ gap: 14px;
+}
+
+.profile-details {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 12px;
+}
+
+.profile-card.profile-compact .profile-item {
+ background: rgba(241, 246, 248, 0.7);
+ padding: 10px 12px;
+ border-radius: 14px;
+ border: 1px solid rgba(15, 111, 129, 0.12);
+}
+
+.profile-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.avatar {
+ width: 54px;
+ height: 54px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--accent-2), var(--accent));
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ font-weight: 600;
+ overflow: hidden;
+}
+
+.avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.profile-name {
+ font-weight: 600;
+}
+
+.profile-meta {
+ font-size: 13px;
+ color: var(--ink-soft);
+}
+
+.profile-item {
+ display: grid;
+ gap: 4px;
+ font-size: 14px;
+}
+
+.profile-item strong {
+ font-size: 12px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.conversation-panel {
+ background: rgba(255, 255, 255, 0.85);
+ border-radius: 22px;
+ padding: 20px;
+ box-shadow: 0 18px 40px var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-height: 420px;
+}
+
+.conversation-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.conversation-header-text {
+ min-width: 0;
+ flex: 1 1 180px;
+}
+
+.conversation-header-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.conversation-header-actions .button {
+ white-space: nowrap;
+}
+
+.button.conversation-toggle {
+ background: rgba(15, 111, 129, 0.12);
+ color: var(--accent-2);
+ box-shadow: none;
+}
+
+.button.conversation-toggle:hover {
+ box-shadow: none;
+ transform: translateY(-1px);
+}
+
+.conversation-header h2 {
+ margin: 0 0 4px;
+ font-family: var(--font-display);
+ letter-spacing: 1px;
+ font-size: 26px;
+}
+
+.conversation-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ overflow-y: auto;
+ padding-right: 6px;
+ flex: 1;
+}
+
+.conversation-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px;
+ border-radius: 16px;
+ background: rgba(241, 246, 248, 0.85);
+ border: 1px solid rgba(15, 111, 129, 0.12);
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.conversation-item.active {
+ border-color: rgba(26, 166, 183, 0.6);
+ box-shadow: 0 12px 24px rgba(26, 166, 183, 0.18);
+}
+
+.conversation-main {
+ flex: 1;
+ display: grid;
+ gap: 4px;
+ text-align: left;
+ background: transparent;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ color: var(--ink);
+ font-family: var(--font-body);
+}
+
+.conversation-title {
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.conversation-meta {
+ font-size: 12px;
+ color: var(--ink-soft);
+}
+
+.conversation-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-left: auto;
+}
+
+.conversation-rename {
+ border: none;
+ background: transparent;
+ color: var(--accent-2);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.conversation-delete {
+ border: none;
+ background: transparent;
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.chat-panel {
+ background: rgba(241, 246, 248, 0.96);
+ border-radius: 26px;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 18px 40px var(--shadow);
+ min-height: 420px;
+ flex: 1;
+}
+
+.chat-log {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ overflow-y: auto;
+ padding-right: 6px;
+}
+
+.chat-suggestions {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 14px;
+}
+
+.chat-suggestions[hidden] {
+ display: none;
+}
+
+.suggestions-label {
+ font-size: 12px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.suggestions-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.suggestion-chip {
+ border: 1px solid rgba(15, 111, 129, 0.2);
+ background: rgba(15, 111, 129, 0.1);
+ color: var(--ink);
+ border-radius: 999px;
+ padding: 8px 14px;
+ font-size: 13px;
+ font-family: var(--font-body);
+ cursor: pointer;
+ transition: transform 0.2s ease, background 0.2s ease;
+}
+
+.suggestion-chip:hover {
+ transform: translateY(-1px);
+ background: rgba(15, 111, 129, 0.18);
+}
+
+.message {
+ --message-bg: #fff;
+ padding: 12px 16px;
+ border-radius: 16px;
+ max-width: 70%;
+ line-height: 1.5;
+ box-shadow: 0 10px 20px rgba(25, 16, 11, 0.08);
+ animation: rise 0.4s ease forwards;
+ display: flex;
+ flex-direction: column;
+}
+
+.message.collapsible .message-content {
+ position: relative;
+}
+
+.message.collapsible.is-collapsed .message-content {
+ max-height: var(--message-collapse-height);
+ overflow: hidden;
+}
+
+.message.collapsible.is-collapsed .message-content::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 54px;
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--message-bg));
+ pointer-events: none;
+}
+
+.message-toggle {
+ align-self: flex-end;
+ margin-top: 10px;
+ padding: 6px 12px;
+ border-radius: 999px;
+ border: 1px solid rgba(15, 111, 129, 0.22);
+ background: rgba(15, 111, 129, 0.08);
+ color: var(--ink-soft);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ font-family: var(--font-body);
+}
+
+.message-toggle:hover {
+ background: rgba(15, 111, 129, 0.14);
+}
+
+.message-content > *:first-child {
+ margin-top: 0;
+}
+
+.message-content > *:last-child {
+ margin-bottom: 0;
+}
+
+.message.streaming .message-content {
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.message-content p {
+ margin: 0 0 8px;
+}
+
+.message-content h1,
+.message-content h2,
+.message-content h3,
+.message-content h4,
+.message-content h5,
+.message-content h6 {
+ margin: 0 0 8px;
+ font-size: 1.05em;
+}
+
+.message-content ul,
+.message-content ol {
+ margin: 0 0 8px 18px;
+ padding: 0;
+}
+
+.message-content li {
+ margin: 4px 0;
+}
+
+.message-content pre {
+ margin: 8px 0 0;
+ padding: 12px;
+ border-radius: 12px;
+ background: rgba(15, 111, 129, 0.12);
+ overflow-x: auto;
+}
+
+.message-content code {
+ padding: 2px 6px;
+ border-radius: 6px;
+ background: rgba(15, 111, 129, 0.12);
+ font-family: var(--font-mono);
+ font-size: 0.92em;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+
+.message-content pre code {
+ padding: 0;
+ background: transparent;
+ white-space: pre;
+ word-break: normal;
+ overflow-wrap: normal;
+}
+
+.message-content blockquote {
+ margin: 8px 0;
+ padding: 4px 12px;
+ border-left: 3px solid rgba(15, 111, 129, 0.4);
+ color: var(--ink-soft);
+}
+
+.message-content a {
+ color: inherit;
+ text-decoration: underline;
+}
+
+.message.user {
+ align-self: flex-end;
+ background: var(--accent);
+ color: #fff;
+ --message-bg: var(--accent);
+}
+
+.message.user .message-toggle {
+ border-color: rgba(255, 255, 255, 0.35);
+ background: rgba(255, 255, 255, 0.18);
+ color: rgba(255, 255, 255, 0.95);
+}
+
+.message.user .message-toggle:hover {
+ background: rgba(255, 255, 255, 0.24);
+}
+
+.message.user .message-content pre {
+ background: rgba(0, 0, 0, 0.18);
+}
+
+.message.user .message-content code {
+ background: rgba(0, 0, 0, 0.18);
+ color: #fff;
+}
+
+.message.bot {
+ align-self: flex-start;
+ background: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.92),
+ rgba(241, 246, 248, 0.95)
+ );
+ border: 1px solid rgba(15, 111, 129, 0.12);
+ --message-bg: rgba(241, 246, 248, 0.95);
+}
+
+.message.system {
+ align-self: center;
+ background: rgba(15, 111, 129, 0.12);
+ border: 1px dashed rgba(15, 111, 129, 0.4);
+ color: var(--ink-soft);
+ font-size: 14px;
+ --message-bg: rgba(15, 111, 129, 0.12);
+}
+
+.message.pending {
+ font-style: italic;
+ animation: pulse 1.4s ease-in-out infinite;
+}
+
+.message.profile-card {
+ align-self: center;
+ max-width: 100%;
+ width: min(560px, 100%);
+ padding: 0;
+ background: transparent;
+ border: none;
+ box-shadow: none;
+}
+
+.profile-card-message {
+ background: #fff;
+ border-radius: 18px;
+ border: 1px solid rgba(15, 111, 129, 0.18);
+ box-shadow: 0 14px 28px rgba(9, 24, 29, 0.12);
+ padding: 16px 18px;
+ display: grid;
+ gap: 10px;
+}
+
+.profile-card-title {
+ font-weight: 700;
+ font-size: 16px;
+}
+
+.profile-card-body {
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--ink-soft);
+}
+
+.profile-card-list {
+ margin: 0;
+ padding-left: 18px;
+ font-size: 14px;
+}
+
+.profile-card-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.profile-card-hint {
+ font-size: 12px;
+ color: var(--ink-soft);
+}
+
+body.modal-open {
+ overflow: hidden;
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 28px;
+ background: rgba(11, 31, 38, 0.55);
+ backdrop-filter: blur(6px);
+}
+
+.modal {
+ width: min(560px, 100%);
+ animation: rise 0.25s ease forwards;
+}
+
+.profile-modal-dismiss {
+ border: none;
+ background: transparent;
+ color: var(--ink-soft);
+ font-weight: 600;
+ cursor: pointer;
+ padding: 0;
+}
+
+.profile-modal-dismiss:hover {
+ text-decoration: underline;
+}
+
+.chat-form {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.chat-input {
+ padding: 12px 16px;
+ border-radius: 999px;
+ border: 1px solid rgba(15, 111, 129, 0.3);
+ background: #fff;
+ font-size: 15px;
+ font-family: var(--font-body);
+}
+
+.chat-button {
+ padding: 12px 18px;
+ border-radius: 999px;
+ border: none;
+ background: var(--accent-2);
+ color: #fff;
+ font-weight: 600;
+ cursor: pointer;
+ transition: transform 0.2s ease;
+}
+
+.chat-button:hover {
+ transform: translateY(-2px);
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.topbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.user-menu {
+ position: relative;
+}
+
+.user-menu-button {
+ border: none;
+ background: transparent;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+}
+
+.user-menu-caret {
+ font-size: 12px;
+ color: var(--ink-soft);
+}
+
+.user-menu-panel {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 10px);
+ min-width: 220px;
+ background: rgba(241, 246, 248, 0.98);
+ border-radius: 16px;
+ border: 1px solid rgba(15, 111, 129, 0.2);
+ box-shadow: 0 18px 40px var(--shadow);
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ z-index: 10;
+}
+
+.user-menu-panel[hidden] {
+ display: none;
+}
+
+.user-menu-header {
+ display: grid;
+ gap: 4px;
+}
+
+.user-menu-name {
+ font-weight: 600;
+}
+
+.user-menu-email {
+ font-size: 13px;
+ color: var(--ink-soft);
+}
+
+.user-menu-list {
+ display: grid;
+ gap: 8px;
+}
+
+.user-menu-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 13px;
+}
+
+.user-menu-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.user-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 14px;
+ border-radius: 999px;
+ background: rgba(15, 111, 129, 0.12);
+ border: 1px solid rgba(15, 111, 129, 0.18);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.admin-link {
+ color: var(--accent-2);
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.admin-link:hover {
+ text-decoration: underline;
+}
+
+.logout {
+ border: none;
+ background: transparent;
+ color: var(--ink-soft);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.user-menu-actions .logout {
+ text-align: left;
+ padding: 0;
+}
+
+@keyframes float-in {
+ from {
+ opacity: 0;
+ transform: translateY(14px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes rise {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 0.7;
+ }
+ 50% {
+ opacity: 1;
+ }
+}
+
+@media (max-width: 900px) {
+ .chat-shell {
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+@media (max-width: 720px) {
+ .page {
+ padding: 20px;
+ }
+
+ .topbar {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .hero {
+ width: 100%;
+ padding-top: 12px;
+ min-height: auto;
+ justify-items: stretch;
+ }
+
+ .hero-card {
+ padding: 22px;
+ }
+
+ .chat-form {
+ grid-template-columns: 1fr;
+ }
+}
+
+.admin-shell {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.admin-card {
+ background: rgba(255, 255, 255, 0.85);
+ border-radius: 20px;
+ padding: 20px;
+ box-shadow: 0 16px 32px var(--shadow);
+ border: 1px solid rgba(15, 111, 129, 0.12);
+}
+
+.admin-messages {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.admin-message {
+ border-radius: 14px;
+ padding: 12px 14px;
+ border: 1px solid rgba(15, 111, 129, 0.12);
+ background: #fff;
+}
+
+.admin-message.user {
+ background: rgba(26, 166, 183, 0.12);
+}
+
+.admin-message.bot {
+ background: rgba(15, 111, 129, 0.1);
+}
+
+.admin-message.system {
+ background: rgba(15, 111, 129, 0.08);
+}
+
+.admin-message-meta {
+ font-size: 12px;
+ color: var(--ink-soft);
+ margin-bottom: 6px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.admin-message-text {
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.admin-login-card {
+ max-width: 420px;
+ margin: 0 auto;
+}
+
+.admin-card h2,
+.admin-card h3 {
+ margin-top: 0;
+}
+
+.admin-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.admin-search {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.admin-input {
+ padding: 10px 14px;
+ border-radius: 12px;
+ border: 1px solid rgba(15, 111, 129, 0.3);
+ background: #fff;
+ font-size: 14px;
+ font-family: var(--font-body);
+ width: 100%;
+}
+
+.table-wrap {
+ width: 100%;
+ overflow-x: auto;
+}
+
+.admin-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 14px;
+}
+
+.admin-table th,
+.admin-table td {
+ padding: 10px 8px;
+ text-align: left;
+ border-bottom: 1px solid rgba(15, 111, 129, 0.15);
+}
+
+.admin-table th {
+ font-size: 12px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ text-transform: uppercase;
+ background: rgba(15, 111, 129, 0.15);
+ color: var(--ink);
+}
+
+.badge-admin {
+ background: rgba(26, 166, 183, 0.16);
+}
+
+.admin-form {
+ display: grid;
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.admin-form label {
+ display: grid;
+ gap: 6px;
+ font-weight: 600;
+}
+
+.admin-form textarea {
+ resize: vertical;
+}
+
+.admin-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.inline-form {
+ margin: 0;
+}
+
+.detail-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 16px;
+ margin-top: 12px;
+}
+
+.detail-item {
+ background: rgba(241, 246, 248, 0.7);
+ border-radius: 14px;
+ padding: 12px;
+ border: 1px solid rgba(15, 111, 129, 0.12);
+}
+
+.detail-item-wide {
+ grid-column: 1 / -1;
+}
+
+.detail-label {
+ font-size: 12px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.detail-value {
+ margin-top: 6px;
+ font-weight: 600;
+ word-break: break-word;
+}
+
+.detail-value.notes {
+ white-space: pre-wrap;
+ font-weight: 500;
+}
+
+.admin-error {
+ background: rgba(209, 73, 91, 0.15);
+ border: 1px solid rgba(209, 73, 91, 0.4);
+ color: #8d1f2a;
+ padding: 10px 14px;
+ border-radius: 12px;
+ margin: 12px 0;
+}
+
+.empty-state {
+ margin-top: 16px;
+ color: var(--ink-soft);
+}
+
+.muted {
+ color: var(--ink-soft);
+ margin: 0;
+}
+
+/* VSCode webview adaptation (theme + spacing + widgets) */
+body.host-vscode {
+ --ink: var(--vscode-editor-foreground, #e7e7e7);
+ --ink-soft: var(--vscode-descriptionForeground, #b9b9b9);
+ --paper: var(--vscode-editor-background, transparent);
+ --paper-2: var(--vscode-editor-background, transparent);
+ --accent: var(--vscode-button-background, #0e639c);
+ --accent-2: var(--vscode-button-background, #0e639c);
+ --accent-3: var(--vscode-button-hoverBackground, #1177bb);
+ --shadow: transparent;
+ --glow: transparent;
+ --font-display: var(--vscode-font-family);
+ --font-body: var(--vscode-font-family);
+ --font-mono: var(
+ --vscode-editor-font-family,
+ ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ Monaco,
+ Consolas,
+ "Liberation Mono",
+ "Courier New",
+ monospace
+ );
+
+ background: var(--vscode-editor-background, #1e1e1e);
+ color: var(--vscode-editor-foreground, #e7e7e7);
+}
+
+body.host-vscode::before {
+ content: none;
+ display: none;
+}
+
+body.host-vscode .page {
+ padding: 12px;
+ gap: 12px;
+}
+
+body.host-vscode .brand {
+ font-family: var(--font-body);
+ letter-spacing: 0.2px;
+}
+
+body.host-vscode .brand-dot {
+ background: var(--vscode-textLink-foreground, var(--accent));
+ box-shadow: none;
+}
+
+body.host-vscode .conversation-panel {
+ background: var(--vscode-sideBar-background, var(--vscode-editorWidget-background, transparent));
+ border: 1px solid
+ var(--vscode-panel-border, var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25)));
+ box-shadow: none;
+ border-radius: 10px;
+ padding: 12px;
+}
+
+body.host-vscode .chat-panel {
+ background: var(--vscode-editor-background, transparent);
+ border: 1px solid
+ var(--vscode-panel-border, var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25)));
+ box-shadow: none;
+ border-radius: 10px;
+ padding: 12px;
+}
+
+body.host-vscode .conversation-header h2 {
+ font-family: var(--font-body);
+ letter-spacing: 0;
+ font-size: 16px;
+}
+
+body.host-vscode .conversation-item {
+ background: var(--vscode-editorWidget-background, rgba(255, 255, 255, 0.06));
+ border: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25));
+ transition: none;
+}
+
+body.host-vscode .conversation-item.active {
+ border-color: var(--vscode-focusBorder, var(--accent));
+ box-shadow: none;
+}
+
+body.host-vscode .conversation-rename,
+body.host-vscode .conversation-delete {
+ color: var(--vscode-textLink-foreground, var(--accent));
+}
+
+body.host-vscode .button,
+body.host-vscode .chat-button {
+ border-radius: 6px;
+ box-shadow: none;
+ transition: none;
+ transform: none;
+ background: var(--vscode-button-background, var(--accent));
+ color: var(--vscode-button-foreground, #ffffff);
+}
+
+body.host-vscode .button:hover,
+body.host-vscode .chat-button:hover {
+ transform: none;
+ background: var(--vscode-button-hoverBackground, var(--accent-3));
+}
+
+body.host-vscode .chat-input {
+ background: var(--vscode-input-background, transparent);
+ color: var(--vscode-input-foreground, var(--ink));
+ border: 1px solid
+ var(--vscode-input-border, var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25)));
+}
+
+body.host-vscode .chat-input:focus {
+ outline: 1px solid var(--vscode-focusBorder, var(--accent));
+ outline-offset: 0;
+}
+
+body.host-vscode .suggestion-chip {
+ border-radius: 6px;
+ border: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25));
+ background: var(--vscode-editorWidget-background, transparent);
+ color: var(--ink);
+ transition: none;
+}
+
+body.host-vscode .suggestion-chip:hover {
+ transform: none;
+ background: var(--vscode-list-hoverBackground, var(--vscode-editorWidget-background, transparent));
+}
+
+body.host-vscode .message {
+ max-width: min(860px, 92%);
+ box-shadow: none;
+ border: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25));
+}
+
+body.host-vscode .message.user {
+ --message-bg: color-mix(in srgb, var(--vscode-button-background, var(--accent)) 18%, transparent);
+ box-shadow: none;
+}
+
+body.host-vscode .message.bot {
+ --message-bg: color-mix(
+ in srgb,
+ var(--vscode-sideBar-background, var(--vscode-editorWidget-background, rgba(127, 127, 127, 0.18))) 92%,
+ var(--vscode-textLink-foreground, var(--accent)) 8%
+ );
+ box-shadow: none;
+ background: var(--vscode-sideBar-background, var(--vscode-editorWidget-background, rgba(127, 127, 127, 0.18)));
+ background: var(--message-bg);
+ border-color: var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25));
+}
+
+body.host-vscode .message.system {
+ --message-bg: color-mix(in srgb, var(--vscode-editorWidget-background, rgba(127, 127, 127, 0.18)) 80%, transparent);
+ color: var(--ink-soft);
+ box-shadow: none;
+}
+
+body.host-vscode .message-content a {
+ color: var(--vscode-textLink-foreground, var(--accent));
+}
+
+body.host-vscode .message-content a:hover {
+ color: var(--vscode-textLink-activeForeground, var(--accent-3));
+}
+
+body.host-vscode .user-menu-panel {
+ background: var(--vscode-editorWidget-background, transparent);
+ border: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25));
+ box-shadow: none;
+}
+
+body.host-vscode .button.conversation-toggle {
+ background: var(--vscode-button-secondaryBackground, transparent);
+ color: var(--vscode-button-secondaryForeground, var(--ink));
+ border: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25));
+}
+
+body.host-vscode .button.conversation-toggle:hover {
+ background: var(--vscode-button-secondaryHoverBackground, var(--vscode-list-hoverBackground, transparent));
+}
+
+body.host-vscode .user-chip {
+ background: var(--vscode-badge-background, rgba(255, 255, 255, 0.12));
+ color: var(--vscode-badge-foreground, var(--ink));
+ border: 1px solid color-mix(in srgb, var(--vscode-badge-background, var(--accent)) 55%, transparent);
+ font-weight: 700;
+}
+
+body.host-vscode .user-menu-button.auth-required .user-menu-caret {
+ display: none;
+}
+
+body.host-vscode .user-chip.auth-required {
+ font-weight: 700;
+ background: color-mix(in srgb, var(--vscode-errorForeground, #f14c4c) 18%, transparent);
+ border: 1px solid color-mix(in srgb, var(--vscode-errorForeground, #f14c4c) 55%, transparent);
+ color: var(--vscode-errorForeground, #f14c4c);
+}
+
+.conversation-drawer-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(9, 24, 29, 0.48);
+ backdrop-filter: blur(2px);
+ z-index: 998;
+}
+
+.conversation-drawer-backdrop[hidden] {
+ display: none;
+}
+
+.conversation-drawer-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ border-radius: 999px;
+ border: 1px solid rgba(15, 111, 129, 0.24);
+ background: rgba(241, 246, 248, 0.94);
+ color: var(--accent-2);
+ font-weight: 700;
+ font-size: 13px;
+ cursor: pointer;
+ box-shadow: 0 18px 40px var(--shadow);
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.conversation-drawer-trigger:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 22px 50px var(--shadow);
+}
+
+.page.page-chat.sidebar-collapsed .chat-shell {
+ grid-template-columns: 1fr;
+}
+
+.page.page-chat.sidebar-collapsed .conversation-panel {
+ position: fixed;
+ left: 0;
+ top: 0;
+ height: 100vh;
+ height: 100dvh;
+ width: min(360px, 92vw);
+ max-width: 360px;
+ border-radius: 0 22px 22px 0;
+ margin: 0;
+ min-height: 0;
+ transform: translateX(-105%);
+ transition: transform 0.22s ease;
+ z-index: 999;
+}
+
+.page.page-chat.sidebar-collapsed.drawer-open .conversation-panel {
+ transform: translateX(0);
+}
+
+.page.page-chat.drawer-open {
+ overflow: hidden;
+}
+
+body.host-vscode .conversation-drawer-backdrop {
+ background: rgba(0, 0, 0, 0.45);
+ backdrop-filter: none;
+}
+
+body.host-vscode .conversation-drawer-trigger {
+ background: var(--vscode-button-secondaryBackground, transparent);
+ color: var(--vscode-button-secondaryForeground, var(--ink));
+ border: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25));
+ box-shadow: none;
+ transition: none;
+}
+
+body.host-vscode .conversation-drawer-trigger:hover {
+ transform: none;
+ background: var(--vscode-button-secondaryHoverBackground, var(--vscode-list-hoverBackground, transparent));
+}
diff --git a/resources/chat-ui/chat-ui.js b/resources/chat-ui/chat-ui.js
new file mode 100644
index 0000000..b9b1d61
--- /dev/null
+++ b/resources/chat-ui/chat-ui.js
@@ -0,0 +1,2047 @@
+const IS_VSCODE_WEBVIEW = typeof acquireVsCodeApi === "function";
+
+function buildErrorMessage(error) {
+ if (!error) {
+ return "Request failed";
+ }
+ if (typeof error === "string") {
+ return error;
+ }
+ if (error && typeof error.message === "string" && error.message.trim()) {
+ return error.message.trim();
+ }
+ return String(error);
+}
+
+function createWebTransport() {
+ async function json(url, options) {
+ const init = options && typeof options === "object" ? options : {};
+ const headers = {
+ "Content-Type": "application/json",
+ ...(init.headers && typeof init.headers === "object" ? init.headers : {})
+ };
+ const hasBody = init.body !== undefined && init.body !== null;
+ const body =
+ hasBody && typeof init.body === "string"
+ ? init.body
+ : hasBody
+ ? JSON.stringify(init.body)
+ : undefined;
+
+ const response = await fetch(url, {
+ credentials: "same-origin",
+ ...init,
+ headers,
+ body
+ });
+
+ if (!response.ok) {
+ let payload = null;
+ try {
+ payload = await response.json();
+ } catch (error) {
+ payload = null;
+ }
+ const message =
+ payload && payload.message ? payload.message : response.statusText;
+ throw new Error(message || "Request failed");
+ }
+ return response.json();
+ }
+
+ function stream(url, options) {
+ const init = options && typeof options === "object" ? options : {};
+ const headers = {
+ "Content-Type": "application/json",
+ ...(init.headers && typeof init.headers === "object" ? init.headers : {})
+ };
+ const hasBody = init.body !== undefined && init.body !== null;
+ const body =
+ hasBody && typeof init.body === "string"
+ ? init.body
+ : hasBody
+ ? JSON.stringify(init.body)
+ : undefined;
+ const onEvent = typeof init.onEvent === "function" ? init.onEvent : null;
+
+ const controller = new AbortController();
+ const finished = (async () => {
+ const response = await fetch(url, {
+ method: init.method || "POST",
+ credentials: "same-origin",
+ headers,
+ body,
+ signal: controller.signal
+ });
+
+ if (!response.ok) {
+ const raw = await response.text();
+ let message = response.statusText || "Request failed";
+ if (raw) {
+ try {
+ const parsed = JSON.parse(raw);
+ if (parsed && parsed.message) {
+ message = parsed.message;
+ } else {
+ message = raw;
+ }
+ } catch (error) {
+ message = raw;
+ }
+ }
+ throw new Error(message);
+ }
+
+ if (!response.body) {
+ throw new Error("浏览器不支持流式响应。");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder("utf-8");
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("data:")) {
+ continue;
+ }
+ const dataStr = trimmed.slice(5).trim();
+ if (!dataStr) {
+ continue;
+ }
+ if (dataStr === "[DONE]") {
+ return;
+ }
+ let payload = null;
+ try {
+ payload = JSON.parse(dataStr);
+ } catch (error) {
+ payload = null;
+ }
+ if (!payload) {
+ continue;
+ }
+ if (onEvent) {
+ onEvent(payload);
+ }
+ if (payload.type === "error") {
+ throw new Error(payload.message || "Stream error");
+ }
+ }
+ }
+ })();
+
+ return {
+ cancel() {
+ controller.abort();
+ },
+ finished
+ };
+ }
+
+ async function signIn() {
+ window.location.href = "/auth/login";
+ }
+
+ async function signOut() {
+ try {
+ await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
+ } catch (error) {
+ // ignore
+ }
+ window.location.href = "/";
+ }
+
+ async function openExternal(url) {
+ const target = String(url || "").trim();
+ if (!target) {
+ return;
+ }
+ window.open(target, "_blank", "noreferrer");
+ }
+
+ async function confirm(message) {
+ return window.confirm(String(message || ""));
+ }
+
+ async function prompt(message, value) {
+ const result = window.prompt(String(message || ""), value === undefined ? "" : String(value));
+ return result === null ? null : String(result);
+ }
+
+ async function notify(_kind, message) {
+ window.alert(String(message || ""));
+ }
+
+ return {
+ kind: "web",
+ json,
+ stream,
+ signIn,
+ signOut,
+ openExternal,
+ confirm,
+ prompt,
+ notify
+ };
+}
+
+function createVscodeTransport() {
+ const vscode = acquireVsCodeApi();
+ let seq = 0;
+ const pending = new Map();
+ const streams = new Map();
+
+ function nextId(prefix) {
+ seq += 1;
+ return `${prefix || "req"}_${Date.now()}_${seq}`;
+ }
+
+ function post(message) {
+ vscode.postMessage(message);
+ }
+
+ window.addEventListener("message", (event) => {
+ const msg = event && event.data ? event.data : null;
+ if (!msg || typeof msg !== "object") {
+ return;
+ }
+
+ if (msg.type === "rt.chat.rpc.response") {
+ const entry = pending.get(msg.id);
+ if (!entry) {
+ return;
+ }
+ pending.delete(msg.id);
+ if (msg.ok) {
+ entry.resolve(msg.data);
+ return;
+ }
+ const requestId = msg.error && msg.error.requestId ? msg.error.requestId : null;
+ const message = msg.error && msg.error.message ? msg.error.message : "Request failed";
+ entry.reject(new Error(requestId ? `${message}(requestId=${requestId})` : message));
+ return;
+ }
+
+ if (msg.type === "rt.chat.stream.event") {
+ const entry = streams.get(msg.id);
+ if (!entry) {
+ return;
+ }
+ if (entry.onEvent && msg.event) {
+ try {
+ entry.onEvent(msg.event);
+ } catch (error) {
+ // ignore UI callback errors
+ }
+ }
+ return;
+ }
+
+ if (msg.type === "rt.chat.stream.end") {
+ const entry = streams.get(msg.id);
+ if (!entry) {
+ return;
+ }
+ streams.delete(msg.id);
+ entry.resolve();
+ return;
+ }
+
+ if (msg.type === "rt.chat.stream.error") {
+ const entry = streams.get(msg.id);
+ if (!entry) {
+ return;
+ }
+ streams.delete(msg.id);
+ const requestId = msg.error && msg.error.requestId ? msg.error.requestId : null;
+ const message = msg.error && msg.error.message ? msg.error.message : "Stream error";
+ entry.reject(new Error(requestId ? `${message}(requestId=${requestId})` : message));
+ return;
+ }
+
+ if (msg.type === "rt.chat.auth.sessionsChanged") {
+ bootstrap().catch(() => {});
+ return;
+ }
+ });
+
+ function rpc(op, payload) {
+ const id = nextId("rpc");
+ const promise = new Promise((resolve, reject) => {
+ pending.set(id, { resolve, reject });
+ });
+ post({ type: "rt.chat.rpc.request", id, op, payload });
+ return promise;
+ }
+
+ async function json(url, options) {
+ return rpc("json", { url, options });
+ }
+
+ function stream(url, options) {
+ const init = options && typeof options === "object" ? options : {};
+ const id = nextId("stream");
+ const finished = new Promise((resolve, reject) => {
+ streams.set(id, {
+ resolve,
+ reject,
+ onEvent: typeof init.onEvent === "function" ? init.onEvent : null
+ });
+ });
+ post({
+ type: "rt.chat.stream.start",
+ id,
+ url,
+ method: init.method || "POST",
+ headers: init.headers || null,
+ body: init.body
+ });
+ return {
+ cancel() {
+ post({ type: "rt.chat.stream.cancel", id });
+ },
+ finished
+ };
+ }
+
+ async function signIn() {
+ await rpc("auth.signIn", {});
+ }
+
+ async function signOut() {
+ await rpc("auth.signOut", {});
+ }
+
+ async function openExternal(url) {
+ await rpc("openExternal", { url });
+ }
+
+ async function confirm(message) {
+ const resp = await rpc("ui.confirm", { message: String(message || "") });
+ return !!resp;
+ }
+
+ async function prompt(message, value) {
+ const resp = await rpc("ui.prompt", {
+ message: String(message || ""),
+ value: value === undefined ? "" : String(value)
+ });
+ if (resp === null || resp === undefined) {
+ return null;
+ }
+ return String(resp);
+ }
+
+ async function notify(kind, message) {
+ await rpc("ui.notify", { kind: String(kind || "info"), message: String(message || "") });
+ }
+
+ return {
+ kind: "vscode",
+ json,
+ stream,
+ signIn,
+ signOut,
+ openExternal,
+ confirm,
+ prompt,
+ notify
+ };
+}
+
+const transport = IS_VSCODE_WEBVIEW ? createVscodeTransport() : createWebTransport();
+
+const userChip = document.getElementById("user-chip");
+const userMenu = document.getElementById("user-menu");
+const userMenuButton = document.getElementById("user-menu-button");
+const userMenuPanel = document.getElementById("user-menu-panel");
+const menuName = document.getElementById("menu-name");
+const menuEmail = document.getElementById("menu-email");
+const menuCompany = document.getElementById("menu-company");
+const menuDomain = document.getElementById("menu-domain");
+const chatLog = document.getElementById("chat-log");
+const chatForm = document.getElementById("chat-form");
+const chatInput = document.getElementById("chat-input");
+const suggestionBar = document.getElementById("chat-suggestions");
+const logoutButton = document.getElementById("logout");
+const adminLink = document.getElementById("admin-link");
+const conversationList = document.getElementById("conversation-list");
+const newConversationButton = document.getElementById("new-conversation");
+const toggleConversationButton = document.getElementById("toggle-conversation");
+const drawerTriggerButton = document.getElementById("conversation-drawer-trigger");
+const drawerBackdrop = document.getElementById("conversation-drawer-backdrop");
+
+const ACTIVE_KEY_PREFIX = "rt_chat_active_id_v2:";
+const SIDEBAR_COLLAPSED_KEY = "rt_chat_sidebar_collapsed_v1";
+const PROFILE_PROMPT_LEGACY_KEY = "rt_profile_card_seen_v1";
+const PROFILE_PROMPT_KEY_PREFIX = "rt_profile_prompt_seen_v1:";
+const PROFILE_PROMPT_MESSAGE =
+ "为了更准确回答,请先完善个人信息:姓名、邮箱、公司、擅长领域。点击个人信息填写。";
+const MESSAGE_COLLAPSE_LINE_LIMIT = 12;
+const MESSAGE_COLLAPSE_TOLERANCE_PX = 6;
+const messageCollapsePreference = new Map();
+
+let conversations = [];
+let activeConversationId = null;
+let loadToken = 0;
+const conversationCache = new Map();
+let profilePromptShown = false;
+let currentUser = null;
+let pollTimer = null;
+let pollInFlight = false;
+const messageElementById = new Map();
+let sidebarCollapsed = false;
+let drawerOpen = false;
+
+function escapeHtml(input) {
+ const div = document.createElement("div");
+ div.textContent = input === null || input === undefined ? "" : String(input);
+ return div.innerHTML;
+}
+
+function hasStorage() {
+ try {
+ const testKey = "__rt_storage_test__";
+ window.localStorage.setItem(testKey, "1");
+ window.localStorage.removeItem(testKey);
+ return true;
+ } catch (error) {
+ return false;
+ }
+}
+
+function readStoredSidebarCollapsed() {
+ if (!hasStorage()) {
+ return false;
+ }
+ try {
+ return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
+ } catch (error) {
+ return false;
+ }
+}
+
+function storeSidebarCollapsed(collapsed) {
+ if (!hasStorage()) {
+ return;
+ }
+ try {
+ window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
+ } catch (error) {
+ // ignore
+ }
+}
+
+function applySidebarCollapsed(collapsed) {
+ sidebarCollapsed = !!collapsed;
+ const page = document.querySelector(".page.page-chat");
+ if (page) {
+ page.classList.toggle("sidebar-collapsed", sidebarCollapsed);
+ }
+ applyDrawerOpen(false);
+ if (toggleConversationButton) {
+ toggleConversationButton.textContent = sidebarCollapsed ? "展开" : "收起";
+ toggleConversationButton.setAttribute(
+ "aria-label",
+ sidebarCollapsed ? "展开对话列表" : "收起对话列表"
+ );
+ toggleConversationButton.title = sidebarCollapsed ? "展开对话列表" : "收起对话列表";
+ }
+ if (newConversationButton) {
+ newConversationButton.textContent = "新建";
+ newConversationButton.removeAttribute("title");
+ newConversationButton.setAttribute("aria-label", "新建");
+ }
+}
+
+function applyDrawerOpen(open) {
+ drawerOpen = !!open;
+ const hasUser = !!currentUser;
+ const page = document.querySelector(".page.page-chat");
+ if (page) {
+ page.classList.toggle("drawer-open", drawerOpen);
+ }
+ if (drawerBackdrop) {
+ drawerBackdrop.hidden = !drawerOpen;
+ }
+ if (drawerTriggerButton) {
+ drawerTriggerButton.hidden = !hasUser || !sidebarCollapsed || drawerOpen;
+ drawerTriggerButton.setAttribute("aria-expanded", drawerOpen ? "true" : "false");
+ }
+}
+
+function getProfilePromptKey(user) {
+ const userId = user && user.id ? String(user.id) : "unknown";
+ return `${PROFILE_PROMPT_KEY_PREFIX}${userId}`;
+}
+
+function getActiveConversationKey(user) {
+ const userId = user && user.id ? String(user.id) : "unknown";
+ return `${ACTIVE_KEY_PREFIX}${userId}`;
+}
+
+function readStoredActiveConversationId(user) {
+ if (!hasStorage()) {
+ return null;
+ }
+ try {
+ const value = window.localStorage.getItem(getActiveConversationKey(user));
+ return value ? String(value).trim() : null;
+ } catch (error) {
+ return null;
+ }
+}
+
+function storeActiveConversationId(user, conversationId) {
+ if (!hasStorage()) {
+ return;
+ }
+ try {
+ window.localStorage.setItem(getActiveConversationKey(user), conversationId);
+ } catch (error) {
+ // ignore
+ }
+}
+
+function hasSeenProfilePrompt(user) {
+ if (!hasStorage()) {
+ return true;
+ }
+ if (window.localStorage.getItem(PROFILE_PROMPT_LEGACY_KEY) === "1") {
+ return true;
+ }
+ return window.localStorage.getItem(getProfilePromptKey(user)) === "1";
+}
+
+function markProfilePromptSeen(user) {
+ if (!hasStorage()) {
+ return;
+ }
+ window.localStorage.setItem(getProfilePromptKey(user), "1");
+}
+
+function isProfileIncomplete(user) {
+ const requiredFields = ["name", "email", "company", "domain"];
+ return requiredFields.some((field) => {
+ const value = user && user[field] ? String(user[field]).trim() : "";
+ return !value;
+ });
+}
+
+function sanitizeUrl(url) {
+ const trimmed = String(url || "").trim();
+ if (!trimmed) {
+ return null;
+ }
+ if (
+ /^https?:/i.test(trimmed) ||
+ /^mailto:/i.test(trimmed) ||
+ /^#/.test(trimmed) ||
+ /^\//.test(trimmed) ||
+ /^\.\//.test(trimmed) ||
+ /^\.\.\//.test(trimmed)
+ ) {
+ return trimmed;
+ }
+ return null;
+}
+
+function escapeHtmlAttribute(input) {
+ return String(input || "")
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+ .replace(//g, ">");
+}
+
+function renderInlineMarkdown(text) {
+ const codeSpans = [];
+ let result = "";
+ let cursor = 0;
+
+ while (cursor < text.length) {
+ if (text[cursor] !== "`") {
+ result += text[cursor];
+ cursor += 1;
+ continue;
+ }
+
+ let fenceEnd = cursor;
+ while (fenceEnd < text.length && text[fenceEnd] === "`") {
+ fenceEnd += 1;
+ }
+
+ const fence = text.slice(cursor, fenceEnd);
+ const closeIndex = text.indexOf(fence, fenceEnd);
+ if (closeIndex === -1) {
+ result += fence;
+ cursor = fenceEnd;
+ continue;
+ }
+
+ const code = text.slice(fenceEnd, closeIndex);
+ const index = codeSpans.length;
+ codeSpans.push(code);
+ result += `\u0000RTCODE${index}\u0000`;
+ cursor = closeIndex + fence.length;
+ }
+
+ result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, url) => {
+ const safeUrl = sanitizeUrl(url);
+ if (!safeUrl) {
+ return label;
+ }
+ const external = /^https?:/i.test(safeUrl);
+ const attrs = external
+ ? ' target="_blank" rel="noopener noreferrer"'
+ : ' rel="noopener noreferrer"';
+ return `${label}`;
+ });
+
+ result = result.replace(/\*\*([^*]+)\*\*/g, "$1");
+ result = result.replace(/__([^_]+)__/g, "$1");
+ result = result.replace(/\*([^*]+)\*/g, "$1");
+ result = result.replace(/_([^_]+)_/g, "$1");
+ result = result.replace(/~~([^~]+)~~/g, "$1");
+
+ result = result.replace(/\u0000RTCODE(\d+)\u0000/g, (_match, index) => {
+ const code = codeSpans[Number(index)] || "";
+ return `${code}`;
+ });
+
+ return result;
+}
+
+function renderMarkdown(text) {
+ const escaped = escapeHtml(text || "");
+ const lines = escaped.split(/\r?\n/);
+ const output = [];
+ let inCodeBlock = false;
+ let codeLang = "";
+ let codeFenceLen = 0;
+ let codeLines = [];
+ let paragraphLines = [];
+ let quoteLines = [];
+ let listType = null;
+ let listItems = [];
+
+ function flushParagraph() {
+ if (!paragraphLines.length) {
+ return;
+ }
+ const paragraph = renderInlineMarkdown(paragraphLines.join("
"));
+ output.push(`
${paragraph}
`);
+ paragraphLines = [];
+ }
+
+ function flushQuote() {
+ if (!quoteLines.length) {
+ return;
+ }
+ const quote = renderInlineMarkdown(quoteLines.join("
"));
+ output.push(`${quote}
`);
+ quoteLines = [];
+ }
+
+ function flushList() {
+ if (!listType || !listItems.length) {
+ listType = null;
+ listItems = [];
+ return;
+ }
+ const items = listItems
+ .map((item) => `${renderInlineMarkdown(item)}`)
+ .join("");
+ output.push(`<${listType}>${items}${listType}>`);
+ listType = null;
+ listItems = [];
+ }
+
+ function flushCodeBlock() {
+ const code = codeLines.join("\n");
+ const language = String(codeLang || "")
+ .trim()
+ .split(/\s+/)[0]
+ .toLowerCase();
+ const mapped =
+ language === "c++"
+ ? "cpp"
+ : language === "c#"
+ ? "csharp"
+ : language === "f#"
+ ? "fsharp"
+ : language;
+ const safeLang = mapped.replace(/[^a-z0-9_-]+/g, "");
+ const className = safeLang ? ` class="language-${safeLang}"` : "";
+ output.push(`${code}
`);
+ codeLines = [];
+ codeLang = "";
+ codeFenceLen = 0;
+ }
+
+ lines.forEach((line) => {
+ const trimmed = line.trim();
+ const fenceMatch = trimmed.match(/^(`{3,})(.*)$/);
+ if (fenceMatch) {
+ const fenceLen = fenceMatch[1].length;
+ const rest = (fenceMatch[2] || "").trim();
+
+ if (inCodeBlock) {
+ const closeMatch = trimmed.match(/^`{3,}\s*$/);
+ if (closeMatch && fenceLen >= codeFenceLen) {
+ flushCodeBlock();
+ inCodeBlock = false;
+ return;
+ }
+ } else {
+ flushParagraph();
+ flushList();
+ flushQuote();
+ inCodeBlock = true;
+ codeFenceLen = fenceLen;
+ codeLang = rest;
+ return;
+ }
+ }
+
+ if (inCodeBlock) {
+ codeLines.push(line);
+ return;
+ }
+
+ if (!trimmed) {
+ flushParagraph();
+ flushList();
+ flushQuote();
+ return;
+ }
+
+ const quoteMatch = trimmed.match(/^>\s?(.*)$/);
+ if (quoteMatch) {
+ flushParagraph();
+ flushList();
+ quoteLines.push(quoteMatch[1]);
+ return;
+ }
+
+ if (quoteLines.length) {
+ flushQuote();
+ }
+
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
+ if (headingMatch) {
+ flushParagraph();
+ flushList();
+ const level = headingMatch[1].length;
+ const content = renderInlineMarkdown(headingMatch[2]);
+ output.push(`${content}`);
+ return;
+ }
+
+ const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/);
+ const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/);
+ if (unorderedMatch || orderedMatch) {
+ flushParagraph();
+ const nextType = unorderedMatch ? "ul" : "ol";
+ if (listType && listType !== nextType) {
+ flushList();
+ }
+ listType = nextType;
+ listItems.push((unorderedMatch || orderedMatch)[1]);
+ return;
+ }
+
+ if (listType) {
+ flushList();
+ }
+
+ paragraphLines.push(line);
+ });
+
+ if (inCodeBlock) {
+ flushCodeBlock();
+ }
+
+ flushParagraph();
+ flushList();
+ flushQuote();
+
+ return output.join("");
+}
+
+async function requestJson(url, options) {
+ return transport.json(url, options);
+}
+
+function setUserMenuOpen(open) {
+ if (!userMenuButton || !userMenuPanel) {
+ return;
+ }
+ userMenuPanel.hidden = !open;
+ userMenuButton.setAttribute("aria-expanded", open ? "true" : "false");
+}
+
+function initializeUserMenu() {
+ if (!userMenuButton || !userMenuPanel || !userMenu) {
+ return;
+ }
+ userMenuButton.addEventListener("click", async (event) => {
+ event.stopPropagation();
+ if (transport.kind === "vscode" && !currentUser) {
+ setUserMenuOpen(false);
+ setChatDisabled(true, "正在跳转登录...");
+ try {
+ await transport.signIn();
+ } catch (error) {
+ void transport
+ .notify("error", `登录失败:${buildErrorMessage(error)}`)
+ .catch(() => {});
+ } finally {
+ await bootstrap();
+ }
+ return;
+ }
+ setUserMenuOpen(userMenuPanel.hidden);
+ });
+
+ userMenuPanel.addEventListener("click", (event) => {
+ event.stopPropagation();
+ });
+
+ document.addEventListener("click", () => {
+ setUserMenuOpen(false);
+ });
+
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") {
+ setUserMenuOpen(false);
+ }
+ });
+}
+
+function setChatDisabled(disabled, placeholder) {
+ chatInput.disabled = disabled;
+ if (placeholder !== undefined) {
+ chatInput.placeholder = placeholder;
+ return;
+ }
+ chatInput.placeholder = disabled ? "加载中..." : "输入一句话...";
+}
+
+function renderSuggestions(items) {
+ if (!suggestionBar) {
+ return;
+ }
+ suggestionBar.innerHTML = "";
+ if (!items || !items.length) {
+ suggestionBar.hidden = true;
+ return;
+ }
+ const label = document.createElement("div");
+ label.className = "suggestions-label";
+ label.textContent = "建议补充";
+ const list = document.createElement("div");
+ list.className = "suggestions-list";
+ items.forEach((text) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "suggestion-chip";
+ button.textContent = text;
+ button.addEventListener("click", () => {
+ const current = chatInput.value.trim();
+ chatInput.value = current ? `${current} ${text}` : text;
+ chatInput.focus();
+ });
+ list.appendChild(button);
+ });
+ suggestionBar.appendChild(label);
+ suggestionBar.appendChild(list);
+ suggestionBar.hidden = false;
+}
+
+function buildProfilePromptCardElement({ messageText, onDismiss }) {
+ const card = document.createElement("div");
+ card.className = "profile-card-message";
+
+ const title = document.createElement("div");
+ title.className = "profile-card-title";
+ title.textContent = "完善个人信息";
+ title.id = "profile-prompt-title";
+
+ const body = document.createElement("div");
+ body.className = "profile-card-body";
+ body.textContent = messageText;
+
+ const list = document.createElement("ul");
+ list.className = "profile-card-list";
+ ["姓名", "邮箱", "公司", "擅长领域"].forEach((label) => {
+ const item = document.createElement("li");
+ item.textContent = label;
+ list.appendChild(item);
+ });
+
+ const actions = document.createElement("div");
+ actions.className = "profile-card-actions";
+
+ const link = document.createElement("a");
+ link.className = "button button-small";
+ link.href = "/profile";
+ link.textContent = "去填写";
+ link.addEventListener("click", () => {
+ if (typeof onDismiss === "function") {
+ onDismiss();
+ }
+ });
+ actions.appendChild(link);
+
+ const dismiss = document.createElement("button");
+ dismiss.type = "button";
+ dismiss.className = "profile-modal-dismiss";
+ dismiss.textContent = "稍后再说";
+ dismiss.addEventListener("click", () => {
+ if (typeof onDismiss === "function") {
+ onDismiss();
+ }
+ });
+ actions.appendChild(dismiss);
+
+ const hint = document.createElement("div");
+ hint.className = "profile-card-hint";
+ hint.textContent = "也可在右上角菜单随时修改。";
+
+ card.appendChild(title);
+ card.appendChild(body);
+ card.appendChild(list);
+ card.appendChild(actions);
+ card.appendChild(hint);
+
+ return card;
+}
+
+function showProfilePromptModal(user) {
+ if (profilePromptShown) {
+ return;
+ }
+ if (!isProfileIncomplete(user)) {
+ return;
+ }
+ if (hasSeenProfilePrompt(user)) {
+ return;
+ }
+
+ profilePromptShown = true;
+ markProfilePromptSeen(user);
+
+ if (document.getElementById("profile-prompt-modal")) {
+ return;
+ }
+
+ const previousFocus =
+ document.activeElement && document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+
+ const overlay = document.createElement("div");
+ overlay.className = "modal-backdrop";
+ overlay.id = "profile-prompt-modal";
+
+ const dialog = document.createElement("div");
+ dialog.className = "modal";
+ dialog.setAttribute("role", "dialog");
+ dialog.setAttribute("aria-modal", "true");
+ dialog.setAttribute("aria-labelledby", "profile-prompt-title");
+
+ function close() {
+ document.removeEventListener("keydown", handleKeydown);
+ document.body.classList.remove("modal-open");
+ overlay.remove();
+ if (previousFocus) {
+ previousFocus.focus();
+ }
+ }
+
+ function handleKeydown(event) {
+ if (event.key === "Escape") {
+ close();
+ }
+ }
+
+ document.addEventListener("keydown", handleKeydown);
+
+ overlay.addEventListener("click", () => close());
+ dialog.addEventListener("click", (event) => event.stopPropagation());
+
+ const card = buildProfilePromptCardElement({
+ messageText: PROFILE_PROMPT_MESSAGE,
+ onDismiss: close
+ });
+
+ dialog.appendChild(card);
+ overlay.appendChild(dialog);
+ document.body.appendChild(overlay);
+ document.body.classList.add("modal-open");
+
+ const primaryAction = card.querySelector("a.button");
+ if (primaryAction && primaryAction.focus) {
+ requestAnimationFrame(() => primaryAction.focus());
+ }
+}
+
+function setMessageElementId(element, messageId) {
+ if (!element || !messageId) {
+ return;
+ }
+ const id = String(messageId).trim();
+ if (!id) {
+ return;
+ }
+ element.dataset.messageId = id;
+ messageElementById.set(id, element);
+}
+
+function getMessageElementById(messageId) {
+ if (!messageId) {
+ return null;
+ }
+ const id = String(messageId).trim();
+ return id ? messageElementById.get(id) || null : null;
+}
+
+function resolveLineHeightPx(element) {
+ if (!element) {
+ return 24;
+ }
+ const computed = window.getComputedStyle(element);
+ const lineHeight = computed.lineHeight;
+ if (lineHeight && lineHeight !== "normal") {
+ const parsed = Number.parseFloat(lineHeight);
+ if (Number.isFinite(parsed) && parsed > 0) {
+ return parsed;
+ }
+ }
+ const fontSize = Number.parseFloat(computed.fontSize);
+ if (Number.isFinite(fontSize) && fontSize > 0) {
+ return fontSize * 1.5;
+ }
+ return 24;
+}
+
+function getMessageCollapseKey(messageElement) {
+ if (!messageElement) {
+ return null;
+ }
+ const messageId = messageElement.dataset.messageId;
+ if (!messageId) {
+ return null;
+ }
+ const conversationId = activeConversationId ? String(activeConversationId) : "unknown";
+ return `${conversationId}:${messageId}`;
+}
+
+function readMessageCollapsePreference(messageElement) {
+ const key = getMessageCollapseKey(messageElement);
+ if (!key) {
+ return null;
+ }
+ return messageCollapsePreference.has(key) ? messageCollapsePreference.get(key) : null;
+}
+
+function writeMessageCollapsePreference(messageElement, preference) {
+ const key = getMessageCollapseKey(messageElement);
+ if (!key) {
+ return;
+ }
+ if (preference === "expanded" || preference === "collapsed") {
+ messageCollapsePreference.set(key, preference);
+ }
+}
+
+function clearMessageCollapsePreference(messageElement) {
+ const key = getMessageCollapseKey(messageElement);
+ if (!key) {
+ return;
+ }
+ messageCollapsePreference.delete(key);
+}
+
+function cleanupMessageCollapse(messageElement) {
+ if (!messageElement) {
+ return;
+ }
+ messageElement.classList.remove("collapsible", "is-collapsed");
+ messageElement.style.removeProperty("--message-collapse-height");
+ delete messageElement.dataset.collapseState;
+ const toggle = messageElement.querySelector(".message-toggle");
+ if (toggle) {
+ toggle.remove();
+ }
+}
+
+function updateMessageToggle(toggle, expanded) {
+ if (!toggle) {
+ return;
+ }
+ toggle.textContent = expanded ? "收起" : "展开";
+ toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
+}
+
+function getOrCreateMessageToggle(messageElement) {
+ const existing = messageElement.querySelector(".message-toggle");
+ if (existing) {
+ return existing;
+ }
+
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "message-toggle";
+ toggle.addEventListener("click", () => {
+ const currentlyCollapsed = messageElement.classList.contains("is-collapsed");
+ const nextCollapsed = !currentlyCollapsed;
+ const nextPreference = nextCollapsed ? "collapsed" : "expanded";
+ messageElement.dataset.collapseState = nextPreference;
+ writeMessageCollapsePreference(messageElement, nextPreference);
+ messageElement.classList.toggle("is-collapsed", nextCollapsed);
+ updateMessageToggle(toggle, !nextCollapsed);
+ });
+ messageElement.appendChild(toggle);
+ return toggle;
+}
+
+function applyAutoCollapse(messageElement) {
+ if (!messageElement) {
+ return;
+ }
+ if (
+ !messageElement.classList.contains("user") &&
+ !messageElement.classList.contains("bot")
+ ) {
+ cleanupMessageCollapse(messageElement);
+ return;
+ }
+ if (messageElement.classList.contains("streaming")) {
+ cleanupMessageCollapse(messageElement);
+ return;
+ }
+
+ const content = messageElement.querySelector(".message-content");
+ if (!content) {
+ cleanupMessageCollapse(messageElement);
+ return;
+ }
+
+ const maxHeightPx = Math.round(resolveLineHeightPx(content) * MESSAGE_COLLAPSE_LINE_LIMIT);
+ messageElement.style.setProperty("--message-collapse-height", `${maxHeightPx}px`);
+
+ const storedPreference = readMessageCollapsePreference(messageElement);
+ if (storedPreference) {
+ messageElement.dataset.collapseState = storedPreference;
+ } else if (messageElement.dataset.collapseState) {
+ writeMessageCollapsePreference(messageElement, messageElement.dataset.collapseState);
+ }
+
+ messageElement.classList.remove("is-collapsed");
+ const fullHeight = content.scrollHeight;
+ const needsCollapse = fullHeight > maxHeightPx + MESSAGE_COLLAPSE_TOLERANCE_PX;
+
+ if (!needsCollapse) {
+ clearMessageCollapsePreference(messageElement);
+ cleanupMessageCollapse(messageElement);
+ return;
+ }
+
+ messageElement.classList.add("collapsible");
+ const toggle = getOrCreateMessageToggle(messageElement);
+ const preference = messageElement.dataset.collapseState;
+ const shouldCollapse = preference !== "expanded";
+ messageElement.classList.toggle("is-collapsed", shouldCollapse);
+ updateMessageToggle(toggle, !shouldCollapse);
+}
+
+function createMessageElement(role, text, options = {}) {
+ const message = document.createElement("div");
+ message.className = `message ${role}`;
+ if (options.messageId) {
+ setMessageElementId(message, options.messageId);
+ }
+ message.dataset.messageText = text || "";
+ if (options.pending) {
+ message.classList.add("pending");
+ }
+ if (options.streaming) {
+ message.classList.add("streaming");
+ }
+ const content = document.createElement("div");
+ content.className = "message-content";
+ if (options.streaming) {
+ content.textContent = text || "";
+ } else {
+ content.innerHTML = renderMarkdown(text);
+ }
+ message.appendChild(content);
+ return message;
+}
+
+function renderMessages(messages) {
+ chatLog.innerHTML = "";
+ messageElementById.clear();
+ if (!messages || !messages.length) {
+ renderSuggestions([]);
+ return;
+ }
+ messages.forEach((message) => {
+ const messageId = message && message.id ? message.id : null;
+ const element = createMessageElement(message.role, message.text, { messageId });
+ chatLog.appendChild(element);
+ applyAutoCollapse(element);
+ });
+ chatLog.scrollTop = chatLog.scrollHeight;
+ renderSuggestions([]);
+}
+
+function appendMessage(role, text, options = {}) {
+ const element = createMessageElement(role, text, options);
+ chatLog.appendChild(element);
+ applyAutoCollapse(element);
+ chatLog.scrollTop = chatLog.scrollHeight;
+ return element;
+}
+
+function appendMessages(messages) {
+ messages.forEach((message) => {
+ const messageId = message && message.id ? message.id : null;
+ const element = createMessageElement(message.role, message.text, { messageId });
+ chatLog.appendChild(element);
+ applyAutoCollapse(element);
+ });
+ chatLog.scrollTop = chatLog.scrollHeight;
+}
+
+function updateMessageElement(element, role, text, options = {}) {
+ if (!element) {
+ return;
+ }
+ const preservedCollapseState = element.dataset.collapseState;
+ element.className = `message ${role}`;
+ if (options.messageId) {
+ setMessageElementId(element, options.messageId);
+ }
+ element.dataset.messageText = text || "";
+ if (preservedCollapseState) {
+ element.dataset.collapseState = preservedCollapseState;
+ }
+ if (options.pending) {
+ element.classList.add("pending");
+ }
+ if (options.streaming) {
+ element.classList.add("streaming");
+ }
+ const content = element.querySelector(".message-content");
+ if (content) {
+ if (options.streaming) {
+ content.textContent = text || "";
+ } else {
+ content.innerHTML = renderMarkdown(text);
+ }
+ } else {
+ element.innerHTML = options.streaming ? "" : renderMarkdown(text);
+ }
+ applyAutoCollapse(element);
+ chatLog.scrollTop = chatLog.scrollHeight;
+}
+
+function sortConversations() {
+ conversations.sort((a, b) => {
+ const aTime = Date.parse(a.updatedAt || a.createdAt || 0);
+ const bTime = Date.parse(b.updatedAt || b.createdAt || 0);
+ return bTime - aTime;
+ });
+}
+
+function updateConversationSummary(summary) {
+ const existing = conversations.find((item) => item.id === summary.id);
+ if (existing) {
+ Object.assign(existing, summary);
+ } else {
+ conversations.push(summary);
+ }
+ sortConversations();
+}
+
+function removeConversationSummary(id) {
+ conversations = conversations.filter((item) => item.id !== id);
+}
+
+function renderConversationList() {
+ if (!conversationList) {
+ return;
+ }
+ conversationList.innerHTML = "";
+ if (!conversations.length) {
+ const empty = document.createElement("div");
+ empty.className = "empty-state";
+ empty.textContent = "暂无对话,点击新建开始。";
+ conversationList.appendChild(empty);
+ return;
+ }
+
+ conversations.forEach((conversation) => {
+ const item = document.createElement("div");
+ item.className = "conversation-item";
+ if (conversation.id === activeConversationId) {
+ item.classList.add("active");
+ }
+
+ const main = document.createElement("button");
+ main.type = "button";
+ main.className = "conversation-main";
+
+ const title = document.createElement("div");
+ title.className = "conversation-title";
+ title.textContent = conversation.title || "未命名对话";
+
+ const meta = document.createElement("div");
+ meta.className = "conversation-meta";
+ const count = conversation.messageCount || 0;
+ meta.textContent = `${count} 条消息`;
+
+ main.appendChild(title);
+ main.appendChild(meta);
+ main.addEventListener("click", () => setActiveConversation(conversation.id));
+
+ const actions = document.createElement("div");
+ actions.className = "conversation-actions";
+
+ const renameBtn = document.createElement("button");
+ renameBtn.type = "button";
+ renameBtn.className = "conversation-rename";
+ renameBtn.textContent = "重命名";
+ renameBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ void renameConversation(conversation).catch((error) => {
+ void transport
+ .notify("error", `重命名失败:${buildErrorMessage(error)}`)
+ .catch(() => {});
+ });
+ });
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.type = "button";
+ deleteBtn.className = "conversation-delete";
+ deleteBtn.textContent = "删除";
+ deleteBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ void deleteConversation(conversation).catch((error) => {
+ void transport
+ .notify("error", `删除失败:${buildErrorMessage(error)}`)
+ .catch(() => {});
+ });
+ });
+
+ actions.appendChild(renameBtn);
+ actions.appendChild(deleteBtn);
+
+ item.appendChild(main);
+ item.appendChild(actions);
+ conversationList.appendChild(item);
+ });
+}
+
+async function loadConversationDetail(conversationId) {
+ const token = ++loadToken;
+ setChatDisabled(true);
+ chatLog.innerHTML = "";
+ chatLog.appendChild(createMessageElement("system", "加载会话中..."));
+
+ try {
+ const data = await requestJson(`/api/conversations/${conversationId}`);
+ if (token !== loadToken) {
+ return;
+ }
+ const conversation = data.conversation;
+ updateConversationSummary({
+ id: conversation.id,
+ title: conversation.title,
+ autoTitle: conversation.autoTitle,
+ updatedAt: conversation.updatedAt,
+ lastMessageAt: conversation.lastMessageAt,
+ messageCount: conversation.messages ? conversation.messages.length : 0
+ });
+ conversationCache.set(conversation.id, conversation);
+ activeConversationId = conversation.id;
+ storeActiveConversationId(currentUser, activeConversationId);
+ renderConversationList();
+ renderMessages(conversation.messages || []);
+ } catch (error) {
+ if (token !== loadToken) {
+ return;
+ }
+ renderMessages([{ role: "system", text: `加载失败:${error.message}` }]);
+ } finally {
+ if (token === loadToken) {
+ setChatDisabled(false);
+ }
+ }
+}
+
+function setActiveConversation(conversationId) {
+ if (!conversationId) {
+ return;
+ }
+ if (conversationId === activeConversationId) {
+ return;
+ }
+ if (sidebarCollapsed && drawerOpen) {
+ applyDrawerOpen(false);
+ }
+ loadConversationDetail(conversationId);
+}
+
+async function refreshConversationList() {
+ const data = await requestJson("/api/conversations");
+ conversations = data.conversations || [];
+ sortConversations();
+ renderConversationList();
+ return conversations;
+}
+
+async function createConversation() {
+ const data = await requestJson("/api/conversations", {
+ method: "POST",
+ body: JSON.stringify({})
+ });
+ const conversation = data.conversation;
+ updateConversationSummary({
+ id: conversation.id,
+ title: conversation.title,
+ autoTitle: conversation.autoTitle,
+ updatedAt: conversation.updatedAt,
+ lastMessageAt: conversation.lastMessageAt,
+ messageCount: conversation.messages ? conversation.messages.length : 0
+ });
+ conversationCache.set(conversation.id, conversation);
+ activeConversationId = conversation.id;
+ storeActiveConversationId(currentUser, activeConversationId);
+ renderConversationList();
+ renderMessages(conversation.messages || []);
+ if (sidebarCollapsed && drawerOpen) {
+ applyDrawerOpen(false);
+ }
+}
+
+async function deleteConversation(conversation) {
+ const ok = await transport.confirm(`确定删除 "${conversation.title}" 吗?`);
+ if (!ok) {
+ return;
+ }
+ await requestJson(`/api/conversations/${conversation.id}`, { method: "DELETE" });
+ removeConversationSummary(conversation.id);
+ conversationCache.delete(conversation.id);
+
+ if (activeConversationId === conversation.id) {
+ const next = conversations[0];
+ if (next) {
+ await loadConversationDetail(next.id);
+ } else {
+ await createConversation();
+ }
+ } else {
+ renderConversationList();
+ }
+}
+
+async function renameConversation(conversation) {
+ const nextTitle = await transport.prompt(
+ "请输入新的对话标题",
+ conversation.title || ""
+ );
+ if (nextTitle === null) {
+ return;
+ }
+ const trimmed = nextTitle.trim();
+ if (!trimmed) {
+ await transport.notify("error", "标题不能为空");
+ return;
+ }
+ const data = await requestJson(`/api/conversations/${conversation.id}`, {
+ method: "PUT",
+ body: JSON.stringify({ title: trimmed })
+ });
+ updateConversationSummary(data.conversation);
+ const cached = conversationCache.get(conversation.id);
+ if (cached) {
+ cached.title = data.conversation.title;
+ }
+ renderConversationList();
+}
+
+async function sendMessage(text, pendingElement, userElement) {
+ const payload = {
+ message: text,
+ conversationId: activeConversationId
+ };
+ let streamedText = "";
+ let donePayload = null;
+ if (pendingElement) {
+ updateMessageElement(pendingElement, "bot", "AI 正在回答...", {
+ pending: true,
+ streaming: true
+ });
+ }
+
+ const stream = transport.stream("/api/chat/stream", {
+ method: "POST",
+ body: payload,
+ onEvent: (eventPayload) => {
+ if (!eventPayload) {
+ return;
+ }
+ if (eventPayload.type === "start") {
+ const nextConversationId = eventPayload.conversationId;
+ if (nextConversationId && nextConversationId !== activeConversationId) {
+ activeConversationId = nextConversationId;
+ storeActiveConversationId(currentUser, activeConversationId);
+ }
+ if (eventPayload.userMessageId && userElement) {
+ updateMessageElement(userElement, "user", text, {
+ messageId: eventPayload.userMessageId
+ });
+ }
+ if (eventPayload.assistantMessageId && pendingElement) {
+ setMessageElementId(pendingElement, eventPayload.assistantMessageId);
+ }
+ return;
+ }
+ if (eventPayload.type === "delta") {
+ const delta = eventPayload.delta ? String(eventPayload.delta) : "";
+ if (!delta) {
+ return;
+ }
+ streamedText += delta;
+ if (pendingElement) {
+ updateMessageElement(pendingElement, "bot", streamedText, {
+ pending: true,
+ streaming: true
+ });
+ }
+ return;
+ }
+ if (eventPayload.type === "done") {
+ donePayload = eventPayload.data || null;
+ }
+ }
+ });
+
+ await stream.finished;
+
+ const data = donePayload || {
+ reply: streamedText,
+ suggestions: [],
+ conversationId: activeConversationId,
+ messages: []
+ };
+ const summary = data.conversation;
+ if (summary) {
+ updateConversationSummary(summary);
+ }
+ const conversationId = data.conversationId || activeConversationId;
+ if (conversationId !== activeConversationId) {
+ activeConversationId = conversationId;
+ }
+ storeActiveConversationId(currentUser, activeConversationId);
+
+ const messages = (data.messages || []).filter(Boolean);
+ const botMessage = messages.find((message) => message.role === "bot") || null;
+ let cached = conversationCache.get(conversationId);
+ if (!cached) {
+ cached = { id: conversationId, messages: [] };
+ conversationCache.set(conversationId, cached);
+ }
+ cached.messages = cached.messages || [];
+ cached.messages.push(...messages);
+ cached.updatedAt = summary ? summary.updatedAt : cached.updatedAt;
+ cached.lastMessageAt = summary ? summary.lastMessageAt : cached.lastMessageAt;
+ if (summary && summary.title) {
+ cached.title = summary.title;
+ }
+ if (conversationId === activeConversationId) {
+ if (botMessage) {
+ if (pendingElement) {
+ updateMessageElement(pendingElement, "bot", botMessage.text || data.reply || streamedText);
+ } else {
+ appendMessage("bot", botMessage.text || data.reply || streamedText);
+ }
+ renderSuggestions(data.suggestions || []);
+ } else if (pendingElement) {
+ updateMessageElement(pendingElement, "system", "AI 返回为空。");
+ renderSuggestions([]);
+ }
+ }
+ renderConversationList();
+}
+
+async function loadProfile() {
+ let data = null;
+ try {
+ data = await requestJson("/api/me");
+ } catch (error) {
+ if (transport.kind !== "vscode") {
+ window.location.href = "/";
+ return null;
+ }
+ throw error;
+ }
+ const user = data.user || {};
+
+ const displayName = user.name || user.email || "Signed in";
+ userChip.textContent = displayName;
+ if (menuName) {
+ menuName.textContent = displayName;
+ }
+ if (menuEmail) {
+ menuEmail.textContent = user.email || "-";
+ }
+ if (menuCompany) {
+ menuCompany.textContent = user.company || "-";
+ }
+ if (menuDomain) {
+ menuDomain.textContent = user.domain || "-";
+ }
+
+ if (adminLink) {
+ adminLink.hidden = user.role !== "admin";
+ }
+
+ showProfilePromptModal(user);
+ return user;
+}
+
+async function initializeConversations() {
+ await refreshConversationList();
+ if (!conversations.length) {
+ await createConversation();
+ setChatDisabled(false);
+ return;
+ }
+ const storedActive = readStoredActiveConversationId(currentUser);
+ const initial = conversations.find((item) => item.id === storedActive) || conversations[0];
+ await loadConversationDetail(initial.id);
+}
+
+function getActiveConversationSummary() {
+ if (!activeConversationId) {
+ return null;
+ }
+ return conversations.find((item) => item.id === activeConversationId) || null;
+}
+
+function conversationFingerprint(summary) {
+ if (!summary) {
+ return "";
+ }
+ const stamp = summary.updatedAt || summary.lastMessageAt || "";
+ const count =
+ typeof summary.messageCount === "number" ? String(summary.messageCount) : "";
+ return `${stamp}|${count}`;
+}
+
+async function refreshActiveConversationIncremental() {
+ const conversationId = activeConversationId;
+ if (!conversationId) {
+ return;
+ }
+ const existing = conversationCache.get(conversationId);
+ const existingMessages = existing && Array.isArray(existing.messages) ? existing.messages : [];
+ const lastKnown = existingMessages.length ? existingMessages[existingMessages.length - 1] : null;
+
+ const data = await requestJson(`/api/conversations/${conversationId}`);
+ const conversation = data.conversation;
+ if (!conversation || conversation.id !== conversationId) {
+ await loadConversationDetail(conversationId);
+ return;
+ }
+
+ updateConversationSummary({
+ id: conversation.id,
+ title: conversation.title,
+ autoTitle: conversation.autoTitle,
+ updatedAt: conversation.updatedAt,
+ lastMessageAt: conversation.lastMessageAt,
+ messageCount: conversation.messages ? conversation.messages.length : 0
+ });
+ conversationCache.set(conversation.id, conversation);
+ renderConversationList();
+
+ const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
+ if (messages.length !== existingMessages.length) {
+ renderMessages(messages);
+ return;
+ }
+ if (!lastKnown || !lastKnown.id) {
+ renderMessages(messages);
+ return;
+ }
+ const index = messages.findIndex((message) => message && message.id === lastKnown.id);
+ if (index === -1) {
+ renderMessages(messages);
+ return;
+ }
+ const next = messages.slice(index + 1);
+ if (next.length) {
+ appendMessages(next);
+ }
+
+ const last = messages.length ? messages[messages.length - 1] : null;
+ if (last && last.id) {
+ const element = getMessageElementById(last.id);
+ const nextText = last.text || "";
+ if (element && element.dataset.messageText !== nextText) {
+ updateMessageElement(element, last.role, nextText, { messageId: last.id });
+ }
+ }
+}
+
+async function pollConversationUpdates() {
+ if (document.hidden) {
+ return;
+ }
+ if (pollInFlight) {
+ return;
+ }
+ if (chatInput && chatInput.disabled) {
+ return;
+ }
+ if (!activeConversationId) {
+ return;
+ }
+
+ pollInFlight = true;
+ try {
+ const before = conversationFingerprint(getActiveConversationSummary());
+ await refreshConversationList();
+ const afterSummary = getActiveConversationSummary();
+ if (!afterSummary) {
+ const next = conversations[0];
+ if (next) {
+ await loadConversationDetail(next.id);
+ } else {
+ await createConversation();
+ }
+ return;
+ }
+ const cached = conversationCache.get(afterSummary.id);
+ const cachedMessages = cached && Array.isArray(cached.messages) ? cached.messages : null;
+ if (
+ cachedMessages &&
+ typeof afterSummary.messageCount === "number" &&
+ cachedMessages.length !== afterSummary.messageCount
+ ) {
+ await refreshActiveConversationIncremental();
+ return;
+ }
+ const after = conversationFingerprint(afterSummary);
+ if (after && after !== before) {
+ await refreshActiveConversationIncremental();
+ }
+ } catch (error) {
+ // ignore polling errors
+ } finally {
+ pollInFlight = false;
+ }
+}
+
+function startPolling() {
+ if (pollTimer) {
+ return;
+ }
+ pollTimer = setInterval(() => {
+ pollConversationUpdates();
+ }, 3000);
+}
+
+function stopPolling() {
+ if (!pollTimer) {
+ return;
+ }
+ clearInterval(pollTimer);
+ pollTimer = null;
+}
+
+let bootstrapToken = 0;
+
+async function bootstrap() {
+ const token = ++bootstrapToken;
+ stopPolling();
+ pollInFlight = false;
+ loadToken += 1;
+
+ currentUser = null;
+ applyDrawerOpen(false);
+
+ conversations = [];
+ activeConversationId = null;
+ conversationCache.clear();
+ messageElementById.clear();
+ profilePromptShown = false;
+
+ if (conversationList) {
+ conversationList.innerHTML = "";
+ }
+ if (chatLog) {
+ chatLog.innerHTML = "";
+ }
+ renderSuggestions([]);
+ setChatDisabled(true);
+ if (newConversationButton) {
+ newConversationButton.disabled = true;
+ }
+
+ if (userChip) {
+ userChip.textContent = "正在加载资料...";
+ userChip.classList.remove("auth-required");
+ }
+ if (userMenuButton) {
+ userMenuButton.classList.remove("auth-required");
+ userMenuButton.removeAttribute("title");
+ userMenuButton.setAttribute("aria-label", "用户菜单");
+ }
+ if (menuName) {
+ menuName.textContent = "---";
+ }
+ if (menuEmail) {
+ menuEmail.textContent = "---";
+ }
+ if (menuCompany) {
+ menuCompany.textContent = "---";
+ }
+ if (menuDomain) {
+ menuDomain.textContent = "---";
+ }
+ if (logoutButton && transport.kind === "vscode") {
+ logoutButton.textContent = "登录";
+ }
+ if (adminLink) {
+ adminLink.hidden = true;
+ }
+
+ try {
+ const user = await loadProfile();
+ if (token !== bootstrapToken) {
+ return;
+ }
+ currentUser = user || null;
+ applyDrawerOpen(false);
+ if (newConversationButton) {
+ newConversationButton.disabled = false;
+ }
+ if (logoutButton && transport.kind === "vscode") {
+ logoutButton.textContent = "退出登录";
+ }
+ if (userChip) {
+ userChip.classList.remove("auth-required");
+ }
+ if (userMenuButton) {
+ userMenuButton.classList.remove("auth-required");
+ userMenuButton.removeAttribute("title");
+ userMenuButton.setAttribute("aria-label", "用户菜单");
+ }
+
+ await initializeConversations();
+ if (token !== bootstrapToken) {
+ return;
+ }
+ startPolling();
+ } catch (error) {
+ if (token !== bootstrapToken) {
+ return;
+ }
+ const errorMessage = buildErrorMessage(error);
+ const isAuthError = /not_authenticated|login required|(^|\\D)401(\\D|$)/i.test(
+ String(errorMessage || "")
+ );
+ const hint =
+ !isAuthError && transport.kind === "vscode"
+ ? "\n\n可在 VSCode 设置中配置 `smart.aiChatBaseUrl` 指向可访问的 AiChat 后端。"
+ : "";
+ currentUser = null;
+ applyDrawerOpen(false);
+ if (userChip) {
+ userChip.textContent = transport.kind === "vscode" ? "未登录" : "Signed out";
+ if (transport.kind === "vscode") {
+ userChip.classList.add("auth-required");
+ }
+ }
+ if (userMenuButton && transport.kind === "vscode") {
+ userMenuButton.classList.add("auth-required");
+ userMenuButton.title = "点击登录";
+ userMenuButton.setAttribute("aria-label", "未登录,点击登录");
+ }
+ if (logoutButton && transport.kind === "vscode") {
+ logoutButton.textContent = "登录";
+ }
+ if (chatLog) {
+ chatLog.innerHTML = "";
+ chatLog.appendChild(
+ createMessageElement(
+ "system",
+ isAuthError ? "请先登录 RT-Thread 账号。" : `初始化失败:${errorMessage}${hint}`
+ )
+ );
+ }
+ setChatDisabled(true, isAuthError ? "请先登录..." : "加载失败...");
+ }
+}
+
+if (chatForm) {
+ chatForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const text = chatInput.value.trim();
+ if (!text) {
+ return;
+ }
+ chatInput.value = "";
+ setChatDisabled(true);
+ const userElement = appendMessage("user", text);
+ const pendingElement = appendMessage("bot", "AI 正在回答...", { pending: true });
+ renderSuggestions([]);
+ try {
+ await sendMessage(text, pendingElement, userElement);
+ } catch (error) {
+ updateMessageElement(
+ pendingElement,
+ "system",
+ `发送失败:${buildErrorMessage(error)}`
+ );
+ renderSuggestions([]);
+ } finally {
+ setChatDisabled(false);
+ }
+ });
+}
+
+if (logoutButton) {
+ logoutButton.addEventListener("click", async () => {
+ if (transport.kind !== "vscode") {
+ await transport.signOut();
+ return;
+ }
+
+ setChatDisabled(true, "处理中...");
+ try {
+ if (currentUser) {
+ await transport.signOut();
+ } else {
+ await transport.signIn();
+ }
+ } catch (error) {
+ if (chatLog) {
+ chatLog.appendChild(
+ createMessageElement("system", `操作失败:${buildErrorMessage(error)}`)
+ );
+ }
+ } finally {
+ await bootstrap();
+ }
+ });
+}
+
+const profileLink = document.getElementById("profile-link");
+if (profileLink && transport.kind === "vscode") {
+ profileLink.addEventListener("click", (event) => {
+ event.preventDefault();
+ transport.openExternal(profileLink.getAttribute("href") || "/profile");
+ });
+}
+
+if (adminLink && transport.kind === "vscode") {
+ adminLink.addEventListener("click", (event) => {
+ event.preventDefault();
+ transport.openExternal(
+ adminLink.getAttribute("href") || "/rt_thread_adzxcdfwrqwafvdsf_admin"
+ );
+ });
+}
+
+if (transport.kind === "vscode") {
+ document.addEventListener(
+ "click",
+ (event) => {
+ const target = event && event.target ? event.target : null;
+ if (!(target instanceof Element)) {
+ return;
+ }
+ const anchor = target.closest("a");
+ if (!anchor) {
+ return;
+ }
+ const href = anchor.getAttribute("href");
+ if (!href || href.startsWith("#")) {
+ return;
+ }
+ event.preventDefault();
+ transport.openExternal(href);
+ },
+ true
+ );
+}
+
+if (newConversationButton) {
+ newConversationButton.addEventListener("click", async () => {
+ if (!currentUser) {
+ if (chatLog) {
+ chatLog.appendChild(createMessageElement("system", "请先登录 RT-Thread 账号。"));
+ }
+ return;
+ }
+ try {
+ await createConversation();
+ } catch (error) {
+ if (chatLog) {
+ chatLog.appendChild(
+ createMessageElement("system", `操作失败:${buildErrorMessage(error)}`)
+ );
+ }
+ }
+ });
+}
+
+initializeUserMenu();
+applySidebarCollapsed(readStoredSidebarCollapsed());
+
+if (drawerTriggerButton) {
+ drawerTriggerButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ applyDrawerOpen(true);
+ });
+}
+
+if (drawerBackdrop) {
+ drawerBackdrop.addEventListener("click", () => {
+ applyDrawerOpen(false);
+ });
+}
+
+document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape" && drawerOpen) {
+ applyDrawerOpen(false);
+ }
+});
+
+if (toggleConversationButton) {
+ toggleConversationButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ const next = !sidebarCollapsed;
+ applySidebarCollapsed(next);
+ storeSidebarCollapsed(next);
+ });
+}
+bootstrap().catch(() => {});
diff --git a/src/ai/chatApi.ts b/src/ai/chatApi.ts
new file mode 100644
index 0000000..90089a9
--- /dev/null
+++ b/src/ai/chatApi.ts
@@ -0,0 +1,135 @@
+export type ChatApiError =
+ | { kind: 'network'; message: string }
+ | {
+ kind: 'http';
+ status: number;
+ message: string;
+ error?: string;
+ body?: string;
+ requestId?: string;
+ conversationId?: string;
+ }
+ | { kind: 'parse'; message: string; body?: string; requestId?: string };
+
+export type ChatMeResponse = {
+ user: {
+ id: string;
+ role?: string;
+ avatar?: string | null;
+ name?: string | null;
+ email?: string | null;
+ };
+ tokens?: {
+ accessToken?: string;
+ scope?: string;
+ expiresIn?: number;
+ };
+};
+
+export type ChatResponse = {
+ reply: string;
+ conversationId?: string;
+ suggestions?: string[];
+};
+
+function normalizeBaseUrl(baseUrl: string): string {
+ const trimmed = (baseUrl || '').trim();
+ if (!trimmed) {
+ return '';
+ }
+ return trimmed.replace(/\/+$/, '');
+}
+
+function formatHttpError(status: number, parsed: any, fallbackBody?: string, requestId?: string): ChatApiError {
+ const error = typeof parsed?.error === 'string' ? parsed.error : undefined;
+ const conversationId = typeof parsed?.conversationId === 'string' ? parsed.conversationId : undefined;
+ const message =
+ typeof parsed?.message === 'string'
+ ? parsed.message
+ : typeof parsed?.error === 'string'
+ ? parsed.error
+ : `Request failed (${status})`;
+ const body = typeof fallbackBody === 'string' && fallbackBody.length ? fallbackBody : undefined;
+ return { kind: 'http', status, message, error, body, requestId, conversationId };
+}
+
+async function requestJson(
+ url: string,
+ init: RequestInit & { timeoutMs?: number },
+): Promise<{ ok: true; data: T } | { ok: false; error: ChatApiError }> {
+ const controller = new AbortController();
+ const timeoutMs = typeof init.timeoutMs === 'number' ? init.timeoutMs : 15_000;
+ const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
+
+ try {
+ const { timeoutMs: _timeoutMs, ...fetchInit } = init;
+ const res = await fetch(url, { ...fetchInit, signal: controller.signal });
+ const requestId = res.headers.get('x-request-id') || undefined;
+ const text = await res.text();
+ let parsed: any = undefined;
+ if (text) {
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ parsed = undefined;
+ }
+ }
+
+ if (!res.ok) {
+ return { ok: false, error: formatHttpError(res.status, parsed, text, requestId) };
+ }
+
+ if (parsed === undefined) {
+ return {
+ ok: false,
+ error: { kind: 'parse', message: 'Invalid JSON response.', body: text, requestId },
+ };
+ }
+
+ return { ok: true, data: parsed as T };
+ } catch (e: any) {
+ const msg = e?.name === 'AbortError' ? 'Request timeout.' : (e?.message ? String(e.message) : String(e));
+ return { ok: false, error: { kind: 'network', message: msg } };
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+function buildHeaders(sid: string, extra?: Record): Record {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...(extra ?? {}),
+ };
+ if (sid) {
+ headers['Cookie'] = `sid=${sid}`;
+ headers['Authorization'] = `Bearer ${sid}`;
+ }
+ return headers;
+}
+
+export async function getMe(
+ baseUrl: string,
+ sid: string,
+): Promise<{ ok: true; data: ChatMeResponse } | { ok: false; error: ChatApiError }> {
+ const b = normalizeBaseUrl(baseUrl);
+ return requestJson(`${b}/api/me`, {
+ method: 'GET',
+ headers: buildHeaders(sid),
+ timeoutMs: 10_000,
+ });
+}
+
+export async function postChat(
+ baseUrl: string,
+ sid: string,
+ message: string,
+ conversationId: string | null,
+): Promise<{ ok: true; data: ChatResponse } | { ok: false; error: ChatApiError }> {
+ const b = normalizeBaseUrl(baseUrl);
+ return requestJson(`${b}/api/chat`, {
+ method: 'POST',
+ headers: buildHeaders(sid),
+ body: JSON.stringify({ message, conversationId }),
+ timeoutMs: 60_000,
+ });
+}
diff --git a/src/auth/authProvider.ts b/src/auth/authProvider.ts
index 439e754..729833f 100644
--- a/src/auth/authProvider.ts
+++ b/src/auth/authProvider.ts
@@ -6,6 +6,35 @@ import * as https from 'https';
import * as http from 'http';
import { URL } from 'url';
+const defaultAiChatBaseUrl = 'https://ai.rt-thread.org';
+
+function normalizeBaseUrl(baseUrl: string): string {
+ const trimmed = (baseUrl || '').trim();
+ if (!trimmed) {
+ return '';
+ }
+ return trimmed.replace(/\/+$/, '');
+}
+
+function getAiChatBaseUrl(): string {
+ const cfg = vscode.workspace.getConfiguration('smart');
+ const url = cfg.get('aiChatBaseUrl');
+ return normalizeBaseUrl(url || defaultAiChatBaseUrl);
+}
+
+function getAiChatTimeoutMs(): number {
+ const cfg = vscode.workspace.getConfiguration('smart');
+ const timeout = cfg.get('aiChatRequestTimeoutMs');
+ const value = typeof timeout === 'number' && Number.isFinite(timeout) ? timeout : 30000;
+ return Math.max(1000, Math.floor(value));
+}
+
+function shouldOpenAuthInVscode(): boolean {
+ const cfg = vscode.workspace.getConfiguration('smart');
+ const raw = cfg.get('authOpenInVscode');
+ return raw === true;
+}
+
// Session data structure persisted to VS Code Secret Storage
type StoredSession = {
id: string;
@@ -25,19 +54,28 @@ export class RTThreadAuthProvider implements vscode.AuthenticationProvider {
this.output.appendLine(`[${new Date().toISOString()}] ${msg}`);
}
private mask(value: string, keyHint?: string): string {
- if (!value) return '';
- const sensitive = ['token', 'access_token', 'code', 'open_id'];
+ if (!value) {
+ return '';
+ }
+ const sensitive = ['token', 'sid', 'access_token', 'code', 'open_id', 'sub'];
if (keyHint && sensitive.includes(keyHint)) {
- if (value.length <= 8) return '*'.repeat(Math.max(4, value.length));
+ if (value.length <= 8) {
+ return '*'.repeat(Math.max(4, value.length));
+ }
return `${value.slice(0, 4)}...${value.slice(-4)}`;
}
- if (value.length <= 8) return value; // keep short non-sensitive values
+ if (value.length <= 8) {
+ return value; // keep short non-sensitive values
+ }
return `${value.slice(0, 24)}...(${value.length})`; // long params truncated for readability
}
private redactBody(body: string): string {
let b = body || '';
try {
- b = b.replace(/("(?:open_id|token|access_token)"\s*:\s*")([^"]+)(")/gi, '$1***redacted***$3');
+ b = b.replace(
+ /("(?:open_id|sub|token|sid|access_token)"\s*:\s*")([^"]+)(")/gi,
+ '$1***redacted***$3',
+ );
} catch {
// ignore
}
@@ -52,6 +90,51 @@ export class RTThreadAuthProvider implements vscode.AuthenticationProvider {
return pairs.join('&');
}
+ private async tryCloseAuthTab(): Promise {
+ if (!this.authTabOpenedInVscode || !this.authTabTargetUri) {
+ return;
+ }
+
+ const expected = this.authTabTargetUri;
+ const expectedState =
+ this.currentState || new URLSearchParams(expected.query).get('state') || '';
+ this.authTabOpenedInVscode = false;
+ this.authTabTargetUri = undefined;
+
+ try {
+ const candidates: vscode.Tab[] = [];
+ for (const group of vscode.window.tabGroups.all) {
+ for (const tab of group.tabs) {
+ if (!(tab.input instanceof vscode.TabInputCustom)) {
+ continue;
+ }
+ const uri = tab.input.uri;
+ if (uri.scheme !== expected.scheme) {
+ continue;
+ }
+ if (uri.authority !== expected.authority) {
+ continue;
+ }
+ if (uri.path !== expected.path) {
+ continue;
+ }
+ if (expectedState) {
+ const gotState = new URLSearchParams(uri.query).get('state') || '';
+ if (!gotState.includes(expectedState)) {
+ continue;
+ }
+ }
+ candidates.push(tab);
+ }
+ }
+ if (candidates.length) {
+ await vscode.window.tabGroups.close(candidates, true);
+ }
+ } catch {
+ // ignore
+ }
+ }
+
// Pending login request (completed/failed through this Promise when browser callback arrives)
private pendingAuth: {
resolve: (value: vscode.AuthenticationSession | PromiseLike) => void;
@@ -61,6 +144,10 @@ export class RTThreadAuthProvider implements vscode.AuthenticationProvider {
// State associated with this login flow (for diagnosis/correlating request-callback)
private currentState: string | undefined;
+ private authTabOpenedInVscode = false;
+ private authTabTargetUri: vscode.Uri | undefined;
+ private authOpenedExternally = false;
+
// Secret Storage key name for saving/reading sessions
private readonly secretKey = `${RTThreadAuthProvider.id}.session`;
@@ -130,12 +217,18 @@ export class RTThreadAuthProvider implements vscode.AuthenticationProvider {
if (uri.path !== '/auth-callback') {
return;
}
+ await this.tryCloseAuthTab();
+ if (this.authOpenedExternally) {
+ this.authOpenedExternally = false;
+ vscode.window.setStatusBarMessage('$(check) 登录成功,可以关闭浏览器页面。', 5000);
+ }
this.log(`Received auth-callback: ${uri.toString(true)}`);
const params = new URLSearchParams(uri.query);
- let token = params.get('token') || '';
- // Username displayed in Accounts view. Prefer value from proxy userInfo
+ let token = '';
+ // Username displayed in Accounts view. Prefer value from backend exchange
// if available; fall back to callback param; finally to a generic label.
let username = params.get('username') || 'user';
+ let accountId = username;
const scopes = (params.get('scopes') || 'default').split(',').filter(Boolean);
const state = params.get('state') || '';
const code = params.get('code') || '';
@@ -149,34 +242,60 @@ export class RTThreadAuthProvider implements vscode.AuthenticationProvider {
this.log(`State check: no expected state recorded (possibly resumed process).`);
}
- // If no token but code is provided, try calling remote exchange interface to get open_id as token
- if (!token && code) {
+ // Prefer exchanging auth code to backend sid (stable user identity in our system).
+ if (code) {
try {
- const proxyUrl = `https://api.rt-thread.org/account/client_proxy_rt_agent.php?code=${encodeURIComponent(code)}`;
- this.log(`Exchanging code via proxy: code=${this.mask(code, 'code')} -> ${proxyUrl}`);
- const resp = await httpGet(proxyUrl);
- this.log(`Proxy response: status=${resp.status}, body_len=${(resp.body || '').length}`);
- this.log(`Proxy body preview: ${this.redactBody(resp.body || '')}`);
- const json = JSON.parse(resp.body || '{}');
- const openId: string | undefined = json?.open_id;
- // Try derive a better display name from userInfo
- try {
- const ui = json?.userInfo ?? {};
- const inferred = ui.username?.toString().trim();
- if (inferred) {
- username = inferred;
- this.log(`Resolved display name from userInfo: ${username}`);
- }
- } catch {
- // ignore userInfo parsing errors
+ // Upgrade to a backend session token (sid) for stable cross-client user mapping.
+ const baseUrl = getAiChatBaseUrl();
+ const exchangeUrl = `${baseUrl}/api/auth/rt/exchange`;
+ this.log(`Exchanging auth code to backend sid: ${exchangeUrl}`);
+ const exchangeResp = await httpPostJson(exchangeUrl, { code }, undefined, {
+ timeoutMs: getAiChatTimeoutMs(),
+ });
+ this.log(
+ `Backend exchange response: status=${exchangeResp.status}, body_len=${(exchangeResp.body || '').length}`,
+ );
+ this.log(`Backend exchange body preview: ${this.redactBody(exchangeResp.body || '')}`);
+ if (exchangeResp.status < 200 || exchangeResp.status >= 300) {
+ throw new Error(`Backend exchange failed with status ${exchangeResp.status}.`);
+ }
+ const exJson = JSON.parse(exchangeResp.body || '{}');
+ const sid: string | undefined = typeof exJson?.sid === 'string' ? exJson.sid : undefined;
+ const backendUserId: string | undefined =
+ typeof exJson?.userId === 'string' ? exJson.userId : undefined;
+ const displayName: string | undefined =
+ typeof exJson?.displayName === 'string' ? exJson.displayName : undefined;
+ if (!sid) {
+ throw new Error('Backend exchange response missing sid.');
}
- if (openId) {
- token = openId;
- this.log(`Obtained open_id from proxy: ${this.mask(openId, 'open_id')}`);
+ token = sid;
+ if (backendUserId) {
+ accountId = backendUserId;
}
+ if (displayName && displayName.trim()) {
+ username = displayName.trim();
+ this.log(`Resolved display name from backend: ${username}`);
+ }
+ this.log(`Obtained backend sid: ${this.mask(sid, 'sid')}`);
} catch (e) {
- // Ignore, will still enter no-token error branch later
- this.log(`Proxy exchange failed: ${String(e)}`);
+ const baseUrl = getAiChatBaseUrl() || defaultAiChatBaseUrl;
+ const message =
+ `RT-Thread 登录成功,但后端会话交换失败。` +
+ `请检查网络连接/代理,或在设置中配置 smart.aiChatBaseUrl(当前:${baseUrl})`;
+ this.log(`Auth flow failed: ${String(e)}`);
+ void vscode.window
+ .showErrorMessage(`${message}。点击查看日志。`, '查看日志')
+ .then((btn) => {
+ if (btn) {
+ this.output.show(true);
+ }
+ });
+ if (this.pendingAuth) {
+ this.pendingAuth.reject(e);
+ this.pendingAuth = undefined;
+ }
+ vscode.window.setStatusBarMessage('$(error) Sign-in failed: backend exchange error.', 3000);
+ return;
}
}
@@ -203,7 +322,7 @@ export class RTThreadAuthProvider implements vscode.AuthenticationProvider {
const session: vscode.AuthenticationSession = {
id: `${Date.now()}`,
accessToken: token,
- account: { id: username, label: username },
+ account: { id: accountId, label: username },
scopes,
};
@@ -282,7 +401,8 @@ export class RTThreadAuthProvider implements vscode.AuthenticationProvider {
const clientId = '46627107';
const authorizePage = 'https://www.rt-thread.org/account/user/index.html';
- const scopesStr = scopes.length ? scopes.join(',') : 'basic';
+ const effectiveScopes = scopes.length ? scopes : ['phone'];
+ const scopesStr = effectiveScopes.join(',');
const authorizeUrl = vscode.Uri.parse(
`${authorizePage.replace(/\/$/, '')}` +
`?response_type=code` +
@@ -292,19 +412,39 @@ export class RTThreadAuthProvider implements vscode.AuthenticationProvider {
`&client_id=${encodeURIComponent(clientId)}` +
`&redirect_uri=${encodeURIComponent(callbackBase)}`,
);
+ const authorizeUrlStr = authorizeUrl.toString(true);
try {
- const u = new URL(authorizeUrl.toString());
- this.log(`Opening authorize URL: ${authorizeUrl.toString()}`);
+ const u = new URL(authorizeUrlStr);
+ this.log(`Opening authorize URL: ${authorizeUrlStr}`);
this.log(`Authorize query: ${this.dumpParams(u.searchParams)}`);
} catch {
- this.log(`Opening authorize URL (raw): ${authorizeUrl.toString()}`);
+ this.log(`Opening authorize URL (raw): ${authorizeUrlStr}`);
+ }
+ this.authTabOpenedInVscode = false;
+ this.authTabTargetUri = undefined;
+ this.authOpenedExternally = false;
+ if (shouldOpenAuthInVscode()) {
+ this.authTabTargetUri = authorizeUrl;
+ try {
+ await vscode.commands.executeCommand('simpleBrowser.show', authorizeUrlStr);
+ this.authTabOpenedInVscode = true;
+ } catch (e: any) {
+ this.authTabOpenedInVscode = false;
+ this.authTabTargetUri = undefined;
+ this.authOpenedExternally = true;
+ this.log(`simpleBrowser.show failed, fallback to external browser: ${String(e?.message ?? e)}`);
+ await vscode.env.openExternal(authorizeUrl);
+ }
+ } else {
+ this.authOpenedExternally = true;
+ await vscode.env.openExternal(authorizeUrl);
}
- await vscode.env.openExternal(authorizeUrl);
const session = await new Promise((resolve, reject) => {
// Set login timeout: fail if no callback within 1 minutes
const timeout = setTimeout(() => {
this.pendingAuth = undefined;
+ void this.tryCloseAuthTab();
const err = new Error('Login timed out. Please try again.');
reject(err);
}, 1 * 60 * 1000);
@@ -328,16 +468,29 @@ export class RTThreadAuthProvider implements vscode.AuthenticationProvider {
}
-// Minimal GET request (for code -> open_id exchange)
-async function httpGet(urlStr: string, headers?: Record): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
+async function httpPostJson(
+ urlStr: string,
+ body: any,
+ headers?: Record,
+ requestConfig?: { timeoutMs?: number },
+): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
const u = new URL(urlStr);
+ const payload = Buffer.from(JSON.stringify(body ?? {}), 'utf-8');
+ const timeoutMs =
+ typeof requestConfig?.timeoutMs === 'number' && Number.isFinite(requestConfig.timeoutMs)
+ ? requestConfig.timeoutMs
+ : 30000;
const opts: https.RequestOptions = {
- method: 'GET',
+ method: 'POST',
protocol: u.protocol,
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + (u.search || ''),
- headers: { ...headers },
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': String(payload.length),
+ ...(headers ?? {}),
+ },
};
const mod = u.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
@@ -349,12 +502,15 @@ async function httpGet(urlStr: string, headers?: Record): Promis
resolve({ status: res.statusCode || 0, headers: res.headers, body: bodyStr });
});
});
+ req.setTimeout(Math.max(1000, Math.floor(timeoutMs)), () => {
+ req.destroy(new Error('Request timeout.'));
+ });
req.on('error', reject);
+ req.write(payload);
req.end();
});
}
-
// Provide initialization entry point, register authentication provider, URI handler, commands uniformly here
export function initRTThreadAuth(context: vscode.ExtensionContext): void {
const provider = new RTThreadAuthProvider(context);
diff --git a/src/dock.ts b/src/dock.ts
index 3508976..7836350 100644
--- a/src/dock.ts
+++ b/src/dock.ts
@@ -38,6 +38,15 @@ class CmdTreeDataProvider implements vscode.TreeDataProvider {
arguments: [],
};
+ let chat = new vscode.TreeItem("AI Chat", vscode.TreeItemCollapsibleState.None);
+ chat.iconPath = new vscode.ThemeIcon("comment-discussion");
+ chat.label = "AI Chat";
+ chat.command = {
+ command: "extension.showChat",
+ title: "show ai chat page",
+ arguments: [],
+ };
+
let about = new vscode.TreeItem("About", vscode.TreeItemCollapsibleState.None);
about.iconPath = new vscode.ThemeIcon("info");
about.label = "About";
@@ -47,7 +56,7 @@ class CmdTreeDataProvider implements vscode.TreeDataProvider {
arguments: [],
};
- return [createProject, rtSetting, about];
+ return [createProject, rtSetting, chat, about];
}
if (!element) {
@@ -82,6 +91,16 @@ class CmdTreeDataProvider implements vscode.TreeDataProvider {
children.push(item);
};
+ let chat = new vscode.TreeItem("AI Chat", vscode.TreeItemCollapsibleState.None);
+ chat.iconPath = new vscode.ThemeIcon("comment-discussion");
+ chat.label = "AI Chat";
+ chat.command = {
+ command: "extension.showChat",
+ title: "show ai chat page",
+ arguments: [],
+ };
+ children.push(chat);
+
let analyze = new vscode.TreeItem("Symbolic Analysis", vscode.TreeItemCollapsibleState.None);
analyze.iconPath = new vscode.ThemeIcon("search-fuzzy");
analyze.label = "Symbolic Analysis";
diff --git a/src/extension.ts b/src/extension.ts
index 352fdd7..f344f0b 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -8,6 +8,7 @@ import { openSettingWebview } from './webviews/setting';
import { openAboutWebview } from './webviews/about';
import { openCreateProjectWebview } from './webviews/create-project';
import { openAnalyzeWebview } from './webviews/analyze';
+import { openChatWebview } from './webviews/chat';
import { initOnDidChangeListener } from './listener';
import { executeCommand, initTerminal } from './terminal';
import { getMenuItems, getParallelBuildNumber } from './smart';
@@ -130,6 +131,9 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('extension.showAnalyze', () => {
openAnalyzeWebview(context);
});
+ vscode.commands.registerCommand('extension.showChat', () => {
+ openChatWebview(context);
+ });
if (isRTThreadWorksapce) {
vscode.commands.registerCommand('extension.showWorkspaceSettings', () => {
diff --git a/src/webviews/chat.ts b/src/webviews/chat.ts
new file mode 100644
index 0000000..d552b5f
--- /dev/null
+++ b/src/webviews/chat.ts
@@ -0,0 +1,622 @@
+import * as vscode from 'vscode';
+
+let chatViewPanel: vscode.WebviewPanel | null = null;
+
+const title = 'RT-Thread AI Chat';
+const defaultApiBaseUrl = 'https://ai.rt-thread.org';
+
+type RpcResponseError = {
+ message: string;
+ status?: number;
+ requestId?: string;
+};
+
+function normalizeBaseUrl(baseUrl: string): string {
+ const trimmed = (baseUrl || '').trim();
+ if (!trimmed) {
+ return '';
+ }
+ return trimmed.replace(/\/+$/, '');
+}
+
+function getAiChatBaseUrl(): string {
+ const cfg = vscode.workspace.getConfiguration('smart');
+ const url = cfg.get('aiChatBaseUrl');
+ return normalizeBaseUrl(url || defaultApiBaseUrl);
+}
+
+function getAiChatTimeoutMs(): number {
+ const cfg = vscode.workspace.getConfiguration('smart');
+ const timeout = cfg.get('aiChatRequestTimeoutMs');
+ const value = typeof timeout === 'number' && Number.isFinite(timeout) ? timeout : 30000;
+ return Math.max(1000, Math.floor(value));
+}
+
+function getNonce(): string {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ let nonce = '';
+ for (let i = 0; i < 32; i++) {
+ nonce += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ return nonce;
+}
+
+async function getRTThreadSession(createIfNone: boolean): Promise {
+ try {
+ return await vscode.authentication.getSession('rt-thread', [], { createIfNone });
+ } catch {
+ return undefined;
+ }
+}
+
+function buildHeaders(sid: string, extra?: Record): Record {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...(extra ?? {}),
+ };
+ if (sid) {
+ headers['Cookie'] = `sid=${sid}`;
+ headers['Authorization'] = `Bearer ${sid}`;
+ }
+ return headers;
+}
+
+function isAllowedApiPath(pathname: string): boolean {
+ const p = String(pathname || '').trim();
+ if (!p.startsWith('/')) {
+ return false;
+ }
+ return p.startsWith('/api/');
+}
+
+function joinBackendUrl(baseUrl: string, maybePath: string): string {
+ const raw = String(maybePath || '').trim();
+ if (!raw) {
+ return '';
+ }
+ if (/^https?:\/\//i.test(raw)) {
+ const b = normalizeBaseUrl(baseUrl);
+ return raw.startsWith(`${b}/`) || raw === b ? raw : '';
+ }
+ if (!isAllowedApiPath(raw)) {
+ return '';
+ }
+ return `${normalizeBaseUrl(baseUrl)}${raw}`;
+}
+
+async function proxyJson(
+ baseUrl: string,
+ sid: string,
+ url: string,
+ options: any,
+): Promise<{ ok: true; data: any } | { ok: false; error: RpcResponseError }> {
+ const fullUrl = joinBackendUrl(baseUrl, url);
+ if (!fullUrl) {
+ return { ok: false, error: { message: 'Forbidden request path.' } };
+ }
+
+ const method = typeof options?.method === 'string' ? options.method.toUpperCase() : 'GET';
+ const rawBody = options?.body;
+ const hasBody = rawBody !== undefined && rawBody !== null && method !== 'GET' && method !== 'HEAD';
+ const body =
+ hasBody && typeof rawBody === 'string'
+ ? rawBody
+ : hasBody
+ ? JSON.stringify(rawBody)
+ : undefined;
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), getAiChatTimeoutMs());
+
+ try {
+ const res = await fetch(fullUrl, {
+ method,
+ headers: buildHeaders(sid, typeof options?.headers === 'object' ? options.headers : undefined),
+ body,
+ signal: controller.signal,
+ });
+ const requestId = res.headers.get('x-request-id') || undefined;
+ const text = await res.text();
+ let parsed: any = undefined;
+ if (text) {
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ parsed = undefined;
+ }
+ }
+
+ if (!res.ok) {
+ const message =
+ typeof parsed?.message === 'string'
+ ? parsed.message
+ : typeof parsed?.error === 'string'
+ ? parsed.error
+ : res.statusText || `Request failed (${res.status})`;
+ return { ok: false, error: { message, status: res.status, requestId } };
+ }
+
+ if (parsed === undefined) {
+ return { ok: false, error: { message: 'Invalid JSON response.', requestId } };
+ }
+
+ return { ok: true, data: parsed };
+ } catch (e: any) {
+ const msg = e?.name === 'AbortError' ? 'Request timeout.' : e?.message ? String(e.message) : String(e);
+ return { ok: false, error: { message: msg } };
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+type StreamEntry = { controller: AbortController; requestId?: string };
+
+async function proxyStream(
+ post: (message: any) => void,
+ activeStreams: Map,
+ baseUrl: string,
+ sid: string,
+ streamId: string,
+ url: string,
+ method: string,
+ body: any,
+): Promise {
+ const fullUrl = joinBackendUrl(baseUrl, url);
+ if (!fullUrl) {
+ post({ type: 'rt.chat.stream.error', id: streamId, error: { message: 'Forbidden request path.' } });
+ post({ type: 'rt.chat.stream.end', id: streamId });
+ return;
+ }
+
+ const controller = new AbortController();
+ activeStreams.set(streamId, { controller });
+ const timeout = setTimeout(() => controller.abort(), getAiChatTimeoutMs());
+
+ try {
+ const rawBody =
+ body !== undefined && body !== null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined;
+ const res = await fetch(fullUrl, {
+ method: method || 'POST',
+ headers: buildHeaders(sid),
+ body: rawBody,
+ signal: controller.signal,
+ });
+ clearTimeout(timeout);
+ const requestId = res.headers.get('x-request-id') || undefined;
+ const entry = activeStreams.get(streamId);
+ if (entry) {
+ entry.requestId = requestId;
+ }
+
+ if (!res.ok) {
+ const text = await res.text();
+ let parsed: any = undefined;
+ if (text) {
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ parsed = undefined;
+ }
+ }
+ const message =
+ typeof parsed?.message === 'string'
+ ? parsed.message
+ : typeof parsed?.error === 'string'
+ ? parsed.error
+ : res.statusText || `Request failed (${res.status})`;
+ post({ type: 'rt.chat.stream.error', id: streamId, error: { message, status: res.status, requestId } });
+ return;
+ }
+
+ if (!res.body) {
+ post({
+ type: 'rt.chat.stream.error',
+ id: streamId,
+ error: { message: 'Stream not supported.', requestId },
+ });
+ return;
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder('utf-8');
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith('data:')) {
+ continue;
+ }
+ const dataStr = trimmed.slice(5).trim();
+ if (!dataStr) {
+ continue;
+ }
+ if (dataStr === '[DONE]') {
+ return;
+ }
+ let payload: any = undefined;
+ try {
+ payload = JSON.parse(dataStr);
+ } catch {
+ payload = undefined;
+ }
+ if (!payload) {
+ continue;
+ }
+ post({ type: 'rt.chat.stream.event', id: streamId, event: payload });
+ }
+ }
+ } catch (e: any) {
+ if (controller.signal.aborted) {
+ return;
+ }
+ const msg = e?.message ? String(e.message) : String(e);
+ const requestId = activeStreams.get(streamId)?.requestId;
+ post({ type: 'rt.chat.stream.error', id: streamId, error: { message: msg, requestId } });
+ } finally {
+ clearTimeout(timeout);
+ activeStreams.delete(streamId);
+ post({ type: 'rt.chat.stream.end', id: streamId });
+ }
+}
+
+function getWebviewHtml(context: vscode.ExtensionContext, webview: vscode.Webview): string {
+ const nonce = getNonce();
+
+ const cssUri = webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, 'resources', 'chat-ui', 'app.css'));
+ const jsUri = webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, 'resources', 'chat-ui', 'chat-ui.js'));
+
+ const csp = [
+ `default-src 'none';`,
+ `img-src ${webview.cspSource} https: data:;`,
+ `style-src ${webview.cspSource} 'unsafe-inline';`,
+ `font-src ${webview.cspSource} data:;`,
+ `script-src 'nonce-${nonce}';`,
+ ].join(' ');
+
+ return /* html */ `
+
+
+
+
+
+ ${title}
+
+
+
+
+
+ RT-Thread AI 智能对话
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+export function openChatWebview(context: vscode.ExtensionContext) {
+ if (chatViewPanel) {
+ chatViewPanel.reveal(vscode.ViewColumn.Beside);
+ return chatViewPanel;
+ }
+
+ const panel = vscode.window.createWebviewPanel('rt-thread-chat', title, vscode.ViewColumn.Beside, {
+ enableScripts: true,
+ retainContextWhenHidden: true,
+ localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'resources', 'chat-ui')],
+ });
+
+ const iconPath = vscode.Uri.joinPath(context.extensionUri, 'resources', 'images', 'rt-thread.png');
+ panel.iconPath = iconPath;
+ panel.webview.html = getWebviewHtml(context, panel.webview);
+
+ const activeStreams = new Map();
+ let disposed = false;
+
+ const post = (message: any) => {
+ if (disposed) {
+ return;
+ }
+ void panel.webview.postMessage(message);
+ };
+
+ const authChangeDisposable = vscode.authentication.onDidChangeSessions((event) => {
+ if (event.provider.id !== 'rt-thread') {
+ return;
+ }
+ post({ type: 'rt.chat.auth.sessionsChanged' });
+ });
+
+ panel.onDidDispose(() => {
+ disposed = true;
+ authChangeDisposable.dispose();
+ for (const entry of activeStreams.values()) {
+ try {
+ entry.controller.abort();
+ } catch {
+ // ignore
+ }
+ }
+ activeStreams.clear();
+ chatViewPanel = null;
+ });
+
+ panel.webview.onDidReceiveMessage(async (message) => {
+ if (!message || typeof message.type !== 'string' || typeof message.id !== 'string') {
+ return;
+ }
+
+ const baseUrl = getAiChatBaseUrl();
+
+ switch (message.type) {
+ case 'rt.chat.rpc.request': {
+ const op = typeof message.op === 'string' ? message.op : '';
+ const payload = message.payload;
+
+ if (op === 'auth.signIn') {
+ try {
+ // When backend check fails we may still have a stored (stale) sid.
+ // Force a fresh login so users don't get stuck in a "未登录" loop.
+ try {
+ await vscode.commands.executeCommand('extension.RTThreadSignOut');
+ } catch {
+ // ignore
+ }
+
+ const session = await getRTThreadSession(true);
+ if (!session?.accessToken) {
+ post({
+ type: 'rt.chat.rpc.response',
+ id: message.id,
+ ok: false,
+ error: { message: '登录已取消。' } satisfies RpcResponseError,
+ });
+ return;
+ }
+ post({ type: 'rt.chat.rpc.response', id: message.id, ok: true, data: { ok: true } });
+ } catch (e: any) {
+ post({
+ type: 'rt.chat.rpc.response',
+ id: message.id,
+ ok: false,
+ error: { message: e?.message ? String(e.message) : String(e) } satisfies RpcResponseError,
+ });
+ }
+ return;
+ }
+
+ if (op === 'auth.signOut') {
+ try {
+ await vscode.commands.executeCommand('extension.RTThreadSignOut');
+ post({ type: 'rt.chat.rpc.response', id: message.id, ok: true, data: { ok: true } });
+ } catch (e: any) {
+ post({
+ type: 'rt.chat.rpc.response',
+ id: message.id,
+ ok: false,
+ error: { message: e?.message ? String(e.message) : String(e) } satisfies RpcResponseError,
+ });
+ }
+ return;
+ }
+
+ if (op === 'ui.confirm') {
+ const msgText = payload && typeof payload.message === 'string' ? payload.message : '';
+ const confirmLabel = '确定';
+ const picked = await vscode.window.showWarningMessage(msgText || '确认操作?', { modal: true }, confirmLabel);
+ post({ type: 'rt.chat.rpc.response', id: message.id, ok: true, data: picked === confirmLabel });
+ return;
+ }
+
+ if (op === 'ui.prompt') {
+ const msgText = payload && typeof payload.message === 'string' ? payload.message : '';
+ const value = payload && typeof payload.value === 'string' ? payload.value : '';
+ const result = await vscode.window.showInputBox({
+ prompt: msgText || '请输入内容',
+ value,
+ ignoreFocusOut: true,
+ });
+ post({ type: 'rt.chat.rpc.response', id: message.id, ok: true, data: result ?? null });
+ return;
+ }
+
+ if (op === 'ui.notify') {
+ const kind = payload && typeof payload.kind === 'string' ? payload.kind : 'info';
+ const msgText = payload && typeof payload.message === 'string' ? payload.message : '';
+ try {
+ if (kind === 'error') {
+ await vscode.window.showErrorMessage(msgText || '发生错误。');
+ } else if (kind === 'warning') {
+ await vscode.window.showWarningMessage(msgText || '提示。');
+ } else {
+ await vscode.window.showInformationMessage(msgText || '提示。');
+ }
+ } catch {
+ // ignore
+ }
+ post({ type: 'rt.chat.rpc.response', id: message.id, ok: true, data: { ok: true } });
+ return;
+ }
+
+ if (op === 'openExternal') {
+ const rawUrl = payload && typeof payload.url === 'string' ? payload.url : '';
+ const b = baseUrl || defaultApiBaseUrl;
+ const trimmed = String(rawUrl || '').trim();
+ const isAbsolute = /^(https?:|mailto:)/i.test(trimmed);
+ const target = isAbsolute ? trimmed : `${b}${trimmed || '/'}`;
+ try {
+ await vscode.env.openExternal(vscode.Uri.parse(target));
+ } catch {
+ // ignore
+ }
+ post({ type: 'rt.chat.rpc.response', id: message.id, ok: true, data: { ok: true } });
+ return;
+ }
+
+ if (op !== 'json') {
+ post({
+ type: 'rt.chat.rpc.response',
+ id: message.id,
+ ok: false,
+ error: { message: `Unknown op: ${op}` } satisfies RpcResponseError,
+ });
+ return;
+ }
+
+ const session = await getRTThreadSession(false);
+ if (!session?.accessToken) {
+ post({
+ type: 'rt.chat.rpc.response',
+ id: message.id,
+ ok: false,
+ error: { message: 'Login required.', status: 401 } satisfies RpcResponseError,
+ });
+ return;
+ }
+
+ const url = payload && typeof payload.url === 'string' ? payload.url : '';
+ const options = payload ? payload.options : undefined;
+ const resp = await proxyJson(baseUrl, session.accessToken, url, options);
+ if (resp.ok) {
+ post({ type: 'rt.chat.rpc.response', id: message.id, ok: true, data: resp.data });
+ } else {
+ post({ type: 'rt.chat.rpc.response', id: message.id, ok: false, error: resp.error });
+ }
+ return;
+ }
+
+ case 'rt.chat.stream.start': {
+ const url = typeof message.url === 'string' ? message.url : '';
+ const method = typeof message.method === 'string' ? message.method : 'POST';
+ const body = message.body;
+
+ const session = await getRTThreadSession(false);
+ if (!session?.accessToken) {
+ post({
+ type: 'rt.chat.stream.error',
+ id: message.id,
+ error: { message: 'Login required.', status: 401 } satisfies RpcResponseError,
+ });
+ post({ type: 'rt.chat.stream.end', id: message.id });
+ return;
+ }
+
+ void proxyStream(post, activeStreams, baseUrl, session.accessToken, message.id, url, method, body);
+ return;
+ }
+
+ case 'rt.chat.stream.cancel': {
+ const entry = activeStreams.get(message.id);
+ if (entry) {
+ try {
+ entry.controller.abort();
+ } catch {
+ // ignore
+ }
+ }
+ return;
+ }
+ }
+ });
+
+ chatViewPanel = panel;
+ return chatViewPanel;
+}