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 = 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; +}