feat: 灵犀 Studio Web UI 定制版
Build / build (push) Has been cancelled
NPM Lockfile Check / npm ci --ignore-scripts (push) Has been cancelled
Playwright / e2e (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yi
2026-06-05 11:29:11 +08:00
commit 7d10320a82
643 changed files with 164406 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>灵犀 Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Prevent FOUC by applying theme classes immediately -->
<script>
(function() {
try {
const brightness = localStorage.getItem('hermes_brightness') || 'system';
const style = localStorage.getItem('hermes_style') || 'ink';
// Resolve dark mode
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = brightness === 'dark' || (brightness === 'system' && prefersDark);
// Resolve comic style
const isComic = style === 'comic';
// Apply classes immediately
if (isDark) {
document.documentElement.classList.add('dark');
}
if (isComic) {
document.documentElement.classList.add('comic');
}
} catch (e) {
console.warn('Failed to apply theme:', e);
}
})();
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>Claude Code</title>
<path clip-rule="evenodd"
d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z"
fill="#D97757"
fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<defs>
<linearGradient id="lx-bg" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563eb"/>
<stop offset="1" stop-color="#0891b2"/>
</linearGradient>
<linearGradient id="lx-glow" x1="24" y1="10" x2="24" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="1" stop-color="#e0f2fe" stop-opacity="0.7"/>
</linearGradient>
</defs>
<rect x="3" y="3" width="42" height="42" rx="11" fill="url(#lx-bg)"/>
<path d="M24 11 L33 24 L24 37 L15 24 Z" fill="url(#lx-glow)"/>
<circle cx="24" cy="22" r="4.5" fill="#ffffff"/>
<circle cx="24" cy="22" r="2" fill="#2563eb"/>
</svg>

After

Width:  |  Height:  |  Size: 771 B

@@ -0,0 +1,228 @@
# Recommended Skills
This page collects useful community skill repositories that can extend Hermes, Claude Code, Codex-style agents, and similar local agent workflows.
Community skills are third-party code and instructions. Review them before installing, especially when a skill can read API keys, cookies, browser sessions, local files, repositories, shell scripts, package managers, or social media accounts.
Useful skill recommendations are welcome. If you find a high-quality skill that should be listed here, please submit a pull request on GitHub with the repository link, usage scenario, and any security notes.
## Maintenance Guidelines
- Keep this document in English. Update `skill-recommendations.zh.md` separately for the Chinese version.
- Add recommendations under the closest existing category before creating a new category.
- Use the same structure for each item: repository link, focus, good-for scenarios, representative skills or capabilities when available, and notes when there are installation, API, or security concerns.
- Keep descriptions factual and concise. Prefer information confirmed from the repository README, `SKILL.md`, examples, or package metadata.
- Do not paste secrets, private tokens, install commands that auto-execute remote code, or unverifiable marketing claims.
- Put security-sensitive skills in context: mention when they can access credentials, browsers, local files, shells, package managers, external APIs, or social accounts.
- For unsupported locales, the UI falls back to this English document.
## Security First
- Treat every third-party skill as untrusted until reviewed.
- Read `SKILL.md`, scripts, hooks, and dependency installers before running.
- Be extra careful with skills that post to social media, access browsers, read credentials, install packages, execute shell commands, or send local files to external APIs.
- Prefer testing new skills in a disposable profile or sandboxed project first.
- Use a security review skill such as SlowMist Agent Security when evaluating unknown repositories, URLs, MCP servers, or skill packages.
## Official And General-Purpose Skills
### Anthropic Official Skills
- Repository: [anthropics/skills](https://github.com/anthropics/skills/tree/main/skills)
- Focus: official reference skills for Claude-style agents.
- Good for: learning the expected skill structure, adapting stable examples, and bootstrapping common workflows.
- Representative skills: `docx`, `pdf`, `pptx`, `xlsx`, `frontend-design`, `webapp-testing`, `skill-creator`, `mcp-builder`, `theme-factory`, `web-artifacts-builder`.
- Notes: a good first source when you want conservative, well-structured examples.
### Matt Pocock Skills
- Repository: [mattpocock/skills](https://github.com/mattpocock/skills)
- Focus: engineering and productivity skills from a real development workflow.
- Good for: TypeScript engineering, test-driven work, triage, diagnosis, reviews, prototyping, and product handoff workflows.
- Representative skills: `tdd`, `triage`, `diagnose`, `prototype`, `review`, `to-prd`, `to-issues`, `handoff`, `write-a-skill`.
- Notes: useful when you want agent behavior that is direct, structured, and engineering-oriented.
## Design, Slides, And Visual Work
### Frontend Slides
- Repository: [zarazhangrui/frontend-slides](https://github.com/zarazhangrui/frontend-slides)
- Focus: creating web-native slide decks with frontend techniques.
- Good for: HTML/CSS slide decks, visual storytelling, and browser-rendered presentations.
- Notes: useful when a deck should be designed as a rich web artifact rather than a traditional office file.
### Huashu Design
- Repository: [alchaincyf/huashu-design](https://github.com/alchaincyf/huashu-design)
- Focus: HTML-native design work for Claude Code and agent workflows.
- Good for: high-fidelity prototypes, slides, animation concepts, visual review, and export-oriented design flows.
- Notes: includes design philosophy, review heuristics, and presentation-oriented workflows.
### Guizang PPT Skill
- Repository: [op7418/guizang-ppt-skill](https://github.com/op7418/guizang-ppt-skill)
- Focus: polished HTML slide decks with editorial, magazine, and Swiss-style layouts.
- Good for: presentation decks, social covers, image prompts, and visual narrative work.
- Notes: includes a presentation runtime and style-oriented slide generation patterns.
### HTML PPT Skill
- Repository: [lewislulu/html-ppt-skill](https://github.com/lewislulu/html-ppt-skill)
- Focus: HTML PPT Studio for professional HTML presentations.
- Good for: themed slide decks, layout-rich presentations, and animated browser presentations.
- Representative capabilities: multiple themes, layout templates, animation patterns, and HTML presentation scaffolding.
### PPT Image First
- Repository: [NyxTides/ppt-image-first](https://github.com/NyxTides/ppt-image-first)
- Focus: image-first presentation generation.
- Good for: decks where the visual direction should lead the content structure.
- Notes: designed for Codex, Claude Code, and OpenCode-style CLI agents.
### GPT Image To PPT
- Repository: [JuneYaooo/gpt-image2-ppt-skills](https://github.com/JuneYaooo/gpt-image2-ppt-skills)
- Focus: cloning or adapting PowerPoint visual layouts using image generation.
- Good for: recreating a deck style from an existing `.pptx` template while replacing the actual content.
- Notes: useful for template-driven presentations, but review external image generation/API behavior before use.
### Fireworks Tech Graph
- Repository: [yizhiyanhua-ai/fireworks-tech-graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph)
- Focus: technical diagram generation.
- Good for: architecture diagrams, workflow charts, UML-style visuals, AI agent workflow diagrams, and production-ready SVG/PNG outputs.
- Notes: a practical choice when you need diagrams rather than full slide decks.
### Diagram Skill
- Repository: [312362115/claude diagram skill](https://github.com/312362115/claude/blob/main/skills/diagram/SKILL.md)
- Focus: diagram generation inside a broader Claude skill collection.
- Good for: generating structured diagrams, templates, and visual explanations.
- Notes: this is a direct skill file link, so review the surrounding `references`, `scripts`, and `templates` folders before installing.
## Writing, Documents, And Knowledge Work
### Huashu Markdown To HTML
- Repository: [alchaincyf/huashu-md-html](https://github.com/alchaincyf/huashu-md-html)
- Focus: Markdown and HTML conversion pipelines.
- Good for: converting files or URLs to Markdown, turning Markdown into polished HTML, and converting HTML back to Markdown.
- Representative tools: MarkItDown, Pandoc, html-to-markdown, and trafilatura-based workflows.
- Notes: useful for content publishing, document cleanup, and HTML presentation pages.
### Chinese Web Novel Skill
- Repository: [Tomsawyerhu/Chinese-WebNovel-Skill](https://github.com/Tomsawyerhu/Chinese-WebNovel-Skill)
- Focus: Chinese web novel writing workflows.
- Good for: long-form fiction planning, chapter writing, style continuity, and web-novel oriented drafting.
- Representative skill: `webnovel-writing`.
### Software Copyright Skill
- Repository: [Fokkyp/SoftwareCopyright-Skill](https://github.com/Fokkyp/SoftwareCopyright-Skill)
- Focus: preparing Chinese software copyright application materials.
- Good for: generating `.docx` application documents from a local software project.
- Representative skills: `software-copyright-materials`, `docx-toolkit`.
- Notes: this may read local project files. Review file access and document generation behavior before running.
### Patent Disclosure Skill
- Repository: [handsomestWei/patent-disclosure-skill](https://github.com/handsomestWei/patent-disclosure-skill)
- Focus: patent disclosure drafting.
- Good for: extracting patentable points from project documents, novelty checks, desensitized drafting, and self-review loops.
- Notes: may involve web research and sensitive technical documents. Review data handling carefully.
## Image, Media, And Social Publishing
### Baoyu Skills
- Repository: [JimLiu/baoyu-skills](https://github.com/JimLiu/baoyu-skills)
- Focus: image generation, content transformation, publishing, and media workflows.
- Good for: image cards, article illustrations, slide decks, URL-to-Markdown conversion, YouTube transcripts, Markdown-to-HTML, and social posting workflows.
- Representative skills: `baoyu-image-gen`, `baoyu-imagine`, `baoyu-slide-deck`, `baoyu-markdown-to-html`, `baoyu-post-to-x`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-url-to-markdown`, `baoyu-youtube-transcript`, `baoyu-translate`, `baoyu-diagram`, `baoyu-comic`.
- Security note: posting and web-reading skills may access account sessions, cookies, browser state, or external APIs. Review carefully before use.
### Virtual Couple Travel Vlog
- Repository: [vibeshotclub/vsc-skills / virtual-couple-travel-vlog](https://github.com/vibeshotclub/vsc-skills/tree/main/virtual-couple-travel-vlog)
- Focus: travel-vlog style media generation.
- Good for: short-form visual storytelling, character-based travel content, and repeatable media production prompts.
- Notes: this is a subdirectory skill inside a larger skill collection.
## Research, Web Access, And Content Monitoring
### Web Access
- Repository: [eze-is/web-access](https://github.com/eze-is/web-access)
- Focus: giving an agent structured web access through layered routing and browser/CDP workflows.
- Good for: web research, browser-assisted tasks, parallel information gathering, and pages that require interaction.
- Security note: browser access can expose logged-in sessions and local browser state. Audit before enabling.
### OpenCLI
- Repository: [jackwener/opencli](https://github.com/jackwener/opencli)
- Focus: converting websites, browser sessions, Electron apps, and local tools into CLI-accessible automation surfaces for humans and AI agents.
- Good for: letting agents operate logged-in Chrome pages, building reusable website adapters, wrapping local binaries, and turning browser workflows into deterministic commands.
- Representative skills: `opencli-browser`, `opencli-adapter-author`, `opencli-autofix`, `opencli-usage`.
- Security note: browser-backed commands can use logged-in sessions and local browser state. Review the extension, daemon, adapters, and any generated commands before enabling in sensitive profiles.
### Follow Builders
- Repository: [zarazhangrui/follow-builders](https://github.com/zarazhangrui/follow-builders)
- Focus: monitoring AI builders across X, blogs, and YouTube podcasts.
- Good for: tracking builders rather than influencers, summarizing feeds, and creating digest-style updates.
- Representative data/config files: X feeds, blog feeds, podcast feeds, prompts, and state files.
- Security note: any social or feed automation should be reviewed for account/session access.
### SlowMist Agent Security
- Repository: [slowmist/slowmist-agent-security](https://github.com/slowmist/slowmist-agent-security)
- Focus: security review for AI agents operating with untrusted inputs.
- Good for: checking skills, MCP servers, repositories, URLs, prompts, and crypto/on-chain addresses for security risks.
- Core idea: external input should be considered untrusted until verified.
- Notes: recommended before installing or running unfamiliar community skills.
## Persona, Thinking, And Advisory Skills
### Huashu Nuwa Skill
- Repository: [alchaincyf/nuwa-skill](https://github.com/alchaincyf/nuwa-skill)
- Focus: distilling a person or viewpoint into a reusable agent skill.
- Good for: advisory-board style thinking, mental models, decision heuristics, and writing in a specific perspective.
- Representative perspectives: Huashu Nuwa, Feynman, Steve Jobs, Elon Musk, Naval Ravikant, Paul Graham, Nassim Taleb.
- Notes: useful for brainstorming and viewpoint simulation, not for factual authority.
### PUA / Anti-PUA Skills
- Repository: [tanweai/pua](https://github.com/tanweai/pua)
- Focus: high-agency, confrontational, coaching, or anti-PUA style agent behavior.
- Good for: motivation, critique, resistance to manipulation, and intentionally sharp agent feedback.
- Representative skills: `pua`, `pua-en`, `pua-ja`, `pua-loop`, `mama`, `p7`, `p9`, `p10`, `pro`, `shot`, `yes`.
- Notes: these skills intentionally change tone and interaction style. Review before enabling in shared or user-facing environments.
### Ex Skill
- Repository: [therealXiaomanChu/ex-skill](https://github.com/therealXiaomanChu/ex-skill)
- Focus: distilling an ex-partner/persona into an AI skill that speaks in that style.
- Good for: persona experiments, emotional roleplay, and style simulation.
- Representative skill: `create-ex`.
- Notes: use carefully. Persona skills can strongly alter tone and emotional framing.
## Quick Shortlist
If you only want a practical starter set:
- [Anthropic Official Skills](https://github.com/anthropics/skills/tree/main/skills) for reference implementations.
- [Matt Pocock Skills](https://github.com/mattpocock/skills) for engineering workflows.
- [Baoyu Skills](https://github.com/JimLiu/baoyu-skills) for image, media, and publishing workflows.
- [Huashu Design](https://github.com/alchaincyf/huashu-design) for high-fidelity HTML-native design.
- [Guizang PPT Skill](https://github.com/op7418/guizang-ppt-skill) or [HTML PPT Skill](https://github.com/lewislulu/html-ppt-skill) for browser-based presentations.
- [Huashu Markdown To HTML](https://github.com/alchaincyf/huashu-md-html) for Markdown/HTML document conversion.
- [Web Access](https://github.com/eze-is/web-access) for web research workflows.
- [OpenCLI](https://github.com/jackwener/opencli) for logged-in browser automation and reusable website CLI adapters.
- [Fireworks Tech Graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph) for technical diagrams.
- [SlowMist Agent Security](https://github.com/slowmist/slowmist-agent-security) for reviewing risky community skills.
## Original Source List
This document was compiled from a curated Hermes / Claude skill sharing list and expanded with public GitHub repository metadata.
@@ -0,0 +1,228 @@
# Skills 推荐清单
这是一份适合 Hermes、Claude Code、Codex 类本地 Agent 工作流的社区 Skill 推荐清单。它主要用于帮助你发现可安装、可参考或可改造的 Skill 来源。
社区 Skill 本质上是第三方指令和代码。安装前请先审计,尤其是会读取 API Key、Cookie、浏览器登录态、本地文件、仓库内容,或者会执行 shell、安装依赖、自动发帖、访问外部 API 的 Skill。
欢迎大家推荐各种好用的 Skill。如果你发现值得收录的高质量 Skill,可以到 GitHub 提交 PR,并附上仓库链接、适用场景和必要的安全说明。
## 维护规范
- 这份文档只维护中文内容;英文版请同步维护 `skill-recommendations.en.md`
- 新增推荐时优先放入最接近的现有分类,不要轻易新增大类。
- 每个条目尽量保持同一结构:仓库链接、方向、适合场景、代表 Skills 或能力、必要备注。
- 描述要简洁、事实化,优先依据仓库 README、`SKILL.md`、示例或包元数据,不写无法验证的宣传语。
- 不要写入密钥、私有 token、会自动执行远程代码的安装命令,或无法确认来源的内容。
- 涉及安全风险的 Skill 要明确说明上下文,例如是否会访问凭据、浏览器、本地文件、shell、包管理器、外部 API 或社交账号。
- 前端只维护中文和英文两份推荐文档,其他语言统一回退到英文版。
## 安全优先
- 默认把所有第三方 Skill 当成不可信内容,审计后再启用。
- 安装前阅读 `SKILL.md`、脚本、hooks、依赖安装逻辑和插件配置。
- 对会访问浏览器、读取凭据、执行 shell、安装 npm/pip/brew 依赖、自动发帖或上传本地文件的 Skill 保持谨慎。
- 建议先在一次性 profile 或沙盒项目里测试新 Skill。
- 可以使用 SlowMist Agent Security 这类安全审计 Skill 来检查陌生仓库、URL、MCP、Skill 包和链上地址。
## 官方与通用 Skills
### Anthropic 官方 Skills
- 仓库:[anthropics/skills](https://github.com/anthropics/skills/tree/main/skills)
- 方向:Claude 官方参考 Skill。
- 适合:学习标准 Skill 结构、参考稳定实现、搭建通用工作流。
- 代表 Skills`docx``pdf``pptx``xlsx``frontend-design``webapp-testing``skill-creator``mcp-builder``theme-factory``web-artifacts-builder`
- 备注:如果你想找保守、规范、可参考的 Skill 示例,优先看这个。
### Matt Pocock Skills
- 仓库:[mattpocock/skills](https://github.com/mattpocock/skills)
- 方向:工程与生产力工作流。
- 适合:TypeScript 工程、TDD、问题诊断、代码评审、原型开发、PRD/Issue/Handoff 等开发流程。
- 代表 Skills`tdd``triage``diagnose``prototype``review``to-prd``to-issues``handoff``write-a-skill`
- 备注:适合希望 Agent 更像工程协作者时使用。
## 设计、幻灯片与可视化
### Frontend Slides
- 仓库:[zarazhangrui/frontend-slides](https://github.com/zarazhangrui/frontend-slides)
- 方向:用前端技术生成网页幻灯片。
- 适合:HTML/CSS 幻灯片、视觉叙事、浏览器渲染的演示稿。
- 备注:适合把演示稿当成 Web Artifact 来做,而不是传统 Office 文件。
### 华叔 Design
- 仓库:[alchaincyf/huashu-design](https://github.com/alchaincyf/huashu-design)
- 方向:Claude Code 中的 HTML 原生设计 Skill。
- 适合:高保真原型、幻灯片、动画概念、视觉评审和导出型设计流程。
- 备注:包含设计哲学、评审维度和演示型工作流。
### 归藏 PPT Skill
- 仓库:[op7418/guizang-ppt-skill](https://github.com/op7418/guizang-ppt-skill)
- 方向:生成高质量 HTML 幻灯片。
- 适合:杂志风、编辑风、瑞士风等视觉风格的演示稿、社交封面、图片提示词和叙事型页面。
- 备注:包含演示运行时和风格化生成模式。
### HTML PPT Skill
- 仓库:[lewislulu/html-ppt-skill](https://github.com/lewislulu/html-ppt-skill)
- 方向:HTML PPT Studio。
- 适合:主题化幻灯片、复杂布局演示稿和带动画的浏览器演示。
- 代表能力:多主题、多布局、动画模式和 HTML 演示脚手架。
### PPT Image First
- 仓库:[NyxTides/ppt-image-first](https://github.com/NyxTides/ppt-image-first)
- 方向:图片优先的 PPT 生成。
- 适合:视觉方向先行的演示稿创作。
- 备注:面向 Codex、Claude Code、OpenCode CLI 等 Agent 工作流。
### GPT Image To PPT
- 仓库:[JuneYaooo/gpt-image2-ppt-skills](https://github.com/JuneYaooo/gpt-image2-ppt-skills)
- 方向:用图像生成能力复刻或改造 PPT 视觉版式。
- 适合:从已有 `.pptx` 模板中学习版式,再替换成自己的内容。
- 备注:涉及图像生成和外部 API 时请先检查配置与数据发送逻辑。
### Fireworks Tech Graph
- 仓库:[yizhiyanhua-ai/fireworks-tech-graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph)
- 方向:技术图表生成。
- 适合:架构图、流程图、UML 风格图、AI Agent 工作流图,以及 SVG/PNG 输出。
- 备注:需要图表而不是整套演示稿时很实用。
### Diagram Skill
- 仓库:[312362115/claude diagram skill](https://github.com/312362115/claude/blob/main/skills/diagram/SKILL.md)
- 方向:结构化图表生成。
- 适合:生成图表、模板化视觉解释和技术说明。
- 备注:这是一个直接指向 `SKILL.md` 的链接,安装前也要检查同目录下的 `references``scripts``templates`
## 写作、文档与知识工作
### 华叔 Markdown To HTML
- 仓库:[alchaincyf/huashu-md-html](https://github.com/alchaincyf/huashu-md-html)
- 方向:Markdown 与 HTML 双向转换流水线。
- 适合:把文件或网页转 Markdown,把 Markdown 转精美 HTML,把 HTML 再转回 Markdown。
- 代表工具:MarkItDown、Pandoc、html-to-markdown、trafilatura。
- 备注:适合内容发布、文档清理和 HTML 页面生成。
### 中文网文写作 Skill
- 仓库:[Tomsawyerhu/Chinese-WebNovel-Skill](https://github.com/Tomsawyerhu/Chinese-WebNovel-Skill)
- 方向:中文网文小说写作。
- 适合:长篇小说规划、章节创作、风格延续和网文式叙事。
- 代表 Skill`webnovel-writing`
### 软件著作权材料 Skill
- 仓库:[Fokkyp/SoftwareCopyright-Skill](https://github.com/Fokkyp/SoftwareCopyright-Skill)
- 方向:中国软件著作权申请材料生成。
- 适合:根据本地项目生成 `.docx` 软著申请材料。
- 代表 Skills`software-copyright-materials``docx-toolkit`
- 备注:可能读取本地项目文件,运行前请审计文件访问和文档生成逻辑。
### 专利交底书 Skill
- 仓库:[handsomestWei/patent-disclosure-skill](https://github.com/handsomestWei/patent-disclosure-skill)
- 方向:专利技术交底书生成。
- 适合:从项目文档挖掘专利点、联网查新、脱敏成文和自检。
- 备注:可能涉及敏感技术资料和联网检索,使用前请关注数据处理方式。
## 图片、媒体与社交发布
### 宝玉 Skills
- 仓库:[JimLiu/baoyu-skills](https://github.com/JimLiu/baoyu-skills)
- 方向:图片生成、内容转换、发布和媒体工作流。
- 适合:图片卡片、文章配图、幻灯片、URL 转 Markdown、YouTube 字幕、Markdown 转 HTML、社交平台发布。
- 代表 Skills`baoyu-image-gen``baoyu-imagine``baoyu-slide-deck``baoyu-markdown-to-html``baoyu-post-to-x``baoyu-post-to-wechat``baoyu-post-to-weibo``baoyu-url-to-markdown``baoyu-youtube-transcript``baoyu-translate``baoyu-diagram``baoyu-comic`
- 安全提示:发帖和网页读取类 Skill 可能访问账号会话、Cookie、浏览器状态或外部 API,使用前务必审计。
### Virtual Couple Travel Vlog
- 仓库:[vibeshotclub/vsc-skills / virtual-couple-travel-vlog](https://github.com/vibeshotclub/vsc-skills/tree/main/virtual-couple-travel-vlog)
- 方向:旅行 vlog 风格媒体生成。
- 适合:短视频视觉叙事、角色化旅行内容和可复用媒体提示词。
- 备注:这是一个大仓库里的子目录 Skill。
## Web 访问、研究与内容监控
### Web Access
- 仓库:[eze-is/web-access](https://github.com/eze-is/web-access)
- 方向:为 Agent 提供结构化联网能力。
- 适合:网页研究、浏览器辅助任务、并行信息收集和需要交互的网站。
- 安全提示:浏览器访问可能暴露已登录状态和本地浏览器数据,启用前要审计。
### OpenCLI
- 仓库:[jackwener/opencli](https://github.com/jackwener/opencli)
- 方向:把网站、浏览器会话、Electron 应用和本地工具转换成 CLI 可调用的自动化入口。
- 适合:让 Agent 操作已登录的 Chrome 页面、编写可复用网站适配器、封装本地命令,以及把浏览器流程变成稳定命令。
- 代表 Skills`opencli-browser``opencli-adapter-author``opencli-autofix``opencli-usage`
- 安全提示:浏览器命令可能使用已登录会话和本地浏览器状态。启用前请审计扩展、daemon、适配器和生成的命令,敏感 profile 里尤其要谨慎。
### Follow Builders
- 仓库:[zarazhangrui/follow-builders](https://github.com/zarazhangrui/follow-builders)
- 方向:跟踪 AI builders 的 X、博客和 YouTube 播客内容。
- 适合:关注 builder 而不是 influencer,生成摘要和内容 digest。
- 代表内容:X feed、blog feed、podcast feed、prompts 和状态文件。
- 安全提示:社交和 feed 自动化要关注账号、Cookie 和访问权限。
### SlowMist Agent Security
- 仓库:[slowmist/slowmist-agent-security](https://github.com/slowmist/slowmist-agent-security)
- 方向:AI Agent 安全审计框架。
- 适合:检查 Skill、MCP、仓库、URL、Prompt 和链上地址的安全风险。
- 核心原则:所有外部输入在验证前都不可信。
- 备注:安装陌生社区 Skill 前建议优先使用。
## Persona、思维方式与顾问类 Skills
### 华叔 Nuwa Skill
- 仓库:[alchaincyf/nuwa-skill](https://github.com/alchaincyf/nuwa-skill)
- 方向:把某个人或视角蒸馏成可复用 Skill。
- 适合:顾问团式思考、心智模型、决策启发式和特定视角写作。
- 代表视角:华叔 Nuwa、Feynman、Jobs、Musk、Naval、Paul Graham、Taleb。
- 备注:适合头脑风暴和视角模拟,不应当作事实权威。
### PUA / 反 PUA 类 Skills
- 仓库:[tanweai/pua](https://github.com/tanweai/pua)
- 方向:高能动性、强反馈、反操控或尖锐教练风格的 Agent 行为。
- 适合:动机强化、批判反馈、反操控和刻意强风格交互。
- 代表 Skills`pua``pua-en``pua-ja``pua-loop``mama``p7``p9``p10``pro``shot``yes`
- 备注:这类 Skill 会明显改变语气和互动方式,不建议直接用于共享或面向用户的环境。
### Ex Skill
- 仓库:[therealXiaomanChu/ex-skill](https://github.com/therealXiaomanChu/ex-skill)
- 方向:把某个前任/人格风格蒸馏成 AI Skill。
- 适合:Persona 实验、情绪化角色扮演和特定语气模拟。
- 代表 Skill`create-ex`
- 备注:Persona 类 Skill 可能强烈影响语气和情绪框架,使用前请确认场景合适。
## 快速推荐
如果你只想先装一批实用的,可以从这些开始:
- [Anthropic 官方 Skills](https://github.com/anthropics/skills/tree/main/skills):参考实现和通用能力。
- [Matt Pocock Skills](https://github.com/mattpocock/skills):工程流程。
- [宝玉 Skills](https://github.com/JimLiu/baoyu-skills):图片、媒体和发布。
- [华叔 Design](https://github.com/alchaincyf/huashu-design):高保真 HTML 设计。
- [归藏 PPT Skill](https://github.com/op7418/guizang-ppt-skill) 或 [HTML PPT Skill](https://github.com/lewislulu/html-ppt-skill):浏览器演示稿。
- [华叔 Markdown To HTML](https://github.com/alchaincyf/huashu-md-html)Markdown/HTML 文档转换。
- [Web Access](https://github.com/eze-is/web-access):网页研究。
- [OpenCLI](https://github.com/jackwener/opencli):已登录浏览器自动化和可复用网站 CLI 适配器。
- [Fireworks Tech Graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph):技术图表。
- [SlowMist Agent Security](https://github.com/slowmist/slowmist-agent-security):社区 Skill 安全审计。
## 来源说明
本文档基于一份 Hermes / Claude Skills 分享清单整理,并补充了公开 GitHub 仓库描述与目录信息。
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { onMounted, onUnmounted, computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { darkTheme, NConfigProvider, NMessageProvider, NDialogProvider, NNotificationProvider } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { getThemeOverrides } from '@/styles/theme'
import { useTheme } from '@/composables/useTheme'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import AppTopBar from '@/components/layout/AppTopBar.vue'
import AppLogo from '@/components/common/AppLogo.vue'
import { useKeyboard } from '@/composables/useKeyboard'
import { useAppStore } from '@/stores/hermes/app'
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
import AuthEventListener from '@/components/auth/AuthEventListener.vue'
import DefaultCredentialPrompt from '@/components/auth/DefaultCredentialPrompt.vue'
const { isDark, isComic } = useTheme()
const { t } = useI18n()
const appStore = useAppStore()
const route = useRoute()
const router = useRouter()
const ready = ref(false)
const themeOverrides = computed(() => getThemeOverrides(isDark.value, isComic.value))
const naiveTheme = computed(() => isDark.value ? darkTheme : null)
const isLoginPage = computed(() => route.name === 'login')
const nodeVersionLow = computed(() => {
const v = appStore.nodeVersion
const major = parseInt(v.split('.')[0], 10)
return !isNaN(major) && major < 23
})
watch(() => route.path, () => {
appStore.closeSidebar()
})
router.isReady().then(() => {
ready.value = true
})
onMounted(() => {
if (!isLoginPage.value) {
appStore.loadModels()
appStore.startHealthPolling()
}
})
onUnmounted(() => {
appStore.stopHealthPolling()
})
useKeyboard()
</script>
<template>
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
<NMessageProvider>
<AuthEventListener />
<NDialogProvider>
<NNotificationProvider>
<div v-if="nodeVersionLow && ready" class="node-warning-bar">
{{ t('sidebar.nodeVersionWarning', { version: appStore.nodeVersion }) }}
</div>
<div v-if="ready" class="app-shell" :class="{ 'no-chrome': isLoginPage, 'has-warning': nodeVersionLow }">
<AppTopBar v-if="!isLoginPage" />
<button v-if="!isLoginPage" class="hamburger-btn" @click="appStore.toggleSidebar">
<AppLogo :size="22" />
</button>
<div v-if="!isLoginPage && appStore.sidebarOpen" class="mobile-backdrop" @click="appStore.closeSidebar" />
<div v-if="!isLoginPage" class="app-body">
<AppSidebar />
<main class="app-main">
<router-view />
</main>
</div>
<main v-else class="app-main app-main--full">
<router-view />
</main>
</div>
<SessionSearchModal />
<DefaultCredentialPrompt />
</NNotificationProvider>
</NDialogProvider>
</NMessageProvider>
</NConfigProvider>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.app-shell {
display: flex;
flex-direction: column;
+160
View File
@@ -0,0 +1,160 @@
import { request } from './client'
export interface AuthStatus {
hasPasswordLogin: boolean
hasUsers?: boolean
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
const res = await fetch('/api/auth/status')
if (!res.ok) throw new Error('Failed to fetch auth status')
return res.json()
}
export async function loginWithPassword(username: string, password: string): Promise<string> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
const err: any = new Error(data.error || 'Login failed')
err.status = res.status
throw err
}
const data = await res.json()
return data.token
}
export interface CurrentUser {
id: number
username: string
role: UserRole
status: UserStatus
created_at: number
updated_at: number
last_login_at: number | null
requiresCredentialChange?: boolean
}
export async function fetchCurrentUser(): Promise<CurrentUser> {
const res = await request<{ user: CurrentUser }>('/api/auth/me')
return res.user
}
export async function setupPassword(username: string, password: string): Promise<void> {
return request('/api/auth/setup', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
return request('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
})
}
export async function changeUsername(currentPassword: string, newUsername: string): Promise<void> {
return request('/api/auth/change-username', {
method: 'POST',
body: JSON.stringify({ currentPassword, newUsername }),
})
}
export async function removePassword(): Promise<void> {
return request('/api/auth/password', {
method: 'DELETE',
})
}
export type UserRole = 'super_admin' | 'admin'
export type UserStatus = 'active' | 'disabled'
export interface ManagedUser {
id: number
username: string
role: UserRole
status: UserStatus
profiles: string[]
default_profile: string | null
created_at: number
updated_at: number
last_login_at: number | null
}
export interface ManagedUsersResponse {
users: ManagedUser[]
profiles: string[]
}
export async function fetchManagedUsers(): Promise<ManagedUsersResponse> {
return request<ManagedUsersResponse>('/api/auth/users')
}
export async function createManagedUser(input: {
username: string
password: string
role: UserRole
status: UserStatus
profiles: string[]
defaultProfile?: string | null
}): Promise<ManagedUsersResponse> {
const res = await request<{ users: ManagedUser[] }>('/api/auth/users', {
method: 'POST',
body: JSON.stringify(input),
})
const current = await fetchManagedUsers()
return { ...current, users: res.users }
}
export async function updateManagedUser(id: number, input: {
username?: string
password?: string
role?: UserRole
status?: UserStatus
profiles?: string[]
defaultProfile?: string | null
}): Promise<ManagedUsersResponse> {
const res = await request<{ users: ManagedUser[] }>(`/api/auth/users/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
})
const current = await fetchManagedUsers()
return { ...current, users: res.users }
}
export async function deleteManagedUser(id: number): Promise<ManagedUsersResponse> {
const res = await request<{ users: ManagedUser[] }>(`/api/auth/users/${id}`, {
method: 'DELETE',
})
const current = await fetchManagedUsers()
return { ...current, users: res.users }
}
export interface LockedIp {
ip: string
type: 'password' | 'token'
failures: number
lockedUntil: number
}
export async function fetchLockedIps(): Promise<LockedIp[]> {
const res = await request<{ locks: LockedIp[] }>('/api/auth/locked-ips')
return res.locks
}
export async function unlockSpecificIp(ip: string): Promise<void> {
return request(`/api/auth/locked-ips?ip=${encodeURIComponent(ip)}`, {
method: 'DELETE',
})
}
export async function unlockAllIps(): Promise<number> {
const res = await request<{ count: number }>('/api/auth/locked-ips', {
method: 'DELETE',
})
return res.count
}
+153
View File
@@ -0,0 +1,153 @@
import router from '@/router'
const DEFAULT_BASE_URL = ''
function isDesktopShell(): boolean {
return typeof window !== 'undefined' &&
(window as typeof window & { hermesDesktop?: { isDesktop?: boolean } }).hermesDesktop?.isDesktop === true
}
function getBaseUrl(): string {
if (import.meta.env.VITE_HERMES_PREVIEW === '1') return DEFAULT_BASE_URL
if (isDesktopShell()) return DEFAULT_BASE_URL
return localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL
}
export function getApiKey(): string {
return localStorage.getItem('hermes_api_key') || ''
}
export function setServerUrl(url: string) {
localStorage.setItem('hermes_server_url', url)
}
export function setApiKey(key: string) {
localStorage.setItem('hermes_api_key', key)
}
export function clearApiKey() {
localStorage.removeItem('hermes_api_key')
}
export function hasApiKey(): boolean {
return !!getApiKey()
}
export type StoredUserRole = 'super_admin' | 'admin'
export function getStoredUserRole(): StoredUserRole | null {
const token = getApiKey()
const payload = token.split('.')[1]
if (!payload) return null
try {
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const data = JSON.parse(atob(padded)) as { role?: unknown }
return data.role === 'super_admin' || data.role === 'admin' ? data.role : null
} catch {
return null
}
}
export function isStoredSuperAdmin(): boolean {
return getStoredUserRole() === 'super_admin'
}
export function getActiveProfileName(): string | null {
return localStorage.getItem('hermes_active_profile_name')
}
function bodyHasProfileSelector(body: BodyInit | null | undefined): boolean {
if (typeof body !== 'string') return false
try {
const parsed = JSON.parse(body) as { profile?: unknown }
return typeof parsed?.profile === 'string' && parsed.profile.trim().length > 0
} catch {
return false
}
}
function shouldAttachProfileHeader(path: string, options: RequestInit): boolean {
try {
const url = new URL(path, 'http://hermes.local')
if (url.searchParams.has('profile')) return false
if (url.pathname.startsWith('/api/hermes/profiles')) return false
if (isProfileWideSessionCollection(url.pathname)) return false
} catch {
if (path.startsWith('/api/hermes/profiles')) return false
if (isProfileWideSessionCollection(path.split('?')[0] || path)) return false
}
return !bodyHasProfileSelector(options.body)
}
function isProfileWideSessionCollection(pathname: string): boolean {
return pathname === '/api/hermes/sessions' ||
pathname === '/api/hermes/sessions/batch-delete' ||
pathname === '/api/hermes/search/sessions' ||
pathname === '/api/hermes/sessions/search' ||
pathname === '/api/hermes/sessions/conversations'
}
function emitAuthNotice(kind: 'expired' | 'forbidden') {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('hermes-auth-notice', { detail: { kind } }))
}
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const base = getBaseUrl()
const url = `${base}${path}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers as Record<string, string>,
}
const apiKey = getApiKey()
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
}
// Inject active profile header for request-scoped endpoints. Explicit profile
// selectors in the URL/body and profile-name routes are validated directly.
const profileName = getActiveProfileName()
if (profileName && shouldAttachProfileHeader(path, options)) {
headers['X-Hermes-Profile'] = profileName
}
const res = await fetch(url, { ...options, headers })
// Global 401 handler — only redirect to login for local BFF endpoints
// Proxied gateway requests should not trigger logout
const isLocalBff = !path.startsWith('/api/hermes/v1/') &&
!path.startsWith('/v1/')
if (res.status === 401 && isLocalBff) {
clearApiKey()
emitAuthNotice('expired')
if (router.currentRoute.value.name !== 'login') {
router.replace({ name: 'login' })
}
throw new Error('Unauthorized')
}
if (!res.ok) {
const text = await res.text().catch(() => '')
if (res.status === 403 && isLocalBff) {
if (text.includes('User is disabled or does not exist')) {
clearApiKey()
emitAuthNotice('expired')
if (router.currentRoute.value.name !== 'login') {
router.replace({ name: 'login' })
}
} else {
emitAuthNotice('forbidden')
}
}
throw new Error(`API Error ${res.status}: ${text || res.statusText}`)
}
return res.json()
}
export function getBaseUrlValue(): string {
return getBaseUrl()
}
+134
View File
@@ -0,0 +1,134 @@
import { request } from './client'
export type CodingAgentId = 'claude-code' | 'codex'
export type CodingAgentApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages'
export type CodingAgentLaunchMode = 'scoped' | 'global'
export interface CodingAgentToolStatus {
id: CodingAgentId
name: string
provider: string
command: string
packageName: string
installed: boolean
version: string
rawVersion: string
error?: string
}
export interface CodingAgentsStatus {
tools: CodingAgentToolStatus[]
}
export interface CodingAgentMutationResult extends CodingAgentsStatus {
success: boolean
tool: CodingAgentToolStatus
message?: string
code?: string
}
export interface CodingAgentConfigFileContent {
key: string
path: string
absolutePath: string
language: string
content: string
exists: boolean
size: number
profile: string
provider: string
rootDir: string
}
export interface CodingAgentConfigScope {
profile?: string | null
provider?: string | null
}
export interface CodingAgentLaunchRequest {
mode?: CodingAgentLaunchMode
profile?: string | null
provider?: string
model?: string
baseUrl?: string
apiKey?: string
apiMode?: CodingAgentApiMode
}
export interface CodingAgentLaunchResult {
agentId: CodingAgentId
mode: CodingAgentLaunchMode
profile: string
provider: string
model: string
rootDir: string
workspaceDir: string
command: string
args: string[]
env: Record<string, string>
shellCommand: string
files: Array<{ key: string; path: string; absolutePath: string }>
}
export interface CodingAgentNativeLaunchResult extends CodingAgentLaunchResult {
nativeTerminal: true
terminal: string
}
export async function fetchCodingAgentsStatus(): Promise<CodingAgentsStatus> {
return request<CodingAgentsStatus>('/api/coding-agents')
}
export async function installCodingAgent(id: CodingAgentId): Promise<CodingAgentMutationResult> {
return request<CodingAgentMutationResult>(`/api/coding-agents/${id}/install`, { method: 'POST' })
}
export async function deleteCodingAgent(id: CodingAgentId): Promise<CodingAgentMutationResult> {
return request<CodingAgentMutationResult>(`/api/coding-agents/${id}`, { method: 'DELETE' })
}
export async function readCodingAgentConfigFile(
id: CodingAgentId,
key: string,
scope: CodingAgentConfigScope = {},
): Promise<CodingAgentConfigFileContent> {
const params = new URLSearchParams()
if (scope.profile) params.set('profile', scope.profile)
if (scope.provider) params.set('provider', scope.provider)
const query = params.toString()
return request<CodingAgentConfigFileContent>(
`/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}${query ? `?${query}` : ''}`,
)
}
export async function writeCodingAgentConfigFile(
id: CodingAgentId,
key: string,
content: string,
scope: CodingAgentConfigScope = {},
): Promise<CodingAgentConfigFileContent> {
return request<CodingAgentConfigFileContent>(`/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}`, {
method: 'PUT',
body: JSON.stringify({ content, profile: scope.profile, provider: scope.provider }),
})
}
export async function prepareCodingAgentLaunch(
id: CodingAgentId,
data: CodingAgentLaunchRequest,
): Promise<CodingAgentLaunchResult> {
return request<CodingAgentLaunchResult>(`/api/coding-agents/${id}/launch/prepare`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function launchCodingAgentNativeTerminal(
id: CodingAgentId,
data: CodingAgentLaunchRequest,
): Promise<CodingAgentNativeLaunchResult> {
return request<CodingAgentNativeLaunchResult>(`/api/coding-agents/${id}/launch/native`, {
method: 'POST',
body: JSON.stringify(data),
})
}
+870
View File
@@ -0,0 +1,870 @@
import { io, type Socket } from 'socket.io-client'
import { getBaseUrlValue, getApiKey } from '../client'
export type ContentBlock =
| { type: 'text'; text: string }
| { type: 'image'; name: string; path: string; media_type: string }
| { type: 'file'; name: string; path: string; media_type?: string }
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string | ContentBlock[]
}
export interface StartRunRequest {
input: string | ContentBlock[]
instructions?: string
session_id?: string
profile?: string
model?: string
provider?: string
model_groups?: Array<{ provider: string; models: string[] }>
queue_id?: string
source?: 'api_server' | 'cli'
}
export interface StartRunResponse {
run_id: string
status: string
}
// SSE event types from /v1/runs/{id}/events
export interface RunEvent {
event: string
run_id?: string
delta?: string
/** Payload text for `reasoning.delta` / `thinking.delta` / `reasoning.available` events. */
text?: string
tool?: string
name?: string
preview?: string
timestamp?: number
error?: string
/** Final response text on `run.completed`. May be empty/null if the agent
* silently swallowed an upstream error — see chat store for fallback. */
output?: string | null
usage?: {
input_tokens: number
output_tokens: number
total_tokens: number
}
/** session_id tag added by server for client-side filtering */
session_id?: string
/** Queue length from run.queued event */
queue_length?: number
/** Queue item that was just removed because it is starting now. */
dequeued_queue_id?: string
/** Queued user messages from run.queued/resume payloads. */
queued_messages?: Array<{
id?: string | number
role?: string
content?: string
timestamp?: number
queued?: boolean
}>
/** User message broadcast to other windows already watching the same session. */
message?: {
id?: string | number
role?: string
content?: string
timestamp?: number
queued?: boolean
}
}
export interface ResumeSessionPayload {
session_id: string
messages: any[]
messageTotal?: number
messageLoadedCount?: number
messagePageLimit?: number
hasMoreBefore?: boolean
isWorking: boolean
isAborting?: boolean
events: Array<{ event: string; data: RunEvent }>
inputTokens?: number
outputTokens?: number
contextTokens?: number
queueLength?: number
queueMessages?: RunEvent['queued_messages']
}
// ============================
// Socket.IO chat run connection
// ============================
let chatRunSocket: Socket | null = null
let globalListenersRegistered = false
let chatRunSocketProfile: string | null = null
const TRANSIENT_DISCONNECT_REASONS = new Set<string>([
'transport close',
'transport error',
'ping timeout',
])
/**
* Session event handlers map
* Maps session_id to event handling functions for isolating concurrent session streams
*/
const sessionEventHandlers = new Map<string, {
onMessageDelta: (event: RunEvent) => void
onReasoningDelta: (event: RunEvent) => void
onThinkingDelta: (event: RunEvent) => void
onReasoningAvailable: (event: RunEvent) => void
onToolStarted: (event: RunEvent) => void
onToolCompleted: (event: RunEvent) => void
onSubagentEvent?: (event: RunEvent) => void
onRunStarted: (event: RunEvent) => void
onRunCompleted: (event: RunEvent) => void
onRunFailed: (event: RunEvent) => void
onCompressionStarted: (event: RunEvent) => void
onCompressionCompleted: (event: RunEvent) => void
onAbortStarted: (event: RunEvent) => void
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onAgentEvent?: (event: RunEvent) => void
onSessionCommand?: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
onPeerUserMessage?: (event: RunEvent) => void
onClarifyRequested?: (event: RunEvent) => void
onClarifyResolved?: (event: RunEvent) => void
}>()
const peerUserMessageHandlers = new Set<(event: RunEvent) => void>()
const sessionCommandHandlers = new Set<(event: RunEvent) => void>()
/**
* Global message.delta event handler
* Distributes events to appropriate session based on session_id
*/
function globalMessageDeltaHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onMessageDelta) {
handlers.onMessageDelta(event)
}
}
/**
* Global reasoning.delta event handler
*/
function globalReasoningDeltaHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onReasoningDelta) {
handlers.onReasoningDelta(event)
}
}
/**
* Global thinking.delta event handler (alias for reasoning.delta)
*/
function globalThinkingDeltaHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onThinkingDelta) {
handlers.onThinkingDelta(event)
}
}
/**
* Global reasoning.available event handler
*/
function globalReasoningAvailableHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onReasoningAvailable) {
handlers.onReasoningAvailable(event)
}
}
/**
* Global tool.started event handler
*/
function globalToolStartedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onToolStarted) {
handlers.onToolStarted(event)
}
}
/**
* Global tool.completed event handler
*/
function globalToolCompletedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onToolCompleted) {
handlers.onToolCompleted(event)
}
}
function globalSubagentEventHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onSubagentEvent) {
handlers.onSubagentEvent(event)
}
}
/**
* Global run.started event handler
*/
function globalRunStartedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onRunStarted) {
handlers.onRunStarted(event)
}
}
/**
* Global run.completed event handler
*/
function globalRunCompletedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onRunCompleted) {
handlers.onRunCompleted(event)
}
// Auto-cleanup session handlers on completion (skip if more runs queued)
if ((event as any).queue_remaining > 0) return
sessionEventHandlers.delete(sid)
}
/**
* Global run.failed event handler
*/
function globalRunFailedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onRunFailed) {
handlers.onRunFailed(event)
}
// Auto-cleanup session handlers on failure (skip if more runs queued)
if ((event as any).queue_remaining > 0) return
sessionEventHandlers.delete(sid)
}
/**
* Global run.queued event handler
*/
function globalRunQueuedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onRunQueued) {
handlers.onRunQueued(event)
}
}
/**
* Global compression.started event handler
*/
function globalCompressionStartedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onCompressionStarted) {
handlers.onCompressionStarted(event)
}
}
/**
* Global compression.completed event handler
*/
function globalCompressionCompletedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onCompressionCompleted) {
handlers.onCompressionCompleted(event)
}
}
/**
* Global abort.started event handler
*/
function globalAbortStartedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onAbortStarted) {
handlers.onAbortStarted(event)
}
}
/**
* Global abort.completed event handler
*/
function globalAbortCompletedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onAbortCompleted) {
handlers.onAbortCompleted(event)
}
// If abort completion is followed by queued runs, keep the handler alive so
// the next run.started/message.delta/run.completed events are still received.
if ((event as any).queue_length > 0) return
sessionEventHandlers.delete(sid)
}
/**
* Global usage.updated event handler
*/
function globalUsageUpdatedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onUsageUpdated) {
handlers.onUsageUpdated(event)
}
}
function globalSessionCommandHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onSessionCommand) {
handlers.onSessionCommand(event)
}
for (const handler of sessionCommandHandlers) {
handler(event)
}
}
function globalAgentEventHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onAgentEvent) {
handlers.onAgentEvent(event)
}
}
function globalApprovalRequestedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onApprovalRequested) {
handlers.onApprovalRequested(event)
}
}
function globalApprovalResolvedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onApprovalResolved) {
handlers.onApprovalResolved(event)
}
}
function globalPeerUserMessageHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onPeerUserMessage) {
handlers.onPeerUserMessage(event)
}
for (const handler of peerUserMessageHandlers) {
handler(event)
}
}
function globalClarifyRequestedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onClarifyRequested) {
handlers.onClarifyRequested(event)
}
}
function globalClarifyResolvedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onClarifyResolved) {
handlers.onClarifyResolved(event)
}
}
/**
* Register event handlers for a session
* @param sessionId - Session ID
* @param handlers - Event handling functions
* @returns Cleanup function to unregister handlers
*/
export function registerSessionHandlers(
sessionId: string,
handlers: {
onMessageDelta: (event: RunEvent) => void
onReasoningDelta: (event: RunEvent) => void
onThinkingDelta: (event: RunEvent) => void
onReasoningAvailable: (event: RunEvent) => void
onToolStarted: (event: RunEvent) => void
onToolCompleted: (event: RunEvent) => void
onSubagentEvent?: (event: RunEvent) => void
onRunStarted: (event: RunEvent) => void
onRunCompleted: (event: RunEvent) => void
onRunFailed: (event: RunEvent) => void
onCompressionStarted: (event: RunEvent) => void
onCompressionCompleted: (event: RunEvent) => void
onAbortStarted: (event: RunEvent) => void
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onAgentEvent?: (event: RunEvent) => void
onSessionCommand?: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
onPeerUserMessage?: (event: RunEvent) => void
onClarifyRequested?: (event: RunEvent) => void
onClarifyResolved?: (event: RunEvent) => void
}
): () => void {
sessionEventHandlers.set(sessionId, handlers)
// Return cleanup function
return () => {
sessionEventHandlers.delete(sessionId)
}
}
/**
* Unregister event handlers for a session
* @param sessionId - Session ID
*/
export function unregisterSessionHandlers(sessionId: string): void {
sessionEventHandlers.delete(sessionId)
}
export function onPeerUserMessage(handler: (event: RunEvent) => void): () => void {
peerUserMessageHandlers.add(handler)
return () => {
peerUserMessageHandlers.delete(handler)
}
}
export function onSessionCommand(handler: (event: RunEvent) => void): () => void {
sessionCommandHandlers.add(handler)
return () => {
sessionCommandHandlers.delete(handler)
}
}
export function respondClarify(
sessionId: string,
clarifyId: string,
response: string,
): void {
const socket = connectChatRun()
socket.emit('clarify.respond', {
session_id: sessionId,
clarify_id: clarifyId,
response,
})
}
export function respondToolApproval(
sessionId: string,
approvalId: string,
choice: 'once' | 'session' | 'always' | 'deny',
): void {
const socket = connectChatRun()
socket.emit('approval.respond', {
session_id: sessionId,
approval_id: approvalId,
choice,
})
}
export function getChatRunSocket(): Socket | null {
return chatRunSocket
}
export function connectChatRun(requestedProfile?: string | null): Socket {
const normalizedRequestedProfile = requestedProfile?.trim() || null
if (chatRunSocket?.connected && (!normalizedRequestedProfile || chatRunSocketProfile === normalizedRequestedProfile)) {
return chatRunSocket
}
// Clean up old socket to prevent duplicate event listeners
if (chatRunSocket) {
chatRunSocket.removeAllListeners()
chatRunSocket.disconnect()
globalListenersRegistered = false
chatRunSocketProfile = null
}
const baseUrl = getBaseUrlValue()
const token = getApiKey()
// Get active profile from store (authoritative source)
let profile = normalizedRequestedProfile || 'default'
try {
if (!normalizedRequestedProfile) {
const { useProfilesStore } = require('@/stores/hermes/profiles')
const profilesStore = useProfilesStore()
profile = profilesStore.activeProfileName || 'default'
}
} catch {
// Fallback to localStorage during early initialization
profile = normalizedRequestedProfile || localStorage.getItem('hermes_active_profile_name') || 'default'
}
chatRunSocketProfile = profile
chatRunSocket = io(`${baseUrl}/chat-run`, {
auth: { token },
query: { profile },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
randomizationFactor: 0.5,
timeout: 30000,
})
// Register global listeners only once per socket connection
if (!globalListenersRegistered) {
// Message events
chatRunSocket.on('message.delta', globalMessageDeltaHandler)
chatRunSocket.on('reasoning.delta', globalReasoningDeltaHandler)
chatRunSocket.on('thinking.delta', globalThinkingDeltaHandler)
chatRunSocket.on('reasoning.available', globalReasoningAvailableHandler)
// Tool events
chatRunSocket.on('tool.started', globalToolStartedHandler)
chatRunSocket.on('tool.completed', globalToolCompletedHandler)
chatRunSocket.on('subagent.start', globalSubagentEventHandler)
chatRunSocket.on('subagent.tool', globalSubagentEventHandler)
chatRunSocket.on('subagent.progress', globalSubagentEventHandler)
chatRunSocket.on('subagent.complete', globalSubagentEventHandler)
// Run lifecycle events
chatRunSocket.on('run.started', globalRunStartedHandler)
chatRunSocket.on('run.failed', globalRunFailedHandler)
chatRunSocket.on('run.completed', globalRunCompletedHandler)
chatRunSocket.on('run.queued', globalRunQueuedHandler)
chatRunSocket.on('approval.requested', globalApprovalRequestedHandler)
chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler)
chatRunSocket.on('run.peer_user_message', globalPeerUserMessageHandler)
chatRunSocket.on('clarify.requested', globalClarifyRequestedHandler)
chatRunSocket.on('clarify.resolved', globalClarifyResolvedHandler)
// Compression events
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
chatRunSocket.on('compression.completed', globalCompressionCompletedHandler)
chatRunSocket.on('abort.started', globalAbortStartedHandler)
chatRunSocket.on('abort.completed', globalAbortCompletedHandler)
// Usage events
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
chatRunSocket.on('agent.event', globalAgentEventHandler)
chatRunSocket.on('session.command', globalSessionCommandHandler)
globalListenersRegistered = true
}
return chatRunSocket
}
export function disconnectChatRun(): void {
if (chatRunSocket) {
chatRunSocket.disconnect()
chatRunSocket = null
chatRunSocketProfile = null
globalListenersRegistered = false
sessionEventHandlers.clear()
}
}
function removeSocketListener(socket: Socket, event: string, handler: (...args: any[]) => void): void {
const candidate = socket as Socket & {
off?: (event: string, handler: (...args: any[]) => void) => Socket
removeListener?: (event: string, handler: (...args: any[]) => void) => Socket
}
if (typeof candidate.off === 'function') {
candidate.off(event, handler)
return
}
candidate.removeListener?.(event, handler)
}
/**
* Start a chat run via Socket.IO and stream events back.
* Returns an AbortController-compatible handle for cancellation.
*/
/**
* Resume a session via Socket.IO. Returns messages, working status, and events.
*/
export function resumeSession(
sessionId: string,
onResumed: (data: ResumeSessionPayload) => void,
profile?: string | null,
): Socket {
const socket = connectChatRun(profile)
socket.once('resumed', onResumed)
socket.emit('resume', { session_id: sessionId, ...(profile ? { profile } : {}) })
return socket
}
export function startRunViaSocket(
body: StartRunRequest,
onEvent: (event: RunEvent) => void,
onDone: () => void,
onError: (err: Error) => void,
onStarted?: (runId: string) => void,
options?: {
onReconnectResume?: (data: ResumeSessionPayload) => void
},
): { abort: () => void } {
const sid = body.session_id
if (!sid) {
throw new Error('session_id is required for startRunViaSocket')
}
let closed = false
const socket = connectChatRun(body.profile)
if (sessionEventHandlers.has(sid)) {
socket.emit('run', body)
return {
abort: () => {
if (!closed) {
socket.emit('abort', { session_id: sid })
}
},
}
}
let sawTransientDisconnect = false
let removeTerminalSocketListeners: () => void = () => {}
let reconnectResumeHandler: ((data: ResumeSessionPayload) => void) | null = null
const clearReconnectResumeHandler = () => {
if (!reconnectResumeHandler) return
removeSocketListener(socket, 'resumed', reconnectResumeHandler)
reconnectResumeHandler = null
}
const emitReconnectResume = () => {
clearReconnectResumeHandler()
if (options?.onReconnectResume) {
reconnectResumeHandler = (data: ResumeSessionPayload) => {
clearReconnectResumeHandler()
if (closed || data.session_id !== sid) return
options.onReconnectResume?.(data)
}
socket.on('resumed', reconnectResumeHandler)
}
socket.emit('resume', { session_id: sid, ...(body.profile ? { profile: body.profile } : {}) })
}
const handleSocketError = (err: Error) => {
if (closed) return
closed = true
removeTerminalSocketListeners()
sessionEventHandlers.delete(sid)
onError(err)
}
const handleSocketConnectError = (err: Error) => {
if (closed) return
if (sawTransientDisconnect) return
handleSocketError(err)
}
socket.on('connect_error', handleSocketConnectError)
const handleSocketDisconnect = (reason: string) => {
if (closed || reason === 'io client disconnect') return
if (TRANSIENT_DISCONNECT_REASONS.has(reason)) {
sawTransientDisconnect = true
return
}
handleSocketError(new Error(`Socket disconnected: ${reason}`))
}
socket.on('disconnect', handleSocketDisconnect)
const handleSocketReconnect = () => {
if (closed || !sawTransientDisconnect) return
sawTransientDisconnect = false
emitReconnectResume()
}
socket.on('connect', handleSocketReconnect)
removeTerminalSocketListeners = () => {
clearReconnectResumeHandler()
removeSocketListener(socket, 'connect_error', handleSocketConnectError)
removeSocketListener(socket, 'disconnect', handleSocketDisconnect)
removeSocketListener(socket, 'connect', handleSocketReconnect)
}
// Define event handlers for this session
const handlers = {
onMessageDelta: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onReasoningDelta: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onThinkingDelta: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onReasoningAvailable: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onToolStarted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onToolCompleted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onSubagentEvent: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onRunStarted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
onStarted?.(evt.run_id || '')
},
onRunCompleted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).queue_remaining > 0) return
closed = true
removeTerminalSocketListeners()
onDone()
},
onRunFailed: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).queue_remaining > 0) return
closed = true
removeTerminalSocketListeners()
onDone()
},
onCompressionStarted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onCompressionCompleted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onAbortStarted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onAbortCompleted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).queue_length > 0) return
closed = true
removeTerminalSocketListeners()
onDone()
},
onUsageUpdated: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onAgentEvent: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onSessionCommand: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).terminal === false) return
closed = true
removeTerminalSocketListeners()
sessionEventHandlers.delete(sid)
onDone()
},
onRunQueued: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onApprovalRequested: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onApprovalResolved: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onClarifyRequested: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onClarifyResolved: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
}
// Register handlers in the global session map
sessionEventHandlers.set(sid, handlers)
// Emit run request
socket.emit('run', body)
return {
abort: () => {
if (!closed) {
socket.emit('abort', { session_id: sid })
}
},
}
}
@@ -0,0 +1,30 @@
import { request } from '../client'
export interface CodexStartResult {
session_id: string
user_code: string
verification_url: string
expires_in: number
}
export interface CodexPollResult {
status: 'pending' | 'approved' | 'expired' | 'error'
error: string | null
}
export interface CodexStatusResult {
authenticated: boolean
last_refresh?: string
}
export async function startCodexLogin(): Promise<CodexStartResult> {
return request<CodexStartResult>('/api/hermes/auth/codex/start', { method: 'POST' })
}
export async function pollCodexLogin(sessionId: string): Promise<CodexPollResult> {
return request<CodexPollResult>(`/api/hermes/auth/codex/poll/${sessionId}`)
}
export async function getCodexAuthStatus(): Promise<CodexStatusResult> {
return request<CodexStatusResult>('/api/hermes/auth/codex/status')
}
+131
View File
@@ -0,0 +1,131 @@
import { request } from '../client'
export interface DisplayConfig {
compact?: boolean
personality?: string
resume_display?: string
busy_input_mode?: string
bell_on_complete?: boolean
show_reasoning?: boolean
streaming?: boolean
inline_diffs?: boolean
show_cost?: boolean
skin?: string
}
export interface AgentConfig {
max_turns?: number
gateway_timeout?: number
restart_drain_timeout?: number
service_tier?: string
tool_use_enforcement?: string
}
export interface MemoryConfig {
memory_enabled?: boolean
user_profile_enabled?: boolean
memory_char_limit?: number
user_char_limit?: number
}
export interface CompressionConfig {
enabled?: boolean
threshold?: number
target_ratio?: number
protect_last_n?: number
protect_first_n?: number
}
export interface SessionResetConfig {
mode?: string
idle_minutes?: number
at_hour?: number
}
export interface PrivacyConfig {
redact_pii?: boolean
}
export interface ApprovalConfig {
mode?: 'off' | 'manual'
timeout?: number
}
export interface AppConfig {
display?: DisplayConfig
agent?: AgentConfig
memory?: MemoryConfig
compression?: CompressionConfig
session_reset?: SessionResetConfig
privacy?: PrivacyConfig
approvals?: ApprovalConfig
telegram?: Record<string, any>
discord?: Record<string, any>
slack?: Record<string, any>
whatsapp?: Record<string, any>
matrix?: Record<string, any>
weixin?: Record<string, any>
wecom?: Record<string, any>
feishu?: Record<string, any>
dingtalk?: Record<string, any>
qqbot?: Record<string, any>
platforms?: Record<string, any>
[key: string]: any
}
export async function fetchConfig(sections?: string[]): Promise<AppConfig> {
const query = sections ? `?sections=${sections.join(',')}` : ''
return request<AppConfig>(`/api/hermes/config${query}`)
}
export async function updateConfigSection(
section: string,
values: Record<string, any>,
options?: { restart?: boolean },
): Promise<void> {
await request('/api/hermes/config', {
method: 'PUT',
body: JSON.stringify({ section, values, ...options }),
})
}
export async function saveCredentials(
platform: string,
values: Record<string, any>,
): Promise<void> {
await request('/api/hermes/config/credentials', {
method: 'PUT',
body: JSON.stringify({ platform, values }),
})
}
export interface WeixinQrCode {
qrcode: string
qrcode_url: string
}
export interface WeixinQrStatus {
status: 'wait' | 'scaned' | 'scaned_but_redirect' | 'expired' | 'confirmed'
account_id?: string
token?: string
base_url?: string
}
export async function fetchWeixinQrCode(): Promise<WeixinQrCode> {
return request<WeixinQrCode>('/api/hermes/weixin/qrcode')
}
export async function pollWeixinQrStatus(qrcode: string): Promise<WeixinQrStatus> {
return request<WeixinQrStatus>(`/api/hermes/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`)
}
export async function saveWeixinCredentials(data: {
account_id: string
token: string
base_url?: string
}): Promise<void> {
await request('/api/hermes/weixin/save', {
method: 'POST',
body: JSON.stringify(data),
})
}
@@ -0,0 +1,59 @@
import { request } from '../client'
export interface ConversationSummary {
id: string
source: string
model: string
provider?: string
title: string | null
started_at: number
ended_at: number | null
last_active: number
message_count: number
tool_call_count: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
billing_provider: string | null
estimated_cost_usd: number
actual_cost_usd: number | null
cost_status: string
preview: string
is_active: boolean
thread_session_count: number
}
export interface ConversationMessage {
id: number | string
session_id: string
role: 'user' | 'assistant'
content: string
timestamp: number
}
export interface ConversationDetail {
session_id: string
messages: ConversationMessage[]
visible_count: number
thread_session_count: number
}
export async function fetchConversationSummaries(params: { humanOnly?: boolean; source?: string; limit?: number } = {}): Promise<ConversationSummary[]> {
const query = new URLSearchParams()
if (params.humanOnly === false) query.set('humanOnly', 'false')
if (params.source) query.set('source', params.source)
if (params.limit != null) query.set('limit', String(params.limit))
const suffix = query.toString() ? `?${query.toString()}` : ''
const res = await request<{ sessions: ConversationSummary[] }>(`/api/hermes/sessions/conversations${suffix}`)
return res.sessions
}
export async function fetchConversationDetail(sessionId: string, params: { humanOnly?: boolean; source?: string } = {}): Promise<ConversationDetail> {
const query = new URLSearchParams()
if (params.humanOnly === false) query.set('humanOnly', 'false')
if (params.source) query.set('source', params.source)
const suffix = query.toString() ? `?${query.toString()}` : ''
return request<ConversationDetail>(`/api/hermes/sessions/conversations/${encodeURIComponent(sessionId)}/messages${suffix}`)
}
@@ -0,0 +1,42 @@
import { request } from '../client'
export type CopilotTokenSource = 'env' | 'gh-cli' | 'apps-json' | null
export interface CopilotStartResult {
session_id: string
user_code: string
verification_url: string
expires_in: number
interval: number
}
export interface CopilotPollResult {
status: 'pending' | 'approved' | 'denied' | 'expired' | 'error'
error: string | null
}
export interface CopilotCheckTokenResult {
has_token: boolean
source: CopilotTokenSource
enabled: boolean
}
export async function startCopilotLogin(): Promise<CopilotStartResult> {
return request<CopilotStartResult>('/api/hermes/auth/copilot/start', { method: 'POST' })
}
export async function pollCopilotLogin(sessionId: string): Promise<CopilotPollResult> {
return request<CopilotPollResult>(`/api/hermes/auth/copilot/poll/${sessionId}`)
}
export async function checkCopilotToken(): Promise<CopilotCheckTokenResult> {
return request<CopilotCheckTokenResult>('/api/hermes/auth/copilot/check-token')
}
export async function enableCopilot(): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('/api/hermes/auth/copilot/enable', { method: 'POST' })
}
export async function disableCopilot(): Promise<{ ok: boolean; cleared_env: boolean; cleared_default?: boolean }> {
return request<{ ok: boolean; cleared_env: boolean; cleared_default?: boolean }>('/api/hermes/auth/copilot/disable', { method: 'POST' })
}
@@ -0,0 +1,27 @@
import { request } from '../client'
export interface RunEntry {
jobId: string
fileName: string
runTime: string
size: number
}
export interface RunDetail {
jobId: string
fileName: string
runTime: string
content: string
}
export async function listCronRuns(jobId?: string): Promise<RunEntry[]> {
const params = new URLSearchParams()
if (jobId) params.set('jobId', jobId)
const qs = params.toString()
const res = await request<{ runs: RunEntry[] }>(`/api/cron-history${qs ? `?${qs}` : ''}`)
return res.runs
}
export async function readCronRun(jobId: string, fileName: string): Promise<RunDetail> {
return request<RunDetail>(`/api/cron-history/${encodeURIComponent(jobId)}/${encodeURIComponent(fileName)}`)
}
@@ -0,0 +1,71 @@
import { getActiveProfileName, getApiKey, getBaseUrlValue } from '../client'
/**
* Construct a download URL with auth token as query parameter.
* Token is passed via query param because <a> tags cannot set headers.
*/
export function getDownloadUrl(filePath: string, fileName?: string): string {
const base = getBaseUrlValue()
// Guard: if filePath is already a full download URL, extract the real path
// to prevent double-wrapping (/api/hermes/download?path=/api/hermes/download?path=...)
if (filePath.startsWith('/api/hermes/download?')) {
try {
const parsed = new URL(filePath, 'http://localhost')
const realPath = parsed.searchParams.get('path')
if (realPath) filePath = realPath
} catch {
// fall through with original filePath
}
}
// Decode the path first in case it's already encoded (e.g., from AI responses)
// URLSearchParams will encode it again, so we need to start with decoded text
const decodedPath = decodeURIComponent(filePath)
const params = new URLSearchParams({ path: decodedPath })
if (fileName) {
const decodedName = decodeURIComponent(fileName)
params.set('name', decodedName)
}
const profileName = getActiveProfileName()
if (profileName) params.set('profile', profileName)
const token = getApiKey()
if (token) params.set('token', token)
return `${base}/api/hermes/download?${params.toString()}`
}
/**
* Download a file. Uses fetch to detect errors, then creates a blob URL
* for the browser download. Throws with error message on failure.
*/
export async function downloadFile(filePath: string, fileName?: string): Promise<void> {
const url = getDownloadUrl(filePath, fileName)
const res = await fetch(url)
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new Error(body.error || `Download failed: ${res.status}`)
}
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = fileName || filePath.split('/').pop() || 'download'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
}
/**
* Get preview file content.
* Throws with error message on failure.
*/
export async function fetchFileText(filePath: string, fileName?: string): Promise<string> {
const url = getDownloadUrl(filePath, fileName)
const res = await fetch(url)
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new Error(body.error || `Preview failed: ${res.status}`)
}
return res.text()
}
+107
View File
@@ -0,0 +1,107 @@
import { request, getActiveProfileName, getApiKey, getBaseUrlValue } from '../client'
export interface FileEntry {
name: string
path: string
absolutePath?: string
isDir: boolean
size: number
modTime: string
}
export interface FileStat {
name: string
path: string
absolutePath?: string
isDir: boolean
size: number
modTime: string
permissions?: string
}
export async function listFiles(path: string = ''): Promise<{ entries: FileEntry[]; path: string; absolutePath?: string }> {
const params = new URLSearchParams()
if (path) params.set('path', path)
const query = params.toString()
return request<{ entries: FileEntry[]; path: string }>(`/api/hermes/files/list${query ? `?${query}` : ''}`)
}
export async function statFile(path: string): Promise<FileStat> {
return request<FileStat>(`/api/hermes/files/stat?path=${encodeURIComponent(path)}`)
}
export async function readFile(path: string): Promise<{ content: string; path: string; size: number }> {
return request<{ content: string; path: string; size: number }>(`/api/hermes/files/read?path=${encodeURIComponent(path)}`)
}
export async function writeFile(path: string, content: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/write', {
method: 'PUT',
body: JSON.stringify({ path, content }),
})
}
export async function deleteFile(path: string, recursive: boolean = false): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/delete', {
method: 'DELETE',
body: JSON.stringify({ path, recursive }),
})
}
export async function renameFile(oldPath: string, newPath: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/rename', {
method: 'POST',
body: JSON.stringify({ oldPath, newPath }),
})
}
export async function mkDir(path: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/mkdir', {
method: 'POST',
body: JSON.stringify({ path }),
})
}
export async function copyFile(srcPath: string, destPath: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/copy', {
method: 'POST',
body: JSON.stringify({ srcPath, destPath }),
})
}
export async function uploadFiles(targetDir: string, files: File[]): Promise<{ name: string; path: string }[]> {
const base = getBaseUrlValue()
const formData = new FormData()
for (const file of files) {
formData.append('file', file)
}
const params = new URLSearchParams()
if (targetDir) params.set('path', targetDir)
const query = params.toString()
const url = `${base}/api/hermes/files/upload${query ? `?${query}` : ''}`
const headers: Record<string, string> = {}
const token = getApiKey()
if (token) headers['Authorization'] = `Bearer ${token}`
const profileName = getActiveProfileName()
if (profileName) headers['X-Hermes-Profile'] = profileName
const res = await fetch(url, { method: 'POST', headers, body: formData })
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new Error(body.error || `Upload failed: ${res.status}`)
}
const data = await res.json()
return data.files
}
export function getFileDownloadUrl(relativePath: string, fileName?: string): string {
const base = getBaseUrlValue()
const params = new URLSearchParams({ path: relativePath })
if (fileName) params.set('name', fileName)
const profileName = getActiveProfileName()
if (profileName) params.set('profile', profileName)
const token = getApiKey()
if (token) params.set('token', token)
return `${base}/api/hermes/download?${params.toString()}`
}
@@ -0,0 +1,235 @@
import { io } from 'socket.io-client'
import { request, getApiKey } from '../client'
// ─── Types ──────────────────────────────────────────────────
export interface RoomInfo {
id: string
name: string
inviteCode: string | null
triggerTokens?: number
maxHistoryTokens?: number
tailMessageCount?: number
totalTokens?: number
}
export interface RoomAgent {
id: string
roomId: string
agentId: string
profile: string
name: string
description: string
invited: number
}
export interface AgentAddResult {
profile: string
ok: boolean
agent?: RoomAgent
code?: string
error?: string
reason?: string
}
export interface ChatMessage {
id: string
roomId: string
senderId: string
senderName: string
content: string
timestamp: number
role?: string
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
finish_reason?: 'streaming' | 'tool_calls' | 'error' | string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
isStreaming?: boolean
toolName?: string
toolCallId?: string
toolArgs?: string
toolPreview?: string
toolResult?: string
toolStatus?: 'running' | 'done' | 'error'
attachments?: Array<{ id: string; name: string; type: string; size: number; url: string }>
}
export interface MemberInfo {
id: string
userId: string
name: string
description: string
joinedAt: number
}
export interface JoinResult {
roomId: string
roomName: string
members: MemberInfo[]
messages: ChatMessage[]
rooms: string[]
}
// ─── Socket.IO Client ──────────────────────────────────────
let socket: ReturnType<typeof io> | null = null
export function connectGroupChat(opts?: { userId?: string; userName?: string; description?: string }): ReturnType<typeof io> {
if (socket?.connected) return socket
const token = getApiKey()
const userId = opts?.userId || localStorage.getItem('gc_user_id') || generateUUID()
localStorage.setItem('gc_user_id', userId)
socket = io('/group-chat', {
auth: {
token: token || undefined,
userId,
name: opts?.userName || localStorage.getItem('gc_user_name') || undefined,
description: opts?.description || localStorage.getItem('gc_user_description') || undefined,
},
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
randomizationFactor: 0.5,
timeout: 30000,
})
return socket
}
export function getStoredUserId(): string {
let id = localStorage.getItem('gc_user_id')
if (!id) {
id = generateUUID()
localStorage.setItem('gc_user_id', id)
}
return id
}
export function getStoredUserName(): string | null {
return localStorage.getItem('gc_user_name')
}
function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
export function getSocket(): ReturnType<typeof io> | null {
return socket?.connected ? socket : null
}
export function disconnectGroupChat(): void {
if (socket) {
socket.disconnect()
socket = null
}
}
// ─── REST API ───────────────────────────────────────────────
export async function createRoom(data: {
name: string
inviteCode: string
agents?: { profile: string; name?: string; description?: string; invited?: boolean }[]
compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }
}): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> {
return request('/api/hermes/group-chat/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
export async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/clone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data || {}),
})
}
export async function listRooms(): Promise<{ rooms: RoomInfo[] }> {
return request('/api/hermes/group-chat/rooms')
}
export async function getRoomDetail(
roomId: string,
options: { offset?: number; limit?: number } = {},
): Promise<{ room: RoomInfo; messages: ChatMessage[]; agents: RoomAgent[]; members: MemberInfo[]; total?: number; offset?: number; limit?: number; hasMore?: boolean }> {
const params = new URLSearchParams()
if (options.offset != null) params.set('offset', String(options.offset))
if (options.limit != null) params.set('limit', String(options.limit))
const query = params.toString()
return request(`/api/hermes/group-chat/rooms/${roomId}${query ? `?${query}` : ''}`)
}
export async function joinRoomByCode(code: string): Promise<{ room: RoomInfo }> {
return request(`/api/hermes/group-chat/rooms/join/${code}`)
}
export async function updateInviteCode(roomId: string, inviteCode: string): Promise<void> {
return request(`/api/hermes/group-chat/rooms/${roomId}/invite-code`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode }),
})
}
export async function addAgent(roomId: string, data: {
profile: string
name?: string
description?: string
invited?: boolean
}): Promise<{ agent: RoomAgent }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/agents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
export async function listAgents(roomId: string): Promise<{ agents: RoomAgent[] }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/agents`)
}
export async function removeAgent(roomId: string, agentId: string): Promise<{ success: boolean; agents: RoomAgent[]; members: MemberInfo[] }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/agents/${agentId}`, {
method: 'DELETE',
})
}
export async function deleteRoom(roomId: string): Promise<void> {
return request(`/api/hermes/group-chat/rooms/${roomId}`, {
method: 'DELETE',
})
}
export async function clearRoomContext(roomId: string): Promise<{ success: boolean; room: RoomInfo }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/clear-context`, {
method: 'POST',
})
}
export async function updateRoomConfig(roomId: string, config: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): Promise<{ room: RoomInfo }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
}
export async function forceCompress(roomId: string): Promise<{ success: boolean; summary: string }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/compress`, {
method: 'POST',
})
}
+188
View File
@@ -0,0 +1,188 @@
import { request } from '../client'
export interface JobScheduleInterval {
kind: 'interval'
minutes: number
display: string
}
export interface JobScheduleCron {
kind: 'cron'
expr: string
display: string
}
export interface JobScheduleOnce {
kind: 'once'
run_at: string
display: string
}
type UnknownJobSchedule = {
kind: string
display?: string
expr?: string
minutes?: number
run_at?: string
}
export type JobSchedule = string | JobScheduleInterval | JobScheduleCron | JobScheduleOnce | UnknownJobSchedule
export interface Job {
job_id: string
id: string
name: string
prompt: string
prompt_preview?: string
skills: string[]
skill: string | null
model: string | null
provider: string | null
base_url: string | null
script: string | null
schedule: JobSchedule
schedule_display: string
repeat: string | { times: number | null; completed: number }
enabled: boolean
state: string
paused_at: string | null
paused_reason: string | null
created_at: string
next_run_at: string | null
last_run_at: string | null
last_status: string | null
last_error: string | null
deliver: string
origin: {
platform: string
chat_id: string
chat_name: string
thread_id: string | null
} | null
last_delivery_error: string | null
}
export interface CreateJobRequest {
name: string
schedule: string
prompt?: string
deliver?: string
skills?: string[]
repeat?: number
}
export interface UpdateJobRequest {
name?: string
schedule?: string
prompt?: string
deliver?: string
skills?: string[]
skill?: string
repeat?: number | null
enabled?: boolean
model?: string
provider?: string
}
export interface JobFormValues {
name: string
schedule: string
prompt: string
deliver: string
repeat_times: number | null
}
function unwrap(res: { job: Job }): Job {
return res.job
}
function isScheduleObject(schedule: JobSchedule | null | undefined): schedule is Exclude<JobSchedule, string> {
return typeof schedule === 'object' && schedule !== null
}
export function scheduleToEditableInput(schedule: JobSchedule | null | undefined, fallback = ''): string {
if (typeof schedule === 'string') return schedule
if (!isScheduleObject(schedule)) return fallback
if (schedule.kind === 'cron') return schedule.expr || schedule.display || fallback
if (schedule.kind === 'once') return schedule.run_at || schedule.display || fallback
if (schedule.kind === 'interval') {
return schedule.display || (typeof schedule.minutes === 'number' ? `every ${schedule.minutes}m` : fallback)
}
const unknownSchedule = schedule as UnknownJobSchedule
return unknownSchedule.expr || unknownSchedule.run_at || unknownSchedule.display || fallback
}
export function scheduleToDisplayText(schedule: JobSchedule | null | undefined, fallback = '—'): string {
if (typeof schedule === 'string') return schedule
if (!isScheduleObject(schedule)) return fallback
if (schedule.kind === 'cron') return schedule.expr || schedule.display || fallback
if (schedule.kind === 'interval') return schedule.display || scheduleToEditableInput(schedule, fallback)
if (schedule.kind === 'once') return schedule.display || scheduleToEditableInput(schedule, fallback)
const unknownSchedule = schedule as UnknownJobSchedule
return unknownSchedule.display || unknownSchedule.expr || unknownSchedule.run_at || fallback
}
export function jobRepeatToEditValue(repeat: Job['repeat']): number | null {
if (repeat && typeof repeat === 'object') return repeat.times ?? null
return null
}
export function buildJobUpdateRequest(original: Job, form: JobFormValues): UpdateJobRequest {
const payload: UpdateJobRequest = {}
const originalSchedule = scheduleToEditableInput(original.schedule, original.schedule_display || '')
const originalRepeat = jobRepeatToEditValue(original.repeat)
const originalDeliver = original.deliver || 'origin'
if (form.name !== original.name) payload.name = form.name
if (form.schedule !== originalSchedule) payload.schedule = form.schedule
if (form.prompt !== (original.prompt || '')) payload.prompt = form.prompt
if (form.deliver !== originalDeliver) payload.deliver = form.deliver
if (form.repeat_times !== originalRepeat) payload.repeat = form.repeat_times
return payload
}
export async function listJobs(): Promise<Job[]> {
const res = await request<{ jobs: Job[] }>('/api/hermes/jobs?include_disabled=true')
return res.jobs
}
export async function getJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}`))
}
export async function createJob(data: CreateJobRequest): Promise<Job> {
return unwrap(await request<{ job: Job }>('/api/hermes/jobs', {
method: 'POST',
body: JSON.stringify(data),
}))
}
export async function updateJob(jobId: string, data: UpdateJobRequest): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}))
}
export async function deleteJob(jobId: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/api/hermes/jobs/${jobId}`, {
method: 'DELETE',
})
}
export async function pauseJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/pause`, { method: 'POST' }))
}
export async function resumeJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/resume`, { method: 'POST' }))
}
export async function runJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/run`, { method: 'POST' }))
}
+442
View File
@@ -0,0 +1,442 @@
import { request, getApiKey, getBaseUrlValue } from '../client'
// ─── Types ──────────────────────────────────────────────────────
export type KanbanTaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived'
export interface KanbanTask {
id: string
title: string
body: string | null
assignee: string | null
status: KanbanTaskStatus
priority: number
created_by: string | null
created_at: number
started_at: number | null
completed_at: number | null
workspace_kind: string
workspace_path: string | null
tenant: string | null
result: string | null
skills: string[] | null
}
export interface KanbanRun {
id: number
task_id: string
profile: string | null
status: string
outcome: string | null
summary: string | null
error: string | null
metadata: Record<string, unknown> | null
worker_pid: number | null
started_at: number
ended_at: number | null
}
export interface KanbanComment {
id: number
task_id: string
author: string
body: string
created_at: number
}
export interface KanbanEvent {
id: number
task_id: string
kind: string
payload: Record<string, unknown> | null
created_at: number
run_id: number | null
}
export interface KanbanTaskMessage {
id: number | string
session_id: string
role: string
content: string
tool_call_id: string | null
tool_calls: any[] | null
tool_name: string | null
timestamp: number
token_count: number | null
finish_reason: string | null
reasoning: string | null
}
export interface KanbanTaskSession {
id: string
title: string | null
source: string
model: string
started_at: number
ended_at: number | null
messages: KanbanTaskMessage[]
}
export interface KanbanTaskDetail {
task: KanbanTask
latest_summary: string | null
session?: KanbanTaskSession
comments: KanbanComment[]
events: KanbanEvent[]
runs: KanbanRun[]
parents?: string[]
children?: string[]
}
export interface KanbanStats {
by_status: Record<string, number>
by_assignee: Record<string, number>
total: number
}
export interface KanbanAssignee {
name: string
on_disk: boolean
counts: Record<string, number> | null
}
export interface KanbanBoard {
slug: string
name: string
description: string
icon: string
color: string
created_at: number | null
archived: boolean
db_path?: string
is_current?: boolean
counts: Record<string, number>
total: number
}
export interface KanbanBoardCreateRequest {
slug: string
name?: string
description?: string
icon?: string
color?: string
switchCurrent?: boolean
}
export interface KanbanCapabilityStatus {
key: string
status: 'supported' | 'partial' | 'missing'
reason?: string
canonicalRoute?: string
canonicalCommand?: string
requiresBoard: boolean
}
export interface KanbanCapabilities {
source: 'hermes-cli'
supports: Record<string, boolean>
missing: string[]
capabilities?: KanbanCapabilityStatus[]
}
export interface KanbanTaskLog {
task_id: string
path: string | null
exists: boolean
size_bytes: number
content: string
truncated: boolean
}
export interface KanbanCreateRequest {
title: string
body?: string
assignee?: string
priority?: number
tenant?: string
}
export interface KanbanBoardOptions {
board?: string
}
export interface KanbanListOptions extends KanbanBoardOptions {
status?: string
assignee?: string
tenant?: string
includeArchived?: boolean
}
export interface KanbanCommentCreateRequest {
body: string
author?: string
}
export interface KanbanTaskLogOptions extends KanbanBoardOptions {
tail?: number
}
export interface KanbanDiagnosticsOptions extends KanbanBoardOptions {
task?: string
severity?: 'warning' | 'error' | 'critical'
}
export interface KanbanReclaimOptions extends KanbanBoardOptions {
reason?: string
}
export interface KanbanReassignOptions extends KanbanBoardOptions {
reclaim?: boolean
reason?: string
}
export interface KanbanSpecifyOptions extends KanbanBoardOptions {
author?: string
}
export interface KanbanDispatchOptions extends KanbanBoardOptions {
dryRun?: boolean
max?: number
failureLimit?: number
}
export interface KanbanLinkRequest {
parent_id: string
child_id: string
}
export interface KanbanBulkUpdateRequest {
ids: string[]
status?: KanbanTaskStatus
assignee?: string | null
archive?: boolean
summary?: string
reason?: string
}
export interface KanbanBulkTaskResult {
id: string
ok: boolean
error?: string
}
function normalizedBoard(board?: string): string {
const trimmed = board?.trim()
return trimmed || 'default'
}
function activeProfileName(): string | null {
try {
return localStorage.getItem('hermes_active_profile_name')
} catch {
return null
}
}
function appendQuery(path: string, params: URLSearchParams): string {
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
function boardParams(board?: string): URLSearchParams {
const params = new URLSearchParams()
params.set('board', normalizedBoard(board))
return params
}
function websocketProtocol(base?: string): string {
if (base) return base.startsWith('https') ? 'wss:' : 'ws:'
return location.protocol === 'https:' ? 'wss:' : 'ws:'
}
function formatHostForPort(hostname: string, port: number): string {
if (hostname.startsWith('[') && hostname.endsWith(']')) return `${hostname}:${port}`
return hostname.includes(':') ? `[${hostname}]:${port}` : `${hostname}:${port}`
}
export function buildKanbanEventsWebSocketUrl(opts?: KanbanBoardOptions): string {
const base = getBaseUrlValue()
const params = boardParams(opts?.board)
const token = getApiKey()
if (token) params.set('token', token)
const profile = activeProfileName()
if (profile) params.set('profile', profile)
const path = `/api/hermes/kanban/events?${params.toString()}`
if (base) {
return `${websocketProtocol(base)}//${new URL(base).host}${path}`
}
const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT
const host = import.meta.env.DEV && directDevPort
? formatHostForPort(location.hostname, Number(directDevPort))
: location.host
return `${websocketProtocol()}//${host}${path}`
}
export function openKanbanEventStream(opts?: KanbanBoardOptions): WebSocket {
return new WebSocket(buildKanbanEventsWebSocketUrl(opts))
}
// ─── API functions ───────────────────────────────────────────────
export async function listBoards(opts?: { includeArchived?: boolean }): Promise<KanbanBoard[]> {
const params = new URLSearchParams()
if (opts?.includeArchived) params.set('includeArchived', 'true')
const res = await request<{ boards: KanbanBoard[] }>(appendQuery('/api/hermes/kanban/boards', params))
return res.boards
}
export async function createBoard(data: KanbanBoardCreateRequest): Promise<KanbanBoard> {
const res = await request<{ board: KanbanBoard }>('/api/hermes/kanban/boards', {
method: 'POST',
body: JSON.stringify(data),
})
return res.board
}
export async function archiveBoard(slug: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/api/hermes/kanban/boards/${encodeURIComponent(slug)}`, {
method: 'DELETE',
})
}
export async function getCapabilities(): Promise<KanbanCapabilities> {
const res = await request<{ capabilities: KanbanCapabilities }>('/api/hermes/kanban/capabilities')
return res.capabilities
}
export async function listTasks(opts?: KanbanListOptions): Promise<KanbanTask[]> {
const params = boardParams(opts?.board)
if (opts?.status) params.set('status', opts.status)
if (opts?.assignee) params.set('assignee', opts.assignee)
if (opts?.tenant) params.set('tenant', opts.tenant)
if (opts?.includeArchived) params.set('includeArchived', 'true')
const res = await request<{ tasks: KanbanTask[] }>(appendQuery('/api/hermes/kanban', params))
return res.tasks
}
export async function getTask(id: string, opts?: KanbanBoardOptions): Promise<KanbanTaskDetail> {
return request<KanbanTaskDetail>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(id)}`, boardParams(opts?.board)))
}
export async function createTask(data: KanbanCreateRequest, opts?: KanbanBoardOptions): Promise<KanbanTask> {
const res = await request<{ task: KanbanTask }>(appendQuery('/api/hermes/kanban', boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify(data),
})
return res.task
}
export async function completeTasks(taskIds: string[], summary?: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(appendQuery('/api/hermes/kanban/complete', boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ task_ids: taskIds, summary }),
})
}
export async function blockTask(taskId: string, reason: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/block`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ reason }),
})
}
export async function unblockTasks(taskIds: string[], opts?: KanbanBoardOptions): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(appendQuery('/api/hermes/kanban/unblock', boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ task_ids: taskIds }),
})
}
export async function assignTask(taskId: string, profile: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/assign`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ profile }),
})
}
export async function addComment(taskId: string, data: KanbanCommentCreateRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/comments`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function linkTasks(data: KanbanLinkRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery('/api/hermes/kanban/links', boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function unlinkTasks(data: KanbanLinkRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> {
const params = boardParams(opts?.board)
params.set('parent_id', data.parent_id)
params.set('child_id', data.child_id)
return request<{ ok: boolean; output?: string }>(appendQuery('/api/hermes/kanban/links', params), {
method: 'DELETE',
})
}
export async function bulkUpdateTasks(data: KanbanBulkUpdateRequest, opts?: KanbanBoardOptions): Promise<{ results: KanbanBulkTaskResult[] }> {
return request<{ results: KanbanBulkTaskResult[] }>(appendQuery('/api/hermes/kanban/tasks/bulk', boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function getTaskLog(taskId: string, opts?: KanbanTaskLogOptions): Promise<KanbanTaskLog> {
const params = boardParams(opts?.board)
if (opts?.tail !== undefined) params.set('tail', String(opts.tail))
return request<KanbanTaskLog>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/log`, params))
}
export async function getDiagnostics(opts?: KanbanDiagnosticsOptions): Promise<unknown[]> {
const params = boardParams(opts?.board)
if (opts?.task) params.set('task', opts.task)
if (opts?.severity) params.set('severity', opts.severity)
const res = await request<{ diagnostics: unknown[] }>(appendQuery('/api/hermes/kanban/diagnostics', params))
return res.diagnostics
}
export async function reclaimTask(taskId: string, opts?: KanbanReclaimOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/reclaim`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ reason: opts?.reason }),
})
}
export async function reassignTask(taskId: string, profile: string, opts?: KanbanReassignOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/reassign`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ profile, reclaim: opts?.reclaim, reason: opts?.reason }),
})
}
export async function specifyTask(taskId: string, opts?: KanbanSpecifyOptions): Promise<unknown[]> {
const res = await request<{ results: unknown[] }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/specify`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ author: opts?.author }),
})
return res.results
}
export async function dispatch(opts?: KanbanDispatchOptions): Promise<unknown> {
const params = boardParams(opts?.board)
const res = await request<{ result: unknown }>(appendQuery('/api/hermes/kanban/dispatch', params), {
method: 'POST',
body: JSON.stringify({ dryRun: opts?.dryRun, max: opts?.max, failureLimit: opts?.failureLimit }),
})
return res.result
}
export async function getStats(opts?: KanbanBoardOptions): Promise<KanbanStats> {
const res = await request<{ stats: KanbanStats }>(appendQuery('/api/hermes/kanban/stats', boardParams(opts?.board)))
return res.stats
}
export async function getAssignees(opts?: KanbanBoardOptions): Promise<KanbanAssignee[]> {
const res = await request<{ assignees: KanbanAssignee[] }>(appendQuery('/api/hermes/kanban/assignees', boardParams(opts?.board)))
return res.assignees
}
+36
View File
@@ -0,0 +1,36 @@
import { request } from '../client'
export interface LogFileInfo {
name: string
size: string
modified: string
}
export interface LogEntry {
timestamp: string
level: string
logger: string
message: string
raw: string
}
export async function fetchLogFiles(): Promise<LogFileInfo[]> {
const res = await request<{ files: LogFileInfo[] }>('/api/hermes/logs')
return res.files
}
export async function fetchLogs(name: string, params?: {
lines?: number
level?: string
session?: string
since?: string
}): Promise<LogEntry[]> {
const query = new URLSearchParams()
if (params?.lines) query.set('lines', String(params.lines))
if (params?.level) query.set('level', params.level)
if (params?.session) query.set('session', params.session)
if (params?.since) query.set('since', params.since)
const qs = query.toString()
const res = await request<{ entries: (LogEntry | null)[] }>(`/api/hermes/logs/${name}${qs ? `?${qs}` : ''}`)
return res.entries.filter((e): e is LogEntry => e !== null)
}
+90
View File
@@ -0,0 +1,90 @@
import { request } from '../client'
export interface McpServerInfo {
name: string
transport: 'stdio' | 'http' | 'sse'
connected: boolean
tools: number
tools_registered: number
tool_names: string[]
tool_names_registered: string[]
tool_details: Array<{ name: string; description?: string }>
error?: string | null
raw_config: McpServerConfig
}
export interface McpServersResponse {
ok: boolean
servers: McpServerInfo[]
total_tools: number
error?: string
}
export interface McpToolsResponse {
ok: boolean
results: Array<{
server: string
tools: Array<{
name: string
description: string
input_schema: Record<string, unknown>
}>
}>
error?: string
}
export interface McpServerConfig {
command?: string
args?: string[]
url?: string
env?: Record<string, string>
headers?: Record<string, string>
timeout?: number
connect_timeout?: number
enabled?: boolean
transport?: 'stdio' | 'http' | 'sse'
tools?: { include?: string[]; exclude?: string[] }
prompts?: boolean
resources?: boolean
}
export async function fetchMcpServers(): Promise<McpServersResponse> {
return request<McpServersResponse>('/api/hermes/mcp/servers')
}
export async function fetchMcpTools(server?: string, raw?: boolean): Promise<McpToolsResponse> {
const params = new URLSearchParams()
if (server) params.set('server', server)
if (raw) params.set('raw', '1')
const query = params.toString() ? `?${params.toString()}` : ''
return request<McpToolsResponse>(`/api/hermes/mcp/tools${query}`)
}
export async function mcpServerAdd(name: string, config: McpServerConfig): Promise<{ ok: boolean; name?: string; error?: string }> {
return request('/api/hermes/mcp/servers', {
method: 'POST',
body: JSON.stringify({ name, config }),
})
}
export async function mcpServerRemove(name: string): Promise<{ ok: boolean; error?: string }> {
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}`, {
method: 'DELETE',
})
}
export async function mcpServerUpdate(name: string, config: McpServerConfig): Promise<{ ok: boolean; error?: string }> {
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify({ config }),
})
}
export async function mcpReload(name?: string): Promise<{ ok: boolean; message?: string; error?: string }> {
const query = name ? `?server=${encodeURIComponent(name)}` : ''
return request(`/api/hermes/mcp/reload${query}`, { method: 'POST' })
}
export async function mcpServerTest(name: string): Promise<{ ok: boolean; tools?: string[]; error?: string }> {
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}/test`, { method: 'POST' })
}
@@ -0,0 +1,41 @@
import { request } from '../client'
export interface ModelContext {
id: number
provider: string
model: string
context_limit: number
}
/**
* 根据 provider 和 model 查询模型上下文配置
*/
export async function getModelContext(provider: string, model: string): Promise<ModelContext | null> {
try {
const res = await request<{ data: ModelContext }>(
`/api/hermes/model-context?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}`
)
return res.data
} catch (err: any) {
if (err.status === 404) return null
throw err
}
}
/**
* 设置模型上下文配置(UPSERT:存在则更新,不存在则插入)
*/
export async function setModelContext(
provider: string,
model: string,
contextLimit: number
): Promise<ModelContext> {
const res = await request<{ success: boolean; data: ModelContext }>(
`/api/hermes/model-context/${encodeURIComponent(provider)}/${encodeURIComponent(model)}`,
{
method: 'PUT',
body: JSON.stringify({ provider, model, context_limit: contextLimit }),
}
)
return res.data
}
@@ -0,0 +1,29 @@
import { request } from '../client'
export interface NousStartResult {
session_id: string
user_code: string
verification_url: string
expires_in: number
}
export interface NousPollResult {
status: 'pending' | 'approved' | 'denied' | 'expired' | 'error'
error: string | null
}
export interface NousStatusResult {
authenticated: boolean
}
export async function startNousLogin(): Promise<NousStartResult> {
return request<NousStartResult>('/api/hermes/auth/nous/start', { method: 'POST' })
}
export async function pollNousLogin(sessionId: string): Promise<NousPollResult> {
return request<NousPollResult>(`/api/hermes/auth/nous/poll/${sessionId}`)
}
export async function getNousAuthStatus(): Promise<NousStatusResult> {
return request<NousStatusResult>('/api/hermes/auth/nous/status')
}
@@ -0,0 +1,63 @@
import { request } from '../client'
export interface ProcessUsage {
pid: number
role: 'web' | 'broker' | 'worker'
profile?: string
running: boolean
cpuPercent: number
memoryRssBytes: number
command?: string
error?: string
}
export interface PerformanceRuntimeSnapshot {
timestamp: number
system: {
platform: string
arch: string
uptimeSeconds: number
cpuCount: number
cpuPercent: number
loadAverage: number[]
totalMemoryBytes: number
freeMemoryBytes: number
usedMemoryBytes: number
memoryPercent: number
}
web: {
pid: number
uptimeSeconds: number
memory: Record<string, number>
cpuPercent: number
}
bridge: {
endpoint: string
reachable: boolean
error?: string
broker: {
running: boolean
ready: boolean
pid?: number
process?: ProcessUsage
restartScheduled: boolean
restartAttempts: number
}
workers: Array<ProcessUsage & {
endpoint?: string
lastUsedAt?: number
sessionCount: number
runningSessionCount: number
}>
totalWorkerMemoryRssBytes: number
}
sessions: {
active: number
running: number
byProfile: Record<string, number>
}
}
export async function fetchPerformanceRuntime(): Promise<PerformanceRuntimeSnapshot> {
return request<PerformanceRuntimeSnapshot>('/api/hermes/performance/runtime')
}
+37
View File
@@ -0,0 +1,37 @@
import { request } from '../client'
export type PluginConfigStatus = 'enabled' | 'disabled' | 'not-enabled' | 'auto' | 'provider-managed'
export type PluginEffectiveStatus = 'enabled' | 'disabled' | 'inactive' | 'auto-active' | 'provider-managed'
export interface HermesPluginInfo {
key: string
name: string
kind: string
source: string
configStatus: PluginConfigStatus | string
effectiveStatus: PluginEffectiveStatus | string
version: string
description: string
author: string
path: string
providesTools: string[]
providesHooks: string[]
requiresEnv: Array<string | Record<string, unknown>>
}
export interface HermesPluginsMetadata {
hermesAgentRoot: string
pythonExecutable: string
cwd: string
projectPluginsEnabled: boolean
}
export interface HermesPluginsResponse {
plugins: HermesPluginInfo[]
warnings: string[]
metadata: HermesPluginsMetadata
}
export async function fetchPlugins(): Promise<HermesPluginsResponse> {
return request<HermesPluginsResponse>('/api/hermes/plugins')
}
+228
View File
@@ -0,0 +1,228 @@
import { request, getBaseUrlValue, getApiKey } from '../client'
export interface HermesProfile {
name: string
active: boolean
model: string
gatewayStatus?: string
alias: string
avatar?: ProfileAvatar | null
}
export interface HermesProfileDetail {
name: string
path: string
model: string
provider: string
skills: number
hasEnv: boolean
hasSoulMd: boolean
avatar?: ProfileAvatar | null
}
export interface ProfileAvatar {
type: 'generated' | 'image'
seed?: string
dataUrl?: string
updatedAt?: number
}
export interface ProfileRuntimeStatus {
profile: string
bridge: {
running: boolean
profile: string
mode?: string
reachable?: boolean
error?: string
}
gateway: {
profile: string
running: boolean
pid?: number
port?: number
host?: string
url?: string
error?: string
diagnostics?: {
health_url?: string
reason?: string
health_ok?: boolean
}
}
}
export interface ProfileRuntimeStatusesResponse {
profiles: ProfileRuntimeStatus[]
refreshing?: boolean
}
export async function fetchProfiles(): Promise<HermesProfile[]> {
const res = await request<{ profiles: HermesProfile[] }>('/api/hermes/profiles')
return res.profiles
}
export async function fetchProfileDetail(name: string): Promise<HermesProfileDetail> {
const res = await request<{ profile: HermesProfileDetail }>(`/api/hermes/profiles/${encodeURIComponent(name)}`)
return res.profile
}
export async function fetchProfileRuntimeStatus(name: string): Promise<ProfileRuntimeStatus> {
return request<ProfileRuntimeStatus>(`/api/hermes/profiles/${encodeURIComponent(name)}/runtime-status`)
}
export async function fetchProfileRuntimeStatusesWithMeta(options: { refresh?: boolean } = {}): Promise<ProfileRuntimeStatusesResponse> {
const query = options.refresh === false ? '?refresh=0' : ''
return request<ProfileRuntimeStatusesResponse>(`/api/hermes/profiles/runtime-statuses${query}`)
}
export async function fetchProfileRuntimeStatuses(): Promise<ProfileRuntimeStatus[]> {
const res = await fetchProfileRuntimeStatusesWithMeta()
return res.profiles
}
export async function updateProfileAvatar(name: string, avatar: ProfileAvatar): Promise<ProfileAvatar> {
const res = await request<{ avatar: ProfileAvatar }>(`/api/hermes/profiles/${encodeURIComponent(name)}/avatar`, {
method: 'PUT',
body: JSON.stringify(avatar),
})
return res.avatar
}
export async function deleteProfileAvatar(name: string): Promise<void> {
await request(`/api/hermes/profiles/${encodeURIComponent(name)}/avatar`, { method: 'DELETE' })
}
export async function restartProfileGateway(name: string): Promise<ProfileRuntimeStatus['gateway']> {
const res = await request<{ success: boolean; gateway: ProfileRuntimeStatus['gateway'] }>(
`/api/hermes/profiles/${encodeURIComponent(name)}/gateway/restart`,
{ method: 'POST' },
)
return res.gateway
}
export async function restartProfileRuntime(name: string): Promise<ProfileRuntimeStatus> {
const res = await request<{ success: boolean; status: ProfileRuntimeStatus }>(
`/api/hermes/profiles/${encodeURIComponent(name)}/restart`,
{ method: 'POST' },
)
return res.status
}
export interface CreateProfileResult {
success: boolean
/** clone=true 时被清理的独占平台凭据 KEY 名 */
strippedCredentials?: string[]
/** clone=true 时被禁用的独占平台名 */
disabledPlatforms?: string[]
/** clone=true 时在 config.yaml 中被清理的内嵌凭据字段路径 */
strippedConfigCredentials?: string[]
}
export async function createProfile(name: string, clone?: boolean): Promise<CreateProfileResult & { error?: string }> {
try {
const res = await request<{
success: boolean
strippedCredentials?: string[]
disabledPlatforms?: string[]
strippedConfigCredentials?: string[]
error?: string
}>('/api/hermes/profiles', {
method: 'POST',
body: JSON.stringify({ name, clone }),
})
return {
success: !!res.success,
strippedCredentials: res.strippedCredentials,
disabledPlatforms: res.disabledPlatforms,
strippedConfigCredentials: res.strippedConfigCredentials,
error: res.error,
}
} catch (err: any) {
return { success: false, error: err.message || 'Unknown error' }
}
}
export async function deleteProfile(name: string): Promise<boolean> {
try {
await request(`/api/hermes/profiles/${encodeURIComponent(name)}`, { method: 'DELETE' })
return true
} catch {
return false
}
}
export async function renameProfile(name: string, newName: string): Promise<boolean> {
try {
await request(`/api/hermes/profiles/${encodeURIComponent(name)}/rename`, {
method: 'POST',
body: JSON.stringify({ new_name: newName }),
})
return true
} catch {
return false
}
}
export async function switchProfile(name: string): Promise<boolean> {
return !!name
}
export async function switchHermesProfile(name: string): Promise<boolean> {
try {
await request('/api/hermes/profiles/active', {
method: 'PUT',
body: JSON.stringify({ name }),
})
return true
} catch {
return false
}
}
export async function exportProfile(name: string): Promise<boolean> {
try {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${baseUrl}/api/hermes/profiles/${encodeURIComponent(name)}/export`, {
method: 'POST',
headers,
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `hermes-profile-${name}.tar.gz`
a.click()
URL.revokeObjectURL(url)
return true
} catch {
return false
}
}
export async function importProfile(file: File): Promise<boolean> {
try {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`${baseUrl}/api/hermes/profiles/import`, {
method: 'POST',
headers,
body: formData,
})
return res.ok
} catch {
return false
}
}
+308
View File
@@ -0,0 +1,308 @@
import { request, getApiKey, getBaseUrlValue } from '../client'
export interface SessionSummary {
id: string
profile?: string | null
source: string
model: string
provider?: string
title: string | null
preview?: string
started_at: number
ended_at: number | null
last_active?: number
message_count: number
tool_call_count: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
billing_provider: string | null
estimated_cost_usd: number
actual_cost_usd: number | null
cost_status: string
workspace?: string | null
webui_imported?: boolean
}
export interface SessionDetail extends SessionSummary {
messages: HermesMessage[]
}
export interface PaginatedSessionMessages {
session: SessionSummary
messages: HermesMessage[]
total: number
offset: number
limit: number
hasMore: boolean
}
export interface SessionSearchResult extends SessionSummary {
matched_message_id: number | null
snippet: string
rank: number
}
export interface HermesMessage {
id: number
session_id: string
role: 'user' | 'assistant' | 'system' | 'tool' | 'command'
content: string
tool_call_id: string | null
tool_calls: any[] | null
tool_name: string | null
timestamp: number
token_count: number | null
finish_reason: string | null
reasoning: string | null
}
export async function fetchSessions(source?: string, limit?: number, profile?: string): Promise<SessionSummary[]> {
const params = new URLSearchParams()
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions${query ? `?${query}` : ''}`)
return res.sessions
}
/**
* Fetch Hermes sessions only (exclude api_server source)
*/
export async function fetchHermesSessions(source?: string, limit?: number, profile?: string | null): Promise<SessionSummary[]> {
const params = new URLSearchParams()
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions/hermes${query ? `?${query}` : ''}`)
return res.sessions
}
export async function searchSessions(q: string, source?: string, limit?: number, profile?: string): Promise<SessionSearchResult[]> {
const params = new URLSearchParams()
params.set('q', q)
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ results: SessionSearchResult[] }>(`/api/hermes/search/sessions?${query}`)
return res.results
}
export async function fetchSession(id: string, profile?: string | null): Promise<SessionDetail | null> {
try {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/${id}${query ? `?${query}` : ''}`)
return res.session
} catch {
return null
}
}
export async function fetchSessionMessagesPage(
id: string,
offset: number,
limit = 300,
profile?: string | null,
): Promise<PaginatedSessionMessages | null> {
try {
const params = new URLSearchParams()
params.set('offset', String(offset))
params.set('limit', String(limit))
if (profile) params.set('profile', profile)
const res = await request<PaginatedSessionMessages>(
`/api/hermes/sessions/conversations/${encodeURIComponent(id)}/messages/paginated?${params}`,
)
return res
} catch {
return null
}
}
/**
* Fetch Hermes session detail only (exclude api_server source)
*/
export async function fetchHermesSession(id: string, profile?: string | null): Promise<SessionDetail | null> {
try {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/hermes/${id}${query ? `?${query}` : ''}`)
return res.session
} catch {
return null
}
}
export async function deleteSession(id: string, profile?: string | null): Promise<boolean> {
try {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
await request(`/api/hermes/sessions/${id}${query ? `?${query}` : ''}`, { method: 'DELETE' })
return true
} catch {
return false
}
}
export async function importHermesSession(id: string, profile?: string | null): Promise<{ ok: boolean; imported: boolean; session?: SessionDetail }> {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
return request<{ ok: boolean; imported: boolean; session?: SessionDetail }>(
`/api/hermes/sessions/hermes/${encodeURIComponent(id)}/import${query ? `?${query}` : ''}`,
{ method: 'POST' },
)
}
export interface BatchDeleteSessionTarget {
id: string
profile?: string | null
}
export async function batchDeleteSessions(targets: Array<string | BatchDeleteSessionTarget>): Promise<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }> {
try {
const sessions = targets.map(target =>
typeof target === 'string'
? { id: target }
: { id: target.id, profile: target.profile || undefined },
)
const res = await request<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }>(
'/api/hermes/sessions/batch-delete',
{
method: 'POST',
body: JSON.stringify({
ids: sessions.map(session => session.id),
sessions,
}),
}
)
return res
} catch (err: any) {
throw err
}
}
export async function renameSession(id: string, title: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/rename`, {
method: 'POST',
body: JSON.stringify({ title }),
})
return true
} catch {
return false
}
}
export async function setSessionWorkspace(id: string, workspace: string | null): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/workspace`, {
method: 'POST',
body: JSON.stringify({ workspace: workspace || '' }),
})
return true
} catch {
return false
}
}
export async function setSessionModel(id: string, model: string, provider: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/model`, {
method: 'POST',
body: JSON.stringify({ model, provider }),
})
return true
} catch {
return false
}
}
export async function exportSession(id: string, mode: 'full' | 'compressed' = 'full', ext: 'json' | 'txt' = 'json'): Promise<void> {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const url = `${baseUrl}/api/hermes/sessions/${id}/export?mode=${mode}&ext=${ext}&token=${encodeURIComponent(token)}`
const res = await fetch(url)
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const contentDisposition = res.headers.get('Content-Disposition') || ''
let filename = `session_${id}.${ext}`
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?([^;\n]+)/i)
if (match) filename = decodeURIComponent(match[1].replace(/"/g, ''))
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
a.click()
URL.revokeObjectURL(a.href)
}
export interface UsageStatsResponse {
total_input_tokens: number
total_output_tokens: number
total_cache_read_tokens: number
total_cache_write_tokens: number
total_reasoning_tokens: number
total_sessions: number
total_cost: number
total_api_calls?: number
period_days?: number
model_usage: Array<{
model: string
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
sessions: number
}>
daily_usage: Array<{
date: string
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
sessions: number
errors: number
cost: number
}>
}
export async function fetchUsageStats(days = 30): Promise<UsageStatsResponse> {
const safeDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30
const params = new URLSearchParams()
params.set('days', String(safeDays))
return request<UsageStatsResponse>(`/api/hermes/usage/stats?${params}`)
}
export async function fetchSessionUsage(ids: string[]): Promise<Record<string, { input_tokens: number; output_tokens: number }>> {
if (ids.length === 0) return {}
const params = new URLSearchParams()
params.set('ids', ids.join(','))
return request(`/api/hermes/sessions/usage?${params}`)
}
export async function fetchSessionUsageSingle(id: string): Promise<{ input_tokens: number; output_tokens: number } | null> {
try {
return await request<{ input_tokens: number; output_tokens: number }>(`/api/hermes/sessions/${id}/usage`)
} catch {
return null
}
}
export async function fetchContextLength(profile?: string, provider?: string, model?: string): Promise<number> {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
if (provider) params.set('provider', provider)
if (model) params.set('model', model)
const query = params.toString()
const res = await request<{ context_length: number }>(`/api/hermes/sessions/context-length${query ? `?${query}` : ''}`)
return res.context_length
}
+127
View File
@@ -0,0 +1,127 @@
import { request } from '../client'
export type SkillSource = 'builtin' | 'hub' | 'local' | 'external'
export interface SkillInfo {
name: string
description: string
enabled?: boolean
source?: SkillSource
modified?: boolean
patchCount?: number
useCount?: number
viewCount?: number
pinned?: boolean
}
export interface SkillCategory {
name: string
description: string
skills: SkillInfo[]
}
export interface SkillListResponse {
categories: SkillCategory[]
archived: SkillInfo[]
}
export interface SkillFileEntry {
path: string
name: string
isDir: boolean
}
export interface MemoryData {
memory: string
user: string
soul: string
memory_mtime: number | null
user_mtime: number | null
soul_mtime: number | null
}
export interface SkillsData {
categories: SkillCategory[]
archived: SkillInfo[]
}
export interface SkillUsageRow {
skill: string
view_count: number
manage_count: number
total_count: number
percentage: number
last_used_at: number | null
}
export interface SkillUsageDailySkillRow {
skill: string
view_count: number
manage_count: number
total_count: number
}
export interface SkillUsageDailyRow {
date: string
view_count: number
manage_count: number
total_count: number
skills: SkillUsageDailySkillRow[]
}
export interface SkillUsageStats {
period_days: number
summary: {
total_skill_loads: number
total_skill_edits: number
total_skill_actions: number
distinct_skills_used: number
}
by_day: SkillUsageDailyRow[]
top_skills: SkillUsageRow[]
}
export async function fetchSkills(): Promise<SkillsData> {
const res = await request<SkillListResponse>('/api/hermes/skills')
return { categories: res.categories, archived: res.archived ?? [] }
}
export async function fetchSkillUsageStats(days = 7): Promise<SkillUsageStats> {
const params = new URLSearchParams({ days: String(days) })
return request<SkillUsageStats>(`/api/hermes/skills/usage/stats?${params}`)
}
export async function fetchSkillContent(skillPath: string): Promise<string> {
const res = await request<{ content: string }>(`/api/hermes/skills/${skillPath}`)
return res.content
}
export async function fetchSkillFiles(category: string, skill: string): Promise<SkillFileEntry[]> {
const res = await request<{ files: SkillFileEntry[] }>(`/api/hermes/skills/${category}/${skill}/files`)
return res.files
}
export async function fetchMemory(): Promise<MemoryData> {
return request<MemoryData>('/api/hermes/memory')
}
export async function saveMemory(section: 'memory' | 'user' | 'soul', content: string): Promise<void> {
await request('/api/hermes/memory', {
method: 'POST',
body: JSON.stringify({ section, content }),
})
}
export async function toggleSkill(name: string, enabled: boolean): Promise<void> {
await request('/api/hermes/skills/toggle', {
method: 'PUT',
body: JSON.stringify({ name, enabled }),
})
}
export async function pinSkillApi(name: string, pinned: boolean): Promise<void> {
await request('/api/hermes/skills/pin', {
method: 'PUT',
body: JSON.stringify({ name, pinned }),
})
}
+258
View File
@@ -0,0 +1,258 @@
import { request } from '../client'
export interface HealthResponse {
status: string
version?: string
webui_version?: string
webui_latest?: string
webui_update_available?: boolean
node_version?: string
}
export interface PreviewTag {
name: string
sha: string
}
export interface PreviewStatus {
preview_dir: string
exists: boolean
has_package: boolean
installed: boolean
running: boolean
pid: number | null
current_tag: string
frontend_url: string
agent_bridge_endpoint: string
log_path: string
webui_home: string
action_log_path: string
dev_log_path: string
active_action: string | null
active_action_started_at: string | null
last_action: string | null
last_action_completed_at: string | null
last_action_success: boolean | null
last_action_message: string
last_action_code: string
action_log: string
dev_log: string
}
export interface PreviewActionResponse extends PreviewStatus {
success: boolean
accepted?: boolean
message?: string
code?: string
}
// Config-based model types
export interface ModelInfo {
id: string
label: string
}
export interface ModelGroup {
provider: string
models: ModelInfo[]
}
export interface ConfigModelsResponse {
default: string
groups: ModelGroup[]
}
export interface ModelVisibilityRule {
mode: 'all' | 'include'
models: string[]
}
export type ModelVisibility = Record<string, ModelVisibilityRule>
export type CustomModels = Record<string, string[]>
export interface AvailableModelGroup {
provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai")
label: string // display name (e.g. "zai", "subrouter.ai")
base_url: string
models: string[]
/** Full unfiltered model catalog for this provider, used to restore hidden WUI models. */
available_models?: string[]
api_key: string
builtin?: boolean
/** Env var used by Hermes to override this provider's base URL. If present, the preset URL is editable. */
base_url_env?: string
/** 可选:模型 ID -> 元数据(preview/disabled/alias)。alias 仅用于 Web UI 展示。 */
model_meta?: Record<string, { preview?: boolean; disabled?: boolean; alias?: string }>
}
export interface ProfileAvailableModels {
profile: string
default: string
default_provider: string
groups: AvailableModelGroup[]
}
export interface AvailableModelsResponse {
default: string
default_provider: string
groups: AvailableModelGroup[]
allProviders: AvailableModelGroup[]
profiles?: ProfileAvailableModels[]
/** Web UI-only display aliases keyed by provider -> canonical model ID. */
model_aliases?: Record<string, Record<string, string>>
model_visibility?: ModelVisibility
custom_models?: CustomModels
}
export interface CustomProvider {
name: string
base_url: string
api_key: string
model: string
context_length?: number
providerKey?: string | null
}
export async function checkHealth(): Promise<HealthResponse> {
return request<HealthResponse>('/health')
}
export async function triggerUpdate(): Promise<{ success: boolean; message: string }> {
return request<{ success: boolean; message: string }>('/api/hermes/update', { method: 'POST' })
}
export async function fetchPreviewStatus(): Promise<PreviewStatus> {
return request<PreviewStatus>('/api/hermes/update/preview')
}
export async function fetchPreviewTags(): Promise<{ tags: PreviewTag[] }> {
return request<{ tags: PreviewTag[] }>('/api/hermes/update/preview/tags')
}
export async function preparePreview(tag: string): Promise<PreviewActionResponse> {
return request<PreviewActionResponse>('/api/hermes/update/preview/prepare', {
method: 'POST',
body: JSON.stringify({ tag }),
})
}
export async function installPreview(): Promise<PreviewActionResponse> {
return request<PreviewActionResponse>('/api/hermes/update/preview/install', { method: 'POST' })
}
export async function startPreview(tag?: string): Promise<PreviewActionResponse> {
return request<PreviewActionResponse>('/api/hermes/update/preview/start', {
method: 'POST',
body: JSON.stringify({ tag }),
})
}
export async function stopPreview(): Promise<PreviewActionResponse> {
return request<PreviewActionResponse>('/api/hermes/update/preview/stop', { method: 'POST' })
}
export async function fetchConfigModels(): Promise<ConfigModelsResponse> {
return request<ConfigModelsResponse>('/api/hermes/config/models')
}
export async function fetchAvailableModels(): Promise<AvailableModelsResponse> {
return request<AvailableModelsResponse>('/api/hermes/available-models')
}
export async function fetchAvailableModelsForProfile(profile: string): Promise<AvailableModelsResponse> {
const params = new URLSearchParams()
params.set('profile', profile || 'default')
return request<AvailableModelsResponse>(`/api/hermes/available-models?${params.toString()}`)
}
export async function fetchProviderModels(data: {
base_url: string
api_key?: string
freeOnly?: boolean
}): Promise<{ models: string[] }> {
return request<{ models: string[] }>('/api/hermes/provider-models', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateDefaultModel(data: {
default: string
provider?: string
base_url?: string
api_key?: string
}): Promise<void> {
await request('/api/hermes/config/model', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function updateModelAlias(data: {
provider: string
model: string
alias: string
}): Promise<void> {
await request('/api/hermes/model-alias', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function addCustomProvider(data: CustomProvider): Promise<void> {
await request('/api/hermes/config/providers', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function removeCustomProvider(name: string): Promise<void> {
await request(`/api/hermes/config/providers/${encodeURIComponent(name)}`, {
method: 'DELETE',
})
}
export async function updateProvider(poolKey: string, data: {
name?: string
base_url?: string
api_key?: string
model?: string
}): Promise<void> {
await request(`/api/hermes/config/providers/${encodeURIComponent(poolKey)}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function updateModelVisibility(data: {
provider: string
mode: 'all' | 'include'
models: string[]
}): Promise<{ success: boolean; model_visibility: ModelVisibility }> {
return request<{ success: boolean; model_visibility: ModelVisibility }>('/api/hermes/model-visibility', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function addCustomModel(data: {
provider: string
model: string
}): Promise<{ success: boolean; custom_models: CustomModels }> {
return request<{ success: boolean; custom_models: CustomModels }>('/api/hermes/custom-model', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function removeCustomModel(data: {
provider: string
model: string
}): Promise<{ success: boolean; custom_models: CustomModels }> {
const params = new URLSearchParams()
params.set('provider', data.provider)
params.set('model', data.model)
return request<{ success: boolean; custom_models: CustomModels }>(`/api/hermes/custom-model?${params.toString()}`, {
method: 'DELETE',
})
}
+36
View File
@@ -0,0 +1,36 @@
export interface TtsOptions {
text: string
lang?: string
rate?: string // Edge TTS rate format: "+NN%" or "-NN%"
pitch?: string // Edge TTS pitch format: "+NNHz" or "-NNHz"
}
export async function generateSpeech(opts: TtsOptions): Promise<{ audio: Blob; engine: string }> {
const res = await fetch(
`${localStorage.getItem('hermes_server_url') || ''}/api/hermes/tts`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('hermes_api_key') || ''}`,
},
body: JSON.stringify(opts),
},
)
if (!res.ok) {
throw new Error(`TTS request failed: ${res.status}`)
}
const audio = await res.blob()
const engine = res.headers.get('X-TTS-Engine') || 'unknown'
return { audio, engine }
}
export function playAudioBlob(blob: Blob): HTMLAudioElement {
const url = URL.createObjectURL(blob)
const audio = new Audio(url)
audio.play()
audio.onended = () => URL.revokeObjectURL(url)
return audio
}
@@ -0,0 +1,29 @@
import { request } from '../client'
export interface XaiStartResult {
session_id: string
authorization_url: string
expires_in: number
}
export interface XaiPollResult {
status: 'pending' | 'approved' | 'expired' | 'error'
error: string | null
}
export interface XaiStatusResult {
authenticated: boolean
last_refresh?: string
}
export async function startXaiLogin(): Promise<XaiStartResult> {
return request<XaiStartResult>('/api/hermes/auth/xai/start', { method: 'POST' })
}
export async function pollXaiLogin(sessionId: string): Promise<XaiPollResult> {
return request<XaiPollResult>(`/api/hermes/auth/xai/poll/${sessionId}`)
}
export async function getXaiAuthStatus(): Promise<XaiStatusResult> {
return request<XaiStatusResult>('/api/hermes/auth/xai/status')
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
const message = useMessage()
const { t } = useI18n()
let lastNoticeAt = 0
function onAuthNotice(event: Event) {
const detail = (event as CustomEvent<{ kind?: string }>).detail || {}
const now = Date.now()
if (now - lastNoticeAt < 1200) return
lastNoticeAt = now
if (detail.kind === 'forbidden') {
message.error(t('login.accessDenied'))
return
}
message.error(t('login.sessionExpired'))
}
onMounted(() => {
window.addEventListener('hermes-auth-notice', onAuthNotice)
})
onUnmounted(() => {
window.removeEventListener('hermes-auth-notice', onAuthNotice)
})
</script>
<template>
<span style="display: none" aria-hidden="true" />
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { NButton, NModal } from "naive-ui";
import { fetchCurrentUser } from "@/api/auth";
import { getApiKey } from "@/api/client";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const show = ref(false);
const loading = ref(false);
const checkedToken = ref("");
const promptedUserId = ref<number | null>(null);
function dismissalKey(userId: number): string {
return `hermes_default_credentials_prompt_dismissed_${userId}`;
}
function isDesktopShell(): boolean {
return (window as typeof window & { hermesDesktop?: { isDesktop?: boolean } }).hermesDesktop?.isDesktop === true;
}
async function checkDefaultCredentials() {
if (isDesktopShell()) {
show.value = false;
return;
}
if (route.name === "login") {
show.value = false;
return;
}
const token = getApiKey();
if (!token || token === checkedToken.value) return;
checkedToken.value = token;
loading.value = true;
try {
const user = await fetchCurrentUser();
promptedUserId.value = user.id;
const dismissed = sessionStorage.getItem(dismissalKey(user.id)) === "1";
show.value = !!user.requiresCredentialChange && !dismissed;
} catch {
show.value = false;
} finally {
loading.value = false;
}
}
function remindLater() {
if (promptedUserId.value != null) {
sessionStorage.setItem(dismissalKey(promptedUserId.value), "1");
}
show.value = false;
}
function goToAccountSettings() {
show.value = false;
router.push({ name: "hermes.settings", query: { tab: "account" } });
}
watch(() => route.fullPath, () => {
void checkDefaultCredentials();
}, { immediate: true });
</script>
<template>
<NModal
v-model:show="show"
preset="dialog"
:title="t('login.defaultCredentialTitle')"
:mask-closable="false"
>
<p class="credential-warning-text">
{{ t("login.defaultCredentialMessage") }}
</p>
<template #action>
<NButton :disabled="loading" @click="remindLater">
{{ t("login.defaultCredentialLater") }}
</NButton>
<NButton type="primary" :loading="loading" @click="goToAccountSettings">
{{ t("login.defaultCredentialAction") }}
</NButton>
</template>
</NModal>
</template>
<style scoped lang="scss">
.credential-warning-text {
margin: 0;
line-height: 1.6;
}
</style>
@@ -0,0 +1,63 @@
<script setup lang="ts">
withDefaults(defineProps<{
size?: number
showText?: boolean
}>(), {
size: 32,
showText: false,
})
</script>
<template>
<div class="app-logo" :class="{ 'with-text': showText }">
<svg
class="app-logo__mark"
:width="size"
:height="size"
viewBox="0 0 48 48"
fill="none"
aria-hidden="true"
>
<defs>
<linearGradient id="lx-bg" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563eb" />
<stop offset="1" stop-color="#0891b2" />
</linearGradient>
<linearGradient id="lx-glow" x1="24" y1="10" x2="24" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffffff" stop-opacity="0.95" />
<stop offset="1" stop-color="#e0f2fe" stop-opacity="0.7" />
</linearGradient>
</defs>
<rect x="3" y="3" width="42" height="42" rx="11" fill="url(#lx-bg)" />
<path d="M24 11 L33 24 L24 37 L15 24 Z" fill="url(#lx-glow)" />
<circle cx="24" cy="22" r="4.5" fill="#ffffff" />
<circle cx="24" cy="22" r="2" fill="#2563eb" />
</svg>
<span v-if="showText" class="app-logo__text">灵犀 Studio</span>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.app-logo {
display: inline-flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
&__mark {
display: block;
filter: drop-shadow(0 2px 8px rgba(var(--accent-primary-rgb), 0.25));
}
&__text {
font-family: $font-display;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.01em;
color: $text-primary;
white-space: nowrap;
}
}
</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
const props = withDefaults(defineProps<{
to: RouteLocationRaw
active?: boolean
exact?: boolean
title?: string
}>(), {
active: undefined,
exact: false,
})
</script>
<template>
<RouterLink v-slot="slotProps" :to="props.to" custom>
<a
class="route-link-item"
:class="{ active: props.active ?? (props.exact ? !!slotProps?.isExactActive : !!slotProps?.isActive) }"
:href="slotProps?.href || '#'"
:title="props.title"
:aria-current="(props.active ?? (props.exact ? !!slotProps?.isExactActive : !!slotProps?.isActive)) ? 'page' : undefined"
@click="slotProps?.navigate"
>
<slot />
</a>
</RouterLink>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { fetchConversationDetail, fetchConversationSummaries, type ConversationDetail, type ConversationSummary } from '@/api/hermes/conversations'
import { formatTimestampSeconds, getSourceLabel } from '@/shared/session-display'
import { useAppStore } from '@/stores/hermes/app'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ humanOnly: boolean }>()
const { t } = useI18n()
const appStore = useAppStore()
const POLL_INTERVAL_MS = 15000
const sessions = ref<ConversationSummary[]>([])
const selectedSessionId = ref<string | null>(null)
const detail = ref<ConversationDetail | null>(null)
const sessionsLoading = ref(false)
const detailLoading = ref(false)
const error = ref('')
let refreshTimer: ReturnType<typeof setInterval> | null = null
let sessionsRequestId = 0
let detailRequestId = 0
const selectedSession = computed(() => sessions.value.find(session => session.id === selectedSessionId.value) || null)
const selectedSessionModelName = computed(() =>
selectedSession.value?.model
? appStore.displayModelName(selectedSession.value.model, selectedSession.value.provider)
: '',
)
function roleLabel(role: string): string {
return role === 'user' ? t('chat.monitorRoleUser') : t('chat.monitorRoleAssistant')
}
function linkedSessionsLabel(count: number): string {
return t('chat.linkedSessions', { count })
}
function invalidateRequests() {
sessionsRequestId += 1
detailRequestId += 1
}
async function loadSessions(silent = false) {
const requestId = ++sessionsRequestId
if (!silent) {
sessionsLoading.value = true
error.value = ''
}
try {
const loaded = await fetchConversationSummaries({ humanOnly: props.humanOnly })
if (requestId !== sessionsRequestId) return
sessions.value = loaded
if (!loaded.length) {
selectedSessionId.value = null
detail.value = null
return
}
if (!selectedSessionId.value || !loaded.some(session => session.id === selectedSessionId.value)) {
selectedSessionId.value = loaded[0].id
}
} catch (err: any) {
if (requestId !== sessionsRequestId || silent) return
error.value = err?.message || String(err)
sessions.value = []
selectedSessionId.value = null
detail.value = null
} finally {
if (!silent && requestId === sessionsRequestId) sessionsLoading.value = false
}
}
async function loadDetail(sessionId: string | null, silent = false) {
const requestId = ++detailRequestId
if (!sessionId) {
detail.value = null
return
}
const requestedHumanOnly = props.humanOnly
if (!silent) {
detailLoading.value = true
error.value = ''
}
try {
const loaded = await fetchConversationDetail(sessionId, { humanOnly: requestedHumanOnly })
if (
requestId !== detailRequestId
|| sessionId !== selectedSessionId.value
|| requestedHumanOnly !== props.humanOnly
) {
return
}
detail.value = loaded
} catch (err: any) {
if (requestId !== detailRequestId || silent) return
error.value = err?.message || String(err)
detail.value = null
} finally {
if (!silent && requestId === detailRequestId) detailLoading.value = false
}
}
watch(selectedSessionId, async sessionId => {
await loadDetail(sessionId, false)
})
watch(() => props.humanOnly, async () => {
invalidateRequests()
selectedSessionId.value = null
detail.value = null
await loadSessions(false)
})
onMounted(async () => {
await loadSessions(false)
refreshTimer = setInterval(async () => {
await loadSessions(true)
if (selectedSessionId.value) {
await loadDetail(selectedSessionId.value, true)
}
}, POLL_INTERVAL_MS)
})
onUnmounted(() => {
invalidateRequests()
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<template>
<div class="conversation-monitor">
<aside class="conversation-monitor__sidebar">
<div v-if="sessionsLoading && sessions.length === 0" class="conversation-monitor__empty">{{ t('common.loading') }}</div>
<div v-else-if="sessions.length === 0" class="conversation-monitor__empty">{{ t('chat.noSessions') }}</div>
<button
v-for="session in sessions"
:key="session.id"
class="conversation-monitor__session"
:class="{ active: session.id === selectedSessionId }"
:aria-pressed="session.id === selectedSessionId"
@click="selectedSessionId = session.id"
>
<div class="conversation-monitor__session-title-row">
<span class="conversation-monitor__session-title">{{ session.title || session.preview || session.id }}</span>
<span v-if="session.is_active" class="conversation-monitor__session-live">{{ t('chat.recentBadge') }}</span>
</div>
<div class="conversation-monitor__session-meta">{{ getSourceLabel(session.source) }} · {{ formatTimestampSeconds(session.last_active) }}</div>
<div v-if="session.preview" class="conversation-monitor__session-preview">{{ session.preview }}</div>
</button>
</aside>
<section class="conversation-monitor__detail">
<header v-if="selectedSession" class="conversation-monitor__detail-header">
<div class="conversation-monitor__detail-title">{{ selectedSession.title || selectedSession.preview || selectedSession.id }}</div>
<div class="conversation-monitor__detail-meta">
<span>{{ getSourceLabel(selectedSession.source) }}</span>
<span>·</span>
<span :title="selectedSession.model">{{ selectedSessionModelName }}</span>
<span>·</span>
<span>{{ linkedSessionsLabel(selectedSession.thread_session_count) }}</span>
</div>
</header>
<div v-if="error" class="conversation-monitor__empty conversation-monitor__empty--error">{{ error }}</div>
<div v-else-if="detailLoading && !detail" class="conversation-monitor__empty">{{ t('common.loading') }}</div>
<div v-else-if="!detail || detail.messages.length === 0" class="conversation-monitor__empty">{{ t('chat.noVisibleMessages') }}</div>
<div v-else class="conversation-monitor__messages">
<article
v-for="message in detail.messages"
:key="`${message.session_id}-${message.id}`"
class="conversation-monitor__message"
:class="`role-${message.role}`"
>
<div class="conversation-monitor__message-meta">{{ roleLabel(message.role) }} · {{ formatTimestampSeconds(message.timestamp) }}</div>
<div class="conversation-monitor__message-content">{{ message.content }}</div>
</article>
</div>
</section>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.conversation-monitor {
display: flex;
min-height: 0;
flex: 1;
}
.conversation-monitor__sidebar {
width: 260px;
border-right: 1px solid $border-color;
overflow-y: auto;
flex-shrink: 0;
scrollbar-gutter: stable;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba($text-muted, 0.3);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba($text-muted, 0.5);
}
}
.conversation-monitor__session {
width: 100%;
border: 0;
border-bottom: 1px solid rgba($border-color, 0.6);
background: transparent;
color: inherit;
text-align: left;
padding: 12px 14px;
cursor: pointer;
&.active {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $text-primary;
font-weight: 500;
}
&.active .conversation-monitor__session-title {
color: $accent-primary;
}
}
.conversation-monitor__session-title-row,
.conversation-monitor__detail-meta,
.conversation-monitor__message-meta {
display: flex;
align-items: center;
gap: 6px;
}
.conversation-monitor__session-title,
.conversation-monitor__detail-title {
font-weight: 600;
}
.conversation-monitor__session-live {
font-size: 11px;
color: $accent-primary;
}
.conversation-monitor__session-meta,
.conversation-monitor__session-preview,
.conversation-monitor__detail-meta,
.conversation-monitor__message-meta {
font-size: 12px;
color: $text-muted;
}
.conversation-monitor__session-preview,
.conversation-monitor__message-content {
margin-top: 6px;
white-space: pre-wrap;
}
.conversation-monitor__detail {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.conversation-monitor__detail-header {
padding: 16px 20px;
border-bottom: 1px solid $border-color;
}
.conversation-monitor__messages {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.conversation-monitor__message {
padding: 12px 14px;
border-radius: 10px;
background: rgba($bg-secondary, 0.8);
&.role-user {
border: 1px solid rgba($accent-primary, 0.18);
}
&.role-assistant {
border: 1px solid rgba($border-color, 0.9);
}
}
.conversation-monitor__empty {
padding: 24px;
color: $text-muted;
}
.conversation-monitor__empty--error {
color: $error;
}
@media (max-width: $breakpoint-mobile) {
.conversation-monitor {
flex-direction: column;
}
.conversation-monitor__sidebar {
width: 100%;
max-height: 220px;
border-right: 0;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.conversation-monitor__detail {
min-height: 0;
overflow: hidden;
}
}
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TerminalPanel from './TerminalPanel.vue'
import FilesPanel from './FilesPanel.vue'
interface Props {
show: boolean
activeTab?: 'terminal' | 'files'
}
interface Emits {
(e: 'update:show', value: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
activeTab: 'files'
})
const emit = defineEmits<Emits>()
const { t } = useI18n()
const activeTab = ref<'terminal' | 'files'>(props.activeTab)
watch(() => props.activeTab, (newVal) => {
if (newVal) activeTab.value = newVal
})
function handleClose() {
emit('update:show', false)
}
</script>
<template>
<Teleport to="body">
<div v-if="show" class="drawer-overlay" @click="handleClose"></div>
<div :class="['drawer-panel', { show }]">
<div class="drawer-header">
<div class="drawer-tabs">
<button
:class="['tab-button', { active: activeTab === 'files' }]"
@click="activeTab = 'files'"
>
{{ t('drawer.files') }}
</button>
<button
:class="['tab-button', { active: activeTab === 'terminal' }]"
@click="activeTab = 'terminal'"
>
{{ t('drawer.terminal') }}
</button>
</div>
<button class="close-button" @click="handleClose">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="drawer-content">
<div v-show="activeTab === 'files'" class="drawer-pane">
<FilesPanel />
</div>
<div v-show="activeTab === 'terminal'" class="drawer-pane">
<TerminalPanel :visible="activeTab === 'terminal' && show" />
</div>
</div>
</div>
</Teleport>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.drawer-panel {
position: fixed;
top: 0;
right: min(-1180px, -88vw);
width: min(1180px, 88vw);
height: calc(100 * var(--vh));
max-height: calc(100 * var(--vh));
background: $bg-card;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
z-index: 1000;
transition: right 0.3s ease;
&.show {
right: 0;
}
@media (max-width: $breakpoint-mobile) {
width: 100%;
right: -100%;
&.show {
right: 0;
}
}
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.drawer-tabs {
display: flex;
gap: 8px;
}
.tab-button {
padding: 8px 16px;
border: none;
background: transparent;
color: $text-secondary;
cursor: pointer;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s;
flex-shrink: 0;
white-space: nowrap;
border-radius: $radius-sm;
&:hover {
color: $text-primary;
background: rgba(var(--accent-primary-rgb), 0.05);
}
&.active {
color: var(--accent-primary);
background: rgba(var(--accent-primary-rgb), 0.1);
}
}
.close-button {
padding: 8px;
border: none;
background: rgba(var(--accent-primary-rgb), 0.08);
color: $text-secondary;
cursor: pointer;
border-radius: $radius-sm;
transition: all 0.2s;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: $text-primary;
background: rgba(var(--accent-primary-rgb), 0.15);
}
}
.drawer-content {
flex: 1;
overflow: hidden;
position: relative;
min-height: 0;
}
.drawer-pane {
height: 100%;
overflow: auto;
}
</style>
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useFilesStore } from '@/stores/hermes/files'
import { useI18n } from 'vue-i18n'
import { NButton } from 'naive-ui'
import FileTree from '@/components/hermes/files/FileTree.vue'
import FileBreadcrumb from '@/components/hermes/files/FileBreadcrumb.vue'
import FileToolbar from '@/components/hermes/files/FileToolbar.vue'
import FileList from '@/components/hermes/files/FileList.vue'
import FileContextMenu from '@/components/hermes/files/FileContextMenu.vue'
import FileEditor from '@/components/hermes/files/FileEditor.vue'
import FilePreview from '@/components/hermes/files/FilePreview.vue'
import FileUploadModal from '@/components/hermes/files/FileUploadModal.vue'
import FileRenameModal from '@/components/hermes/files/FileRenameModal.vue'
import type { FileEntry } from '@/api/hermes/files'
const filesStore = useFilesStore()
const { t } = useI18n()
const contextMenuRef = ref<InstanceType<typeof FileContextMenu> | null>(null)
const showUpload = ref(false)
const showRenameModal = ref(false)
const renameMode = ref<'newFile' | 'newFolder' | 'rename'>('newFile')
const renameEntry = ref<FileEntry | null>(null)
const showSidebar = ref(false)
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
contextMenuRef.value?.show(e, entry)
}
function handleShowNewFile() {
renameMode.value = 'newFile'
renameEntry.value = null
showRenameModal.value = true
}
function handleShowNewFolder() {
renameMode.value = 'newFolder'
renameEntry.value = null
showRenameModal.value = true
}
function handleRename(entry: FileEntry) {
renameMode.value = 'rename'
renameEntry.value = entry
showRenameModal.value = true
}
onMounted(() => {
filesStore.fetchEntries('')
})
</script>
<template>
<div class="files-panel-drawer">
<div
v-if="showSidebar"
class="sidebar-overlay"
@click="showSidebar = false"
></div>
<div
class="files-tree-panel"
:class="{ 'mobile-visible': showSidebar }"
>
<FileTree />
</div>
<div class="files-main-panel">
<div class="main-toolbar">
<NButton
size="small"
@click="showSidebar = !showSidebar"
class="sidebar-toggle"
>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
</template>
{{ t('files.fileTree') }}
</NButton>
<FileToolbar
@show-new-file="handleShowNewFile"
@show-new-folder="handleShowNewFolder"
@show-upload="showUpload = true"
/>
</div>
<FileBreadcrumb />
<div class="files-content">
<FileEditor v-if="filesStore.editingFile" />
<FilePreview v-else-if="filesStore.previewFile" />
<FileList v-else @contextmenu-entry="handleContextMenu" />
</div>
</div>
<FileContextMenu ref="contextMenuRef" @rename="handleRename" />
<FileUploadModal v-model:show="showUpload" />
<FileRenameModal v-model:show="showRenameModal" :mode="renameMode" :entry="renameEntry" />
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.files-panel-drawer {
display: flex;
height: 100%;
min-height: 0;
overflow: hidden;
position: relative;
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
@media (min-width: $breakpoint-mobile + 1) {
display: none;
}
}
.files-tree-panel {
width: 200px;
min-width: 150px;
max-width: 300px;
border-right: 1px solid $border-color;
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
@media (max-width: $breakpoint-mobile) {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 80%;
max-width: 300px;
z-index: 51;
background: $bg-card;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
transform: translateX(-100%);
transition: transform 0.3s ease;
&.mobile-visible {
transform: translateX(0);
}
}
}
.files-main-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.main-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
@media (max-width: $breakpoint-mobile) {
gap: 4px;
padding: 8px 8px;
flex-wrap: wrap;
}
}
.sidebar-toggle {
@media (min-width: $breakpoint-mobile + 1) {
display: none;
}
@media (max-width: $breakpoint-mobile) {
font-size: 12px;
padding: 0 8px;
height: 32px;
}
}
.files-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
</style>
@@ -0,0 +1,281 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { NSpin } from 'naive-ui'
import { request } from '@/api/client'
interface FolderEntry {
name: string
path: string
fullPath: string
}
interface FolderListResponse {
base: string
current: string
folders: FolderEntry[]
}
/** Flat display node for rendering tree without recursion */
interface FlatNode {
folder: FolderEntry
depth: number
isExpanded: boolean
isLoading: boolean
hasChildren: boolean | null // null = unknown
}
const props = defineProps<{
modelValue: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const loading = ref(false)
const basePath = ref('')
const folders = ref<FolderEntry[]>([])
const expandedPaths = ref<Set<string>>(new Set())
const childrenCache = ref<Map<string, FolderEntry[]>>(new Map())
const loadingPaths = ref<Set<string>>(new Set())
const selectedPath = ref(props.modelValue || '')
watch(() => props.modelValue, (v) => { selectedPath.value = v || '' })
async function loadFolders(subPath = ''): Promise<FolderListResponse | null> {
try {
const query = subPath ? `?path=${encodeURIComponent(subPath)}` : ''
return await request<FolderListResponse>(`/api/hermes/workspace/folders${query}`)
} catch {
return null
}
}
onMounted(async () => {
loading.value = true
const res = await loadFolders()
if (res) {
basePath.value = res.base
folders.value = res.folders
}
loading.value = false
})
async function toggleExpand(folder: FolderEntry) {
if (expandedPaths.value.has(folder.path)) {
expandedPaths.value.delete(folder.path)
expandedPaths.value = new Set(expandedPaths.value)
return
}
expandedPaths.value.add(folder.path)
expandedPaths.value = new Set(expandedPaths.value)
if (!childrenCache.value.has(folder.path)) {
loadingPaths.value.add(folder.path)
loadingPaths.value = new Set(loadingPaths.value)
const res = await loadFolders(folder.path)
childrenCache.value.set(folder.path, res?.folders || [])
childrenCache.value = new Map(childrenCache.value)
loadingPaths.value.delete(folder.path)
loadingPaths.value = new Set(loadingPaths.value)
}
}
function selectFolder(folder: FolderEntry) {
const fullPath = `${basePath.value}/${folder.path}`
selectedPath.value = fullPath
emit('update:modelValue', fullPath)
}
function selectBase() {
selectedPath.value = basePath.value
emit('update:modelValue', basePath.value)
}
/** Build a flat list by DFS traversal of expanded nodes */
const flatNodes = computed<FlatNode[]>(() => {
const result: FlatNode[] = []
function traverse(entries: FolderEntry[], depth: number) {
for (const folder of entries) {
const isExpanded = expandedPaths.value.has(folder.path)
const isLoading = loadingPaths.value.has(folder.path)
const children = childrenCache.value.get(folder.path)
result.push({
folder,
depth,
isExpanded,
isLoading,
hasChildren: children ? children.length > 0 : null,
})
if (isExpanded && children && children.length > 0) {
traverse(children, depth + 1)
}
}
}
traverse(folders.value, 0)
return result
})
</script>
<template>
<div class="folder-picker">
<div v-if="loading" class="folder-picker-loading">
<NSpin size="small" />
</div>
<div v-else class="folder-tree">
<!-- Base path as root -->
<div
class="folder-item root"
:class="{ selected: selectedPath === basePath }"
@click="selectBase"
>
<span class="folder-icon">📂</span>
<span class="folder-name">{{ basePath || '/' }}</span>
</div>
<!-- Flat rendered tree -->
<div
v-for="node in flatNodes"
:key="node.folder.path"
class="folder-item"
:class="{ selected: selectedPath === `${basePath}/${node.folder.path}` }"
:style="{ paddingLeft: `${12 + node.depth * 16}px` }"
>
<span class="folder-expand" @click.stop="toggleExpand(node.folder)">
<template v-if="node.isLoading"></template>
<template v-else>{{ node.isExpanded ? '' : '' }}</template>
</span>
<span class="folder-icon" @click="selectFolder(node.folder)">📁</span>
<span class="folder-name" @click="selectFolder(node.folder)">{{ node.folder.name }}</span>
</div>
<!-- Empty children indicator for expanded folders with no children -->
<template v-for="node in flatNodes" :key="'empty-' + node.folder.path">
<div
v-if="node.isExpanded && !node.isLoading && node.hasChildren === false"
class="folder-item empty"
:style="{ paddingLeft: `${28 + node.depth * 16}px` }"
>
<span class="folder-empty-text"></span>
</div>
</template>
<div v-if="folders.length === 0 && !loading" class="folder-empty">
暂无工作区文件夹
</div>
</div>
<!-- Selected path display -->
<div v-if="selectedPath" class="folder-selected">
<span class="folder-selected-label">已选择</span>
<span class="folder-selected-path">{{ selectedPath }}</span>
</div>
</div>
</template>
<style scoped lang="scss">
.folder-picker {
max-height: 360px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
}
.folder-picker-loading {
display: flex;
justify-content: center;
padding: 24px;
}
.folder-tree {
font-size: 13px;
}
.folder-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.06);
}
&.selected {
background: rgba(64, 158, 255, 0.15);
outline: 1px solid rgba(64, 158, 255, 0.4);
}
&.root {
font-weight: 600;
margin-bottom: 4px;
}
&.empty {
opacity: 0.5;
cursor: default;
}
}
.folder-expand {
width: 14px;
font-size: 10px;
text-align: center;
flex-shrink: 0;
user-select: none;
opacity: 0.6;
}
.folder-icon {
flex-shrink: 0;
}
.folder-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-empty-text {
font-size: 11px;
opacity: 0.5;
font-style: italic;
}
.folder-empty {
text-align: center;
padding: 16px;
opacity: 0.5;
}
.folder-selected {
margin-top: 8px;
padding: 6px 8px;
background: rgba(64, 158, 255, 0.08);
border-radius: 4px;
font-size: 12px;
display: flex;
gap: 4px;
align-items: center;
}
.folder-selected-label {
opacity: 0.6;
flex-shrink: 0;
}
.folder-selected-path {
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,245 @@
<script lang="ts">
type HistorySessionScrollSnapshot = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
wasNearBottom: boolean;
}
const historySessionScrollPositions = new Map<string, HistorySessionScrollSnapshot>();
</script>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, watch } from "vue";
import { useI18n } from "vue-i18n";
import VirtualMessageList from "./VirtualMessageList.vue";
import MessageItem from "./MessageItem.vue";
import { useChatStore } from "@/stores/hermes/chat";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
import type { Session } from "@/stores/hermes/chat";
const props = defineProps<{
session?: Session | null; // Optional: use this session instead of chatStore.activeSession
loadOlder?: (sessionId: string) => Promise<boolean>;
}>();
const chatStore = useChatStore();
const { toolTraceVisible } = useToolTraceVisibility();
const { t } = useI18n();
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
const pendingInitialScrollSessionId = ref<string | null>(null);
const activeSession = computed(() => props.session || null);
const listInstanceKey = computed(() => activeSession.value?.id ? `history-${activeSession.value.id}` : "history-empty");
const displayMessages = computed(() =>
(activeSession.value?.messages || []).filter((m) => {
// Tool messages without a name are internal use only and remain hidden.
if (m.role === 'tool') return toolTraceVisible.value && !!m.toolName
// Filter out messages with empty content.
if (!m.content?.trim()) return false
return true
}),
);
function isNearBottom(threshold = 200): boolean {
return listRef.value?.isNearBottom(threshold) ?? true;
}
function scrollToBottom() {
listRef.value?.scrollToBottom();
}
function scrollToMessage(messageId: string) {
listRef.value?.scrollToMessage(messageId);
}
function scrollToAnchor(messageId: string, anchorId: string) {
listRef.value?.scrollToAnchor(messageId, anchorId);
}
function saveSessionScrollPosition(sessionId: string | null | undefined) {
if (!sessionId) return;
const snapshot = listRef.value?.captureViewportPosition() ?? null;
if (snapshot) historySessionScrollPositions.set(sessionId, snapshot);
}
function applyInitialSessionScroll(sessionId: string) {
if (activeSession.value?.id !== sessionId) return;
if (chatStore.focusMessageId) {
pendingInitialScrollSessionId.value = null;
scrollToMessage(chatStore.focusMessageId);
return;
}
const snapshot = historySessionScrollPositions.get(sessionId);
if (snapshot) {
pendingInitialScrollSessionId.value = null;
if (snapshot.wasNearBottom) {
scrollToBottom();
} else {
listRef.value?.restoreViewportPosition(snapshot);
}
return;
}
scrollToBottom();
if ((activeSession.value?.messages.length || 0) > 0) pendingInitialScrollSessionId.value = null;
}
async function handleTopReach() {
const session = activeSession.value;
if (!session?.hasMoreBefore || session.isLoadingOlderMessages || !props.loadOlder) return;
const snapshot = listRef.value?.captureScrollPosition() ?? null;
const loaded = await props.loadOlder(session.id);
if (!loaded) return;
await nextTick();
listRef.value?.restoreScrollPosition(snapshot);
}
watch(
() => activeSession.value?.id,
async (id, previousId) => {
saveSessionScrollPosition(previousId);
if (!id) return;
pendingInitialScrollSessionId.value = id;
await nextTick();
applyInitialSessionScroll(id);
},
{ immediate: true },
);
watch(
() => chatStore.focusMessageId,
(messageId) => {
if (!messageId) return;
scrollToMessage(messageId);
},
);
// During streaming, only auto-scroll if the user is already near the bottom
watch(
() => (activeSession.value?.messages || [])[((activeSession.value?.messages || []).length - 1)]?.content,
(content) => {
if (pendingInitialScrollSessionId.value === activeSession.value?.id) return;
if (!content) return
if (!isNearBottom()) return;
scrollToBottom();
},
);
watch(
() => (activeSession.value?.messages || []).length,
(length) => {
if (length === 0) return
const id = activeSession.value?.id
if (id && pendingInitialScrollSessionId.value === id) {
applyInitialSessionScroll(id);
return;
}
if (!isNearBottom()) return;
scrollToBottom();
},
{ flush: "post" },
);
onBeforeUnmount(() => {
saveSessionScrollPosition(activeSession.value?.id);
});
defineExpose({
scrollToBottom,
scrollToMessage,
scrollToAnchor,
});
</script>
<template>
<VirtualMessageList
:key="listInstanceKey"
ref="listRef"
:messages="displayMessages"
@top-reach="handleTopReach"
>
<template #empty>
<div class="empty-state">
<img src="/logo.svg" alt="灵犀" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
</template>
<template #before>
<div
v-if="activeSession?.hasMoreBefore || activeSession?.isLoadingOlderMessages"
class="history-loader"
>
<span v-if="activeSession?.isLoadingOlderMessages" class="history-loader-spinner"></span>
</div>
</template>
<template #item="{ message: msg }">
<MessageItem
:message="msg"
:highlight="chatStore.focusMessageId === msg.id"
/>
</template>
</VirtualMessageList>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $text-muted;
gap: 12px;
.empty-logo {
width: 48px;
height: 48px;
opacity: 0.25;
}
p {
font-size: 14px;
}
}
.history-loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.history-loader-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, 0.16);
border-top-color: $accent-primary;
border-radius: 50%;
animation: spin 0.7s linear infinite;
.dark & {
border-color: rgba(255, 255, 255, 0.18);
border-top-color: $accent-primary;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,780 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NDrawer, NDrawerContent, NSpin, useMessage } from 'naive-ui'
import type MarkdownIt from 'markdown-it'
import MarkdownItConstructor from 'markdown-it'
import katex from 'katex'
import markdownItKatex from '@vscode/markdown-it-katex'
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
import { repairNestedMarkdownFences } from './markdownFenceRepair'
import {
MERMAID_MAX_DIAGRAMS_PER_MESSAGE,
MERMAID_MAX_SOURCE_LENGTH,
MERMAID_RENDER_TIMEOUT_MS,
decodeMermaidSource,
isMermaidFence,
renderMermaidPlaceholder,
SUPPORT_PREVIEW_FILE_TYPES,
} from './mermaidRenderer'
import { downloadFile, getDownloadUrl, fetchFileText } from '@/api/hermes/download'
const LATEX_FENCE_LANGS = new Set(['latex', 'tex', 'math', 'katex'])
const PREVIEW_AREA_WIDTH = 'min(800px, 100vw)'
function getFenceLanguage(info: string): string {
return info.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
}
function isLatexFence(info: string): boolean {
return LATEX_FENCE_LANGS.has(getFenceLanguage(info))
}
function normalizeLatexFenceContent(content: string): string {
const trimmed = content.trim()
if (trimmed.startsWith('\\[') && trimmed.endsWith('\\]')) {
return trimmed.slice(2, -2).trim()
}
if (trimmed.startsWith('$$') && trimmed.endsWith('$$')) {
return trimmed.slice(2, -2).trim()
}
if (trimmed.startsWith('\\(') && trimmed.endsWith('\\)')) {
return trimmed.slice(2, -2).trim()
}
return trimmed
}
function renderLatexFence(content: string): string {
const latex = normalizeLatexFenceContent(content)
return `<div class="latex-block">${katex.renderToString(latex, {
displayMode: true,
output: 'htmlAndMathml',
throwOnError: false,
strict: 'ignore',
})}</div>`
}
const props = withDefaults(defineProps<{
content: string
mentionNames?: string[]
headingIdPrefix?: string
}>(), {
mentionNames: () => [],
headingIdPrefix: '',
})
const { t } = useI18n()
const message = useMessage()
const md: MarkdownIt = new MarkdownItConstructor({
html: false,
breaks: true,
linkify: true,
typographer: true,
highlight(str: string, lang: string): string {
return renderHighlightedCodeBlock(str, lang, t('common.copy'))
},
})
md.use(markdownItKatex, {
katex,
throwOnError: false,
strict: 'ignore',
})
const defaultFenceRenderer = md.renderer.rules.fence?.bind(md.renderer.rules)
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx]
if (isLatexFence(token.info)) {
return renderLatexFence(token.content)
}
if (isMermaidFence(token.info)) {
return renderMermaidPlaceholder(token.content)
}
if (defaultFenceRenderer) {
return defaultFenceRenderer(tokens, idx, options, env, self)
}
return self.renderToken(tokens, idx, options)
}
const markdownBody = ref<HTMLElement | null>(null)
const componentId = `hermes-mermaid-${Math.random().toString(36).slice(2)}`
const previewUrl = ref<string | null>(null)
// Preview config variable
const textPreviewContent = ref<string | null>(null)
const textPreviewFileName = ref('')
const textPreviewLoading = ref(false)
const textPreviewVisible = ref(false)
const textPreviewIsMarkdown = computed(() => /\.(md|markdown)$/i.test(textPreviewFileName.value))
let renderGeneration = 0
let unmounted = false
function isLocalFilePath(path: string): boolean {
return path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(path)
}
function normalizeLocalFilePath(path: string): string {
return /^[a-zA-Z]:\\/.test(path) ? path.replace(/\\/g, '/') : path
}
const renderedHtml = computed(() => {
let html = md.render(repairNestedMarkdownFences(props.content))
// Add IDs to headings for anchor links
const prefix = props.headingIdPrefix ? `${props.headingIdPrefix}-` : ''
let headingCounter = 0
// Match any h1-h6 tags, with or without attributes
html = html.replace(/<(h[1-6])([^>]*)>/g, (match, tag, attrs) => {
headingCounter++
const id = `${prefix}heading-${headingCounter}`
// Check if id attribute already exists
if (attrs.includes('id=')) {
// Replace existing id
return match.replace(/id="[^"]*"/, `id="${id}"`).replace(/id='[^']*'/, `id="${id}"`)
}
// Add new id
if (attrs.trim() === '') {
return `<${tag} id="${id}">`
}
return `<${tag} ${attrs.trim()} id="${id}">`
})
// Replace image src paths with download URLs
html = html.replace(/\bsrc=(["'])([^"']+)\1/g, (match, quote, path) => {
if (!isLocalFilePath(path)) return match
const downloadUrl = getDownloadUrl(normalizeLocalFilePath(path))
return `src=${quote}${downloadUrl}${quote}`
})
// Replace local file links with file card UI or video player
// Match <a href="/tmp/file.pdf">filename</a> or <a href="C:/tmp/file.pdf">filename</a>
html = html.replace(/<a href="([^"]+)">([^<]+)<\/a>/g, (match, rawPath, filename) => {
if (!isLocalFilePath(rawPath)) return match
const path = normalizeLocalFilePath(rawPath)
const fileName = filename.trim()
const ext = path.split('.').pop()?.toLowerCase()
// Video files: render as video player
if (ext === 'mp4' || ext === 'webm' || ext === 'mov') {
const downloadUrl = getDownloadUrl(path)
return `<div class="markdown-video-container">
<video class="markdown-video" controls preload="metadata" src="${downloadUrl}"></video>
<div class="markdown-video-footer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<span class="att-name">${fileName}</span>
</div>
</div>`
}
// Other files: render as file card
return `<div class="markdown-file-card" data-path="${path}" data-filename="${fileName}" title="${t('download.downloadFile')}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="att-name">${fileName}</span>
<button class="att-download-btn" type="button" title="${t('download.downloadFile')}" aria-label="${t('download.downloadFile')}">
<svg class="att-download-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>`
})
if (props.mentionNames && props.mentionNames.length > 0) {
const escaped = [...props.mentionNames]
.sort((a, b) => b.length - a.length)
.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const re = new RegExp(`(?<=[\\s>({\\[<]|^)@(${escaped.join('|')})(?=[\\s.,!?;:,。!?;:)\\]}>]|<|$)`, 'gi')
html = html.replace(re, '<span class="mention-highlight">@$1</span>')
}
return html
})
function renderMermaidFallback(element: HTMLElement, source: string): void {
element.outerHTML = renderHighlightedCodeBlock(source, 'mermaid', t('common.copy'))
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`))
}, timeoutMs)
})
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
})
}
function getScrollParent(el: HTMLElement | null): HTMLElement | null {
if (!el) return null
let current: HTMLElement | null = el.parentElement
while (current) {
const { overflow, overflowY } = getComputedStyle(current)
if (overflow === 'auto' || overflow === 'scroll' || overflowY === 'auto' || overflowY === 'scroll') {
return current
}
current = current.parentElement
}
return null
}
function isNearScrollBottom(el: HTMLElement, threshold = 200): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold
}
function cleanupMermaidRenderArtifacts(id: string): void {
document.getElementById(id)?.remove()
document.getElementById(`d${id}`)?.remove()
}
async function renderMermaidDiagrams(): Promise<void> {
const generation = ++renderGeneration
await nextTick()
const root = markdownBody.value
if (unmounted || generation !== renderGeneration || !root) return
const pendingDiagrams = Array.from(root.querySelectorAll<HTMLElement>('[data-mermaid-pending="true"]'))
if (pendingDiagrams.length === 0) return
const diagramsToRender = pendingDiagrams.slice(0, MERMAID_MAX_DIAGRAMS_PER_MESSAGE)
const diagramsToFallback = pendingDiagrams.slice(MERMAID_MAX_DIAGRAMS_PER_MESSAGE)
for (const element of diagramsToFallback) {
renderMermaidFallback(element, decodeMermaidSource(element.getAttribute('data-mermaid-source')))
}
const renderCandidates = diagramsToRender
.map(element => ({
element,
source: decodeMermaidSource(element.getAttribute('data-mermaid-source')),
}))
const validDiagrams = [] as typeof renderCandidates
for (const candidate of renderCandidates) {
if (unmounted || generation !== renderGeneration || !root.contains(candidate.element)) return
if (!candidate.source || candidate.source.length > MERMAID_MAX_SOURCE_LENGTH) {
renderMermaidFallback(candidate.element, candidate.source)
continue
}
validDiagrams.push(candidate)
}
if (validDiagrams.length === 0) return
let mermaid: typeof import('mermaid').default
try {
mermaid = (await withTimeout(import('mermaid'), MERMAID_RENDER_TIMEOUT_MS, 'Mermaid import')).default
if (unmounted || generation !== renderGeneration) return
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
})
} catch {
if (unmounted || generation !== renderGeneration) return
for (const { element, source } of validDiagrams) {
if (root.contains(element)) {
renderMermaidFallback(element, source)
}
}
return
}
for (const [index, { element, source }] of validDiagrams.entries()) {
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
try {
const id = `${componentId}-${generation}-${index}`
const result = await withTimeout(mermaid.render(id, source), MERMAID_RENDER_TIMEOUT_MS, 'Mermaid render')
cleanupMermaidRenderArtifacts(id)
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
const scrollParent = getScrollParent(markdownBody.value)
const shouldKeepBottom = scrollParent ? isNearScrollBottom(scrollParent) : false
element.removeAttribute('data-mermaid-pending')
element.removeAttribute('data-mermaid-source')
element.innerHTML = result.svg
if (scrollParent && shouldKeepBottom) {
nextTick(() => {
scrollParent.scrollTop = scrollParent.scrollHeight
})
}
} catch {
cleanupMermaidRenderArtifacts(`${componentId}-${generation}-${index}`)
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
renderMermaidFallback(element, source)
}
}
}
onMounted(() => {
void renderMermaidDiagrams()
})
watch(renderedHtml, () => {
void renderMermaidDiagrams()
}, { flush: 'post' })
onBeforeUnmount(() => {
unmounted = true
renderGeneration += 1
})
async function handleMarkdownClick(event: MouseEvent): Promise<void> {
const copyResult = await handleCodeBlockCopyClick(event)
if (copyResult !== null) {
if (copyResult) {
message.success(t('common.copied'))
} else {
message.error(t('chat.copyFailed'))
}
return
}
const target = event.target as HTMLElement
// Handle image clicks for preview
const img = target.closest('img') as HTMLImageElement | null
if (img) {
event.preventDefault()
previewUrl.value = img.src
return
}
// Handle file card clicks for download
const fileCard = target.closest('.markdown-file-card') as HTMLElement | null
if (fileCard) {
event.preventDefault()
event.stopPropagation()
const path = fileCard.getAttribute('data-path')
const fileName = fileCard.getAttribute('data-filename') || undefined
const isDownloadBtn = target.closest('.att-download-btn')
if (isDownloadBtn && path) { // Only download file with download icon clicked.
message.info(t('download.downloading'))
downloadFile(path, fileName).catch((err: Error) => {
message.error(err.message || t('download.downloadFailed'))
})
return
}
if (path) {
const ext = fileName?.split('.').pop()?.toLowerCase()
if (SUPPORT_PREVIEW_FILE_TYPES.includes(ext || '')) {
previewTextFile(path, fileName || '')
} else { // Download file immediately
downloadFile(path, fileName).catch((err: Error) => {
message.error(err.message || t('download.downloadFailed'))
})
}
}
return
}
// Handle file path link clicks for download
const link = target.closest('a') as HTMLAnchorElement | null
if (!link) return
const href = link.getAttribute('href')
if (!href) return
// Let http(s) links behave normally — use window.open to prevent
// the hash-based router from intercepting the click
if (href.startsWith('http://') || href.startsWith('https://')) {
event.preventDefault()
window.open(href, '_blank', 'noopener,noreferrer')
return
}
// Full download URL: open directly (already has /api/hermes/download?path=...)
if (href.startsWith('/api/hermes/download?')) {
event.preventDefault()
event.stopPropagation()
const linkText = link.textContent || ''
const fileName = linkText.startsWith('File: ') ? linkText.slice(6).trim() : linkText.trim()
message.info(t('download.downloading'))
// Parse the real file path from the existing query param
const url = new URL(href, window.location.origin)
const realPath = url.searchParams.get('path') || href
downloadFile(realPath, fileName || undefined).catch((err: Error) => {
message.error(err.message || t('download.downloadFailed'))
})
return
}
// File path links: intercept and download
if (isLocalFilePath(href)) {
event.preventDefault()
event.stopPropagation()
const linkText = link.textContent || ''
const fileName = linkText.startsWith('File: ') ? linkText.slice(6).trim() : linkText.trim()
message.info(t('download.downloading'))
downloadFile(normalizeLocalFilePath(href), fileName || undefined).catch((err: Error) => {
message.error(err.message || t('download.downloadFailed'))
})
}
}
// Get file content and show preview area.
async function previewTextFile(path: string, fileName: string): Promise<void> {
textPreviewLoading.value = true
textPreviewVisible.value = true
textPreviewFileName.value = fileName
textPreviewContent.value = null
try {
textPreviewContent.value = await fetchFileText(path, fileName)
} catch (err: any) {
message.error(err.message || t('download.downloadFailed'))
} finally {
textPreviewLoading.value = false
}
}
function closeTextPreview(): void {
textPreviewVisible.value = false
}
</script>
<template>
<div ref="markdownBody" class="markdown-body" v-html="renderedHtml" @click="handleMarkdownClick"></div>
<!-- File preview area -->
<NDrawer
v-model:show="textPreviewVisible"
:width="PREVIEW_AREA_WIDTH"
placement="right"
:show-mask="false"
:trap-focus="false"
class="markdown-text-preview-drawer"
>
<NDrawerContent
:title="t('download.contentDisplay')"
closable
:body-content-style="{ padding: 0 }"
@close="closeTextPreview"
>
<NSpin :show="textPreviewLoading">
<div v-if="textPreviewContent !== null && textPreviewIsMarkdown" class="text-preview-markdown">
<MarkdownRenderer :content="textPreviewContent" />
</div>
<pre v-else-if="textPreviewContent !== null" class="text-preview-body">{{ textPreviewContent }}</pre>
</NSpin>
</NDrawerContent>
</NDrawer>
<Teleport to="body">
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
</div>
</Teleport>
</template>
<style lang="scss">
@use '@/styles/variables' as *;
.markdown-body {
font-size: 14px;
line-height: 1.65;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
overflow-x: auto;
p {
margin: 0 0 8px;
&:last-child {
margin-bottom: 0;
}
}
ul, ol {
padding-left: 20px;
margin: 4px 0 8px;
}
li {
margin: 2px 0;
}
strong {
color: $text-primary;
font-weight: 600;
}
em {
color: $text-secondary;
}
a {
color: $accent-primary;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: $accent-hover;
}
}
img {
display: block;
max-width: 200px;
max-height: 160px;
object-fit: contain;
cursor: pointer;
border-radius: 4px;
margin: 8px 0;
}
.markdown-video-container {
margin: 12px 0;
border-radius: $radius-sm;
overflow: hidden;
background: #000;
border: 1px solid $border-color;
}
.markdown-video {
display: block;
width: 100%;
max-width: 640px;
max-height: 480px;
object-fit: contain;
}
.markdown-video-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 12px;
.att-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.markdown-file-card {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 12px;
color: $text-secondary;
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid $border-light;
border-radius: $radius-sm;
margin: 8px 0;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.08);
border-color: $border-color;
}
.att-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.att-download-icon {
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.15s ease;
}
.att-download-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
cursor: pointer;
}
&:hover .att-download-icon,
.att-download-btn:hover .att-download-icon {
opacity: 1;
}
}
blockquote {
margin: 8px 0;
padding: 4px 12px;
border-left: 3px solid $border-color;
color: $text-secondary;
}
code:not(.hljs) {
background: $code-bg;
padding: 2px 6px;
border-radius: 4px;
font-family: $font-code;
font-size: 13px;
color: $accent-primary;
}
table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
display: block;
overflow-x: auto;
th, td {
padding: 6px 12px;
border: 1px solid $border-color;
text-align: left;
font-size: 13px;
}
th {
background: rgba(var(--accent-primary-rgb), 0.08);
color: $text-primary;
font-weight: 600;
}
td {
color: $text-secondary;
}
}
hr {
border: none;
border-top: 1px solid $border-color;
margin: 12px 0;
}
.mermaid-diagram {
margin: 10px 0;
padding: 14px;
border: 1px solid $border-color;
border-radius: 8px;
background: rgba(var(--accent-primary-rgb), 0.04);
overflow-x: auto;
svg {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto;
}
}
.mermaid-loading {
color: $text-secondary;
font-size: 13px;
font-family: $font-code;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
}
.image-preview-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.image-preview-img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 4px;
cursor: pointer;
}
.text-preview-body {
flex: 1;
overflow: auto;
padding: 16px;
margin: 0;
font-family: $font-code;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
color: $text-primary;
}
.text-preview-markdown {
padding: 16px;
overflow: auto;
}
.markdown-text-preview-drawer {
max-width: 100vw;
.n-drawer-content,
.n-drawer-body-content-wrapper {
max-width: 100vw;
}
}
@media (max-width: $breakpoint-mobile) {
.markdown-text-preview-drawer {
max-width: 100vw;
.n-drawer-content,
.n-drawer-body-content-wrapper {
max-width: 100vw;
}
}
.text-preview-body {
padding: 12px;
max-width: 100vw;
}
.text-preview-markdown {
padding: 12px;
max-width: 100vw;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,798 @@
<script lang="ts">
type SessionScrollSnapshot = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
wasNearBottom: boolean;
}
const sessionScrollPositions = new Map<string, SessionScrollSnapshot>();
</script>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, watch } from "vue";
import { useI18n } from "vue-i18n";
import VirtualMessageList from "./VirtualMessageList.vue";
import MessageItem from "./MessageItem.vue";
import { useChatStore } from "@/stores/hermes/chat";
import ThinkingIndicator from "./ThinkingIndicator.vue";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
const chatStore = useChatStore();
const { t } = useI18n();
const { toolTraceVisible } = useToolTraceVisibility();
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
const pendingInitialScrollSessionId = ref<string | null>(null);
function formatTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
return String(n)
}
function formatToolDuration(seconds: number): string {
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`
const mins = Math.floor(seconds / 60)
const secs = Math.round(seconds % 60)
return `${mins}m ${secs}s`
}
const currentToolCalls = computed(() => {
const msgs = chatStore.messages;
// Find the last user message index
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === "user") {
lastUserIdx = i;
break;
}
}
// Only tool calls after the last user message, newest on top
const tools = msgs.filter((m, i) => m.role === "tool" && i > lastUserIdx);
return [...tools].reverse();
});
const visibleToolCalls = computed(() =>
currentToolCalls.value.filter((tool) => !!tool.toolName),
);
const displayMessages = computed(() => {
const currentToolIds = new Set(currentToolCalls.value.map((tool) => tool.id));
return chatStore.messages.filter((m) => {
if (m.role === "tool") {
return toolTraceVisible.value && !!m.toolName && !(chatStore.isRunActive && currentToolIds.has(m.id));
}
if (
m.role === "assistant" &&
m.isStreaming &&
!m.content?.trim() &&
!!m.reasoning?.trim() &&
currentToolCalls.value.length === 0
) {
return false;
}
return true;
});
});
const queuedMessages = computed(() => {
const sid = chatStore.activeSessionId;
if (!sid) return [];
return chatStore.queuedUserMessages.get(sid) || [];
});
function removeQueuedMessage(messageId: string) {
const sid = chatStore.activeSessionId;
if (!sid) return;
chatStore.removeQueuedMessage(sid, messageId);
}
function queuedPreview(content: string): string {
const normalized = content.replace(/\s+/g, " ").trim();
return normalized.length > 48 ? `${normalized.slice(0, 48)}...` : normalized;
}
function isNearBottom(threshold = 200): boolean {
return listRef.value?.isNearBottom(threshold) ?? true;
}
function scrollToBottom() {
listRef.value?.scrollToBottom();
}
function scrollToMessage(messageId: string) {
listRef.value?.scrollToMessage(messageId);
}
function scrollToAnchor(messageId: string, anchorId: string) {
listRef.value?.scrollToAnchor(messageId, anchorId);
}
function saveSessionScrollPosition(sessionId: string | null | undefined) {
if (!sessionId) return;
const snapshot = listRef.value?.captureViewportPosition() ?? null;
if (snapshot) sessionScrollPositions.set(sessionId, snapshot);
}
function applyInitialSessionScroll(sessionId: string) {
if (chatStore.activeSessionId !== sessionId) return;
if (chatStore.focusMessageId) {
pendingInitialScrollSessionId.value = null;
scrollToMessage(chatStore.focusMessageId);
return;
}
const snapshot = sessionScrollPositions.get(sessionId);
if (snapshot) {
pendingInitialScrollSessionId.value = null;
if (snapshot.wasNearBottom) {
scrollToBottom();
} else {
listRef.value?.restoreViewportPosition(snapshot);
}
return;
}
scrollToBottom();
if (chatStore.messages.length > 0) pendingInitialScrollSessionId.value = null;
}
async function handleTopReach() {
const session = chatStore.activeSession;
if (!session?.hasMoreBefore || session.isLoadingOlderMessages) return;
const snapshot = listRef.value?.captureScrollPosition() ?? null;
const loaded = await chatStore.loadOlderMessages(session.id);
if (!loaded) return;
await nextTick();
listRef.value?.restoreScrollPosition(snapshot);
}
watch(
() => chatStore.activeSessionId,
async (id, previousId) => {
saveSessionScrollPosition(previousId);
if (!id) return;
pendingInitialScrollSessionId.value = id;
await nextTick();
applyInitialSessionScroll(id);
},
{ immediate: true },
);
watch(
() => [chatStore.activeSessionId, chatStore.messages.length] as const,
([id, length]) => {
if (!id || pendingInitialScrollSessionId.value !== id || length === 0) return;
applyInitialSessionScroll(id);
},
{ flush: "post" },
);
watch(
() => chatStore.focusMessageId,
(messageId) => {
if (!messageId) return;
scrollToMessage(messageId);
},
);
// When a run starts (user just sent a message), always scroll to bottom once
watch(
() => chatStore.isRunActive,
(v) => {
if (v) scrollToBottom();
},
);
// During streaming, only auto-scroll if the user is already near the bottom
watch(
() => chatStore.messages[chatStore.messages.length - 1]?.content,
() => {
if (pendingInitialScrollSessionId.value === chatStore.activeSessionId) return;
if (chatStore.focusMessageId) {
scrollToMessage(chatStore.focusMessageId);
return;
}
if (!isNearBottom()) return;
scrollToBottom();
},
);
watch(currentToolCalls, () => {
if (pendingInitialScrollSessionId.value === chatStore.activeSessionId) return;
if (chatStore.focusMessageId) {
scrollToMessage(chatStore.focusMessageId);
return;
}
if (!isNearBottom()) return;
scrollToBottom();
});
onBeforeUnmount(() => {
saveSessionScrollPosition(chatStore.activeSessionId);
});
defineExpose({
scrollToBottom,
scrollToMessage,
scrollToAnchor,
});
</script>
<template>
<VirtualMessageList
:key="chatStore.activeSessionId || 'chat-empty'"
ref="listRef"
:messages="displayMessages"
@top-reach="handleTopReach"
>
<template #empty>
<div class="empty-state">
<img src="/logo.svg" alt="灵犀" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
</template>
<template #before>
<div
v-if="chatStore.activeSession?.hasMoreBefore || chatStore.activeSession?.isLoadingOlderMessages"
class="history-loader"
>
<span v-if="chatStore.activeSession?.isLoadingOlderMessages" class="history-loader-spinner"></span>
</div>
</template>
<template #item="{ message: msg }">
<MessageItem
:message="msg"
:highlight="chatStore.focusMessageId === msg.id"
/>
</template>
<template #after>
<Transition name="fade">
<div v-if="chatStore.isRunActive || chatStore.abortState" class="streaming-indicator">
<ThinkingIndicator />
<div v-if="visibleToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
<!-- Abort indicator -->
<div v-if="chatStore.abortState" class="tool-call-item compression-item">
<svg
v-if="chatStore.abortState.aborting"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M10 9v6m4-6v6M5 5h14v14H5z" />
</svg>
<svg
v-else
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M5 13l4 4L19 7" />
</svg>
<span class="tool-call-name">
{{
chatStore.abortState.aborting
? 'Pausing... waiting for the run to stop and sync'
: chatStore.abortState.synced
? 'Paused and synced'
: 'Paused'
}}
</span>
<span
v-if="chatStore.abortState.aborting"
class="tool-call-spinner"
></span>
</div>
<!-- Compression indicator -->
<div v-if="chatStore.compressionState" class="tool-call-item compression-item">
<svg
v-if="chatStore.compressionState.compressing"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<svg
v-else-if="chatStore.compressionState.compressed"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M5 13l4 4L19 7" />
</svg>
<span class="tool-call-name">
{{
chatStore.compressionState.compressing
? `Compressing... (${chatStore.compressionState.messageCount} msgs, ~${formatTokens(chatStore.compressionState.beforeTokens)} tokens)`
: chatStore.compressionState.compressed
? `Compressed ${chatStore.compressionState.messageCount} msgs: ~${formatTokens(chatStore.compressionState.beforeTokens)} → ~${formatTokens(chatStore.compressionState.afterTokens)} tokens`
: `Compression skipped`
}}
</span>
<span
v-if="chatStore.compressionState.compressing"
class="tool-call-spinner"
></span>
</div>
<!-- Tool calls -->
<div
v-for="tc in visibleToolCalls"
:key="tc.id"
class="tool-call-item"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
<span class="tool-call-name">{{ tc.toolName }}</span>
<span v-if="tc.toolPreview" class="tool-call-preview">{{
tc.toolPreview
}}</span>
<span
v-if="tc.toolDuration && tc.toolStatus !== 'running'"
class="tool-call-duration"
:title="$t('chat.executionDuration')"
>{{ formatToolDuration(tc.toolDuration) }}</span
>
<svg
v-if="tc.toolStatus === 'done'"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
class="tool-call-success-icon"
>
<circle cx="12" cy="12" r="10" fill="currentColor" fill-opacity="0.15"/>
<path
d="M8 12L11 15L16 9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
<span
v-if="tc.toolStatus === 'running'"
class="tool-call-spinner"
></span>
<svg
v-if="tc.toolStatus === 'error'"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
class="tool-call-error-icon"
>
<circle cx="12" cy="12" r="10" fill="currentColor" fill-opacity="0.15"/>
<path
d="M15 9L9 15M9 9L15 15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
</div>
</div>
</div>
</Transition>
<Transition name="queue-float">
<div v-if="queuedMessages.length > 0" class="queue-float-panel">
<div class="queue-float-header">
<span class="queue-orbit" aria-hidden="true">
<span></span>
</span>
<span>{{ t('chat.messageQueue') }}</span>
<strong>{{ queuedMessages.length }}</strong>
</div>
<div class="queue-float-list">
<div
v-for="(message, index) in queuedMessages"
:key="message.id"
class="queue-float-item"
>
<span class="queue-index">{{ index + 1 }}</span>
<span class="queue-text">{{ queuedPreview(message.content) }}</span>
<button
type="button"
class="queue-remove"
:title="t('chat.removeQueuedMessage')"
@click="removeQueuedMessage(message.id)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
</div>
</Transition>
</template>
</VirtualMessageList>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.queue-float-panel {
position: sticky;
right: 16px;
bottom: 16px;
z-index: 4;
width: min(340px, calc(100% - 16px));
margin-top: 16px;
margin-left: auto;
padding: 10px;
border: 1px solid rgba(var(--accent-info-rgb), 0.22);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.14);
backdrop-filter: blur(14px);
.dark & {
background: #262626;
}
}
.queue-float-header {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 4px 8px;
color: $text-secondary;
font-size: 12px;
font-weight: 600;
strong {
margin-left: auto;
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(var(--accent-info-rgb), 0.16);
color: var(--accent-info);
}
}
.queue-orbit {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid rgba(var(--accent-info-rgb), 0.28);
position: relative;
animation: queue-spin 1.6s linear infinite;
span {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
right: -2px;
top: 5px;
background: var(--accent-info);
box-shadow: 0 0 12px rgba(var(--accent-info-rgb), 0.65);
}
}
.queue-float-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 172px;
overflow-y: auto;
}
.queue-float-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 7px 8px;
border-radius: 11px;
background: rgba(255, 255, 255, 0.68);
color: $text-primary;
.dark & {
background: rgba(255, 255, 255, 0.08);
}
}
.queue-index {
flex: 0 0 auto;
width: 20px;
height: 20px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--accent-info);
background: rgba(var(--accent-info-rgb), 0.12);
}
.queue-text {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.queue-remove {
flex: 0 0 auto;
width: 24px;
height: 24px;
border: none;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: $text-muted;
background: transparent;
cursor: pointer;
transition: all $transition-fast;
&:hover {
color: $error;
background: rgba($error, 0.1);
}
}
@media (max-width: 640px) {
.queue-float-panel {
right: 8px;
bottom: 8px;
width: min(260px, calc(100% - 8px));
padding: 7px;
border-radius: 14px;
}
.queue-float-header {
padding: 0 2px;
font-size: 11px;
span:nth-child(2) {
display: none;
}
}
.queue-orbit {
width: 16px;
height: 16px;
span {
width: 5px;
height: 5px;
top: 5px;
}
}
.queue-float-list {
margin-top: 6px;
max-height: min(220px, 34dvh);
overflow-y: auto;
}
.queue-float-item {
min-height: 30px;
padding: 5px 6px;
}
.queue-index {
width: 18px;
height: 18px;
border-radius: 6px;
font-size: 10px;
}
.queue-text {
font-size: 11px;
}
.queue-remove {
width: 22px;
height: 22px;
}
}
@keyframes queue-spin {
to {
transform: rotate(360deg);
}
}
.queue-float-enter-active,
.queue-float-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.queue-float-enter-from,
.queue-float-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $text-muted;
gap: 12px;
.empty-logo {
width: 48px;
height: 48px;
opacity: 0.25;
}
p {
font-size: 14px;
}
}
.history-loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.history-loader-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, 0.16);
border-top-color: $accent-primary;
border-radius: 50%;
animation: spin 0.7s linear infinite;
.dark & {
border-color: rgba(255, 255, 255, 0.18);
border-top-color: $accent-primary;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.streaming-indicator {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 4px 4px 4px 0;
}
.tool-calls-panel {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: auto;
padding-top: 4px;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.tool-call-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $text-secondary;
padding: 3px 8px;
background: rgba(0, 0, 0, 0.03);
border-radius: $radius-sm;
.dark & {
background: rgba(255, 255, 255, 0.06);
}
&.compression-item {
color: $text-muted;
font-size: 10px;
}
.tool-call-icon {
flex-shrink: 0;
color: $text-muted;
}
.tool-call-name {
font-family: $font-code;
flex-shrink: 0;
}
.tool-call-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
color: $text-muted;
}
}
.tool-call-spinner {
width: 10px;
height: 10px;
border: 1.5px solid $text-muted;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
.tool-call-error-icon {
color: #ff4d4f;
flex-shrink: 0;
margin-left: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.tool-call-duration {
font-size: 10px;
color: $text-muted;
font-family: $font-code;
margin-left: 4px;
flex-shrink: 0;
}
.tool-call-success-icon {
color: #52c41a;
flex-shrink: 0;
margin-left: 6px;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,311 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Message } from '@/stores/hermes/chat'
interface OutlineItem {
id: string
type: 'user' | 'outline'
content: string
messageId: string
level: number
anchorId: string
}
const props = defineProps<{
messages: Message[]
}>()
const emit = defineEmits<{
navigate: [target: { messageId: string; anchorId: string }]
}>()
const { t } = useI18n()
function extractAllHeadings(text: string, messageId: string): OutlineItem[] {
const items: OutlineItem[] = []
let cleanedText = text.replace(/<think>[\s\S]*?<\/think>/g, '')
const lines = cleanedText.split('\n')
let headingIndex = 0
for (const line of lines) {
const trimmed = line.trim()
const h1Match = trimmed.match(/^#\s+(.+)/)
const h2Match = trimmed.match(/^##\s+(.+)/)
const h3Match = trimmed.match(/^###\s+(.+)/)
if (h1Match) {
headingIndex++
items.push({
id: `outline-${messageId}-h${headingIndex}`,
type: 'outline',
content: h1Match[1].trim(),
messageId,
level: 1,
anchorId: `msg-${messageId}-heading-${headingIndex}`
})
} else if (h2Match) {
headingIndex++
items.push({
id: `outline-${messageId}-h${headingIndex}`,
type: 'outline',
content: h2Match[1].trim(),
messageId,
level: 2,
anchorId: `msg-${messageId}-heading-${headingIndex}`
})
} else if (h3Match) {
headingIndex++
items.push({
id: `outline-${messageId}-h${headingIndex}`,
type: 'outline',
content: h3Match[1].trim(),
messageId,
level: 3,
anchorId: `msg-${messageId}-heading-${headingIndex}`
})
}
}
return items
}
function extractUserQuestion(text: string): string {
const cleanedText = text.replace(/<think>[\s\S]*?<\/think>/g, '')
const firstLine = cleanedText.split('\n')[0] || ''
if (firstLine.length > 50) {
return firstLine.slice(0, 50) + '...'
}
return firstLine || t('chat.outlineUserQuestion')
}
const outlineItems = computed<OutlineItem[]>(() => {
const items: OutlineItem[] = []
let i = 0
const filteredMessages = props.messages.filter(m => m.role === 'user' || m.role === 'assistant')
while (i < filteredMessages.length) {
const msg = filteredMessages[i]
if (msg.role === 'user') {
items.push({
id: `user-${msg.id}`,
type: 'user',
content: extractUserQuestion(msg.content || ''),
messageId: msg.id,
level: 0,
anchorId: `message-${msg.id}`
})
i++
while (i < filteredMessages.length && filteredMessages[i].role !== 'assistant') {
i++
}
if (i < filteredMessages.length) {
const assistantMsg = filteredMessages[i]
const headings = extractAllHeadings(assistantMsg.content || '', assistantMsg.id)
items.push(...headings)
}
}
i++
}
return items
})
function scrollToTarget(item: OutlineItem) {
emit('navigate', {
messageId: item.messageId,
anchorId: item.anchorId,
})
}
</script>
<template>
<div class="outline-panel">
<div class="outline-header">
<span class="outline-title">{{ t('chat.outlineTitle') }}</span>
</div>
<div class="outline-content">
<template v-if="outlineItems.length > 0">
<template v-for="item in outlineItems" :key="item.id">
<div
v-if="item.type === 'user'"
class="outline-item user-item"
@click="scrollToTarget(item)"
>
<div class="user-question">
<span class="q-label">Q:</span>
<span class="q-text">{{ item.content }}</span>
</div>
</div>
<div
v-else
class="outline-item outline-heading-item"
:class="`level-${item.level}`"
@click="scrollToTarget(item)"
>
<div class="heading-item">
<span class="heading-text">{{ item.content }}</span>
</div>
</div>
</template>
</template>
<div v-else class="outline-empty">{{ t('chat.outlineEmpty') }}</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.outline-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: $bg-card;
border-left: 1px solid $border-color;
width: 280px;
flex-shrink: 0;
@media (max-width: $breakpoint-mobile) {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(280px, 86vw);
z-index: 8;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
}
}
.outline-header {
padding: 16px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.outline-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.outline-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.outline-item {
margin-bottom: 4px;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
}
.user-item {
margin-bottom: 6px;
}
.user-question {
background-color: $bg-secondary;
color: $text-primary;
padding: 8px 12px;
border-radius: 8px;
display: flex;
align-items: flex-start;
gap: 6px;
.dark & {
background-color: $bg-input;
}
.q-label {
font-weight: 600;
flex-shrink: 0;
font-size: 13px;
line-height: 1.4;
}
.q-text {
font-size: 13px;
line-height: 1.4;
word-break: break-word;
}
}
.outline-heading-item {
&.level-1 {
padding-left: 0;
}
&.level-2 {
padding-left: 12px;
}
&.level-3 {
padding-left: 24px;
}
}
.heading-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
.dark & {
background-color: rgba(255, 255, 255, 0.06);
}
}
.level-1 & {
.heading-marker {
color: $text-primary;
font-weight: 600;
}
.heading-text {
color: $text-primary;
font-weight: 500;
}
}
.level-2 & {
.heading-marker {
color: $text-secondary;
}
.heading-text {
color: $text-secondary;
}
}
.level-3 & {
.heading-marker {
color: $text-muted;
}
.heading-text {
color: $text-muted;
font-size: 12px;
}
}
}
.heading-text {
font-size: 13px;
line-height: 1.4;
word-break: break-word;
}
.outline-empty {
text-align: center;
color: $text-muted;
font-size: 13px;
padding: 20px 0;
}
</style>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import { NPopconfirm, NCheckbox, NTooltip } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import type { Session } from '@/stores/hermes/chat'
import { useAppStore } from '@/stores/hermes/app'
import { useProfilesStore } from '@/stores/hermes/profiles'
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
import { formatTimestampMs } from '@/shared/session-display'
const props = withDefaults(defineProps<{
session: Session
active: boolean
pinned: boolean
canDelete: boolean
streaming?: boolean
selectable?: boolean
selected?: boolean
showProfile?: boolean
to?: string
}>(), {
showProfile: true,
})
const emit = defineEmits<{
select: []
contextmenu: [event: MouseEvent]
delete: []
'toggle-select': []
}>()
const { t } = useI18n()
const appStore = useAppStore()
const profilesStore = useProfilesStore()
const sessionModelName = computed(() =>
props.session.model
? appStore.displayModelName(props.session.model, props.session.provider)
: '',
)
const profileName = computed(() => props.session.profile || 'default')
const profileAvatar = computed(() => profilesStore.profiles.find(profile => profile.name === profileName.value)?.avatar)
const profileHasModels = computed(() => {
const profileModels = appStore.profileModelGroups.find(profile => profile.profile === profileName.value)
return !!profileModels?.groups?.some(group => group.models.length > 0)
})
const profileModelsMissing = computed(() =>
appStore.profileModelGroups.length > 0 && !profileHasModels.value,
)
let longPressTimer: ReturnType<typeof setTimeout> | null = null
const longPressTriggered = ref(false)
function onTouchStart(e: TouchEvent) {
longPressTriggered.value = false
longPressTimer = setTimeout(() => {
longPressTriggered.value = true
const touch = e.touches[0]
const syntheticEvent = new MouseEvent('contextmenu', {
clientX: touch.clientX,
clientY: touch.clientY,
bubbles: true,
})
emit('contextmenu', syntheticEvent)
}, 500)
}
function onTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
function onTouchMove() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
function isModifiedNavigation(event?: MouseEvent) {
return !!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
}
function onClick(event?: MouseEvent) {
if (longPressTriggered.value) {
longPressTriggered.value = false
event?.preventDefault()
return
}
if (isModifiedNavigation(event)) return
if (props.to && !props.selectable) event?.preventDefault()
emit('select')
}
onUnmounted(() => {
if (longPressTimer) clearTimeout(longPressTimer)
})
</script>
<template>
<component
:is="selectable || !to ? 'button' : 'a'"
class="session-item"
:class="{ active, 'batch-mode': selectable, 'missing-models': profileModelsMissing }"
:aria-current="active ? 'page' : undefined"
:href="!selectable ? to : undefined"
:type="selectable || !to ? 'button' : undefined"
@click="onClick"
@contextmenu="emit('contextmenu', $event)"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@touchmove="onTouchMove"
>
<div v-if="selectable" class="session-item-checkbox">
<NCheckbox :checked="selected" @click.stop="emit('toggle-select')" />
</div>
<div class="session-item-content">
<span class="session-item-title-row">
<span v-if="pinned" class="session-item-pin" aria-hidden="true">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 17v5" />
<path d="M5 8l14 0" />
<path d="M8 3l8 0 0 5 3 5-14 0 3-5z" />
</svg>
</span>
<span class="session-item-title">
<svg v-if="streaming" class="session-item-streaming" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
{{ session.title }}
</span>
<NTooltip v-if="profileModelsMissing" trigger="click" placement="top">
<template #trigger>
<button class="session-item-warning" type="button" @click.stop.prevent>
!
</button>
</template>
{{ t('chat.profileMissingModelsTip', { profile: profileName }) }}
</NTooltip>
</span>
<span class="session-item-meta">
<span v-if="sessionModelName" class="session-item-model" :title="session.model">{{ sessionModelName }}</span>
<span class="session-item-time">{{ formatTimestampMs(session.createdAt) }}</span>
</span>
<span v-if="props.showProfile" class="session-item-profile">
<ProfileAvatar class="session-item-profile-avatar" :name="profileName" :avatar="profileAvatar" :size="16" />
<span class="session-item-profile-name">{{ profileName }}</span>
</span>
</div>
<NPopconfirm v-if="canDelete && !selectable" @positive-click="emit('delete')">
<template #trigger>
<button class="session-item-delete" @click.stop.prevent>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
{{ t('chat.deleteSession') }}
</NPopconfirm>
</component>
</template>
<style scoped>
.session-item-profile {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
margin-top: 4px;
}
.session-item-profile-avatar {
background: var(--bg-secondary);
}
.session-item-profile-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
line-height: 16px;
color: var(--text-muted);
}
.session-item-warning {
flex-shrink: 0;
width: 16px;
height: 16px;
border: 1px solid rgba(180, 35, 24, 0.35);
border-radius: 50%;
background: rgba(220, 38, 38, 0.1);
color: #b42318;
font-size: 11px;
font-weight: 700;
line-height: 14px;
cursor: pointer;
}
</style>
@@ -0,0 +1,464 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NInput, NModal, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { fetchSessions, searchSessions, type SessionSearchResult, type SessionSummary } from '@/api/hermes/sessions'
import { useChatStore } from '@/stores/hermes/chat'
import { useSessionSearch } from '@/composables/useSessionSearch'
const { t } = useI18n()
const message = useMessage()
const router = useRouter()
const chatStore = useChatStore()
const { sessionSearchOpen } = useSessionSearch()
const query = ref('')
const loading = ref(false)
const recentSessions = ref<SessionSummary[]>([])
const searchResults = ref<SessionSearchResult[]>([])
const activeIndex = ref(0)
const inputRef = ref<InstanceType<typeof NInput> | null>(null)
const profileFilter = computed(() => chatStore.sessionProfileFilter || undefined)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let requestSeq = 0
type SearchItem = SessionSearchResult | (SessionSummary & {
snippet?: string
matched_message_id: number | null
rank: number
})
const hasQuery = computed(() => query.value.trim().length > 0)
const items = computed<SearchItem[]>(() => {
if (hasQuery.value) return searchResults.value
return recentSessions.value.map(session => ({
...session,
matched_message_id: null,
snippet: session.preview || '',
rank: 0,
}))
})
function formatSource(source: string): string {
const map: Record<string, string> = {
api_server: 'API Server',
cli: 'CLI',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
matrix: 'Matrix',
whatsapp: 'WhatsApp',
signal: 'Signal',
cron: 'Cron',
weixin: 'WeChat',
}
return map[source] || source
}
function formatTime(ts?: number): string {
if (!ts) return ''
const date = new Date(ts * 1000)
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function getItemTitle(item: SearchItem): string {
const title = item.title?.trim()
if (title) return title
if (item.preview?.trim()) return item.preview.trim()
return item.id
}
async function loadRecentSessions() {
const seq = ++requestSeq
loading.value = true
try {
const sessions = profileFilter.value
? await fetchSessions(undefined, 8, profileFilter.value)
: await fetchSessions(undefined, 8)
if (seq !== requestSeq) return
recentSessions.value = sessions
searchResults.value = []
activeIndex.value = 0
} catch (err) {
if (seq !== requestSeq) return
message.error(err instanceof Error ? err.message : t('chat.searchFailed'))
} finally {
if (seq === requestSeq) {
loading.value = false
}
}
}
async function runSearch(text: string) {
const seq = ++requestSeq
loading.value = true
try {
const results = text.trim()
? profileFilter.value
? await searchSessions(text.trim(), undefined, 10, profileFilter.value)
: await searchSessions(text.trim(), undefined, 10)
: []
if (seq !== requestSeq) return
searchResults.value = results
activeIndex.value = 0
} catch (err) {
if (seq !== requestSeq) return
message.error(err instanceof Error ? err.message : t('chat.searchFailed'))
} finally {
if (seq === requestSeq) {
loading.value = false
}
}
}
async function ensureChatSessionsLoaded() {
if (chatStore.sessions.length === 0) {
await chatStore.loadSessions(chatStore.sessionProfileFilter)
}
}
async function openItem(item: SearchItem) {
const messageId = item.matched_message_id != null ? String(item.matched_message_id) : null
sessionSearchOpen.value = false
await ensureChatSessionsLoaded()
if (!chatStore.sessions.some(session => session.id === item.id) && typeof chatStore.addOrUpdateSession === 'function') {
chatStore.addOrUpdateSession({
id: item.id,
profile: item.profile || 'default',
title: item.title || '',
source: item.source,
messages: [],
createdAt: Math.round(item.started_at * 1000),
updatedAt: Math.round((item.last_active || item.ended_at || item.started_at) * 1000),
model: item.model,
provider: item.provider || item.billing_provider || '',
messageCount: item.message_count,
endedAt: item.ended_at != null ? Math.round(item.ended_at * 1000) : null,
lastActiveAt: item.last_active != null ? Math.round(item.last_active * 1000) : undefined,
workspace: item.workspace || null,
})
}
await chatStore.switchSession(item.id, messageId)
if (router.currentRoute.value.name !== 'hermes.chat') {
await router.push({ name: 'hermes.chat' })
}
}
function closeModal() {
sessionSearchOpen.value = false
}
function moveSelection(delta: number) {
const list = items.value
if (list.length === 0) return
const next = activeIndex.value + delta
activeIndex.value = (next + list.length) % list.length
}
async function handleKeydown(e: KeyboardEvent) {
if (!sessionSearchOpen.value) return
if (e.key === 'ArrowDown') {
e.preventDefault()
moveSelection(1)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
moveSelection(-1)
return
}
if (e.key === 'Enter') {
e.preventDefault()
const item = items.value[activeIndex.value]
if (item) {
await openItem(item)
}
return
}
if (e.key === 'Escape') {
e.preventDefault()
closeModal()
}
}
watch(
() => sessionSearchOpen.value,
async (open) => {
if (!open) {
query.value = ''
searchResults.value = []
recentSessions.value = []
activeIndex.value = 0
return
}
query.value = ''
searchResults.value = []
activeIndex.value = 0
await loadRecentSessions()
await nextTick()
inputRef.value?.focus?.()
},
)
watch(query, (value) => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
debounceTimer = setTimeout(() => {
if (!sessionSearchOpen.value) return
void runSearch(value)
}, 160)
})
watch(items, () => {
if (activeIndex.value >= items.value.length) {
activeIndex.value = 0
}
})
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
if (debounceTimer) {
clearTimeout(debounceTimer)
}
})
</script>
<template>
<NModal
v-model:show="sessionSearchOpen"
preset="card"
:title="t('chat.searchTitle')"
:style="{ width: 'min(760px, calc(100vw - 24px))' }"
:mask-closable="true"
:auto-focus="false"
>
<div class="session-search-modal">
<div class="search-header">
<div class="search-title">{{ t('chat.searchSubtitle') }}</div>
<div class="search-hint">{{ t('chat.searchHint') }}</div>
</div>
<div class="search-scope">{{ t('chat.searchScope') }}</div>
<NInput
ref="inputRef"
v-model:value="query"
:placeholder="t('chat.searchPlaceholder')"
clearable
size="large"
/>
<div class="search-body">
<NSpin :show="loading">
<div v-if="items.length === 0" class="search-empty">
{{ hasQuery ? t('chat.searchNoResults') : t('chat.searchEmpty') }}
</div>
<div v-else class="result-list">
<button
v-for="(item, idx) in items"
:key="item.id"
class="result-item"
:class="{ active: idx === activeIndex }"
@click="openItem(item)"
@mouseenter="activeIndex = idx"
>
<div class="result-main">
<div class="result-title-row">
<span class="result-title">{{ getItemTitle(item) }}</span>
<span class="result-source">{{ formatSource(item.source) }}</span>
</div>
<div class="result-snippet">
{{ hasQuery ? item.snippet || t('chat.searchNoSnippet') : item.preview || t('chat.searchRecent') }}
</div>
</div>
<div class="result-meta">
<span class="result-time">{{ formatTime(item.last_active || item.started_at) }}</span>
<span v-if="hasQuery && item.matched_message_id != null" class="result-match">
#{{ item.matched_message_id }}
</span>
</div>
</button>
</div>
</NSpin>
</div>
<div class="search-footer">
<span>{{ t('chat.searchEnterHint') }}</span>
<NButton quaternary size="small" @click="closeModal">{{ t('common.cancel') }}</NButton>
</div>
</div>
</NModal>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.session-search-modal {
display: flex;
flex-direction: column;
gap: 14px;
}
.search-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.search-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.search-hint {
font-size: 12px;
color: $text-muted;
}
.search-scope {
font-size: 12px;
color: $text-muted;
line-height: 1.5;
}
.search-body {
max-height: min(60vh, 540px);
overflow: hidden;
}
.search-empty {
padding: 28px 0;
text-align: center;
color: $text-muted;
font-size: 13px;
}
.result-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(60vh, 540px);
overflow-y: auto;
padding-right: 2px;
}
.result-item {
width: 100%;
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid $border-color;
border-radius: $radius-md;
background: $bg-card;
color: $text-primary;
text-align: left;
cursor: pointer;
transition: border-color $transition-fast, background-color $transition-fast, transform $transition-fast;
&:hover,
&.active {
border-color: $accent-muted;
background: rgba(var(--accent-primary-rgb), 0.04);
}
}
.result-main {
flex: 1;
min-width: 0;
}
.result-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.result-title {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-source {
flex-shrink: 0;
font-size: 11px;
color: $text-muted;
}
.result-snippet {
margin-top: 4px;
font-size: 12px;
color: $text-secondary;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.result-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 11px;
color: $text-muted;
flex-shrink: 0;
}
.result-match {
font-family: $font-code;
}
.search-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: $text-muted;
}
@media (max-width: $breakpoint-mobile) {
:deep(.n-modal-body-wrapper) {
width: calc(100vw - 24px);
}
.search-header {
flex-direction: column;
align-items: flex-start;
}
.result-item {
flex-direction: column;
align-items: flex-start;
}
.result-meta {
align-items: flex-start;
flex-direction: row;
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,920 @@
<script setup lang="ts">
import { ref, onUnmounted, computed, watch } from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css";
import { getApiKey, getBaseUrlValue } from "@/api/client";
import { NButton, NPopconfirm, NTooltip, NSelect, useMessage } from "naive-ui";
import { useI18n } from "vue-i18n";
import type { ITheme } from "@xterm/xterm";
const { t } = useI18n();
const message = useMessage();
const props = defineProps<{ visible?: boolean; initialCommand?: string }>();
// ─── Terminal themes ────────────────────────────────────────────
const TERMINAL_THEMES: Record<string, { label: string; theme: ITheme }> = {
default: {
label: "Default",
theme: {
background: "#1a1a2e",
foreground: "#e0e0e0",
cursor: "#4cc9f0",
cursorAccent: "#1a1a2e",
selectionBackground: "rgba(76, 201, 240, 0.3)",
black: "#000000", red: "#e06c75", green: "#98c379", yellow: "#e5c07b",
blue: "#61afef", magenta: "#c678dd", cyan: "#56b6c2", white: "#abb2bf",
brightBlack: "#5c6370", brightRed: "#e06c75", brightGreen: "#98c379",
brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd",
brightCyan: "#56b6c2", brightWhite: "#ffffff",
},
},
"solarized-dark": {
label: "Solarized Dark",
theme: {
background: "#002b36", foreground: "#839496",
cursor: "#93a1a1", cursorAccent: "#002b36",
selectionBackground: "rgba(147, 161, 161, 0.3)",
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
},
},
"tokyo-night": {
label: "Tokyo Night",
theme: {
background: "#1a1b26", foreground: "#a9b1d6",
cursor: "#c0caf5", cursorAccent: "#1a1b26",
selectionBackground: "rgba(192, 202, 245, 0.2)",
black: "#15161e", red: "#f7768e", green: "#9ece6a", yellow: "#e0af68",
blue: "#7aa2f7", magenta: "#bb9af7", cyan: "#7dcfff", white: "#a9b1d6",
brightBlack: "#414868", brightRed: "#f7768e", brightGreen: "#9ece6a",
brightYellow: "#e0af68", brightBlue: "#7aa2f7", brightMagenta: "#bb9af7",
brightCyan: "#7dcfff", brightWhite: "#c0caf5",
},
},
"github-dark": {
label: "GitHub Dark",
theme: {
background: "#0d1117", foreground: "#c9d1d9",
cursor: "#58a6ff", cursorAccent: "#0d1117",
selectionBackground: "rgba(88, 166, 255, 0.25)",
black: "#484f58", red: "#ff7b72", green: "#7ee787", yellow: "#ffa657",
blue: "#79c0ff", magenta: "#d2a8ff", cyan: "#a5d6ff", white: "#c9d1d9",
brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364",
brightYellow: "#e3b341", brightBlue: "#58a6ff", brightMagenta: "#bc8cff",
brightCyan: "#79c0ff", brightWhite: "#f0f6fc",
},
},
};
const STORAGE_KEY_THEME = "hermes_terminal_theme";
// ─── Types ──────────────────────────────────────────────────────
interface SessionInfo {
id: string;
shell: string;
pid: number;
title: string;
createdAt: number;
exited: boolean;
}
// ─── State ──────────────────────────────────────────────────────
const terminalRef = ref<HTMLDivElement | null>(null);
const sessions = ref<SessionInfo[]>([]);
const activeSessionId = ref<string | null>(null);
const selectedTheme = ref(localStorage.getItem(STORAGE_KEY_THEME) || "default");
const connectionError = ref<string | null>(null);
const isConnecting = ref(false);
const showSidebar = ref(false);
let ws: WebSocket | null = null;
const termMap = new Map<string, { term: Terminal; fitAddon: FitAddon; opened: boolean }>();
let activeTerm: Terminal | null = null;
let activeFitAddon: FitAddon | null = null;
let resizeObserver: ResizeObserver | null = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 3;
let touchScrollLastY: number | null = null;
let touchScrollRemainder = 0;
const TOUCH_SCROLL_LINE_PX = 18;
const initialCommandSent = ref(false);
// ─── Computed ──────────────────────────────────────────────────
const activeSession = computed(
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
);
const themeOptions = computed(() =>
Object.entries(TERMINAL_THEMES).map(([key, val]) => ({
label: val.label,
value: key,
})),
);
const terminalBg = computed(
() => TERMINAL_THEMES[selectedTheme.value]?.theme.background ?? "#1a1a2e",
);
// ─── WebSocket ──────────────────────────────────────────────────
function formatHostForPort(hostname: string, port: number): string {
if (hostname.startsWith("[") && hostname.endsWith("]")) {
return `${hostname}:${port}`;
}
return hostname.includes(":") ? `[${hostname}]:${port}` : `${hostname}:${port}`;
}
function buildWsUrl(): string {
const token = getApiKey();
const base = getBaseUrlValue();
const wsProtocol = base
? base.startsWith("https")
? "wss:"
: "ws:"
: location.protocol === "https:"
? "wss:"
: "ws:";
if (base) {
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
}
const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT;
const host = import.meta.env.DEV && directDevPort
? formatHostForPort(location.hostname, Number(directDevPort))
: location.host;
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
}
function connect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
connectionError.value = t('terminal.connectionFailed');
isConnecting.value = false;
return;
}
const url = buildWsUrl();
connectionError.value = null;
isConnecting.value = true;
reconnectAttempts++;
ws = new WebSocket(url);
ws.onopen = () => {
isConnecting.value = false;
connectionError.value = null;
};
ws.onmessage = (event) => {
const data = typeof event.data === "string" ? event.data : "";
if (data.charCodeAt(0) === 0x7b) {
try {
handleControl(JSON.parse(data));
} catch {}
} else {
activeTerm?.write(data);
}
};
ws.onclose = (event) => {
isConnecting.value = false;
// 如果是正常关闭(code 1000)或认证失败,不重连
if (event.code === 1000 || event.code === 1003 || event.code === 1008) {
connectionError.value = t('terminal.connectionClosed');
return;
}
// 其他情况尝试重连
setTimeout(connect, 3000);
};
ws.onerror = (error) => {
console.error('[Terminal] WebSocket error:', error);
connectionError.value = t('terminal.connectionError');
};
}
function send(data: object | string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(typeof data === "string" ? data : JSON.stringify(data));
}
// ─── Control message handlers ──────────────────────────────────
function handleControl(msg: any) {
switch (msg.type) {
case "created":
reconnectAttempts = 0;
sessions.value.push({
id: msg.id,
shell: msg.shell,
pid: msg.pid,
title: `${msg.shell} #${sessions.value.length + 1}`,
createdAt: Date.now(),
exited: false,
});
switchSession(msg.id);
runInitialCommand();
break;
case "exited": {
const s = sessions.value.find((s) => s.id === msg.id);
if (s) {
s.exited = true;
if (activeSessionId.value === msg.id) {
activeTerm?.write(
`\r\n\x1b[90m[${t("terminal.processExited", { code: msg.exitCode })}]\x1b[0m\r\n`,
);
}
}
break;
}
case "error":
message.error(msg.message);
break;
}
}
// ─── Session actions ────────────────────────────────────────────
function createSession() {
send({ type: "create" });
}
function runInitialCommand() {
const command = props.initialCommand?.trim();
if (!command || initialCommandSent.value) return;
initialCommandSent.value = true;
setTimeout(() => {
send(`${command}\r`);
}, 100);
}
function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } {
let entry = termMap.get(id);
if (!entry) {
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: { ...TERMINAL_THEMES[selectedTheme.value].theme },
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
entry = { term, fitAddon, opened: false };
termMap.set(id, entry);
}
return entry;
}
function switchSession(id: string) {
if (activeSessionId.value === id) return;
activeSessionId.value = id;
const entry = getOrCreateTerm(id);
activeTerm = entry.term;
activeFitAddon = entry.fitAddon;
mountActiveTerminal();
send({ type: "switch", sessionId: id });
}
function closeSession(id: string) {
send({ type: "close", sessionId: id });
sessions.value = sessions.value.filter((s) => s.id !== id);
const entry = termMap.get(id);
if (entry) {
entry.term.dispose();
termMap.delete(id);
}
if (activeSessionId.value === id) {
activeSessionId.value = sessions.value.length > 0 ? sessions.value[0].id : null;
activeTerm = null;
activeFitAddon = null;
if (activeSessionId.value) {
switchSession(activeSessionId.value);
} else {
unmountActiveTerminal();
createSession();
}
}
}
// ─── Terminal mount/unmount ─────────────────────────────────────
function mountActiveTerminal() {
if (!terminalRef.value) return;
const container = terminalRef.value;
while (container.firstChild) container.removeChild(container.firstChild);
const entry = termMap.get(activeSessionId.value!);
if (!entry) return;
if (!entry.opened) {
entry.term.open(container);
entry.opened = true;
} else {
const termEl = entry.term.element;
if (termEl) {
container.appendChild(termEl);
}
}
resizeObserver?.disconnect();
resizeObserver = new ResizeObserver(() => {
tryFit();
sendResize();
});
resizeObserver.observe(terminalRef.value);
setTimeout(() => tryFit(), 50);
setTimeout(() => tryFit(), 200);
}
function unmountActiveTerminal() {
if (!terminalRef.value) return;
const container = terminalRef.value;
while (container.firstChild) container.removeChild(container.firstChild);
}
function tryFit() {
if (!activeFitAddon) return;
try {
activeFitAddon.fit();
} catch {}
}
function sendResize() {
if (!activeTerm || !ws || ws.readyState !== WebSocket.OPEN) return;
try {
send({
type: "resize",
cols: activeTerm.cols,
rows: activeTerm.rows,
});
} catch {}
}
function handleTerminalTouchStart(event: TouchEvent) {
if (event.touches.length !== 1) {
touchScrollLastY = null;
touchScrollRemainder = 0;
return;
}
touchScrollLastY = event.touches[0].clientY;
touchScrollRemainder = 0;
}
function handleTerminalTouchMove(event: TouchEvent) {
if (!activeTerm || event.touches.length !== 1 || touchScrollLastY === null) return;
const nextY = event.touches[0].clientY;
touchScrollRemainder += touchScrollLastY - nextY;
touchScrollLastY = nextY;
const lines = Math.trunc(touchScrollRemainder / TOUCH_SCROLL_LINE_PX);
if (lines === 0) return;
activeTerm.scrollLines(lines);
touchScrollRemainder -= lines * TOUCH_SCROLL_LINE_PX;
event.preventDefault();
}
function handleTerminalTouchEnd() {
touchScrollLastY = null;
touchScrollRemainder = 0;
}
// ─── Theme ───────────────────────────────────────────────────────
function applyTheme(themeName: string) {
selectedTheme.value = themeName;
localStorage.setItem(STORAGE_KEY_THEME, themeName);
const themeObj = TERMINAL_THEMES[themeName]?.theme;
if (!themeObj) return;
for (const entry of termMap.values()) {
entry.term.options.theme = { ...themeObj };
}
}
// ─── Helpers ────────────────────────────────────────────────────
function formatTime(ts: number) {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// ─── Lifecycle ──────────────────────────────────────────────────
let hasConnected = false;
watch(() => props.visible, (visible) => {
if (visible && !hasConnected && !ws) {
hasConnected = true;
connect();
}
}, { immediate: true });
onUnmounted(() => {
unmountActiveTerminal();
for (const entry of termMap.values()) {
entry.term.dispose();
}
termMap.clear();
activeTerm = null;
activeFitAddon = null;
ws?.close();
ws = null;
});
</script>
<template>
<div class="terminal-panel-drawer">
<div
v-if="showSidebar"
class="sidebar-overlay"
@click="showSidebar = false"
></div>
<div
class="terminal-sidebar"
:class="{ 'mobile-visible': showSidebar }"
>
<div class="sidebar-header">
<span class="sidebar-title">{{ t("terminal.sessions") }}</span>
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" @click="createSession" circle>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</template>
</NButton>
</template>
{{ t("terminal.newTab") }}
</NTooltip>
</div>
<div class="session-list">
<div v-if="connectionError" class="session-error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{{ connectionError }}</span>
<NButton size="tiny" @click="connect">{{ t("common.retry") }}</NButton>
</div>
<div v-else-if="sessions.length === 0" class="session-empty">
<template v-if="isConnecting">
{{ t("common.loading") }}
</template>
<template v-else>
{{ t("terminal.noSessions") }}
</template>
</div>
<button
v-for="s in sessions"
:key="s.id"
class="session-item"
:class="{ active: s.id === activeSessionId, exited: s.exited }"
@click="switchSession(s.id)"
>
<div class="session-item-content">
<span class="session-item-title">{{ s.title }}</span>
<span class="session-item-meta">
<span class="session-item-shell">{{ s.shell }}</span>
<span v-if="s.exited" class="session-item-status">{{
t("terminal.sessionExited")
}}</span>
<span v-else class="session-item-time">{{
formatTime(s.createdAt)
}}</span>
</span>
</div>
<NPopconfirm v-if="sessions.length > 1" @positive-click="closeSession(s.id)">
<template #trigger>
<button class="session-item-delete" @click.stop>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</template>
{{ t("terminal.closeSession") }}
</NPopconfirm>
</button>
</div>
</div>
<div class="terminal-main">
<header class="terminal-header">
<span v-if="activeSession" class="header-session-title">{{
activeSession.title
}}</span>
<div class="header-actions">
<NButton
size="small"
@click="showSidebar = !showSidebar"
class="sidebar-toggle"
>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
</template>
{{ t("terminal.sessions") }}
</NButton>
<NSelect
:value="selectedTheme"
:options="themeOptions"
size="small"
:consistent-menu-width="false"
class="theme-select"
@update:value="applyTheme"
/>
<NButton size="small" @click="createSession">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</template>
{{ t("terminal.newTab") }}
</NButton>
</div>
</header>
<div class="terminal-container">
<div
ref="terminalRef"
class="terminal-xterm"
:style="{ backgroundColor: terminalBg }"
@touchstart="handleTerminalTouchStart"
@touchmove="handleTerminalTouchMove"
@touchend="handleTerminalTouchEnd"
@touchcancel="handleTerminalTouchEnd"
/>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.terminal-panel-drawer {
display: flex;
height: 100%;
width: 100%;
min-height: 0;
min-width: 0;
position: relative;
overflow: hidden;
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
@media (min-width: $breakpoint-mobile + 1) {
display: none;
}
}
.terminal-sidebar {
width: 180px;
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
flex-shrink: 0;
@media (max-width: $breakpoint-mobile) {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 80%;
max-width: 300px;
z-index: 51;
background: $bg-card;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
transform: translateX(-100%);
transition: transform 0.3s ease;
&.mobile-visible {
transform: translateX(0);
}
}
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
flex-shrink: 0;
border-bottom: 1px solid $border-color;
}
.sidebar-title {
font-size: 11px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.session-empty {
padding: 16px 8px;
font-size: 12px;
color: $text-muted;
text-align: center;
}
.session-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 12px;
font-size: 12px;
color: $error;
text-align: center;
svg {
width: 32px;
height: 32px;
opacity: 0.8;
}
span {
flex: 1;
}
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px 8px;
border: none;
background: none;
border-radius: $radius-sm;
cursor: pointer;
text-align: left;
color: $text-secondary;
transition: all $transition-fast;
margin-bottom: 2px;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.06);
color: $text-primary;
.session-item-delete {
opacity: 1;
}
}
&.active {
background: rgba(var(--accent-primary-rgb), 0.1);
color: $text-primary;
font-weight: 500;
}
&.exited {
opacity: 0.5;
}
}
.session-item-content {
flex: 1;
overflow: hidden;
}
.session-item-title {
display: block;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-item-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
}
.session-item-shell {
font-size: 9px;
color: $accent-primary;
background: rgba(var(--accent-primary-rgb), 0.08);
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
}
.session-item-time,
.session-item-status {
font-size: 10px;
color: $text-muted;
}
.session-item-delete {
flex-shrink: 0;
opacity: 0.5;
padding: 2px;
border: none;
background: none;
color: $text-muted;
cursor: pointer;
border-radius: 3px;
transition: all $transition-fast;
&:hover {
color: $error;
background: rgba(var(--error-rgb), 0.1);
}
}
.terminal-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
min-width: 0;
}
.header-session-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
min-width: 0;
}
.theme-select {
width: 120px;
}
.sidebar-toggle {
@media (min-width: $breakpoint-mobile + 1) {
display: none;
}
}
.terminal-container {
flex: 1;
margin: 8px;
overflow: hidden;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.terminal-xterm {
flex: 1;
min-height: 0;
min-width: 0;
border-radius: $radius-md;
overflow: hidden;
border: 1px solid $border-color;
:deep(.xterm) {
height: 100%;
padding: 8px;
}
:deep(.xterm-viewport) {
overflow-y: scroll !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
background-color: transparent !important;
}
:deep(.xterm-viewport::-webkit-scrollbar) {
display: none !important;
}
:deep(.xterm-screen) {
background-color: transparent !important;
}
:deep(.xterm-scrollable-element) {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
display: none !important;
}
}
@media (max-width: $breakpoint-mobile) {
.terminal-panel-drawer {
height: 100%;
max-height: 100%;
}
.terminal-main {
min-height: 0;
min-width: 0;
}
.terminal-header {
padding: 8px;
gap: 6px;
}
.header-session-title {
display: none;
}
.header-actions {
width: 100%;
justify-content: flex-end;
gap: 6px;
}
.theme-select {
width: 96px;
}
.terminal-container {
margin: 6px;
margin-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
}
.terminal-xterm {
border-radius: $radius-sm;
:deep(.xterm) {
padding: 6px;
}
:deep(.xterm-viewport),
:deep(.xterm-scrollable-element) {
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
scrollbar-width: thin !important;
}
:deep(.xterm-viewport::-webkit-scrollbar),
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
display: block !important;
width: 6px !important;
}
}
}
</style>
@@ -0,0 +1,118 @@
<template>
<div class="thinking-indicator" role="status" :aria-label="label">
<div class="thinking-indicator__mark">
<svg viewBox="0 0 48 48" fill="none" aria-hidden="true">
<defs>
<linearGradient id="thinking-grad" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563eb" />
<stop offset="1" stop-color="#0891b2" />
</linearGradient>
</defs>
<rect x="6" y="6" width="36" height="36" rx="10" fill="url(#thinking-grad)" class="thinking-indicator__bg" />
<path d="M24 14 L30 24 L24 34 L18 24 Z" fill="rgba(255,255,255,0.9)" />
<circle cx="24" cy="22" r="3" fill="#ffffff" />
</svg>
<span class="thinking-indicator__ring" aria-hidden="true" />
</div>
<div class="thinking-indicator__dots" aria-hidden="true">
<span />
<span />
<span />
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
label?: string
}>(), {
label: '思考中',
})
</script>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.thinking-indicator {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 4px;
flex-shrink: 0;
}
.thinking-indicator__mark {
position: relative;
width: 40px;
height: 40px;
svg {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 2px 8px rgba(var(--accent-primary-rgb), 0.25));
}
}
.thinking-indicator__bg {
transform-origin: center;
animation: thinking-breathe 2s ease-in-out infinite;
}
.thinking-indicator__ring {
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid rgba(var(--accent-primary-rgb), 0.35);
animation: thinking-ring 1.6s ease-out infinite;
}
.thinking-indicator__dots {
display: flex;
align-items: center;
gap: 5px;
span {
width: 6px;
height: 6px;
border-radius: 50%;
background: $accent-primary;
animation: thinking-dot 1.2s ease-in-out infinite;
&:nth-child(2) {
animation-delay: 0.15s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
}
}
@keyframes thinking-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
@keyframes thinking-ring {
0% {
transform: scale(0.85);
opacity: 0.8;
}
100% {
transform: scale(1.35);
opacity: 0;
}
}
@keyframes thinking-dot {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.35;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
</style>
@@ -0,0 +1,482 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
DynamicScroller,
DynamicScrollerItem,
type DynamicScrollerExposed,
type ScrollToOptions,
} from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
type VirtualItem = {
id: string | number;
}
type AnchorAlign = "start" | "center";
type AnchorTarget = {
token: number;
index: number;
messageId: string;
anchorId: string;
align: AnchorAlign;
}
type BottomScrollOptions = number | {
frames?: number;
keepAliveMs?: number;
}
type ViewportScrollSnapshot = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
wasNearBottom: boolean;
}
const props = withDefaults(defineProps<{
messages: VirtualItem[];
estimatedItemHeight?: number;
overscan?: number;
rowGap?: number;
padding?: string;
topThreshold?: number;
}>(), {
estimatedItemHeight: 180,
overscan: 8,
rowGap: 16,
padding: "20px",
topThreshold: 120,
});
const emit = defineEmits<{
scroll: [];
topReach: [];
}>();
defineSlots<{
empty?: () => any;
before?: () => any;
item?: (props: { message: any }) => any;
after?: () => any;
}>();
const hostRef = ref<HTMLElement | null>(null);
const scrollerRef = ref<DynamicScrollerExposed<VirtualItem> | null>(null);
const scrollTop = ref(0);
const viewportHeight = ref(0);
let keepBottomUntil = 0;
let bottomFrame: number | null = null;
let bottomFrameRemaining = 0;
let bottomFrameAttempts = 0;
let anchorFrame: number | null = null;
let anchorToken = 0;
let activeAnchorTarget: AnchorTarget | null = null;
let viewportRestoreFrame: number | null = null;
const messageKeys = computed(() => props.messages.map(messageKey));
const bufferPx = computed(() => Math.max(props.estimatedItemHeight, props.estimatedItemHeight * props.overscan));
function messageKey(message: VirtualItem): string {
return String(message.id);
}
function getScrollerElement(): HTMLElement | null {
return hostRef.value?.querySelector<HTMLElement>(".virtual-message-list") ?? null;
}
function syncViewport() {
const el = getScrollerElement();
if (!el) return;
scrollTop.value = el.scrollTop;
viewportHeight.value = el.clientHeight;
}
function handleScroll() {
syncViewport();
emit("scroll");
if (scrollTop.value <= props.topThreshold) emit("topReach");
}
function handleResize() {
syncViewport();
if (Date.now() < keepBottomUntil || isNearBottom(64)) scheduleScrollToBottom(2);
if (activeAnchorTarget) scheduleAnchorAlignment(activeAnchorTarget.token, 4);
}
function isNearBottom(threshold = 200): boolean {
const el = getScrollerElement();
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
}
function scrollToBottom(options: BottomScrollOptions = {}) {
const frames = typeof options === "number" ? options : options.frames ?? 5;
const keepAliveMs = typeof options === "number" ? 700 : options.keepAliveMs ?? 700;
keepBottomUntil = Date.now() + keepAliveMs;
nextTick(() => {
scheduleScrollToBottom(frames);
});
}
function setScrollToBottomNow(): boolean {
const el = getScrollerElement();
scrollerRef.value?.scrollToBottom();
if (el) {
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
syncViewport();
return true;
}
return false;
}
function scheduleScrollToBottom(frames = 1) {
bottomFrameRemaining = Math.max(bottomFrameRemaining, frames);
if (bottomFrame != null) return;
const step = () => {
const scrolled = setScrollToBottomNow();
if (scrolled) {
bottomFrameAttempts = 0;
bottomFrameRemaining -= 1;
} else {
bottomFrameAttempts += 1;
}
if (bottomFrameRemaining <= 0) {
bottomFrame = null;
bottomFrameRemaining = 0;
bottomFrameAttempts = 0;
return;
}
if (bottomFrameAttempts > 30) {
bottomFrame = null;
bottomFrameRemaining = 0;
bottomFrameAttempts = 0;
return;
}
bottomFrame = requestAnimationFrame(step);
};
bottomFrame = requestAnimationFrame(step);
}
function findTargetElement(messageId: string, anchorId: string): HTMLElement | null {
const el = getScrollerElement();
if (!el) return null;
const anchor = document.getElementById(anchorId);
if (anchor instanceof HTMLElement && el.contains(anchor)) return anchor;
const message = document.getElementById(`message-${messageId}`);
if (message instanceof HTMLElement && el.contains(message)) return message;
return null;
}
function alignElement(targetEl: HTMLElement, align: AnchorAlign) {
const el = getScrollerElement();
if (!el) return;
const scrollerRect = el.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const delta = align === "center"
? targetRect.top + targetRect.height / 2 - (scrollerRect.top + scrollerRect.height / 2)
: targetRect.top - scrollerRect.top - 24;
if (Math.abs(delta) > 1) {
el.scrollTop = Math.max(0, el.scrollTop + delta);
}
syncViewport();
}
function scrollToItem(index: number, options?: ScrollToOptions) {
scrollerRef.value?.scrollToItem(index, options);
syncViewport();
}
function scheduleAnchorAlignment(token: number, frames = 1) {
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
const step = (remaining: number) => {
const target = activeAnchorTarget;
if (!target || target.token !== token) {
anchorFrame = null;
return;
}
const targetEl = findTargetElement(target.messageId, target.anchorId);
if (targetEl) {
alignElement(targetEl, target.align);
} else {
scrollToItem(target.index, {
align: target.align,
offset: target.align === "start" ? -24 : 0,
});
}
if (remaining <= 1) {
anchorFrame = null;
activeAnchorTarget = null;
return;
}
anchorFrame = requestAnimationFrame(() => step(remaining - 1));
};
anchorFrame = requestAnimationFrame(() => step(frames));
}
function cancelAnchorAlignment() {
anchorToken += 1;
activeAnchorTarget = null;
if (anchorFrame != null) {
cancelAnimationFrame(anchorFrame);
anchorFrame = null;
}
}
function scrollToMessage(messageId: string) {
const index = props.messages.findIndex(message => String(message.id) === messageId);
if (index < 0) return;
cancelAnchorAlignment();
const token = anchorToken;
activeAnchorTarget = {
token,
index,
messageId,
anchorId: `message-${messageId}`,
align: "center",
};
nextTick(() => {
scrollToItem(index, { align: "center" });
scheduleAnchorAlignment(token, 8);
});
}
function scrollToAnchor(messageId: string, anchorId: string) {
const index = props.messages.findIndex(message => String(message.id) === messageId);
if (index < 0) return;
cancelAnchorAlignment();
const token = anchorToken;
activeAnchorTarget = {
token,
index,
messageId,
anchorId,
align: "start",
};
nextTick(() => {
scrollToItem(index, { align: "start", offset: -24 });
scheduleAnchorAlignment(token, 10);
});
}
function captureScrollPosition() {
const el = getScrollerElement();
if (!el) return null;
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
};
}
function restoreScrollPosition(snapshot: { scrollTop: number; scrollHeight: number } | null) {
if (!snapshot) return;
nextTick(() => {
const el = getScrollerElement();
if (!el) return;
const nextScrollTop = Math.max(0, el.scrollHeight - snapshot.scrollHeight + snapshot.scrollTop);
scrollerRef.value?.scrollToPosition(nextScrollTop);
el.scrollTop = nextScrollTop;
syncViewport();
});
}
function captureViewportPosition(): ViewportScrollSnapshot | null {
const el = getScrollerElement();
if (!el) return null;
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
wasNearBottom: isNearBottom(64),
};
}
function restoreViewportPosition(snapshot: ViewportScrollSnapshot | null, frames = 4) {
if (!snapshot) return;
keepBottomUntil = 0;
if (bottomFrame != null) {
cancelAnimationFrame(bottomFrame);
bottomFrame = null;
bottomFrameRemaining = 0;
bottomFrameAttempts = 0;
}
if (viewportRestoreFrame != null) cancelAnimationFrame(viewportRestoreFrame);
nextTick(() => {
let remaining = frames;
const step = () => {
const el = getScrollerElement();
if (!el) {
viewportRestoreFrame = null;
return;
}
const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
const nextScrollTop = Math.min(maxScrollTop, Math.max(0, snapshot.scrollTop));
scrollerRef.value?.scrollToPosition(nextScrollTop);
el.scrollTop = nextScrollTop;
syncViewport();
remaining -= 1;
if (remaining <= 0) {
viewportRestoreFrame = null;
return;
}
viewportRestoreFrame = requestAnimationFrame(step);
};
viewportRestoreFrame = requestAnimationFrame(step);
});
}
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
nextTick(() => {
syncViewport();
const el = getScrollerElement();
if (el && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(el);
}
});
});
onBeforeUnmount(() => {
if (bottomFrame != null) cancelAnimationFrame(bottomFrame);
bottomFrameRemaining = 0;
bottomFrameAttempts = 0;
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
if (viewportRestoreFrame != null) cancelAnimationFrame(viewportRestoreFrame);
resizeObserver?.disconnect();
});
watch(messageKeys, () => {
cancelAnchorAlignment();
nextTick(syncViewport);
});
defineExpose({
isNearBottom,
scrollToBottom,
scrollToMessage,
scrollToAnchor,
captureScrollPosition,
restoreScrollPosition,
captureViewportPosition,
restoreViewportPosition,
});
</script>
<template>
<div
ref="hostRef"
class="virtual-message-list-host"
:style="{ '--virtual-row-gap': `${rowGap}px`, '--virtual-list-padding': padding }"
>
<DynamicScroller
ref="scrollerRef"
class="virtual-message-list"
:items="messages"
key-field="id"
:min-item-size="estimatedItemHeight"
:buffer="bufferPx"
:flow-mode="true"
:prerender="overscan"
@scroll.passive="handleScroll"
@resize="handleResize"
@visible="syncViewport"
>
<template #before>
<slot v-if="messages.length > 0" name="before" />
</template>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:index="index"
:active="active"
class="virtual-row"
>
<slot v-if="active" name="item" :message="item" />
</DynamicScrollerItem>
</template>
<template #after>
<slot v-if="messages.length > 0" name="after" />
</template>
</DynamicScroller>
<div v-if="messages.length === 0 && $slots.empty" class="virtual-message-list-empty">
<slot name="empty" />
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.virtual-message-list-host {
flex: 1;
min-height: 0;
display: flex;
position: relative;
animation: message-list-fade-in 1.5s ease both;
}
.virtual-message-list {
flex: 1;
min-height: 0;
padding: var(--virtual-list-padding);
box-sizing: border-box;
background-color: $bg-card;
.dark & {
background-color: #333333;
}
}
.virtual-row {
box-sizing: border-box;
padding-bottom: var(--virtual-row-gap);
}
.virtual-message-list-empty {
position: absolute;
inset: var(--virtual-list-padding);
display: grid;
place-items: center;
min-width: 0;
min-height: 0;
pointer-events: auto;
}
.virtual-message-list-empty :deep(.empty-state) {
width: 100%;
height: 100%;
min-height: 0;
}
@keyframes message-list-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.virtual-message-list-host {
animation: none;
}
}
</style>
@@ -0,0 +1,103 @@
import hljs from 'highlight.js'
import { copyToClipboard } from '@/utils/clipboard'
const LANGUAGE_ALIASES: Record<string, string> = {
shellscript: 'bash',
sh: 'bash',
zsh: 'bash',
yml: 'yaml',
vue: 'xml',
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function sanitizeLanguageClass(value: string): string {
return value.replace(/[^a-z0-9_-]/gi, '-') || 'plain'
}
export function normalizeHighlightLanguage(lang?: string): string {
const normalized = lang?.trim().toLowerCase() || ''
return LANGUAGE_ALIASES[normalized] || normalized
}
export function inferStructuredLanguage(content: string): string | undefined {
try {
JSON.parse(content)
return 'json'
} catch {
return undefined
}
}
type RenderHighlightedCodeBlockOptions = {
maxHighlightLength?: number
}
export function renderHighlightedCodeBlock(
content: string,
lang: string | undefined,
copyLabel: string,
options: RenderHighlightedCodeBlockOptions = {},
): string {
const requestedLanguage = lang?.trim().toLowerCase() || ''
const normalizedLanguage = normalizeHighlightLanguage(requestedLanguage)
const highlightLimit = options.maxHighlightLength ?? Number.POSITIVE_INFINITY
let highlighted = ''
let codeClassLanguage = normalizedLanguage || requestedLanguage || 'plain'
let labelLanguage = requestedLanguage
try {
if (normalizedLanguage && hljs.getLanguage(normalizedLanguage) && content.length <= highlightLimit) {
highlighted = hljs.highlight(content, {
language: normalizedLanguage,
ignoreIllegals: true,
}).value
codeClassLanguage = normalizedLanguage
} else {
highlighted = escapeHtml(content)
if (!labelLanguage) {
labelLanguage = 'text'
}
}
} catch {
highlighted = escapeHtml(content)
if (!labelLanguage) {
labelLanguage = 'text'
}
}
const languageLabelHtml = labelLanguage
? `<span class="code-lang">${escapeHtml(labelLanguage)}</span>`
: ''
return `<pre class="hljs-code-block"><div class="code-header">${languageLabelHtml}<button type="button" class="copy-btn" data-copy-code="true">${escapeHtml(copyLabel)}</button></div><code class="hljs language-${sanitizeLanguageClass(codeClassLanguage)}">${highlighted}</code></pre>`
}
export async function copyTextToClipboard(text: string): Promise<boolean> {
return copyToClipboard(text)
}
export async function handleCodeBlockCopyClick(event: MouseEvent): Promise<boolean | null> {
const target = event.target
if (!(target instanceof HTMLElement)) return null
const button = target.closest<HTMLElement>('[data-copy-code="true"]')
if (!button) return null
event.preventDefault()
const block = button.closest('.hljs-code-block')
const code = block?.querySelector('code')
const text = code?.textContent ?? ''
if (!text) return false
return copyTextToClipboard(text)
}
@@ -0,0 +1,216 @@
const MARKDOWN_FENCE_LANGUAGES = new Set(['md', 'markdown', 'mdown', 'mkd'])
type FenceInfo = {
indent: string
marker: string
fence: string
length: number
info: string
}
function parseFence(line: string): FenceInfo | null {
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/)
if (!match) return null
const [, indent, fence, rawInfo = ''] = match
const marker = fence[0]
const info = rawInfo.trim()
// CommonMark permits backticks in tilde-fence info strings, but not in
// backtick-fence info strings. Keeping this distinction prevents inline-ish
// malformed backtick text from being promoted into a fence opener.
if (marker === '`' && info.includes('`')) return null
return {
indent,
marker,
fence,
length: fence.length,
info,
}
}
function serializeFence(fence: FenceInfo, length = fence.length, info = fence.info): string {
return `${fence.indent}${fence.marker.repeat(length)}${info ? ` ${info}` : ''}`
}
function isMarkdownFence(fence: FenceInfo): boolean {
const language = fence.info.split(/\s+/)[0]?.toLowerCase()
return MARKDOWN_FENCE_LANGUAGES.has(language)
}
function isClosingFence(line: string, opener: FenceInfo): boolean {
const fence = parseFence(line)
return Boolean(
fence
&& fence.marker === opener.marker
&& fence.length >= opener.length
&& fence.info === '',
)
}
function findLastNonEmptyLine(lines: string[], start = lines.length - 1): number {
let index = start
while (index >= 0 && lines[index].trim() === '') {
index -= 1
}
return index
}
function findFinalClosingFence(lines: string[], opener: FenceInfo, start: number): number {
for (let i = findLastNonEmptyLine(lines); i > start; i -= 1) {
if (isClosingFence(lines[i], opener)) {
return i
}
}
return -1
}
type OpenFence = {
marker: string
length: number
}
function canBalanceNestedFences(lines: string[], marker: string): boolean {
const stack: OpenFence[] = []
let sawFence = false
for (const line of lines) {
const fence = parseFence(line)
if (!fence || fence.marker !== marker) continue
sawFence = true
const current = stack[stack.length - 1]
if (fence.info === '' && current && fence.length >= current.length) {
stack.pop()
continue
}
// Inside a Markdown example, an unlabeled fence can be either a closing
// fence or a literal nested unlabeled example opener. If there is no nested
// opener waiting to close, treat it as the latter while evaluating a later
// candidate closing fence for the outer example.
stack.push({ marker: fence.marker, length: fence.length })
}
return sawFence && stack.length === 0
}
function findBalancedClosingFence(lines: string[], opener: FenceInfo, start: number): number {
const candidates: number[] = []
for (let i = start; i < lines.length; i += 1) {
const fence = parseFence(lines[i])
if (
fence
&& fence.marker === opener.marker
&& fence.info === ''
&& fence.length >= opener.length
) {
candidates.push(i)
}
}
for (let i = candidates.length - 1; i >= 0; i -= 1) {
const candidate = candidates[i]
if (canBalanceNestedFences(lines.slice(start, candidate), opener.marker)) {
return candidate
}
}
return candidates[0] ?? -1
}
function maxFenceLength(lines: string[], marker: string): number {
let maxLength = 0
for (const line of lines) {
const fence = parseFence(line)
if (fence?.marker === marker) {
maxLength = Math.max(maxLength, fence.length)
}
}
return maxLength
}
function promoteMarkdownExampleFences(lines: string[]): string[] {
const output: string[] = []
for (let i = 0; i < lines.length; i += 1) {
const opener = parseFence(lines[i])
if (!opener || !isMarkdownFence(opener)) {
output.push(lines[i])
continue
}
const balancedClose = findBalancedClosingFence(lines, opener, i + 1)
if (balancedClose === -1) {
output.push(lines[i])
continue
}
const body = lines.slice(i + 1, balancedClose)
const innerMaxLength = maxFenceLength(body, opener.marker)
if (innerMaxLength >= opener.length) {
const promotedLength = innerMaxLength + 1
output.push(serializeFence(opener, promotedLength))
output.push(...body)
output.push(serializeFence(opener, promotedLength, ''))
} else {
output.push(lines[i])
output.push(...body)
output.push(lines[balancedClose])
}
i = balancedClose
}
return output
}
/**
* LLMs often wrap a complete PR draft or Markdown answer in an outer
* ```md fence. Showing that outer wrapper as a code block makes the UI look
* like Markdown rendering is broken: headings, lists, and inline code remain
* literal text. Strip only that outer draft wrapper before handing content to
* markdown-it.
*
* The unwrapped draft can still contain Markdown examples that themselves
* contain fenced examples. CommonMark closes fences at the first same-marker
* line with at least the opener length, so a malformed example like
* ```md ... ```md ... ``` ... ``` must be normalized by making the example's
* outer fence longer than the literal fences inside it.
*/
export function repairNestedMarkdownFences(content: string): string {
if (!content.includes('```') && !content.includes('~~~')) return content
const lines = content.split('\n')
const output: string[] = []
let changed = false
for (let i = 0; i < lines.length; i += 1) {
const opener = parseFence(lines[i])
if (!opener || !isMarkdownFence(opener)) {
output.push(lines[i])
continue
}
const finalClose = findFinalClosingFence(lines, opener, i + 1)
if (finalClose === -1) {
output.push(lines[i])
continue
}
const lastNonEmpty = findLastNonEmptyLine(lines)
if (finalClose !== lastNonEmpty) {
output.push(lines[i])
continue
}
output.push(...promoteMarkdownExampleFences(lines.slice(i + 1, finalClose)))
output.push(...lines.slice(finalClose + 1))
changed = true
break
}
return changed ? output.join('\n') : content
}
@@ -0,0 +1,46 @@
const MERMAID_LANGUAGE = 'mermaid'
export const MERMAID_MAX_DIAGRAMS_PER_MESSAGE = 4
export const MERMAID_MAX_SOURCE_LENGTH = 20_000
export const MERMAID_RENDER_TIMEOUT_MS = 5_000
export const SUPPORT_PREVIEW_FILE_TYPES = ['txt', 'md', 'json', 'csv', 'log', 'py', 'yaml', 'yml', 'toml', 'sh', 'xml', 'html', 'css', 'js', 'ts', 'rs', 'go', 'java', 'c', 'cpp', 'h']
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
export function getFenceLanguage(info: string | undefined): string {
return info?.trim().split(/\s+/)[0]?.toLowerCase() || ''
}
export function isMermaidFence(info: string | undefined): boolean {
return getFenceLanguage(info) === MERMAID_LANGUAGE
}
export function encodeMermaidSource(source: string): string {
return encodeURIComponent(source)
}
export function decodeMermaidSource(encoded: string | null | undefined): string {
if (!encoded) return ''
try {
return decodeURIComponent(encoded)
} catch {
return ''
}
}
export function renderMermaidPlaceholder(source: string): string {
return [
'<div class="mermaid-diagram" data-mermaid-pending="true"',
` data-mermaid-source="${escapeHtml(encodeMermaidSource(source))}">`,
'<div class="mermaid-loading">Rendering Mermaid diagram…</div>',
'</div>',
].join('')
}
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
import { useFilesStore } from '@/stores/hermes/files'
const { t } = useI18n()
const filesStore = useFilesStore()
function handleClick(index: number) {
if (index < 0) {
filesStore.navigateTo('')
} else {
const path = filesStore.pathSegments.slice(0, index + 1).join('/')
filesStore.navigateTo(path)
}
}
</script>
<template>
<div class="file-breadcrumb">
<NBreadcrumb>
<NBreadcrumbItem @click="handleClick(-1)">
{{ t('files.breadcrumbRoot') }}
</NBreadcrumbItem>
<NBreadcrumbItem
v-for="(segment, index) in filesStore.pathSegments"
:key="index"
@click="handleClick(index)"
>
{{ segment }}
</NBreadcrumbItem>
</NBreadcrumb>
</div>
</template>
<style scoped lang="scss">
.file-breadcrumb {
padding: 0 16px;
}
</style>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { NDropdown, useMessage, useDialog } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore, isTextFile, isImageFile, isMarkdownFile } from '@/stores/hermes/files'
import { downloadFile } from '@/api/hermes/download'
import type { FileEntry } from '@/api/hermes/files'
import { copyToClipboard } from '@/utils/clipboard'
import { getClipboardPathForEntry } from '@/utils/file-path'
const { t } = useI18n()
const message = useMessage()
const dialog = useDialog()
const filesStore = useFilesStore()
const showMenu = ref(false)
const menuX = ref(0)
const menuY = ref(0)
const targetEntry = ref<FileEntry | null>(null)
const emit = defineEmits<{
(e: 'rename', entry: FileEntry): void
}>()
function show(e: MouseEvent, entry: FileEntry) {
targetEntry.value = entry
menuX.value = e.clientX
menuY.value = e.clientY
showMenu.value = false
nextTick(() => {
showMenu.value = true
})
}
function getOptions() {
const entry = targetEntry.value
if (!entry) return []
const options: any[] = []
if (entry.isDir) {
options.push({ label: t('files.open'), key: 'open' })
} else {
if (isTextFile(entry.name)) {
options.push({ label: t('files.edit'), key: 'edit' })
}
if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
options.push({ label: t('files.preview'), key: 'preview' })
}
options.push({ label: t('files.download'), key: 'download' })
}
options.push({ type: 'divider', key: 'd1' })
options.push({ label: t('files.copyPath'), key: 'copyPath' })
options.push({ label: t('files.rename'), key: 'rename' })
options.push({ type: 'divider', key: 'd2' })
options.push({ label: t('files.delete'), key: 'delete' })
return options
}
async function handleSelect(key: string) {
showMenu.value = false
const entry = targetEntry.value
if (!entry) return
switch (key) {
case 'open':
filesStore.navigateTo(entry.path)
break
case 'edit':
try { await filesStore.openEditor(entry.path) } catch { message.error(t('files.backendError')) }
break
case 'preview':
try { await filesStore.openPreview(entry) } catch { message.error(t('files.backendError')) }
break
case 'download':
try { await downloadFile(entry.path, entry.name) } catch (err: any) { message.error(err.message) }
break
case 'copyPath': {
const ok = await copyToClipboard(getClipboardPathForEntry(entry))
if (ok) {
message.success(t('files.pathCopied'))
} else {
message.error(t('files.pathCopied') + ' ✗')
}
break
}
case 'rename':
emit('rename', entry)
break
case 'delete':
dialog.warning({
title: t('files.delete'),
content: entry.isDir ? t('files.confirmDeleteDir', { name: entry.name }) : t('files.confirmDelete', { name: entry.name }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await filesStore.deleteEntry(entry)
message.success(t('files.deleted'))
} catch {
message.error(t('files.deleteFailed'))
}
},
})
break
}
}
function handleClickOutside() {
showMenu.value = false
}
defineExpose({ show })
</script>
<template>
<NDropdown
:show="showMenu"
:x="menuX"
:y="menuY"
:options="getOptions()"
placement="bottom-start"
trigger="manual"
@select="handleSelect"
@clickoutside="handleClickOutside"
/>
</template>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { NButton, NSpace, useMessage, useDialog } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import * as monaco from 'monaco-editor'
// Configure Monaco workers using import.meta.url
;(self as any).MonacoEnvironment = {
getWorker(_: any, _label: string) {
return new Worker(
new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url),
{ type: 'module' }
)
},
}
const { t } = useI18n()
const message = useMessage()
const dialogApi = useDialog()
const filesStore = useFilesStore()
const editorContainer = ref<HTMLElement | null>(null)
let editor: monaco.editor.IStandaloneCodeEditor | null = null
const saving = ref(false)
onMounted(() => {
if (!editorContainer.value || !filesStore.editingFile) return
editor = monaco.editor.create(editorContainer.value, {
value: filesStore.editingFile.content,
language: filesStore.editingFile.language,
theme: document.documentElement.classList.contains('dark') ? 'vs-dark' : 'vs',
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
})
editor.onDidChangeModelContent(() => {
if (filesStore.editingFile) {
filesStore.editingFile.content = editor!.getValue()
}
})
// Ctrl/Cmd + S to save
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave()
})
})
onBeforeUnmount(() => {
editor?.dispose()
editor = null
})
async function handleSave() {
saving.value = true
try {
await filesStore.saveEditor()
message.success(t('files.saved'))
} catch {
message.error(t('files.saveFailed'))
} finally {
saving.value = false
}
}
function handleClose() {
if (filesStore.hasUnsavedChanges) {
dialogApi.warning({
title: t('files.unsavedChanges'),
positiveText: t('common.ok'),
negativeText: t('common.cancel'),
onPositiveClick: () => {
filesStore.closeEditor()
},
})
} else {
filesStore.closeEditor()
}
}
</script>
<template>
<div class="file-editor">
<div class="editor-header">
<span class="editor-filename">{{ filesStore.editingFile?.path }}</span>
<NSpace>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">
{{ t('files.saveFile') }}
</NButton>
<NButton size="small" @click="handleClose">
{{ t('files.closeEditor') }}
</NButton>
</NSpace>
</div>
<div ref="editorContainer" class="editor-container" />
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-editor {
display: flex;
flex-direction: column;
height: 100%;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid $border-color;
background-color: $bg-card;
}
.editor-filename {
font-size: 13px;
color: $text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
@media (max-width: $breakpoint-mobile) {
max-width: 120px;
font-size: 12px;
}
}
.editor-container {
flex: 1;
min-height: 0;
}
</style>
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { NButton, NSpin, NEmpty, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore, isImageFile, isMarkdownFile, isTextFile } from '@/stores/hermes/files'
import { downloadFile } from '@/api/hermes/download'
import type { FileEntry } from '@/api/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const emit = defineEmits<{
(e: 'contextmenu-entry', event: MouseEvent, entry: FileEntry): void
}>()
function formatSize(bytes: number): string {
if (bytes === 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
function formatDate(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString()
}
function getFileIcon(entry: FileEntry): string {
if (entry.isDir) return '📁'
const ext = entry.name.split('.').pop()?.toLowerCase() || ''
const iconMap: Record<string, string> = {
yaml: '⚙️', yml: '⚙️', json: '📋', toml: '⚙️',
md: '📝', txt: '📄', log: '📄',
py: '🐍', js: '📜', ts: '📜', vue: '💚',
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
zip: '📦', gz: '📦', tar: '📦',
sh: '⚡', bash: '⚡',
}
return iconMap[ext] || '📄'
}
function handleDoubleClick(entry: FileEntry) {
if (entry.isDir) {
filesStore.navigateTo(entry.path)
} else if (isTextFile(entry.name)) {
filesStore.openEditor(entry.path)
} else if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
filesStore.openPreview(entry)
}
}
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
e.preventDefault()
emit('contextmenu-entry', e, entry)
}
async function handleDownload(entry: FileEntry) {
try {
await downloadFile(entry.path, entry.name)
} catch (err: any) {
message.error(err.message || t('files.backendError'))
}
}
</script>
<template>
<div class="file-list">
<NSpin :show="filesStore.loading">
<NEmpty v-if="!filesStore.loading && filesStore.sortedEntries.length === 0" :description="t('files.emptyDir')" />
<div v-else class="file-list-items">
<div class="file-list-header">
<div class="file-name sort-header" @click="filesStore.setSort('name')">
{{ t('files.name') }}
<span v-if="filesStore.sortBy === 'name'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '' : '' }}</span>
</div>
<div class="file-size sort-header" @click="filesStore.setSort('size')">
{{ t('files.size') }}
<span v-if="filesStore.sortBy === 'size'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '' : '' }}</span>
</div>
<div class="file-date sort-header" @click="filesStore.setSort('modTime')">
{{ t('files.modified') }}
<span v-if="filesStore.sortBy === 'modTime'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '' : '' }}</span>
</div>
<div class="file-actions-placeholder" />
</div>
<div
v-for="entry in filesStore.sortedEntries"
:key="entry.path"
class="file-list-row"
@dblclick="handleDoubleClick(entry)"
@contextmenu="handleContextMenu($event, entry)"
>
<div class="file-name">
<span class="file-icon">{{ getFileIcon(entry) }}</span>
<span>{{ entry.name }}</span>
</div>
<div class="file-size">{{ entry.isDir ? '—' : formatSize(entry.size) }}</div>
<div class="file-date">{{ formatDate(entry.modTime) }}</div>
<div class="file-actions">
<NButton v-if="isTextFile(entry.name) && !entry.isDir" size="tiny" quaternary @click.stop="filesStore.openEditor(entry.path)" :title="t('files.edit')"></NButton>
<NButton v-if="!entry.isDir" size="tiny" quaternary @click.stop="handleDownload(entry)" :title="t('files.download')"></NButton>
</div>
</div>
</div>
</NSpin>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-list {
padding: 8px 16px;
}
.file-list-header {
display: flex;
align-items: center;
padding: 6px 12px;
gap: 16px;
font-size: 12px;
font-weight: 500;
color: $text-muted;
border-bottom: 1px solid $border-light;
margin-bottom: 4px;
user-select: none;
}
.sort-header {
cursor: pointer;
&:hover {
color: $text-primary;
}
}
.sort-indicator {
margin-left: 2px;
font-size: 11px;
}
.file-actions-placeholder {
width: 60px;
flex-shrink: 0;
}
.file-list-row {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: $radius-sm;
cursor: pointer;
gap: 16px;
font-size: 13px;
&:hover {
background-color: rgba(var(--accent-primary-rgb), 0.06);
.file-actions {
opacity: 1;
}
}
}
.file-name {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-icon {
flex-shrink: 0;
}
.file-size {
width: 80px;
text-align: right;
color: $text-secondary;
flex-shrink: 0;
}
.file-date {
width: 160px;
color: $text-secondary;
flex-shrink: 0;
}
.file-actions {
opacity: 0;
transition: opacity $transition-fast;
display: flex;
gap: 4px;
flex-shrink: 0;
}
@media (max-width: $breakpoint-mobile) {
.file-size, .file-date {
display: none;
}
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { h } from 'vue'
import { NButton, NIcon } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import { getFileDownloadUrl } from '@/api/hermes/files'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
const { t } = useI18n()
const filesStore = useFilesStore()
function getImageUrl(): string {
if (!filesStore.previewFile) return ''
return getFileDownloadUrl(filesStore.previewFile.path)
}
const CloseIcon = () =>
h(
'svg',
{ viewBox: '0 0 24 24', width: '14', height: '14', fill: 'currentColor' },
[h('path', { d: 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z' })],
)
</script>
<template>
<div class="file-preview" v-if="filesStore.previewFile">
<div class="preview-header">
<span class="preview-filename">{{ filesStore.previewFile.path }}</span>
<NButton size="small" quaternary @click="filesStore.closePreview()">
<template #icon>
<NIcon><CloseIcon /></NIcon>
</template>
{{ t('files.closePreview') }}
</NButton>
</div>
<div class="preview-content">
<img
v-if="filesStore.previewFile.type === 'image'"
:src="getImageUrl()"
class="preview-image"
:alt="filesStore.previewFile.path"
/>
<div v-else-if="filesStore.previewFile.type === 'markdown'" class="preview-markdown">
<MarkdownRenderer :content="filesStore.previewFile.content || ''" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-preview {
display: flex;
flex-direction: column;
height: 100%;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid $border-color;
}
.preview-filename {
font-size: 13px;
color: $text-secondary;
}
.preview-content {
flex: 1;
overflow: auto;
padding: 16px;
display: flex;
justify-content: center;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.preview-markdown {
max-width: 800px;
width: 100%;
}
</style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { NModal, NInput, NButton, NSpace, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import type { FileEntry } from '@/api/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const props = defineProps<{
show: boolean
mode: 'newFile' | 'newFolder' | 'rename'
entry?: FileEntry | null
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
}>()
const inputValue = ref('')
const submitting = ref(false)
watch(() => props.show, (val) => {
if (val) {
if (props.mode === 'rename' && props.entry) {
inputValue.value = props.entry.name
} else {
inputValue.value = ''
}
}
})
const title = computed(() => {
switch (props.mode) {
case 'newFile': return t('files.newFile')
case 'newFolder': return t('files.newFolder')
case 'rename': return t('files.rename')
}
})
const placeholder = computed(() => {
switch (props.mode) {
case 'newFile': return t('files.newFileName')
case 'newFolder': return t('files.newFolderName')
case 'rename': return t('files.renameTo')
}
})
async function handleSubmit() {
if (!inputValue.value.trim()) return
submitting.value = true
try {
switch (props.mode) {
case 'newFile':
await filesStore.createFile(inputValue.value.trim())
message.success(t('files.created'))
break
case 'newFolder':
await filesStore.createDir(inputValue.value.trim())
message.success(t('files.created'))
break
case 'rename':
if (props.entry) {
await filesStore.renameEntry(props.entry, inputValue.value.trim())
message.success(t('files.renamed'))
}
break
}
emit('update:show', false)
} catch (err: any) {
const msg = props.mode === 'rename' ? t('files.renameFailed') : t('files.createFailed')
message.error(err.message || msg)
} finally {
submitting.value = false
}
}
</script>
<template>
<NModal :show="props.show" preset="dialog" :title="title" @update:show="emit('update:show', false)" style="width: 400px;">
<NInput
v-model:value="inputValue"
:placeholder="placeholder"
@keydown.enter="handleSubmit"
autofocus
/>
<template #action>
<NSpace>
<NButton @click="emit('update:show', false)">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="submitting" :disabled="!inputValue.trim()" @click="handleSubmit">
{{ t('common.ok') }}
</NButton>
</NSpace>
</template>
</NModal>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { NButton, NSpace, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const emit = defineEmits<{
(e: 'showNewFile'): void
(e: 'showNewFolder'): void
(e: 'showUpload'): void
}>()
async function handleRefresh() {
try {
await filesStore.fetchEntries()
} catch {
message.error(t('files.backendError'))
}
}
</script>
<template>
<div class="file-toolbar">
<NSpace :size="8" :wrap="true" class="toolbar-space">
<NButton size="small" @click="emit('showNewFile')" class="toolbar-btn">
{{ t('files.newFile') }}
</NButton>
<NButton size="small" @click="emit('showNewFolder')" class="toolbar-btn">
{{ t('files.newFolder') }}
</NButton>
<NButton size="small" @click="emit('showUpload')" class="toolbar-btn">
{{ t('files.upload') }}
</NButton>
<NButton size="small" @click="handleRefresh" class="toolbar-btn">
{{ t('files.refresh') }}
</NButton>
</NSpace>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-toolbar {
padding: 12px 16px;
@media (max-width: $breakpoint-mobile) {
padding: 8px 4px;
}
}
.toolbar-space {
@media (max-width: $breakpoint-mobile) {
:deep(.n-space) {
gap: 4px !important;
}
}
}
.toolbar-btn {
@media (max-width: $breakpoint-mobile) {
font-size: 12px;
padding: 0 8px;
height: 32px;
white-space: nowrap;
}
}
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NTree } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import * as filesApi from '@/api/hermes/files'
import type { TreeOption } from 'naive-ui'
const { t } = useI18n()
const filesStore = useFilesStore()
const treeData = ref<TreeOption[]>([])
const selectedKeys = ref<string[]>([])
async function loadChildren(path: string): Promise<TreeOption[]> {
try {
const result = await filesApi.listFiles(path)
return result.entries
.filter(e => e.isDir)
.sort((a, b) => a.name.localeCompare(b.name))
.map(e => ({
key: e.path,
label: e.name,
isLeaf: false,
}))
} catch {
return []
}
}
async function handleLoad(node: TreeOption): Promise<void> {
node.children = await loadChildren(node.key as string)
}
function handleSelect(keys: string[]) {
if (keys.length > 0) {
selectedKeys.value = keys
filesStore.navigateTo(keys[0])
}
}
function handleRootClick() {
selectedKeys.value = []
filesStore.navigateTo('')
}
onMounted(async () => {
treeData.value = await loadChildren('')
})
</script>
<template>
<div class="file-tree">
<div class="tree-header" @click="handleRootClick">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<span>{{ t('files.breadcrumbRoot') }}</span>
</div>
<NTree
:data="treeData"
:selected-keys="selectedKeys"
:on-load="handleLoad"
expand-on-click
block-line
@update:selected-keys="handleSelect"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-tree {
padding: 8px;
}
.tree-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: $radius-sm;
font-size: 13px;
font-weight: 500;
color: $text-primary;
&:hover {
background-color: rgba(var(--accent-primary-rgb), 0.06);
}
}
</style>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NModal, NButton, NUpload, NSpace, useMessage } from 'naive-ui'
import type { UploadFileInfo } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ (e: 'update:show', value: boolean): void }>()
const uploading = ref(false)
const fileList = ref<File[]>([])
function handleFileChange(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) {
fileList.value = data.fileList
.map((f: UploadFileInfo) => f.file)
.filter((f): f is File => f != null)
}
async function handleUpload() {
if (fileList.value.length === 0) return
uploading.value = true
try {
await filesStore.uploadFiles(fileList.value)
message.success(t('files.uploadSuccess', { count: fileList.value.length }))
fileList.value = []
emit('update:show', false)
} catch (err: any) {
message.error(err.message || t('files.uploadFailed'))
} finally {
uploading.value = false
}
}
function handleClose() {
fileList.value = []
emit('update:show', false)
}
</script>
<template>
<NModal :show="props.show" preset="dialog" :title="t('files.upload')" @update:show="handleClose" style="width: 500px;">
<NUpload
multiple
directory-dnd
:default-upload="false"
@change="handleFileChange"
>
<div class="upload-dragger">
<p>{{ t('files.dragDropHint') }}</p>
</div>
</NUpload>
<template #action>
<NSpace>
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="uploading" :disabled="fileList.length === 0" @click="handleUpload">
{{ t('files.upload') }} ({{ fileList.length }})
</NButton>
</NSpace>
</template>
</NModal>
</template>
<style scoped>
.upload-dragger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
cursor: pointer;
}
.upload-dragger p {
margin-top: 12px;
opacity: 0.6;
font-size: 14px;
}
</style>
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { NInput, NButton, NSpace, NInputNumber, NCollapse, NCollapseItem } from 'naive-ui'
type InputLikeInstance = {
focus: () => void
}
const { t } = useI18n()
const emit = defineEmits<{
submit: [name: string, inviteCode: string, userName: string, description: string, compression: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }]
cancel: []
}>()
const roomName = ref('')
const inviteCode = ref('')
const userName = ref(localStorage.getItem('gc_user_name') || '')
const description = ref(localStorage.getItem('gc_user_description') || '')
const roomInput = ref<InputLikeInstance | null>(null)
const compression = ref({
triggerTokens: 100000,
maxHistoryTokens: 32000,
tailMessageCount: 10,
})
function generateCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)]
}
return code
}
function handleCreate() {
const name = roomName.value.trim()
const code = inviteCode.value.trim() || generateCode()
const user = userName.value.trim()
if (!name || !user) return
emit('submit', name, code, user, description.value.trim(), { ...compression.value })
}
function focusRoomInput() {
nextTick(() => roomInput.value?.focus())
}
</script>
<template>
<div class="create-form">
<div class="form-group">
<label class="form-label">{{ t('groupChat.yourName') }}</label>
<NInput
v-model:value="userName"
:placeholder="t('groupChat.yourNamePlaceholder')"
@keyup.enter="focusRoomInput"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.yourDescription') }}</label>
<NInput
v-model:value="description"
type="textarea"
:rows="2"
:placeholder="t('groupChat.yourDescriptionPlaceholder')"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.roomName') }}</label>
<NInput
ref="roomInput"
v-model:value="roomName"
:placeholder="t('groupChat.roomNamePlaceholder')"
@keyup.enter="handleCreate"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.inviteCode') }}</label>
<div class="code-row">
<NInput
v-model:value="inviteCode"
:placeholder="t('groupChat.autoGenerate')"
/>
<NButton size="small" @click="inviteCode = generateCode()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</NButton>
</div>
</div>
<NCollapse class="compression-collapse">
<NCollapseItem :title="t('groupChat.compressionSettings')" name="compression">
<div class="compression-fields">
<div class="form-group">
<label class="form-label">{{ t('groupChat.triggerTokens') }}</label>
<NInputNumber v-model:value="compression.triggerTokens" :min="1000" :step="1000" style="width: 100%" />
<p class="form-hint">{{ t('groupChat.triggerTokensDesc') }}</p>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.maxHistoryTokens') }}</label>
<NInputNumber v-model:value="compression.maxHistoryTokens" :min="1000" :step="1000" style="width: 100%" />
<p class="form-hint">{{ t('groupChat.maxHistoryTokensDesc') }}</p>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.tailMessageCount') }}</label>
<NInputNumber v-model:value="compression.tailMessageCount" :min="1" :step="5" style="width: 100%" />
<p class="form-hint">{{ t('groupChat.tailMessageCountDesc') }}</p>
</div>
</div>
</NCollapseItem>
</NCollapse>
<div class="modal-actions">
<NSpace justify="end">
<NButton @click="emit('cancel')">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :disabled="!roomName.trim() || !userName.trim()" @click="handleCreate">{{ t('common.create') }}</NButton>
</NSpace>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.create-form {
.form-group {
margin-bottom: 16px;
}
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: $text-secondary;
margin-bottom: 6px;
}
.form-hint {
font-size: 11px;
color: $text-muted;
margin: 4px 0 0;
}
.code-row {
display: flex;
gap: 8px;
align-items: center;
}
.compression-collapse {
margin-bottom: 16px;
}
.compression-fields {
padding-top: 8px;
}
</style>
@@ -0,0 +1,774 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton, NSwitch, NTooltip } from 'naive-ui'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import { buildMentionOptions, type MentionOption } from './mention-options'
import type { Attachment } from '@/stores/hermes/chat'
const { t } = useI18n()
const emit = defineEmits<{ send: [content: string, attachments?: Attachment[]] }>()
const store = useGroupChatStore()
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const dropdownRef = ref<HTMLDivElement>()
const fileInputRef = ref<HTMLInputElement>()
const attachments = ref<Attachment[]>([])
const isDragging = ref(false)
const dragCounter = ref(0)
const isComposing = ref(false)
const autoPlaySpeech = ref(false)
onMounted(() => {
const saved = localStorage.getItem('autoPlaySpeech')
if (saved !== null) {
autoPlaySpeech.value = saved === 'true'
store.setAutoPlaySpeech(autoPlaySpeech.value)
}
})
watch(autoPlaySpeech, (value) => {
localStorage.setItem('autoPlaySpeech', String(value))
store.setAutoPlaySpeech(value)
})
// 自定义高度拖拽
const textareaHeight = ref<number | null>(null)
function startResize(e: MouseEvent) {
e.preventDefault()
const el = textareaRef.value
if (!el) return
const startHeight = el.clientHeight
const startY = e.clientY
function onMouseMove(e: MouseEvent) {
const deltaY = e.clientY - startY
const newHeight = startHeight - deltaY
textareaHeight.value = Math.max(20, Math.min(400, Math.round(newHeight)))
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// ─── Mention State ───────────────────────────────────────
const mentionActive = ref(false)
const mentionQuery = ref('')
const mentionStartIndex = ref(-1)
const dropdownX = ref(0)
const dropdownY = ref(0)
const dropdownBottom = ref(0)
const placement = ref<'bottom' | 'top'>('bottom')
const activeIndex = ref(0)
const filteredMentionOptions = computed(() => buildMentionOptions(store.agents, mentionQuery.value))
const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0)
// ─── Scroll active item into view ──────────────────────
function scrollToActive() {
nextTick(() => {
if (!dropdownRef.value) return
const active = dropdownRef.value.querySelector('.active') as HTMLElement | null
if (active) active.scrollIntoView({ block: 'nearest', behavior: 'instant' })
})
}
// ─── Mention Logic ───────────────────────────────────────
function updateMentionState() {
const el = textareaRef.value
if (!el) { mentionActive.value = false; return }
const text = inputText.value
const cursorPos = el.selectionStart
// Find the last @ before the cursor
let atPos = -1
for (let i = cursorPos - 1; i >= 0; i--) {
if (text[i] === '@') { atPos = i; break }
if (text[i] === ' ' || text[i] === '\n') break
}
if (atPos === -1) {
mentionActive.value = false
return
}
// Make sure the @ is not part of a word (preceded by space or start of line)
if (atPos > 0 && text[atPos - 1] !== ' ' && text[atPos - 1] !== '\n') {
mentionActive.value = false
return
}
const query = text.slice(atPos + 1, cursorPos)
if (query.includes(' ')) {
mentionActive.value = false
return
}
mentionQuery.value = query
mentionStartIndex.value = atPos
activeIndex.value = 0
// Calculate dropdown position using mirror span
const mirror = document.createElement('span')
const style = getComputedStyle(el)
const props = ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'textTransform', 'wordSpacing', 'textIndent', 'border', 'padding', 'boxSizing', 'lineHeight']
props.forEach(p => { (mirror.style as any)[p] = style[p as any] })
mirror.style.position = 'absolute'
mirror.style.visibility = 'hidden'
mirror.style.whiteSpace = 'nowrap'
mirror.textContent = text.slice(0, atPos + 1)
const rect = el.getBoundingClientRect()
document.body.appendChild(mirror)
const mirrorRect = mirror.getBoundingClientRect()
document.body.removeChild(mirror)
dropdownX.value = rect.left + mirrorRect.width - el.scrollLeft
// Decide placement: if dropdown would go below viewport, flip upward
const estimatedHeight = Math.min(filteredMentionOptions.value.length * 36 + 8, 240)
const spaceBelow = window.innerHeight - rect.top + el.scrollTop - 8
if (spaceBelow < estimatedHeight && rect.top - el.scrollTop - 8 > estimatedHeight) {
placement.value = 'top'
dropdownY.value = rect.top - el.scrollTop - 8
} else {
placement.value = 'bottom'
dropdownY.value = rect.top - el.scrollTop - 8
}
dropdownBottom.value = window.innerHeight - dropdownY.value
mentionActive.value = filteredMentionOptions.value.length > 0
}
function selectMention(name: string) {
const el = textareaRef.value
if (!el || mentionStartIndex.value === -1) return
const before = inputText.value.slice(0, mentionStartIndex.value)
const after = inputText.value.slice(el.selectionStart)
inputText.value = `${before}@${name} ${after}`
mentionActive.value = false
nextTick(() => {
if (el) {
const newPos = before.length + name.length + 2
el.setSelectionRange(newPos, newPos)
el.focus()
if (textareaHeight.value === null) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
}
}
})
}
// ─── Event Handlers ──────────────────────────────────────
function handleKeydown(e: KeyboardEvent) {
// Mention navigation — fully custom, no NDropdown interference
if (mentionActive.value && filteredMentionOptions.value.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % filteredMentionOptions.value.length
scrollToActive()
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = (activeIndex.value - 1 + filteredMentionOptions.value.length) % filteredMentionOptions.value.length
scrollToActive()
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
selectMention(filteredMentionOptions.value[activeIndex.value].name)
return
}
if (e.key === 'Escape') {
e.preventDefault()
mentionActive.value = false
return
}
}
if (e.key !== 'Enter' || e.shiftKey) return
if (isComposing.value || e.isComposing || e.keyCode === 229) return
e.preventDefault()
handleSend()
}
function handleSend() {
const content = inputText.value.trim()
if (!content && attachments.value.length === 0) return
emit('send', content, attachments.value.length > 0 ? attachments.value : undefined)
inputText.value = ''
attachments.value = []
mentionActive.value = false
// 发送后重置到自定义高度(不清除拖拽状态)
}
function handleInput(e: Event) {
// 用户手动拖拽自定义高度时,不覆盖
if (textareaHeight.value !== null) return
store.emitTyping()
const el = e.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
if (!isComposing.value) {
updateMentionState()
}
}
function handleMentionClick(option: MentionOption) {
selectMention(option.name)
}
function handleMentionHover(index: number) {
activeIndex.value = index
}
// ─── Click outside to close dropdown ─────────────────
function onDocumentMousedown(e: MouseEvent) {
if (!mentionActive.value) return
const target = e.target as HTMLElement
if (!target.closest('.mention-dropdown')) {
mentionActive.value = false
}
}
onMounted(() => {
document.addEventListener('mousedown', onDocumentMousedown)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocumentMousedown)
})
function handleCompositionStart() {
isComposing.value = true
}
function handleCompositionEnd() {
requestAnimationFrame(() => {
isComposing.value = false
updateMentionState()
})
}
function addFile(file: File) {
if (attachments.value.find(a => a.name === file.name)) return
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
attachments.value.push({
id,
name: file.name,
type: file.type,
size: file.size,
url: URL.createObjectURL(file),
file,
})
}
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
for (const file of input.files) addFile(file)
input.value = ''
}
function handlePaste(e: ClipboardEvent) {
const items = Array.from(e.clipboardData?.items || [])
const imageItems = items.filter(i => i.type.startsWith('image/'))
if (!imageItems.length) return
e.preventDefault()
for (const item of imageItems) {
const blob = item.getAsFile()
if (!blob) continue
const ext = item.type.split('/')[1] || 'png'
addFile(new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type }))
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
}
function handleDragEnter(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer?.types.includes('Files')) {
dragCounter.value++
isDragging.value = true
}
}
function handleDragLeave() {
dragCounter.value--
if (dragCounter.value <= 0) {
dragCounter.value = 0
isDragging.value = false
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
dragCounter.value = 0
isDragging.value = false
for (const file of Array.from(e.dataTransfer?.files || [])) addFile(file)
textareaRef.value?.focus()
}
function removeAttachment(id: string) {
const idx = attachments.value.findIndex(a => a.id === id)
if (idx !== -1) {
URL.revokeObjectURL(attachments.value[idx].url)
attachments.value.splice(idx, 1)
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function isImage(type: string): boolean {
return type.startsWith('image/')
}
</script>
<template>
<div class="chat-input-area">
<div class="input-top-bar">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" circle @click="handleAttachClick">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</template>
</NButton>
</template>
{{ t('chat.attachFiles') }}
</NTooltip>
<div class="auto-play-speech-switch">
<NTooltip trigger="hover">
<template #trigger>
<div class="switch-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
</template>
{{ t('chat.autoPlaySpeech') }}
</NTooltip>
<NSwitch v-model:value="autoPlaySpeech" size="small" :round="false" />
</div>
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" class="tool-trace-toggle" :class="{ active: toolTraceVisible }" @click="toggleToolTraceVisible">
<svg class="tool-trace-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a4.5 4.5 0 0 0-5.8 5.8L3.5 17.5a2.1 2.1 0 0 0 3 3l5.4-5.4a4.5 4.5 0 0 0 5.8-5.8l-3 3-3-3 3-3z"/>
</svg>
</NButton>
</template>
{{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }}
</NTooltip>
</div>
<div v-if="attachments.length > 0" class="attachment-previews">
<div v-for="att in attachments" :key="att.id" class="attachment-preview" :class="{ image: isImage(att.type) }">
<img v-if="isImage(att.type)" :src="att.url" :alt="att.name" class="attachment-thumb" />
<div v-else class="attachment-file">
<span class="file-name">{{ att.name }}</span>
<span class="file-size">{{ formatSize(att.size) }}</span>
</div>
<button class="attachment-remove" @click="removeAttachment(att.id)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<div
class="input-wrapper"
:class="{ 'drag-over': isDragging }"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<input ref="fileInputRef" type="file" multiple class="file-input-hidden" @change="handleFileChange" />
<div class="resize-handle" @mousedown="startResize"></div>
<textarea
ref="textareaRef"
v-model="inputText"
class="input-textarea"
:style="textareaHeight ? { height: textareaHeight + 'px' } : {}"
:placeholder="t('groupChat.inputPlaceholder')"
rows="1"
@keydown="handleKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@input="handleInput"
@paste="handlePaste"
/>
<div class="input-actions">
<NButton
size="small"
type="primary"
:disabled="!canSend"
@click="handleSend"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</template>
{{ t('chat.send') }}
</NButton>
</div>
</div>
<Transition name="dropdown-fade">
<div
v-if="mentionActive && filteredMentionOptions.length > 0"
ref="dropdownRef"
class="mention-dropdown"
:class="{ 'placement-top': placement === 'top' }"
:style="{
left: dropdownX + 'px',
top: placement === 'bottom' ? dropdownY + 'px' : 'auto',
bottom: placement === 'top' ? dropdownBottom + 'px' : 'auto',
}"
>
<div
v-for="(option, i) in filteredMentionOptions"
:key="option.key"
class="mention-dropdown-item"
:class="{ active: i === activeIndex, 'mention-all-option': option.type === 'all' }"
@mousedown.prevent="handleMentionClick(option)"
@mouseenter="handleMentionHover(i)"
>
<span class="mention-name">{{ option.label }}</span>
<span class="mention-profile">{{ option.description }}</span>
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.chat-input-area {
padding: 12px 20px 16px;
border-top: 1px solid $border-color;
flex-shrink: 0;
}
.input-top-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 0 6px;
}
.auto-play-speech-switch {
display: flex;
align-items: center;
gap: 6px;
padding-left: 8px;
border-left: 1px solid $border-light;
margin-left: 4px;
.switch-label {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #999999;
}
}
.tool-trace-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
color: #999999;
width: 24px;
min-width: 24px;
height: 22px;
margin-left: -4px;
padding: 0;
background: transparent !important;
:deep(.n-button__state-border),
:deep(.n-button__border),
:deep(.n-button__ripple) {
display: none;
}
.tool-trace-icon {
display: block;
width: 16px;
height: 16px;
}
}
.attachment-previews {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 10px;
}
.attachment-preview {
position: relative;
border-radius: $radius-sm;
overflow: hidden;
background-color: $bg-secondary;
border: 1px solid $border-color;
&.image {
width: 64px;
height: 64px;
}
}
.attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-file {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 12px;
min-width: 80px;
max-width: 140px;
color: $text-secondary;
.file-name {
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.file-size {
font-size: 10px;
color: $text-muted;
}
}
.attachment-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.5);
color: var(--text-on-overlay);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity $transition-fast;
.attachment-preview:hover & {
opacity: 1;
}
}
.file-input-hidden {
display: none;
}
.typing-dots {
display: inline-flex;
align-items: center;
gap: 2px;
span {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: $text-muted;
animation: typing-bounce 1.2s infinite;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
}
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-3px); opacity: 1; }
}
.input-wrapper {
display: flex;
align-items: center;
gap: 10px;
background-color: $bg-input;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 10px 12px;
position: relative;
transition: border-color $transition-fast, background-color $transition-fast;
&:focus-within {
border-color: $accent-primary;
}
&.drag-over {
border-color: $accent-primary;
background-color: rgba($accent-primary, 0.08);
}
.dark & {
background-color: #333333;
}
}
.resize-handle {
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 8px;
cursor: row-resize;
z-index: 2;
&:hover {
background: rgba($accent-primary, 0.15);
border-radius: 4px;
}
}
.input-textarea {
flex: 1;
background: none;
border: none;
outline: none;
color: $text-primary;
font-family: $font-ui;
font-size: 14px;
line-height: 1.5;
resize: none;
max-height: 400px;
min-height: 20px;
overflow-y: auto;
@media (max-width: 768px) {
font-size: 16px;
}
&::placeholder {
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.input-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
align-items: center;
}
/* ── Custom mention dropdown (replaces NDropdown) ── */
.mention-dropdown {
position: fixed;
background: $bg-card;
border: 1px solid $border-color;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
min-width: 200px;
max-height: 240px;
overflow-y: auto;
z-index: 9999;
padding: 4px;
}
.mention-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
&:hover,
&.active {
background: rgba(var(--text-primary-rgb), 0.08);
}
.mention-name {
color: $text-primary;
font-size: 14px;
font-weight: 500;
}
.mention-profile {
color: $text-muted;
font-size: 12px;
}
&.mention-all-option .mention-name {
color: $accent-primary;
font-weight: 600;
}
}
/* ── Dropdown fade/scale animation (matching NDropdown) ── */
.dropdown-fade-enter-active {
transition: opacity 0.2s cubic-bezier(0, 0, .2, 1), transform 0.2s cubic-bezier(0, 0, .2, 1);
transform-origin: top;
}
.dropdown-fade-leave-active {
transition: opacity 0.2s cubic-bezier(.4, 0, 1, 1), transform 0.2s cubic-bezier(.4, 0, 1, 1);
transform-origin: top;
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
opacity: 0;
transform: scale(0.9);
}
.placement-top.dropdown-fade-enter-active,
.placement-top.dropdown-fade-leave-active {
transform-origin: bottom;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import GroupMessageItem from './GroupMessageItem.vue'
import VirtualMessageList from '../chat/VirtualMessageList.vue'
const store = useGroupChatStore()
const { t } = useI18n()
const { toolTraceVisible } = useToolTraceVisibility()
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null)
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
let pendingInitialBottomRoomId: string | null = store.currentRoomId
type BottomScrollOptions = number | {
frames?: number
keepAliveMs?: number
}
function scrollToBottom(options?: BottomScrollOptions): void {
const list = listRef.value as (InstanceType<typeof VirtualMessageList> & {
scrollToBottom: (options?: BottomScrollOptions) => void
}) | null
list?.scrollToBottom(options)
}
async function handleTopReach(): Promise<void> {
if (!store.hasMoreBefore || store.isLoadingOlderMessages) return
const snapshot = listRef.value?.captureScrollPosition() ?? null
const loaded = await store.loadOlderMessages()
if (!loaded) return
await nextTick()
listRef.value?.restoreScrollPosition(snapshot)
}
watch(() => store.currentRoomId, (roomId) => {
pendingInitialBottomRoomId = roomId
})
watch(() => displayMessages.value.map(msg => [
msg.id,
msg.content?.length ?? 0,
msg.reasoning?.length ?? 0,
msg.reasoning_content?.length ?? 0,
msg.toolStatus ?? '',
].join(':')).join('|'), async () => {
const shouldForceInitialBottom = !!store.currentRoomId &&
pendingInitialBottomRoomId === store.currentRoomId &&
displayMessages.value.length > 0
const shouldScroll = shouldForceInitialBottom || (listRef.value?.isNearBottom(200) ?? true)
await nextTick()
if (shouldScroll) {
scrollToBottom(shouldForceInitialBottom ? { frames: 5, keepAliveMs: 700 } : { frames: 1, keepAliveMs: 120 })
if (shouldForceInitialBottom) pendingInitialBottomRoomId = null
}
})
onMounted(async () => {
if (!store.currentRoomId || displayMessages.value.length === 0) return
pendingInitialBottomRoomId = null
await nextTick()
scrollToBottom({ frames: 5, keepAliveMs: 700 })
})
defineExpose({ scrollToBottom })
</script>
<template>
<VirtualMessageList
ref="listRef"
:messages="displayMessages"
:estimated-item-height="170"
:row-gap="12"
padding="16px 20px"
@top-reach="handleTopReach"
>
<template #empty>
<div class="empty-state">
<img src="/logo.svg" alt="灵犀" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
</template>
<template #before>
<div
v-if="store.hasMoreBefore || store.isLoadingOlderMessages"
class="history-loader"
>
<span v-if="store.isLoadingOlderMessages" class="history-loader-spinner"></span>
</div>
</template>
<template #item="{ message: msg }">
<GroupMessageItem
:message="msg"
:agents="store.agents"
:current-user-id="store.userId"
/>
</template>
</VirtualMessageList>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: $text-muted;
.empty-logo {
width: 48px;
height: 48px;
opacity: 0.25;
}
p {
font-size: 14px;
}
}
.history-loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.history-loader-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, 0.16);
border-top-color: $accent-primary;
border-radius: 50%;
animation: spin 0.7s linear infinite;
.dark & {
border-color: rgba(255, 255, 255, 0.18);
border-top-color: $accent-primary;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,46 @@
export type MentionOption = {
key: string
type: 'all' | 'agent'
name: string
label: string
description: string
}
type MentionAgent = {
name: string
profile?: string
}
function isReservedMentionName(name: string): boolean {
return name.trim().toLowerCase() === 'all'
}
export function buildMentionOptions(agents: MentionAgent[], query: string): MentionOption[] {
const normalizedQuery = query.trim().toLowerCase()
const options: MentionOption[] = []
if (!normalizedQuery || 'all'.includes(normalizedQuery)) {
options.push({
key: 'special:all',
type: 'all',
name: 'all',
label: '@all',
description: 'All agents',
})
}
for (const agent of agents) {
const agentName = agent.name || ''
if (isReservedMentionName(agentName)) continue
if (!agentName.toLowerCase().includes(normalizedQuery)) continue
options.push({
key: `agent:${agentName}`,
type: 'agent',
name: agentName,
label: `@${agentName}`,
description: agent.profile || '',
})
}
return options
}
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NTooltip, useMessage } from 'naive-ui'
import type { Job } from '@/api/hermes/jobs'
import { scheduleToDisplayText } from '@/api/hermes/jobs'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
job: Job
selected?: boolean
}>()
const emit = defineEmits<{
edit: [jobId: string]
select: [jobId: string]
}>()
const { t } = useI18n()
const jobsStore = useJobsStore()
const message = useMessage()
const jobId = computed(() => props.job.job_id || props.job.id)
const statusLabel = computed(() => {
if (props.job.state === 'running') return t('jobs.status.running')
if (props.job.state === 'paused') return t('jobs.status.paused')
if (!props.job.enabled) return t('jobs.status.disabled')
return t('jobs.status.scheduled')
})
const statusType = computed(() => {
if (props.job.state === 'running') return 'info' as const
if (props.job.state === 'paused') return 'warning' as const
if (!props.job.enabled) return 'error' as const
return 'success' as const
})
const scheduleExpr = computed(() => scheduleToDisplayText(props.job.schedule, props.job.schedule_display || '—'))
const formatTime = (t?: string | null) => {
if (!t) return '—'
return new Date(t).toLocaleString()
}
async function handlePause() {
try {
await jobsStore.pauseJob(jobId.value)
message.success(t('jobs.jobPaused'))
} catch (e: any) {
message.error(e.message)
}
}
async function handleResume() {
try {
await jobsStore.resumeJob(jobId.value)
message.success(t('jobs.jobResumed'))
} catch (e: any) {
message.error(e.message)
}
}
async function handleRun() {
try {
await jobsStore.runJob(jobId.value)
message.info(t('jobs.jobTriggered'))
} catch (e: any) {
message.error(e.message)
}
}
async function handleDelete() {
try {
await jobsStore.deleteJob(jobId.value)
message.success(t('jobs.jobDeleted'))
} catch (e: any) {
message.error(e.message)
}
}
function handleCardClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.closest('.card-actions')) return
emit('select', jobId.value)
}
</script>
<template>
<div class="job-card" :class="{ selected }" @click="handleCardClick">
<div class="card-header">
<h3 class="job-name">{{ job.name }}</h3>
<span class="status-badge" :class="statusType">{{ statusLabel }}</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">{{ t('jobs.info.schedule') }}</span>
<code class="info-value mono">{{ scheduleExpr }}</code>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.model') }}</span>
<span class="info-value mono">{{ job.model || '—' }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.lastRun') }}</span>
<span class="info-value">
{{ formatTime(job.last_run_at) }}
<span v-if="job.last_status" class="run-status" :class="{ ok: job.last_status === 'ok', err: job.last_status !== 'ok' }">
{{ job.last_status === 'ok' ? t('common.ok') : job.last_status }}
</span>
</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.nextRun') }}</span>
<span class="info-value">{{ formatTime(job.next_run_at) }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.deliver') }}</span>
<span class="info-value">{{ job.deliver }}<template v-if="job.origin"> ({{ job.origin.platform }})</template></span>
</div>
<div v-if="job.repeat" class="info-row">
<span class="info-label">{{ t('jobs.info.repeat') }}</span>
<span class="info-value">
<template v-if="typeof job.repeat === 'string'">{{ job.repeat }}</template>
<template v-else>{{ job.repeat.completed }} / {{ job.repeat.times ?? '' }}</template>
</span>
</div>
</div>
<div class="card-actions">
<NTooltip v-if="job.state !== 'paused' && job.enabled">
<template #trigger>
<NButton size="tiny" quaternary @click.stop="handlePause">{{ t('jobs.action.pause') }}</NButton>
</template>
{{ t('jobs.action.pauseJob') }}
</NTooltip>
<NTooltip v-else-if="job.state === 'paused'">
<template #trigger>
<NButton size="tiny" quaternary @click.stop="handleResume">{{ t('jobs.action.resume') }}</NButton>
</template>
{{ t('jobs.action.resumeJob') }}
</NTooltip>
<NTooltip>
<template #trigger>
<NButton size="tiny" quaternary @click.stop="handleRun">{{ t('jobs.action.runNow') }}</NButton>
</template>
{{ t('jobs.action.triggerImmediately') }}
</NTooltip>
<NButton size="tiny" quaternary @click.stop="emit('edit', jobId)">{{ t('common.edit') }}</NButton>
<NButton size="tiny" quaternary type="error" @click.stop="handleDelete">{{ t('common.delete') }}</NButton>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.job-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
transition: border-color $transition-fast;
cursor: pointer;
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
&.selected {
border-color: rgba(var(--accent-primary-rgb), 0.6);
background-color: rgba(var(--accent-primary-rgb), 0.04);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.job-name {
font-size: 15px;
font-weight: 600;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.status-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
&.success {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.info {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.warning {
background: rgba(var(--warning-rgb), 0.12);
color: $warning;
}
&.error {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 12px;
color: $text-muted;
}
.info-value {
font-size: 12px;
color: $text-secondary;
}
.run-status {
margin-left: 6px;
font-size: 11px;
font-weight: 500;
&.ok { color: $success; }
&.err { color: $error; }
}
.mono {
font-family: $font-code;
font-size: 12px;
}
.card-actions {
display: flex;
gap: 4px;
border-top: 1px solid $border-light;
padding-top: 10px;
}
</style>
@@ -0,0 +1,268 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useSettingsStore } from '@/stores/hermes/settings'
import {
buildJobUpdateRequest,
getJob,
jobRepeatToEditValue,
scheduleToEditableInput,
} from '@/api/hermes/jobs'
import type { CreateJobRequest, Job } from '@/api/hermes/jobs'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
jobId: string | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const jobsStore = useJobsStore()
const settingsStore = useSettingsStore()
const message = useMessage()
const showModal = ref(true)
const loading = ref(false)
const formData = ref({
name: '',
schedule: '',
prompt: '',
deliver: 'origin',
repeat_times: null as number | null,
})
const presetValue = ref<string | null>(null)
const isEdit = computed(() => !!props.jobId)
const schedulePresets = computed(() => [
{ label: t('jobs.presetEveryMinute'), value: '* * * * *' },
{ label: t('jobs.presetEvery5Min'), value: '*/5 * * * *' },
{ label: t('jobs.presetEveryHour'), value: '0 * * * *' },
{ label: t('jobs.presetEveryDay'), value: '0 0 * * *' },
{ label: t('jobs.presetEveryDay9'), value: '0 9 * * *' },
{ label: t('jobs.presetEveryMonday'), value: '0 9 * * 1' },
{ label: t('jobs.presetEveryMonth'), value: '0 9 1 * *' },
])
function hasText(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0
}
function isDeliverTargetConfigured(key: string): boolean {
const config = settingsStore.platforms[key] || {}
switch (key) {
case 'telegram':
case 'discord':
case 'slack':
return hasText(config.token)
case 'whatsapp':
return config.enabled === true || config.enabled === 'true'
case 'matrix':
return hasText(config.token) && hasText(config.extra?.homeserver)
case 'weixin':
return hasText(config.token) && hasText(config.extra?.account_id)
case 'wecom':
return hasText(config.extra?.bot_id) && hasText(config.extra?.secret)
case 'feishu':
return hasText(config.extra?.app_id) && hasText(config.extra?.app_secret)
case 'dingtalk':
return (hasText(config.extra?.client_id) && hasText(config.extra?.client_secret))
|| (hasText(config.extra?.app_key) && hasText(config.extra?.client_secret))
case 'qqbot':
return hasText(config.extra?.app_id) && hasText(config.extra?.client_secret)
default:
return false
}
}
const targetOptions = computed(() => {
const options: Array<{ label: string; value: string; disabled?: boolean }> = [
{ label: t('jobs.origin'), value: 'origin' },
{ label: t('jobs.local'), value: 'local' },
]
const channels = [
{ key: 'telegram', label: 'Telegram' },
{ key: 'discord', label: 'Discord' },
{ key: 'slack', label: 'Slack' },
{ key: 'whatsapp', label: 'WhatsApp' },
{ key: 'matrix', label: 'Matrix' },
{ key: 'weixin', label: 'WeChat' },
{ key: 'wecom', label: 'WeCom' },
{ key: 'feishu', label: 'Feishu' },
{ key: 'dingtalk', label: 'DingTalk' },
{ key: 'qqbot', label: 'QQBot' },
]
for (const ch of channels) {
options.push({
label: ch.label,
value: ch.key,
disabled: !isDeliverTargetConfigured(ch.key),
})
}
return options
})
const originalJob = ref<Job | null>(null)
onMounted(async () => {
if (Object.keys(settingsStore.platforms || {}).length === 0) {
await settingsStore.fetchSettings()
}
if (props.jobId) {
try {
const job = await getJob(props.jobId)
originalJob.value = job
formData.value = {
name: job.name,
schedule: scheduleToEditableInput(job.schedule, job.schedule_display || ''),
prompt: job.prompt,
deliver: job.deliver || 'origin',
repeat_times: jobRepeatToEditValue(job.repeat),
}
} catch (e: any) {
message.error(t('jobs.loadFailed') + ': ' + e.message)
}
}
})
async function handleSave() {
if (!formData.value.name.trim()) {
message.warning(t('jobs.nameRequired'))
return
}
if (!formData.value.schedule.trim()) {
message.warning(t('jobs.scheduleRequired'))
return
}
loading.value = true
try {
if (isEdit.value) {
if (!originalJob.value) {
message.error(t('jobs.loadFailed'))
return
}
const payload = buildJobUpdateRequest(originalJob.value, formData.value)
if (Object.keys(payload).length === 0) {
message.success(t('jobs.jobUpdated'))
emit('saved')
return
}
await jobsStore.updateJob(props.jobId!, payload)
message.success(t('jobs.jobUpdated'))
} else {
const payload: CreateJobRequest = {
name: formData.value.name,
schedule: formData.value.schedule,
prompt: formData.value.prompt,
deliver: formData.value.deliver,
repeat: formData.value.repeat_times ?? undefined,
}
await jobsStore.createJob(payload)
message.success(t('jobs.jobCreated'))
}
emit('saved')
} catch (e: any) {
message.error(e.message)
} finally {
loading.value = false
}
}
function handleClose() {
showModal.value = false
setTimeout(() => emit('close'), 200)
}
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="isEdit ? t('jobs.editJob') : t('jobs.createJob')"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem :label="t('jobs.name')" required>
<NInput
v-model:value="formData.name"
:placeholder="t('jobs.namePlaceholder')"
maxlength="200"
show-count
/>
</NFormItem>
<NFormItem :label="t('jobs.schedule')" required>
<NInput
v-model:value="formData.schedule"
:placeholder="t('jobs.schedulePlaceholder')"
/>
</NFormItem>
<NFormItem :label="t('jobs.quickPresets')">
<NSelect
v-model:value="presetValue"
:options="schedulePresets"
:placeholder="t('jobs.selectPreset')"
@update:value="v => formData.schedule = v"
/>
</NFormItem>
<NFormItem :label="t('jobs.prompt')" required>
<NInput
v-model:value="formData.prompt"
type="textarea"
:placeholder="t('jobs.promptPlaceholder')"
:rows="4"
maxlength="5000"
show-count
/>
</NFormItem>
<NFormItem :label="t('jobs.deliverTarget')">
<NSelect
v-model:value="formData.deliver"
:options="targetOptions"
/>
</NFormItem>
<NFormItem :label="t('jobs.repeatCount')">
<NInputNumber
v-model:value="formData.repeat_times"
:min="1"
:placeholder="t('jobs.repeatPlaceholder')"
clearable
style="width: 100%"
/>
</NFormItem>
</NForm>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
{{ isEdit ? t('common.update') : t('common.create') }}
</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { NSpin, NEmpty, NCollapse, NCollapseItem } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { listCronRuns, readCronRun } from '@/api/hermes/cron-history'
import type { RunEntry, RunDetail } from '@/api/hermes/cron-history'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
const props = defineProps<{
selectedJobId: string | null
jobNameMap: Record<string, string>
profileKey: string
}>()
const { t } = useI18n()
const loading = ref(false)
const runs = ref<RunEntry[]>([])
const expandedContent = ref<Record<string, string>>({})
const loadingContent = ref<Record<string, boolean>>({})
const filteredRuns = computed(() => {
if (!props.selectedJobId) return runs.value
return runs.value.filter(r => r.jobId === props.selectedJobId)
})
async function fetchRuns() {
loading.value = true
try {
runs.value = await listCronRuns(props.selectedJobId ?? undefined)
} catch (err) {
console.error('Failed to fetch cron runs:', err)
runs.value = []
} finally {
loading.value = false
}
}
async function handleExpand(key: string | number | Array<string | number>) {
// accordion mode emits a single value; non-accordion emits an array
const keys = Array.isArray(key) ? key : key != null ? [key] : []
for (const raw of keys) {
const k = String(raw)
if (expandedContent.value[k] || loadingContent.value[k]) continue
const run = filteredRuns.value.find(r => `${r.jobId}/${r.fileName}` === k)
if (!run) continue
loadingContent.value[k] = true
try {
const detail: RunDetail = await readCronRun(run.jobId, run.fileName)
expandedContent.value[k] = detail.content
} catch (err) {
expandedContent.value[k] = `[Error loading content]`
} finally {
loadingContent.value[k] = false
}
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
function getJobName(jobId: string): string {
return props.jobNameMap[jobId] || jobId
}
watch(() => [props.selectedJobId, props.profileKey], () => {
expandedContent.value = {}
fetchRuns()
}, { immediate: true })
</script>
<template>
<div class="run-history">
<div class="history-header">
<span class="history-title">{{ t('jobs.runHistory.title') }}</span>
<span class="history-count">{{ filteredRuns.length }} {{ t('jobs.runHistory.runs') }}</span>
</div>
<div class="history-body">
<NSpin :show="loading">
<NEmpty v-if="!loading && filteredRuns.length === 0" :description="t('jobs.runHistory.noRuns')" />
<NCollapse
v-else
accordion
@update:expanded-names="handleExpand"
>
<NCollapseItem
v-for="run in filteredRuns"
:key="`${run.jobId}/${run.fileName}`"
:title="`${getJobName(run.jobId)} — ${run.runTime}`"
:name="`${run.jobId}/${run.fileName}`"
>
<template #header-extra>
<span class="run-meta">{{ formatSize(run.size) }}</span>
</template>
<NSpin v-if="loadingContent[`${run.jobId}/${run.fileName}`]" size="small" />
<MarkdownRenderer v-else-if="expandedContent[`${run.jobId}/${run.fileName}`]" :content="expandedContent[`${run.jobId}/${run.fileName}`]" />
</NCollapseItem>
</NCollapse>
</NSpin>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.run-history {
height: 100%;
display: flex;
flex-direction: column;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid $border-light;
flex-shrink: 0;
}
.history-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.history-count {
font-size: 12px;
color: $text-muted;
}
.history-body {
flex: 1;
overflow-y: auto;
padding: 8px 20px 20px;
}
.run-meta {
font-size: 11px;
color: $text-muted;
font-family: $font-code;
}
</style>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import JobCard from './JobCard.vue'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
selectedJobId: string | null
}>()
const emit = defineEmits<{
edit: [jobId: string]
select: [jobId: string | null]
}>()
const { t } = useI18n()
const jobsStore = useJobsStore()
function handleSelect(jobId: string) {
emit('select', props.selectedJobId === jobId ? null : jobId)
}
function handleDeselect() {
if (props.selectedJobId) {
emit('select', null)
}
}
</script>
<template>
<div v-if="jobsStore.jobs.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<p>{{ t('jobs.noJobs') }}</p>
</div>
<div v-else class="jobs-grid">
<JobCard
v-for="job in jobsStore.jobs"
:key="job.id"
:job="job"
:selected="selectedJobId === (job.job_id || job.id)"
@edit="emit('edit', job.id)"
@select="handleSelect"
/>
</div>
<!-- Click outside cards to deselect -->
<div
v-if="selectedJobId"
class="deselect-overlay"
@click="handleDeselect"
/>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: $text-muted;
gap: 12px;
.empty-icon {
opacity: 0.3;
}
p {
font-size: 14px;
}
}
.jobs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
gap: 14px;
}
.deselect-overlay {
display: none;
}
</style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NCollapse, NCollapseItem } from 'naive-ui'
import KanbanTaskCard from './KanbanTaskCard.vue'
import type { KanbanTask, KanbanTaskStatus } from '@/api/hermes/kanban'
const props = defineProps<{
status: KanbanTaskStatus
tasks: KanbanTask[]
}>()
const emit = defineEmits<{
taskClick: [taskId: string]
}>()
const { t } = useI18n()
const statusLabel = computed(() => t(`kanban.columns.${props.status}`, props.status))
const statusCount = computed(() => props.tasks.length)
const statusIcon = computed(() => {
switch (props.status) {
case 'todo': return '○'
case 'ready': return '◎'
case 'running': return '●'
case 'blocked': return '⊘'
case 'done': return '✓'
default: return '○'
}
})
const headerTitle = computed(() => `${statusIcon.value} ${statusLabel.value} (${statusCount.value})`)
</script>
<template>
<div class="kanban-column">
<NCollapse :default-expanded-names="[status]" display-directive="show">
<NCollapseItem :title="headerTitle" :name="status">
<KanbanTaskCard
v-for="task in tasks"
:key="task.id"
:task="task"
@click="emit('taskClick', task.id)"
/>
<div v-if="tasks.length === 0" class="column-empty">
{{ t('kanban.noTasks') }}
</div>
</NCollapseItem>
</NCollapse>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.kanban-column {
flex: 1 1 calc(20% - 12px);
min-width: 200px;
background-color: rgba(var(--accent-primary-rgb), 0.02);
border-radius: $radius-md;
border: 1px solid $border-light;
:deep(.n-collapse) {
--n-title-font-size: 13px;
--n-title-font-weight: 600;
}
:deep(.n-collapse-item__header-main) {
color: $text-primary;
}
:deep(.n-collapse-item__content-wrapper) {
padding: 0 10px 10px;
}
:deep(.n-collapse-item) {
display: flex;
flex-direction: column;
}
}
.column-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 60px;
font-size: 12px;
color: $text-muted;
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NSelect, NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useKanbanStore } from '@/stores/hermes/kanban'
import { withDefaultAssignee } from '@/utils/hermes/kanban-assignees'
const emit = defineEmits<{
close: []
created: []
}>()
const { t } = useI18n()
const message = useMessage()
const kanbanStore = useKanbanStore()
const title = ref('')
const body = ref('')
const assignee = ref<string | null>(null)
const priority = ref<number | null>(null)
const saving = ref(false)
const priorityOptions = computed(() => [
{ label: t('kanban.card.priority.low'), value: 1 },
{ label: t('kanban.card.priority.medium'), value: 2 },
{ label: t('kanban.card.priority.high'), value: 3 },
])
const assigneeOptions = computed(() => {
return withDefaultAssignee(kanbanStore.assignees, kanbanStore.stats?.by_assignee || {})
.map(a => ({ label: a.name, value: a.name }))
})
async function handleSubmit() {
if (!title.value.trim()) {
message.warning(t('kanban.form.titleRequired'))
return
}
saving.value = true
try {
await kanbanStore.createTask({
title: title.value.trim(),
body: body.value.trim() || undefined,
assignee: assignee.value || undefined,
priority: priority.value ?? undefined,
})
message.success(t('kanban.message.taskCreated'))
emit('created')
emit('close')
} catch (err: any) {
message.error(err.message)
} finally {
saving.value = false
}
}
</script>
<template>
<NModal :show="true" preset="dialog" :title="t('kanban.createTask')" style="width: 480px;" @close="emit('close')">
<NForm label-placement="top">
<NFormItem :label="t('kanban.form.title')">
<NInput v-model:value="title" :placeholder="t('kanban.form.titlePlaceholder')" />
</NFormItem>
<NFormItem :label="t('kanban.form.body')">
<NInput v-model:value="body" type="textarea" :rows="3" :placeholder="t('kanban.form.bodyPlaceholder')" />
</NFormItem>
<NFormItem :label="t('kanban.form.assignee')">
<NSelect v-model:value="assignee" :options="assigneeOptions" :placeholder="t('kanban.form.selectAssignee')" clearable />
</NFormItem>
<NFormItem :label="t('kanban.form.priority')">
<NSelect v-model:value="priority" :options="priorityOptions" :placeholder="t('kanban.form.selectPriority')" clearable />
</NFormItem>
</NForm>
<template #action>
<NButton @click="emit('close')">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="saving" @click="handleSubmit">{{ t('common.create') }}</NButton>
</template>
</NModal>
</template>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NTooltip } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
import type { KanbanTask } from '@/api/hermes/kanban'
import type { ProfileAvatar as ProfileAvatarData } from '@/api/hermes/profiles'
const props = defineProps<{
task: KanbanTask
assigneeAvatar?: ProfileAvatarData | null
}>()
const emit = defineEmits<{
click: [taskId: string]
}>()
const { t } = useI18n()
const timeAgo = computed(() => {
const diff = Date.now() / 1000 - props.task.created_at
if (diff < 60) return t('kanban.card.timeAgo.justNow')
if (diff < 3600) return t('kanban.card.timeAgo.minutes', { count: Math.floor(diff / 60) })
if (diff < 86400) return t('kanban.card.timeAgo.hours', { count: Math.floor(diff / 3600) })
return t('kanban.card.timeAgo.days', { count: Math.floor(diff / 86400) })
})
const priorityLabel = computed(() => {
if (props.task.priority >= 3) return 'high'
if (props.task.priority === 2) return 'medium'
return 'low'
})
const priorityText = computed(() => {
return t(`kanban.card.priority.${priorityLabel.value}`)
})
</script>
<template>
<div class="kanban-task-card" :class="`status-${task.status}`" @click="emit('click', task.id)">
<div class="card-title">{{ task.title }}</div>
<div class="card-meta">
<NTooltip v-if="task.assignee" trigger="hover">
<template #trigger>
<span class="meta-tag assignee-tag">
<ProfileAvatar
class="assignee-profile-avatar"
:name="task.assignee"
:avatar="assigneeAvatar"
:size="18"
aria-hidden="true"
/>
<span>{{ task.assignee }}</span>
</span>
</template>
{{ t('kanban.card.assigneeTooltip') }}
</NTooltip>
<span v-if="task.priority >= 2" class="meta-tag priority-tag" :class="priorityLabel">{{ priorityText }}</span>
<span class="meta-time">{{ timeAgo }}</span>
</div>
<div v-if="task.body" class="card-body-preview">{{ task.body.slice(0, 80) }}{{ task.body.length > 80 ? '...' : '' }}</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.kanban-task-card {
--kanban-card-status-color: #64748b;
background-color: $bg-card;
border: 1px solid $border-color;
border-left: 3px solid var(--kanban-card-status-color);
border-radius: $radius-md;
padding: 12px;
cursor: pointer;
transition: border-color $transition-fast, box-shadow $transition-fast;
&.status-triage { --kanban-card-status-color: #94a3b8; }
&.status-todo { --kanban-card-status-color: #38bdf8; }
&.status-ready { --kanban-card-status-color: #f59e0b; }
&.status-running { --kanban-card-status-color: #2563eb; }
&.status-blocked { --kanban-card-status-color: #ef4444; }
&.status-done { --kanban-card-status-color: #22c55e; }
&.status-archived { --kanban-card-status-color: #64748b; }
&:hover {
border-color: var(--kanban-card-status-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
}
.card-title {
font-size: 13px;
font-weight: 600;
color: $text-primary;
line-height: 1.4;
word-break: break-word;
}
.card-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.meta-tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
}
.assignee-tag {
display: inline-flex;
align-items: center;
gap: 5px;
background: rgba(var(--accent-primary-rgb), 0.1);
color: $accent-primary;
padding-left: 2px;
}
.assignee-profile-avatar {
box-shadow: 0 0 0 1px rgba(var(--accent-primary-rgb), 0.28);
}
.priority-tag {
&.high {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
&.medium {
background: rgba(var(--warning-rgb), 0.12);
color: $warning;
}
&.low {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
}
.meta-time {
font-size: 11px;
color: $text-muted;
margin-left: auto;
}
.card-body-preview {
font-size: 12px;
color: $text-muted;
margin-top: 6px;
line-height: 1.4;
}
</style>
@@ -0,0 +1,704 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { NDrawer, NDrawerContent, NButton, NSelect, NInput, NSpin, NModal, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { request } from '@/api/client'
import { getTask } from '@/api/hermes/kanban'
import { useKanbanStore } from '@/stores/hermes/kanban'
import { withDefaultAssignee } from '@/utils/hermes/kanban-assignees'
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
import type { Session, Message } from '@/stores/hermes/chat'
import type { KanbanTaskDetail } from '@/api/hermes/kanban'
const props = defineProps<{
taskId: string | null
}>()
const emit = defineEmits<{
close: []
updated: []
}>()
const { t } = useI18n()
const router = useRouter()
const message = useMessage()
const kanbanStore = useKanbanStore()
const detail = ref<KanbanTaskDetail | null>(null)
const loading = ref(false)
const assignProfile = ref<string | null>(null)
const blockReason = ref('')
const showBlockInput = ref(false)
const completeSummary = ref('')
const showCompleteInput = ref(false)
const showMessagesModal = ref(false)
const completionSummary = computed(() => {
if (!detail.value) return ''
return detail.value.task.result || detail.value.latest_summary || ''
})
const localizedTaskStatus = computed(() => {
if (!detail.value) return ''
return t(`kanban.columns.${detail.value.task.status}`, detail.value.task.status)
})
const canMutateTask = computed(() => {
const status = detail.value?.task.status
return status !== 'done' && status !== 'archived'
})
const sessionResults = ref<any[]>([])
const sessionLoading = ref(false)
const showSessions = ref(false)
const latestRunProfile = computed(() => {
if (!detail.value) return null
return [...detail.value.runs].reverse().find(run => run.profile)?.profile || null
})
async function searchTaskSessions() {
if (!detail.value) return
const profile = latestRunProfile.value
if (!profile) return
showSessions.value = !showSessions.value
if (!showSessions.value) return
sessionLoading.value = true
try {
const res = await request<{ results: any[] }>(
`/api/hermes/kanban/search-sessions?task_id=${encodeURIComponent(detail.value.task.id)}&profile=${encodeURIComponent(profile)}&board=${encodeURIComponent(kanbanStore.selectedBoard)}`
)
sessionResults.value = res.results
} catch {
sessionResults.value = []
} finally {
sessionLoading.value = false
}
}
function openResultDetail() {
if (detail.value?.session) {
showMessagesModal.value = true
}
}
const historySession = computed<Session | null>(() => {
const s = detail.value?.session
if (!s) return null
return {
id: s.id,
title: s.title || '',
source: s.source,
messages: s.messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({
id: String(m.id),
role: m.role as Message['role'],
content: m.content,
timestamp: m.timestamp,
})),
createdAt: s.started_at,
updatedAt: s.ended_at || s.started_at,
model: s.model,
messageCount: s.messages.length,
endedAt: s.ended_at,
}
})
const assigneeOptions = computed(() => {
return withDefaultAssignee(kanbanStore.assignees, kanbanStore.stats?.by_assignee || {})
.map(a => ({ label: a.name, value: a.name }))
})
watch(() => [props.taskId, kanbanStore.selectedBoard] as const, async ([id, board]) => {
if (!id) {
detail.value = null
return
}
loading.value = true
try {
const nextDetail = await getTask(id, { board })
if (props.taskId === id && kanbanStore.selectedBoard === board) {
detail.value = nextDetail
}
} catch (err: any) {
if (props.taskId === id && kanbanStore.selectedBoard === board) {
message.error(t('kanban.message.loadFailed'))
}
} finally {
if (props.taskId === id && kanbanStore.selectedBoard === board) {
loading.value = false
}
}
}, { immediate: true })
function formatTime(ts: number | null) {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString()
}
async function handleComplete() {
if (!props.taskId) return
if (!showCompleteInput.value) {
showCompleteInput.value = true
return
}
try {
await kanbanStore.completeTasks([props.taskId], completeSummary.value.trim() || undefined)
message.success(t('kanban.message.taskCompleted'))
showCompleteInput.value = false
completeSummary.value = ''
emit('updated')
emit('close')
} catch (err: any) {
message.error(err.message)
}
}
async function handleBlock() {
if (!props.taskId || !blockReason.value.trim()) return
try {
await kanbanStore.blockTask(props.taskId, blockReason.value.trim())
message.success(t('kanban.message.taskBlocked'))
showBlockInput.value = false
blockReason.value = ''
emit('updated')
emit('close')
} catch (err: any) {
message.error(err.message)
}
}
async function handleUnblock() {
if (!props.taskId) return
try {
await kanbanStore.unblockTasks([props.taskId])
message.success(t('kanban.message.taskUnblocked'))
emit('updated')
emit('close')
} catch (err: any) {
message.error(err.message)
}
}
async function handleAssign() {
if (!props.taskId || !assignProfile.value) return
try {
await kanbanStore.assignTask(props.taskId, assignProfile.value)
message.success(t('kanban.message.taskAssigned'))
assignProfile.value = null
if (detail.value) {
detail.value = await getTask(props.taskId, { board: kanbanStore.selectedBoard })
}
emit('updated')
} catch (err: any) {
message.error(err.message)
}
}
</script>
<template>
<NDrawer :show="!!taskId" :width="420" placement="right" @update:show="(v: boolean) => { if (!v) emit('close') }">
<NDrawerContent :title="detail?.task.title || ''" closable>
<NSpin :show="loading">
<template v-if="detail">
<!-- Metadata -->
<div class="detail-section">
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.status') }}</span>
<span class="detail-value status-badge" :class="detail.task.status">{{ localizedTaskStatus }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.assignee') }}</span>
<span class="detail-value">{{ detail.task.assignee || '—' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.priority') }}</span>
<span class="detail-value">{{ detail.task.priority }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.tenant') }}</span>
<span class="detail-value">{{ detail.task.tenant || '—' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.createdAt') }}</span>
<span class="detail-value">{{ formatTime(detail.task.created_at) }}</span>
</div>
<div v-if="detail.task.started_at" class="detail-row">
<span class="detail-label">{{ t('kanban.detail.startedAt') }}</span>
<span class="detail-value">{{ formatTime(detail.task.started_at) }}</span>
</div>
<div v-if="detail.task.completed_at" class="detail-row">
<span class="detail-label">{{ t('kanban.detail.completedAt') }}</span>
<span class="detail-value">{{ formatTime(detail.task.completed_at) }}</span>
</div>
</div>
<!-- Body -->
<div v-if="detail.task.body" class="detail-section">
<div class="section-title">{{ t('kanban.form.body') }}</div>
<div class="detail-body">{{ detail.task.body }}</div>
</div>
<!-- Result / Summary -->
<div v-if="completionSummary" class="detail-section">
<div class="section-title">{{ t('kanban.detail.result') }}</div>
<div class="result-summary" @click="openResultDetail">{{ completionSummary }}</div>
</div>
<!-- Actions (only for active, mutable tasks) -->
<div v-if="canMutateTask" class="detail-section">
<div class="section-title">{{ t('kanban.action.title') }}</div>
<div class="action-group">
<template v-if="!showCompleteInput">
<NButton size="small" @click="showCompleteInput = true">
{{ t('kanban.action.complete') }}
</NButton>
</template>
<div v-else class="complete-input">
<NInput v-model:value="completeSummary" size="small" :placeholder="t('kanban.action.completeSummary')" />
<NButton size="small" type="primary" @click="handleComplete">{{ t('common.ok') }}</NButton>
<NButton size="small" @click="showCompleteInput = false; completeSummary = ''">{{ t('common.cancel') }}</NButton>
</div>
<template v-if="detail.task.status === 'blocked'">
<NButton size="small" @click="handleUnblock">{{ t('kanban.action.unblock') }}</NButton>
</template>
<template v-else>
<NButton v-if="!showBlockInput" size="small" @click="showBlockInput = true">{{ t('kanban.action.block') }}</NButton>
<div v-else class="block-input">
<NInput v-model:value="blockReason" size="small" :placeholder="t('kanban.action.blockReason')" />
<NButton size="small" type="primary" @click="handleBlock">{{ t('common.ok') }}</NButton>
</div>
</template>
</div>
<div v-if="detail.task.status !== 'running'" class="assign-group">
<NSelect v-model:value="assignProfile" :options="assigneeOptions" size="small" :placeholder="t('kanban.action.assignTo')" style="flex: 1;" />
<NButton size="small" :disabled="!assignProfile" @click="handleAssign">{{ t('kanban.action.assign') }}</NButton>
</div>
</div>
<!-- Related Sessions -->
<div v-if="detail.runs.length > 0" class="detail-section">
<div class="section-title" style="cursor: pointer;" @click="searchTaskSessions">
{{ t('kanban.detail.sessions') }}
<NSpin v-if="sessionLoading" :size="12" style="margin-left: 6px;" />
</div>
<div v-if="showSessions && sessionResults.length > 0" class="session-list">
<div v-for="session in sessionResults" :key="session.id" class="session-item" @click="router.push({ name: 'hermes.chat', query: { session: session.id } })">
<div class="session-title">{{ session.title || session.id }}</div>
<div class="session-meta">
<span>{{ session.source }}</span>
<span>{{ session.model }}</span>
<span>{{ formatTime(session.started_at) }}</span>
</div>
</div>
</div>
<div v-if="showSessions && !sessionLoading && sessionResults.length === 0" class="column-empty">{{ t('kanban.detail.noSessions') }}</div>
</div>
<!-- Runs -->
<div v-if="detail.runs.length > 0" class="detail-section">
<div class="section-title">{{ t('kanban.detail.runs') }}</div>
<div v-for="run in detail.runs" :key="run.id" class="run-item">
<div class="run-header">
<span class="run-status" :class="run.status">{{ run.status }}</span>
<span class="run-profile">{{ run.profile || '—' }}</span>
<span class="run-time">{{ formatTime(run.started_at) }}</span>
</div>
<div v-if="run.summary" class="run-summary">{{ run.summary }}</div>
<div v-if="run.error" class="run-error">{{ run.error }}</div>
</div>
</div>
<!-- Comments -->
<div v-if="detail.comments.length > 0" class="detail-section">
<div class="section-title">{{ t('kanban.detail.comments') }}</div>
<div v-for="comment in detail.comments" :key="comment.id" class="comment-item">
<div class="comment-header">
<span class="comment-author">{{ comment.author }}</span>
<span class="comment-time">{{ formatTime(comment.created_at) }}</span>
</div>
<div class="comment-body">{{ comment.body }}</div>
</div>
</div>
<!-- Events -->
<div v-if="detail.events.length > 0" class="detail-section">
<div class="section-title">{{ t('kanban.detail.events') }}</div>
<div v-for="event in detail.events.slice(-10)" :key="event.id" class="event-item">
<span class="event-kind">{{ event.kind }}</span>
<span class="event-time">{{ formatTime(event.created_at) }}</span>
</div>
</div>
</template>
</NSpin>
</NDrawerContent>
</NDrawer>
<!-- Session messages modal (click result summary) -->
<NModal v-if="historySession" :show="showMessagesModal" preset="card" :title="detail?.task.title || ''" :style="{ width: '900px', maxWidth: 'calc(100vw - 48px)' }" @close="showMessagesModal = false">
<div class="messages-modal-body">
<HistoryMessageList :session="historySession" />
</div>
</NModal>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.detail-section {
margin-bottom: 20px;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.result-summary {
cursor: pointer;
border-radius: $radius-sm;
padding: 8px 10px;
background: rgba(var(--accent-primary-rgb), 0.04);
border: 1px solid $border-light;
font-size: 13px;
color: $text-secondary;
line-height: 1.5;
transition: border-color $transition-fast;
&:hover { border-color: rgba(var(--accent-primary-rgb), 0.3); }
}
.result-detail {
margin-top: 10px;
padding: 10px;
background: rgba(var(--accent-primary-rgb), 0.02);
border: 1px solid $border-light;
border-radius: $radius-sm;
}
.meta-label {
font-size: 11px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.3px;
margin: 8px 0 4px;
&:first-child { margin-top: 0; }
}
.meta-list {
list-style: none;
padding: 0;
margin: 0;
li {
font-size: 12px;
color: $text-secondary;
padding: 2px 0;
code {
font-family: $font-code;
font-size: 11px;
background: rgba(var(--accent-primary-rgb), 0.06);
padding: 1px 4px;
border-radius: 3px;
word-break: break-all;
}
}
}
.meta-kv {
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-kv-row {
display: flex;
gap: 8px;
font-size: 12px;
}
.meta-kv-key {
color: $text-muted;
font-family: $font-code;
font-size: 11px;
min-width: 100px;
flex-shrink: 0;
}
.meta-kv-val {
color: $text-secondary;
}
.artifact-link {
cursor: pointer;
transition: color $transition-fast;
&:hover { color: $accent-primary; }
}
.artifact-modal-body,
.messages-modal-body {
max-height: 65vh;
overflow: hidden;
padding: 4px 0;
:deep(.message-list) {
max-height: 65vh;
background: transparent;
padding: 0;
}
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid $border-light;
}
.detail-label {
font-size: 12px;
color: $text-muted;
}
.detail-value {
font-size: 12px;
color: $text-primary;
}
.status-badge {
padding: 1px 8px;
border-radius: 4px;
font-weight: 500;
&.triage {
background: rgba(148, 163, 184, 0.14);
color: #94a3b8;
}
&.todo {
background: rgba(56, 189, 248, 0.14);
color: #38bdf8;
}
&.ready {
background: rgba(var(--warning-rgb), 0.12);
color: $warning;
}
&.running {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.blocked {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
&.done {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.archived {
background: rgba(100, 116, 139, 0.14);
color: #94a3b8;
}
}
.detail-body {
font-size: 13px;
color: $text-secondary;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.action-group {
display: flex;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.block-input,
.complete-input {
display: flex;
gap: 6px;
flex: 1;
}
.assign-group {
display: flex;
gap: 8px;
}
.run-item,
.comment-item {
padding: 8px 0;
border-bottom: 1px solid $border-light;
&:last-child {
border-bottom: none;
}
}
.run-header,
.comment-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.run-status {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
&.running {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.done, &.completed {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.crashed, &.failed {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
}
.run-profile,
.comment-author {
font-size: 12px;
font-weight: 500;
color: $text-primary;
}
.run-time,
.comment-time {
font-size: 11px;
color: $text-muted;
margin-left: auto;
}
.run-summary,
.run-error,
.comment-body {
font-size: 12px;
color: $text-secondary;
line-height: 1.4;
}
.run-error {
color: $error;
}
.event-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.event-kind {
font-size: 11px;
font-family: $font-code;
color: $accent-primary;
}
.event-time {
font-size: 11px;
color: $text-muted;
margin-left: auto;
}
.session-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.session-item {
padding: 8px 10px;
border-radius: $radius-sm;
border: 1px solid $border-light;
cursor: pointer;
transition: border-color $transition-fast;
&:hover { border-color: rgba(var(--accent-primary-rgb), 0.3); }
}
.session-title {
font-size: 13px;
font-weight: 500;
color: $text-primary;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta {
display: flex;
gap: 8px;
font-size: 11px;
color: $text-muted;
}
.session-messages {
display: flex;
flex-direction: column;
gap: 10px;
}
.session-msg {
padding: 10px 12px;
border-radius: $radius-sm;
border: 1px solid $border-light;
&.user {
background: rgba(var(--accent-primary-rgb), 0.04);
}
&.assistant {
background: transparent;
}
}
.session-msg-role {
font-size: 11px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
margin-bottom: 6px;
}
.session-msg-content {
font-size: 13px;
color: $text-secondary;
line-height: 1.5;
:deep(p) {
margin: 0 0 8px;
&:last-child { margin-bottom: 0; }
}
}
</style>
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NSwitch, NPopconfirm } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import type { McpServerInfo } from '@/api/hermes/mcp'
const props = defineProps<{
server: McpServerInfo
toolsByServer: Record<string, Array<{ name: string; description?: string }>>
}>()
const emit = defineEmits<{
edit: [server: McpServerInfo]
test: [server: McpServerInfo]
reload: [name: string]
remove: [server: McpServerInfo]
toggleEnabled: [server: McpServerInfo]
manageTools: [server: McpServerInfo]
}>()
const { t } = useI18n()
function statusClass(server: McpServerInfo) {
if (server.raw_config.enabled === false) return 'disabled'
return server.connected ? 'connected' : 'disconnected'
}
function statusLabel(server: McpServerInfo) {
if (server.raw_config.enabled === false) return t('mcp.disabledStatus')
return server.connected ? t('mcp.connectedStatus') : t('mcp.disconnectedStatus')
}
const tools = computed(() => props.toolsByServer[props.server.name] || [])
const MAX_VISIBLE_TOOLS = 20
</script>
<template>
<div class="mcp-card" :class="{ disconnected: !server.connected, disabled: server.raw_config.enabled === false }">
<!-- 第一行标题 + 标签 -->
<div class="card-header">
<h3 class="server-name">{{ server.name }}</h3>
<div class="server-badges">
<span class="type-badge transport">{{ server.transport }}</span>
<span class="type-badge" :class="statusClass(server)">{{ statusLabel(server) }}</span>
</div>
</div>
<!-- 第二行工具列表 + 数量 -->
<div class="card-body">
<div v-if="server.error" class="error-row">
<span class="error-text">{{ server.error }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('mcp.toolList') }}</span>
<span class="info-value">
{{ server.tools_registered }}/{{ server.tools }}{{ t('mcp.count') }}{{ t('mcp.tools') }}
</span>
</div>
<!-- 工具标签列表 -->
<div v-if="server.tools > 0" class="tools-list">
<span
v-for="tool in tools.slice(0, MAX_VISIBLE_TOOLS)"
:key="tool.name"
class="tool-tag"
:title="tool.description"
>
{{ tool.name }}
</span>
<span v-if="tools.length > MAX_VISIBLE_TOOLS" class="tool-tag tool-tag-more">
+{{ tools.length - MAX_VISIBLE_TOOLS }} {{ t('mcp.more') }}
</span>
</div>
<div v-else class="tools-empty">
<span class="muted">{{ t('mcp.zeroTools') }}</span>
</div>
</div>
<!-- 底部按钮 + 开关 -->
<div class="card-footer">
<div class="card-actions">
<NButton size="tiny" quaternary @click="emit('edit', server)">{{ t('mcp.edit') }}</NButton>
<NButton size="tiny" quaternary :disabled="!server.connected" @click="emit('manageTools', server)">{{ t('mcp.manageTools') }}</NButton>
<NButton size="tiny" quaternary @click="emit('test', server)">{{ t('mcp.test') }}</NButton>
<NButton size="tiny" quaternary @click="emit('reload', server.name)">{{ t('mcp.reload') }}</NButton>
<NPopconfirm @positive-click="emit('remove', server)">
<template #trigger>
<NButton size="tiny" quaternary type="error">{{ t('mcp.remove') }}</NButton>
</template>
{{ t('mcp.confirmRemove', { name: server.name }) }}
</NPopconfirm>
</div>
<NSwitch
:value="server.raw_config.enabled !== false"
size="small"
@update:value="() => emit('toggleEnabled', server)"
/>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.mcp-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
transition: border-color $transition-fast;
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
&.disconnected {
border-color: rgba(var(--error-rgb), 0.3);
}
&.disabled {
opacity: 0.7;
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.server-name {
font-size: 15px;
font-weight: 600;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.server-badges {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
}
.type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
&.transport {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.connected {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.disconnected {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
&.disabled {
background: rgba(var(--text-muted-rgb, 128,128,128), 0.12);
color: $text-muted;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.error-row {
margin-bottom: 4px;
}
.error-text {
color: $error;
font-size: 11px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 12px;
color: $text-muted;
}
.info-value {
font-size: 12px;
color: $text-secondary;
}
.tools-list {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
height: 88px;
overflow-y: auto;
align-content: flex-start;
}
.tool-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
font-size: 10px;
font-family: $font-code;
padding: 2px 6px;
border-radius: 3px;
background: rgba(var(--accent-primary-rgb), 0.08);
color: $text-secondary;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.16);
}
&-more {
background: rgba(var(--accent-primary-rgb), 0.15);
color: $accent-primary;
font-weight: 500;
}
}
.tools-empty {
height: 88px;
display: flex;
align-items: center;
justify-content: center;
}
.muted {
color: $text-muted;
font-size: 12px;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid $border-light;
padding-top: 10px;
}
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startCodexLogin, pollCodexLogin } from '@/api/hermes/codex-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const userCode = ref('')
const verificationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startCodexLogin()
userCode.value = data.user_code
verificationUrl.value = data.verification_url
sessionId.value = data.session_id
status.value = 'waiting'
startPolling()
} catch (err: any) {
status.value = 'error'
const msg = err.message || ''
// Try to extract friendly error from response
try {
const match = msg.match(/\{[\s\S]*\}$/)
if (match) {
const body = JSON.parse(match[0])
errorMessage.value = body.error || msg
} else {
errorMessage.value = msg
}
} catch {
errorMessage.value = msg
}
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollCodexLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.codexApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.codexCopyCode'))
else message.error(t('models.codexCopyCode') + ' ✗')
}
function openLink() {
window.open(verificationUrl.value, '_blank')
}
function retry() {
status.value = 'idle'
userCode.value = ''
verificationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(() => {
stopPolling()
})
// Auto-start when modal opens
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.codexLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="codex-login">
<!-- Idle / Loading -->
<div v-if="status === 'idle' || status === 'loading'" class="codex-login__state">
<NSpin size="small" />
</div>
<!-- Waiting for authorization -->
<div v-else-if="status === 'waiting'" class="codex-login__state">
<p class="codex-login__hint">{{ t('models.codexWaiting') }}</p>
<div class="codex-login__code" @click="copyCode">
<span class="codex-login__code-text">{{ userCode }}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</div>
<NButton type="primary" block @click="openLink">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</template>
{{ t('models.codexOpenLink') }}
</NButton>
</div>
<!-- Approved -->
<div v-else-if="status === 'approved'" class="codex-login__state codex-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.codexApproved') }}</p>
</div>
<!-- Expired -->
<div v-else-if="status === 'expired'" class="codex-login__state">
<p class="codex-login__error">{{ t('models.codexExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="codex-login__state">
<p class="codex-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.codex-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.codex-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.codex-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.codex-login__code {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
background: var(--n-color, #fafafa);
&:hover {
border-color: var(--n-primary-color, #18a058);
}
}
.codex-login__code-text {
font-size: 28px;
font-weight: 700;
font-family: monospace;
letter-spacing: 4px;
color: var(--n-text-color, inherit);
}
.codex-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.codex-login__error {
color: #d03050;
text-align: center;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startCopilotLogin, pollCopilotLogin } from '@/api/hermes/copilot-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const userCode = ref('')
const verificationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startCopilotLogin()
userCode.value = data.user_code
verificationUrl.value = data.verification_url
sessionId.value = data.session_id
status.value = 'waiting'
startPolling()
} catch (err: any) {
status.value = 'error'
const msg = err?.message || ''
try {
const match = msg.match(/\{[\s\S]*\}$/)
if (match) {
const body = JSON.parse(match[0])
errorMessage.value = body.error || msg
} else {
errorMessage.value = msg
}
} catch {
errorMessage.value = msg
}
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollCopilotLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.copilotApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'denied') {
status.value = 'error'
errorMessage.value = t('models.copilotDenied')
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.copilotCopyCode'))
else message.error(t('models.copilotCopyCode') + ' ✗')
}
function openLink() {
window.open(verificationUrl.value, '_blank')
}
function retry() {
status.value = 'idle'
userCode.value = ''
verificationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(() => {
stopPolling()
})
// Auto-start when modal opens
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.copilotLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="copilot-login">
<!-- Idle / Loading -->
<div v-if="status === 'idle' || status === 'loading'" class="copilot-login__state">
<NSpin size="small" />
</div>
<!-- Waiting for authorization -->
<div v-else-if="status === 'waiting'" class="copilot-login__state">
<p class="copilot-login__hint">{{ t('models.copilotWaiting') }}</p>
<div class="copilot-login__code" @click="copyCode">
<span class="copilot-login__code-text">{{ userCode }}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</div>
<NButton type="primary" block @click="openLink">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</template>
{{ t('models.copilotOpenLink') }}
</NButton>
</div>
<!-- Approved -->
<div v-else-if="status === 'approved'" class="copilot-login__state copilot-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.copilotApproved') }}</p>
</div>
<!-- Expired -->
<div v-else-if="status === 'expired'" class="copilot-login__state">
<p class="copilot-login__error">{{ t('models.copilotExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="copilot-login__state">
<p class="copilot-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.copilot-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.copilot-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.copilot-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.copilot-login__code {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
background: var(--n-color, #fafafa);
&:hover {
border-color: var(--n-primary-color, #18a058);
}
}
.copilot-login__code-text {
font-size: 28px;
font-weight: 700;
font-family: monospace;
letter-spacing: 4px;
color: var(--n-text-color, inherit);
}
.copilot-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.copilot-login__error {
color: #d03050;
text-align: center;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startNousLogin, pollNousLogin } from '@/api/hermes/nous-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const userCode = ref('')
const verificationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startNousLogin()
userCode.value = data.user_code
verificationUrl.value = data.verification_url
sessionId.value = data.session_id
status.value = 'waiting'
startPolling()
} catch (err: any) {
status.value = 'error'
const msg = err.message || ''
try {
const match = msg.match(/\{[\s\S]*\}$/)
if (match) {
const body = JSON.parse(match[0])
errorMessage.value = body.error || msg
} else {
errorMessage.value = msg
}
} catch {
errorMessage.value = msg
}
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollNousLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.nousApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'denied') {
status.value = 'error'
errorMessage.value = t('models.nousDenied')
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.nousCopyCode'))
else message.error(t('models.nousCopyCode') + ' ✗')
}
function openLink() {
window.open(verificationUrl.value, '_blank')
}
function retry() {
status.value = 'idle'
userCode.value = ''
verificationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(() => {
stopPolling()
})
// Auto-start when modal opens
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.nousLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="nous-login">
<!-- Idle / Loading -->
<div v-if="status === 'idle' || status === 'loading'" class="nous-login__state">
<NSpin size="small" />
</div>
<!-- Waiting for authorization -->
<div v-else-if="status === 'waiting'" class="nous-login__state">
<p class="nous-login__hint">{{ t('models.nousWaiting') }}</p>
<div class="nous-login__code" @click="copyCode">
<span class="nous-login__code-text">{{ userCode }}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</div>
<NButton type="primary" block @click="openLink">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</template>
{{ t('models.nousOpenLink') }}
</NButton>
</div>
<!-- Approved -->
<div v-else-if="status === 'approved'" class="nous-login__state nous-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.nousApproved') }}</p>
</div>
<!-- Expired -->
<div v-else-if="status === 'expired'" class="nous-login__state">
<p class="nous-login__error">{{ t('models.nousExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="nous-login__state">
<p class="nous-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.nous-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.nous-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.nous-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.nous-login__code {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
background: var(--n-color, #fafafa);
&:hover {
border-color: var(--n-primary-color, #18a058);
}
}
.nous-login__code-text {
font-size: 28px;
font-weight: 700;
font-family: monospace;
letter-spacing: 4px;
color: var(--n-text-color, inherit);
}
.nous-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.nous-login__error {
color: #d03050;
text-align: center;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,603 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NButton, NCheckbox, NCheckboxGroup, NModal, NInput, useMessage, useDialog } from 'naive-ui'
import type { AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useAppStore } from '@/stores/hermes/app'
import { useChatStore } from '@/stores/hermes/chat'
import { checkCopilotToken, disableCopilot } from '@/api/hermes/copilot-auth'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ provider: AvailableModelGroup }>()
const { t } = useI18n()
const modelsStore = useModelsStore()
const appStore = useAppStore()
const chatStore = useChatStore()
const message = useMessage()
const dialog = useDialog()
const isCustom = computed(() => !props.provider.builtin && props.provider.provider.startsWith('custom:'))
const isCopilot = computed(() => props.provider.provider === 'copilot')
const displayName = computed(() => props.provider.label)
const deleting = ref(false)
const showAliasListModal = ref(false)
const showAliasModal = ref(false)
const aliasProvider = ref('')
const aliasModel = ref('')
const aliasInput = ref('')
const showVisibilityModal = ref(false)
const visibilitySaving = ref(false)
const selectedVisibleModels = ref<string[]>([])
const sourceProvider = computed(() => modelsStore.allProviders.find(g => g.provider === props.provider.provider))
const allModels = computed(() => props.provider.available_models?.length ? props.provider.available_models : (sourceProvider.value?.models?.length ? sourceProvider.value.models : props.provider.models))
const visibilityRule = computed(() => appStore.getProviderVisibility(props.provider.provider))
const isFiltered = computed(() => visibilityRule.value.mode === 'include')
const visibleCountLabel = computed(() => `${props.provider.models.length}/${allModels.value.length}`)
const isDefaultProvider = computed(() => modelsStore.defaultProvider === props.provider.provider)
function isDefaultModel(model: string) {
return isDefaultProvider.value && modelsStore.defaultModel === model
}
function modelAlias(model: string) {
return appStore.getModelAlias(model, props.provider.provider)
}
function modelDisplayName(model: string) {
return appStore.displayModelName(model, props.provider.provider)
}
function openAliasEditor(model: string) {
aliasProvider.value = props.provider.provider
aliasModel.value = model
aliasInput.value = appStore.getModelAlias(model, props.provider.provider)
showAliasModal.value = true
}
async function saveAlias() {
if (!aliasModel.value || !aliasProvider.value) return
try {
await appStore.setModelAlias(aliasModel.value, aliasProvider.value, aliasInput.value)
showAliasModal.value = false
} catch (e: any) {
message.error(e.message || t('models.aliasSaveFailed'))
}
}
async function clearAlias() {
aliasInput.value = ''
await saveAlias()
}
function openVisibilityModal() {
const rule = appStore.getProviderVisibility(props.provider.provider)
selectedVisibleModels.value = rule.mode === 'include' ? allModels.value.filter(m => rule.models.includes(m)) : [...allModels.value]
showVisibilityModal.value = true
}
async function handleVisibilitySave() {
if (selectedVisibleModels.value.length === 0) {
message.error(t('models.visibilitySelectOne'))
return
}
visibilitySaving.value = true
try {
const selected = selectedVisibleModels.value.filter(m => allModels.value.includes(m))
const mode = selected.length === allModels.value.length ? 'all' : 'include'
await appStore.setModelVisibility(props.provider.provider, { mode, models: selected })
await modelsStore.fetchProviders()
showVisibilityModal.value = false
message.success(t('models.visibilitySaved'))
} catch (e: any) {
message.error(e.message || t('models.visibilitySaveFailed'))
} finally {
visibilitySaving.value = false
}
}
function resetVisibility() {
selectedVisibleModels.value = [...allModels.value]
}
function clearVisibility() {
selectedVisibleModels.value = []
}
async function handleDelete() {
let copilotMsg = ''
if (isCopilot.value) {
// 提前查 source,让用户清楚移除会不会影响 VS Code/gh CLI 等其他工具的登录态
try {
const status = await checkCopilotToken()
if (status.source === 'env') copilotMsg = t('models.copilotDeleteHintEnv')
else if (status.source === 'gh-cli') copilotMsg = t('models.copilotDeleteHintGhCli')
else if (status.source === 'apps-json') copilotMsg = t('models.copilotDeleteHintAppsJson')
} catch { /* ignore — fall back to generic confirm copy */ }
}
dialog.warning({
title: t('models.deleteProvider'),
content: isCopilot.value && copilotMsg
? `${t('models.deleteConfirm', { name: displayName.value })}\n\n${copilotMsg}`
: t('models.deleteConfirm', { name: displayName.value }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
deleting.value = true
try {
if (isCopilot.value) {
// Copilot 走显式 opt-in 模型:disable 把 enabled 置 false
// 仅当 token 来自 ~/.hermes/.env 时才清掉,gh-cli / apps.json 不动。
await disableCopilot()
// 服务端会在默认模型属于 copilot 时清掉 model.default,这里再清理本地
// 会话级 model/provider,避免 Chat 页继续显示已下架的 copilot 模型。
chatStore.clearProviderFromSessions('copilot')
await modelsStore.fetchProviders()
} else {
await modelsStore.removeProvider(props.provider.provider)
}
// 删完之后若已没有默认模型,自动从剩余 provider 里挑一个,避免 chat 页
// "无默认模型"的尴尬态。与 hermes CLI `model` 子命令的隐含行为对齐。
if (!appStore.selectedModel && appStore.modelGroups.length > 0) {
const first = appStore.modelGroups.find(g => g.models.length > 0)
if (first) {
await appStore.switchModel(first.models[0], first.provider)
}
}
message.success(t('models.providerDeleted'))
} catch (e: any) {
message.error(e.message)
} finally {
deleting.value = false
}
},
})
}
</script>
<template>
<div class="provider-card">
<div class="card-header">
<h3 class="provider-name">{{ displayName }}</h3>
<div class="provider-badges">
<span v-if="isDefaultProvider" class="type-badge default">{{ t('models.currentDefault') }}</span>
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
</span>
</div>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">{{ t('models.provider') }}</span>
<code class="info-value mono">{{ provider.provider }}</code>
</div>
<div class="info-row">
<span class="info-label">{{ t('models.baseUrl') }}</span>
<code class="info-value mono">{{ provider.base_url }}</code>
</div>
<div class="info-row models-row">
<span class="info-label">{{ t('models.models') }}</span>
<span class="info-value models-count">
{{ isFiltered ? visibleCountLabel : provider.models.length }} {{ t('models.count') }}
</span>
</div>
<div class="models-list">
<button
v-for="model in provider.models.slice(0, 20)"
:key="model"
class="model-tag model-tag-button"
:class="{ default: isDefaultModel(model) }"
type="button"
:title="t('models.aliasTitleFor', { model })"
@click="openAliasEditor(model)"
>
<span class="model-tag-name">{{ modelDisplayName(model) }}</span>
<span v-if="isDefaultModel(model)" class="model-tag-default">{{ t('models.defaultShort') }}</span>
<span v-if="modelAlias(model)" class="model-tag-id">{{ model }}</span>
</button>
<span v-if="provider.models.length > 20" class="model-tag model-tag-more">
+{{ provider.models.length - 20 }} {{ t('models.more') }}
</span>
</div>
</div>
<div class="card-actions">
<NButton size="tiny" quaternary @click="showAliasListModal = true">{{ t('models.aliasManage') }}</NButton>
<NButton size="tiny" quaternary @click="openVisibilityModal">{{ t('models.manageVisibleModels') }}</NButton>
<NButton size="tiny" quaternary type="error" :loading="deleting" @click="handleDelete">{{ t('common.delete') }}</NButton>
</div>
<NModal
v-model:show="showAliasListModal"
preset="card"
:title="t('models.aliasManageFor', { provider: displayName })"
:style="{ width: 'min(560px, calc(100vw - 32px))' }"
:mask-closable="true"
>
<div class="alias-list-hint">{{ t('models.aliasHint') }}</div>
<div class="alias-list">
<div v-for="model in provider.models" :key="model" class="alias-row">
<div class="alias-row-text">
<span class="alias-row-name">{{ modelDisplayName(model) }}</span>
<span v-if="isDefaultModel(model)" class="alias-row-default">{{ t('models.defaultShort') }}</span>
<code class="alias-row-id">{{ model }}</code>
</div>
<NButton size="tiny" quaternary @click="openAliasEditor(model)">{{ t('models.aliasEdit') }}</NButton>
</div>
</div>
</NModal>
<NModal
v-model:show="showAliasModal"
preset="card"
:title="aliasModel ? t('models.aliasTitleFor', { model: aliasModel }) : t('models.aliasTitle')"
:style="{ width: 'min(420px, calc(100vw - 32px))' }"
:mask-closable="true"
>
<NInput
v-model:value="aliasInput"
:placeholder="t('models.aliasPlaceholder')"
clearable
@keydown.enter="saveAlias"
/>
<div v-if="aliasModel" class="model-alias-canonical">
{{ t('models.aliasCanonical', { model: aliasModel }) }}
</div>
<div class="model-alias-hint">{{ t('models.aliasHint') }}</div>
<template #footer>
<div class="model-alias-actions">
<NButton quaternary :disabled="!appStore.getModelAlias(aliasModel, aliasProvider)" @click="clearAlias">
{{ t('models.aliasUseOriginal') }}
</NButton>
<div class="model-alias-spacer" />
<NButton @click="showAliasModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" @click="saveAlias">{{ t('common.save') }}</NButton>
</div>
</template>
</NModal>
<NModal
v-model:show="showVisibilityModal"
preset="card"
:title="t('models.manageVisibleModelsFor', { name: displayName })"
:style="{ width: 'min(560px, calc(100vw - 32px))' }"
:mask-closable="!visibilitySaving"
>
<p class="visibility-hint">{{ t('models.visibilityHint') }}</p>
<div class="visibility-count">
{{ selectedVisibleModels.length }}/{{ allModels.length }} {{ t('models.count') }}
</div>
<div class="visibility-list">
<NCheckboxGroup v-model:value="selectedVisibleModels">
<NCheckbox
v-for="model in allModels"
:key="model"
:value="model"
class="visibility-model"
>
<code>{{ modelDisplayName(model) }}</code>
<code v-if="modelAlias(model)" class="visibility-model-id">{{ model }}</code>
</NCheckbox>
</NCheckboxGroup>
</div>
<div class="visibility-actions">
<NButton size="small" quaternary :disabled="visibilitySaving" @click="resetVisibility">
{{ t('models.showAllModels') }}
</NButton>
<NButton size="small" quaternary :disabled="visibilitySaving" @click="clearVisibility">
{{ t('models.clearVisibleModels') }}
</NButton>
<div class="visibility-action-spacer" />
<NButton size="small" :disabled="visibilitySaving" @click="showVisibilityModal = false">
{{ t('common.cancel') }}
</NButton>
<NButton size="small" type="primary" :loading="visibilitySaving" @click="handleVisibilitySave">
{{ t('common.save') }}
</NButton>
</div>
</NModal>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.provider-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
transition: border-color $transition-fast;
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.provider-name {
font-size: 15px;
font-weight: 600;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.provider-badges {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
}
.type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
&.builtin {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.custom {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.default {
background: rgba(var(--warning-rgb), 0.14);
color: $warning;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 12px;
color: $text-muted;
}
.info-value {
font-size: 12px;
color: $text-secondary;
}
.mono {
font-family: $font-code;
font-size: 12px;
}
.models-row {
margin-top: 4px;
}
.models-count {
color: $text-muted;
font-size: 12px;
}
.models-list {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
margin-top: 6px;
height: 100px;
overflow-y: auto;
align-content: flex-start;
}
.model-tag {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 22px;
font-size: 10px;
font-family: $font-code;
padding: 2px 6px;
border-radius: 3px;
background: rgba(var(--accent-primary-rgb), 0.08);
color: $text-secondary;
white-space: nowrap;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
&-more {
background: rgba(var(--accent-primary-rgb), 0.15);
color: $accent-primary;
font-weight: 500;
}
&.default {
background: rgba(var(--warning-rgb), 0.14);
color: $text-primary;
}
}
.model-tag-button {
border: 0;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.16);
color: $text-primary;
}
}
.model-tag-name,
.model-tag-id {
overflow: hidden;
text-overflow: ellipsis;
}
.model-tag-id {
color: $text-muted;
font-size: 9px;
}
.model-tag-default,
.alias-row-default {
color: $warning;
font-family: $font-ui;
font-size: 10px;
font-weight: 600;
}
.card-actions {
display: flex;
gap: 8px;
border-top: 1px solid $border-light;
padding-top: 10px;
}
.alias-list-hint,
.model-alias-hint {
color: $text-muted;
font-size: 12px;
}
.alias-list-hint {
margin-bottom: 12px;
}
.alias-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 45vh;
overflow-y: auto;
}
.alias-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border: 1px solid $border-light;
border-radius: $radius-sm;
}
.alias-row-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.alias-row-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-primary;
font-family: $font-code;
font-size: 12px;
}
.alias-row-id,
.model-alias-canonical {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-muted;
font-family: $font-code;
font-size: 11px;
}
.model-alias-canonical {
margin-top: 8px;
}
.model-alias-hint {
margin-top: 6px;
}
.model-alias-actions {
display: flex;
align-items: center;
gap: 8px;
}
.model-alias-spacer {
flex: 1;
}
.visibility-hint {
margin: 0 0 10px;
color: $text-secondary;
font-size: 13px;
line-height: 1.5;
}
.visibility-count {
color: $text-muted;
font-size: 12px;
margin-bottom: 10px;
}
.visibility-list {
max-height: 360px;
overflow-y: auto;
border: 1px solid $border-light;
border-radius: $radius-sm;
padding: 8px;
}
.visibility-model {
display: flex;
width: 100%;
padding: 4px 2px;
code {
font-family: $font-code;
font-size: 12px;
color: $text-secondary;
}
}
.visibility-model-id {
margin-left: 6px;
color: $text-muted !important;
font-size: 11px !important;
}
.visibility-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.visibility-action-spacer {
flex: 1;
}
</style>
@@ -0,0 +1,492 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { NModal, NForm, NFormItem, NInput, NInputNumber, NButton, NSelect, NRadioGroup, NRadioButton, useMessage, useDialog } from 'naive-ui'
import { useModelsStore } from '@/stores/hermes/models'
import { useI18n } from 'vue-i18n'
import CodexLoginModal from './CodexLoginModal.vue'
import NousLoginModal from './NousLoginModal.vue'
import CopilotLoginModal from './CopilotLoginModal.vue'
import XaiOAuthLoginModal from './XaiOAuthLoginModal.vue'
import { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth'
import { fetchProviderModels } from '@/api/hermes/system'
import { normalizeCustomProviderBaseUrl } from '@/utils/providerBaseUrl'
const { t } = useI18n()
const emit = defineEmits<{
close: []
saved: []
}>()
const modelsStore = useModelsStore()
const message = useMessage()
const dialog = useDialog()
const showModal = ref(true)
const loading = ref(false)
const fetchingModels = ref(false)
const showCodexLogin = ref(false)
const showNousLogin = ref(false)
const showCopilotLogin = ref(false)
const showXaiLogin = ref(false)
const copilotChecking = ref(false)
const providerType = ref<'preset' | 'custom'>('preset')
const selectedPreset = ref<string | null>(null)
const formData = ref({
name: '',
base_url: '',
api_key: '',
model: '',
context_length: null as number | null,
})
const modelOptions = ref<Array<{ label: string; value: string }>>([])
const CODEX_KEY = 'openai-codex'
const NOUS_KEY = 'nous'
const COPILOT_KEY = 'copilot'
const CLIPROXYAPI_KEY = 'cliproxyapi'
const XAI_OAUTH_KEY = 'xai-oauth'
const ALIBABA_CODING_KEY = 'alibaba-coding-plan'
const ALIBABA_CODING_REGIONS = {
intl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
cn: 'https://coding.dashscope.aliyuncs.com/v1',
} as const
const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
const isNous = computed(() => selectedPreset.value === NOUS_KEY)
const isCopilot = computed(() => selectedPreset.value === COPILOT_KEY)
const isCliproxyApi = computed(() => selectedPreset.value === CLIPROXYAPI_KEY)
const isXaiOAuth = computed(() => selectedPreset.value === XAI_OAUTH_KEY)
const isAlibabaCoding = computed(() => selectedPreset.value === ALIBABA_CODING_KEY)
const alibabaCodingRegion = ref<'intl' | 'cn'>('intl')
const presetOptions = computed(() =>
modelsStore.allProviders.map(g => ({ label: g.label, value: g.provider })),
)
const selectedPresetProvider = computed(() =>
selectedPreset.value ? modelsStore.allProviders.find(g => g.provider === selectedPreset.value) : null,
)
const canEditPresetBaseUrl = computed(() => !!selectedPresetProvider.value?.base_url_env)
const FUN_LINK_MAP: Record<string, string> = {
'fun-codex': 'https://apikey.fun/register?aff=LIBAPI',
'fun-claude': 'https://apikey.fun/register?aff=LIBAPI',
}
const funProviderLink = computed(() => selectedPreset.value ? FUN_LINK_MAP[selectedPreset.value] || '' : '')
function autoGenerateName(url: string): string {
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
const host = clean.split('/')[0]
if (host.includes('localhost') || host.includes('127.0.0.1')) {
return t('models.local', { host })
}
return host.charAt(0).toUpperCase() + host.slice(1)
}
watch(selectedPreset, (val) => {
formData.value.model = ''
alibabaCodingRegion.value = 'intl'
if (val) {
const group = selectedPresetProvider.value
if (group) {
formData.value.name = group.label
formData.value.base_url = group.base_url
modelOptions.value = group.models.map((m: string) => ({ label: m, value: m }))
if (group.models.length > 0) {
formData.value.model = group.models[0]
}
}
if (val === COPILOT_KEY) {
// 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow
void triggerCopilotAdd()
} else if (val === XAI_OAUTH_KEY) {
showXaiLogin.value = true
}
}
})
watch(alibabaCodingRegion, (region) => {
if (isAlibabaCoding.value) {
formData.value.base_url = ALIBABA_CODING_REGIONS[region]
}
})
watch(() => formData.value.base_url, (url) => {
if (providerType.value === 'custom' && url.trim() && !formData.value.name) {
formData.value.name = autoGenerateName(url.trim())
}
})
watch(providerType, () => {
modelOptions.value = []
formData.value = { name: '', base_url: '', api_key: '', model: '', context_length: null }
selectedPreset.value = null
})
onMounted(() => {
if (modelsStore.providers.length === 0) {
modelsStore.fetchProviders()
}
})
async function fetchModels() {
const { base_url } = formData.value
if (!base_url.trim()) {
message.warning(t('models.enterBaseUrl'))
return
}
fetchingModels.value = true
try {
const data = await fetchProviderModels({
base_url: base_url.trim(),
api_key: formData.value.api_key.trim(),
})
modelOptions.value = data.models.map(m => ({ label: m, value: m }))
if (modelOptions.value.length > 0 && !formData.value.model) {
formData.value.model = modelOptions.value[0].value
}
message.success(t('models.foundModels', { count: modelOptions.value.length }))
} catch (e: any) {
message.error(t('models.fetchFailed') + ': ' + e.message)
} finally {
fetchingModels.value = false
}
}
async function handleSave() {
if (providerType.value === 'preset' && !selectedPreset.value) {
message.warning(t('models.selectProviderRequired'))
return
}
// Codex: 弹出授权码弹窗
if (isCodex.value) {
showCodexLogin.value = true
return
}
// Nous: 弹出 OAuth 设备码弹窗
if (isNous.value) {
showNousLogin.value = true
return
}
// Copilot: 走 token-aware 的添加流程(已有 token → 确认窗;否则 device flow
if (isCopilot.value) {
void triggerCopilotAdd()
return
}
if (isXaiOAuth.value) {
showXaiLogin.value = true
return
}
if (!formData.value.base_url.trim()) {
message.warning(t('models.baseUrlRequired'))
return
}
if (!formData.value.api_key.trim() && !isCliproxyApi.value && !isXaiOAuth.value) {
message.warning(t('models.apiKeyRequired'))
return
}
if (!formData.value.model) {
message.warning(t('models.modelRequired'))
return
}
loading.value = true
try {
const providerKey = providerType.value === 'preset'
? selectedPreset.value
: null
const contextLength = formData.value.context_length ?? undefined
const baseUrl = providerType.value === 'custom'
? normalizeCustomProviderBaseUrl(formData.value.base_url)
: formData.value.base_url.trim()
await modelsStore.addProvider({
name: formData.value.name.trim(),
base_url: baseUrl,
api_key: formData.value.api_key.trim(),
model: formData.value.model,
context_length: contextLength,
providerKey,
})
message.success(t('models.providerAdded'))
emit('saved')
} catch (e: any) {
message.error(e.message)
} finally {
loading.value = false
}
}
async function handleCodexSuccess() {
showCodexLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
async function handleNousSuccess() {
showNousLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
async function handleCopilotSuccess() {
showCopilotLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
async function handleXaiSuccess() {
showXaiLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
function copilotSourceLabel(source: CopilotTokenSource): string {
if (source === 'env') return t('models.copilotAddSourceEnv')
if (source === 'gh-cli') return t('models.copilotAddSourceGhCli')
if (source === 'apps-json') return t('models.copilotAddSourceAppsJson')
return ''
}
async function triggerCopilotAdd() {
if (copilotChecking.value) return
copilotChecking.value = true
try {
const status = await checkCopilotToken()
if (status.has_token) {
// 已能解析到 token:弹确认窗,用户点 [添加] → enable + saved
const sourceText = copilotSourceLabel(status.source)
dialog.success({
title: t('models.copilotAddDetectedTitle'),
content: sourceText
? `${t('models.copilotAddDetected')}\n\n${sourceText}`
: t('models.copilotAddDetected'),
positiveText: t('common.add'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await enableCopilot()
message.success(t('models.providerAdded'))
emit('saved')
} catch (e: any) {
message.error(e?.message ?? String(e))
}
},
onNegativeClick: () => {
selectedPreset.value = null
},
onClose: () => {
selectedPreset.value = null
},
})
} else {
// 无 tokendevice flow
showCopilotLogin.value = true
}
} catch (e: any) {
message.error(e?.message ?? String(e))
selectedPreset.value = null
} finally {
copilotChecking.value = false
}
}
function handleCopilotClose() {
showCopilotLogin.value = false
// 用户取消 Copilot 引导时,清空选择避免卡在无 api_key 状态
selectedPreset.value = null
}
function handleXaiClose() {
showXaiLogin.value = false
selectedPreset.value = null
}
function handleClose() {
showModal.value = false
setTimeout(() => emit('close'), 200)
}
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.addProvider')"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
:mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin && !showXaiLogin"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem :label="t('models.providerType')">
<div style="display: flex; gap: 12px">
<NButton
:type="providerType === 'preset' ? 'primary' : 'default'"
size="small"
@click="providerType = 'preset'"
>
{{ t('models.preset') }}
</NButton>
<NButton
:type="providerType === 'custom' ? 'primary' : 'default'"
size="small"
@click="providerType = 'custom'"
>
{{ t('models.custom') }}
</NButton>
</div>
</NFormItem>
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
<NSelect
v-model:value="selectedPreset"
:options="presetOptions"
:placeholder="t('models.chooseProvider')"
filterable
/>
<div v-if="selectedPreset && funProviderLink" class="fun-provider-hint">
<a :href="funProviderLink" target="_blank" rel="noopener noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
{{ t('models.getApiKey') }}
</a>
</div>
</NFormItem>
<NFormItem v-if="providerType === 'custom'" :label="t('models.name')">
<NInput
v-model:value="formData.name"
:placeholder="t('models.autoGeneratedName')"
/>
</NFormItem>
<NFormItem v-if="isAlibabaCoding" :label="t('models.region')">
<NRadioGroup v-model:value="alibabaCodingRegion">
<NRadioButton value="intl">{{ t('models.regionIntl') }}</NRadioButton>
<NRadioButton value="cn">{{ t('models.regionCn') }}</NRadioButton>
</NRadioGroup>
</NFormItem>
<NFormItem v-if="!isCodex && !isNous" :label="t('models.baseUrl')" required>
<NInput
v-model:value="formData.base_url"
:placeholder="t('models.baseUrlPlaceholder')"
:disabled="providerType === 'preset' && !canEditPresetBaseUrl"
/>
</NFormItem>
<NFormItem v-if="!isCodex && !isNous" :label="t('models.apiKey')" :required="!isCliproxyApi && !isXaiOAuth">
<NInput
v-model:value="formData.api_key"
type="password"
show-password-on="click"
:placeholder="t('models.apiKeyPlaceholder')"
autocomplete="off"
/>
</NFormItem>
<NFormItem :label="t('models.defaultModel')" required>
<div style="display: flex; gap: 8px; width: 100%">
<NSelect
v-model:value="formData.model"
:options="modelOptions"
filterable
tag
:placeholder="t('models.selectOrInput')"
style="flex: 1"
/>
<NButton
v-if="providerType === 'custom' || (providerType === 'preset' && modelOptions.length === 0)"
:loading="fetchingModels"
@click="fetchModels"
>
{{ t('common.fetch') }}
</NButton>
</div>
</NFormItem>
<NFormItem v-if="providerType === 'custom'" :label="t('models.contextLength')">
<NInputNumber
v-model:value="formData.context_length as number | null"
:placeholder="t('models.contextLengthPlaceholder')"
:min="0"
clearable
style="width: 100%"
/>
</NFormItem>
</NForm>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
{{ t('common.add') }}
</NButton>
</div>
</template>
<CodexLoginModal
v-if="showCodexLogin"
@close="showCodexLogin = false"
@success="handleCodexSuccess"
/>
<NousLoginModal
v-if="showNousLogin"
@close="showNousLogin = false"
@success="handleNousSuccess"
/>
<CopilotLoginModal
v-if="showCopilotLogin"
@close="handleCopilotClose"
@success="handleCopilotSuccess"
/>
<XaiOAuthLoginModal
v-if="showXaiLogin"
@close="handleXaiClose"
@success="handleXaiSuccess"
/>
</NModal>
</template>
<style scoped lang="scss">
.fun-provider-hint {
margin-top: 6px;
font-size: 12px;
a {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
white-space: nowrap;
color: var(--accent-primary);
text-decoration: none;
opacity: 0.7;
transition: opacity 0.2s;
svg {
flex-shrink: 0;
}
&:hover { opacity: 1; }
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import ProviderCard from './ProviderCard.vue'
import { useModelsStore } from '@/stores/hermes/models'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const modelsStore = useModelsStore()
</script>
<template>
<div v-if="modelsStore.providers.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<p>{{ t('models.noProviders') }}</p>
</div>
<div v-else class="providers-grid">
<ProviderCard
v-for="g in modelsStore.providers"
:key="g.provider"
:provider="g"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: $text-muted;
gap: 12px;
.empty-icon {
opacity: 0.3;
}
p {
font-size: 14px;
}
}
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
gap: 14px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More