feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -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 |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -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 |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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 仓库描述与目录信息。
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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' }))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
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>
|
||||
@@ -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>
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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 {
|
||||
// 无 token:device 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>
|
||||