feat(gui): add transitions, responsive breakpoints and bug fixes (U7)

- Add transitions.css with fade, slide, collapse, scale, stagger-list, skeleton-pulse, pulse-dot animations
- Add responsive.css with breakpoints (≥1440px full, 1280-1439px compact, <1280px prompt)
- Add small-screen prompt in AgentLayout with DesktopOutlined icon
- Fix SPA serving in app.py for Vue build output
- Fix TypeScript errors in kb.ts, skills.ts, workflow.ts, FlowCanvas.vue, SideNav.vue
- Fix unused imports in ExperienceTimeline, PathOptimizerPanel, PitfallPanel
This commit is contained in:
chiguyong 2026-06-13 02:47:51 +08:00
parent db216a5cc4
commit 4d051c2f25
14 changed files with 323 additions and 937 deletions

View File

@ -680,6 +680,7 @@ def create_app(
if gui_mode:
from pathlib import Path as _Path
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
_static_dir = _Path(__file__).parent / "static"
@ -691,4 +692,27 @@ def create_app(
return FileResponse(str(index_path))
return HTMLResponse("<h1>AgentKit GUI not found</h1>", status_code=404)
# SPA fallback: serve index.html for all non-API, non-static routes
@app.get("/{path:path}", response_class=HTMLResponse, include_in_schema=False)
async def spa_fallback(path: str):
"""Serve index.html for SPA client-side routing."""
# Don't intercept API routes
if path.startswith("api/"):
return HTMLResponse("<h1>Not Found</h1>", status_code=404)
# Try to serve a real static file first
file_path = _static_dir / path
if file_path.is_file():
return FileResponse(str(file_path))
# Fallback to index.html for SPA routing
index_path = _static_dir / "index.html"
if index_path.exists():
return FileResponse(str(index_path))
return HTMLResponse("<h1>Not Found</h1>", status_code=404)
# Mount static assets last (js, css, images, etc.)
# mount() is checked after route matching, so API routes take priority
assets_dir = _static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="static-assets")
return app

View File

@ -11,6 +11,7 @@ export interface IKbSource {
status: string
document_count: number
last_synced: string | null
config?: Record<string, unknown>
}
export interface IAddSourceRequest {

View File

@ -71,6 +71,36 @@ class SkillsApiClient extends BaseApiClient {
return this.request<{ capabilities: ICapabilityInfo[] }>('/capabilities')
}
/** Install a skill by name (searches GitHub) or from a source URL */
async installSkill(
name: string,
source?: string
): Promise<{ status: string; name: string; path: string }> {
// The install endpoint is on /api/v1/skills/install, not under skill-management
const url = `/api/v1/skills/install`
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, source }),
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }))
throw new Error(err.detail ?? resp.statusText)
}
return resp.json()
}
/** Uninstall a skill */
async uninstallSkill(skillName: string): Promise<{ status: string; name: string }> {
const url = `/api/v1/skills/${encodeURIComponent(skillName)}`
const resp = await fetch(url, { method: 'DELETE' })
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }))
throw new Error(err.detail ?? resp.statusText)
}
return resp.json()
}
/** Reload a skill */
async reloadSkill(
skillName: string

View File

@ -61,11 +61,11 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref } from 'vue'
import { Select as ASelect, SelectOption as ASelectOption, Tag as ATag, Empty as AEmpty } from 'ant-design-vue'
import type { Experience } from '@/api/evolution'
const props = defineProps<{
defineProps<{
experiences: Experience[]
}>()

View File

@ -62,7 +62,7 @@ import { ref } from 'vue'
import { Tag as ATag, Empty as AEmpty } from 'ant-design-vue'
import type { PathOptimization } from '@/api/evolution'
const props = defineProps<{
defineProps<{
optimizations: PathOptimization[]
}>()

View File

@ -49,7 +49,7 @@ import { ref } from 'vue'
import { Input as AInput, Button as AButton, Tag as ATag, Empty as AEmpty } from 'ant-design-vue'
import type { PitfallWarning } from '@/api/evolution'
const props = defineProps<{
defineProps<{
warnings: PitfallWarning[]
loading?: boolean
}>()

View File

@ -84,6 +84,11 @@
</template>
</SplitPane>
</div>
<div class="agent-layout__small-screen">
<DesktopOutlined style="font-size: 48px; color: var(--text-placeholder)" />
<h2>请使用更大的屏幕</h2>
<p>Fischer AgentKit 四象限布局需要至少 1280px 宽度的屏幕才能正常使用</p>
</div>
</div>
</template>
@ -98,6 +103,7 @@ import {
DashboardOutlined,
AppstoreOutlined,
SettingOutlined,
DesktopOutlined,
} from '@ant-design/icons-vue'
import TopNav from './TopNav.vue'
import SplitPane from './SplitPane.vue'

View File

@ -88,8 +88,8 @@ watch(
}
)
function handleMenuClick({ key }: { key: string }): void {
router.push(key)
function handleMenuClick({ key }: { key: string | number }): void {
router.push(String(key))
}
</script>

View File

@ -21,8 +21,8 @@
</a-space>
</div>
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
v-model:nodes="store.flowNodes"
v-model:edges="store.flowEdges"
:node-types="nodeTypes"
:default-edge-options="defaultEdgeOptions"
:snap-to-grid="true"
@ -58,9 +58,7 @@ import { useWorkflowStore } from '@/stores/workflow'
const store = useWorkflowStore()
const props = defineProps<{
nodes: any[]
edges: any[]
defineProps<{
saving?: boolean
executing?: boolean
}>()
@ -69,15 +67,13 @@ const emit = defineEmits<{
save: []
execute: []
clear: []
'update:nodes': [nodes: any[]]
'update:edges': [edges: any[]]
'node-select': [nodeId: string | null]
'node-drop': [nodeType: string, position: { x: number; y: number }]
}>()
const canvasRef = ref<HTMLElement>()
const nodeTypes = {
const nodeTypes: Record<string, any> = {
skill: markRaw(SkillNode),
condition: markRaw(ConditionNode),
approval: markRaw(ApprovalNode),
@ -164,17 +160,17 @@ onUnmounted(() => {
function onValidate() {
// Basic validation
if (props.nodes.length === 0) {
if (store.flowNodes.length === 0) {
message.warning('工作流为空,请添加节点')
return
}
// Check for nodes without connections (except the first)
const connectedSources = new Set(props.edges.map((e: any) => e.source))
const connectedTargets = new Set(props.edges.map((e: any) => e.target))
const connectedSources = new Set(store.flowEdges.map((e: any) => e.source))
const connectedTargets = new Set(store.flowEdges.map((e: any) => e.target))
const allConnected = new Set([...connectedSources, ...connectedTargets])
const orphanNodes = props.nodes.filter((n: any) => !allConnected.has(n.id))
if (orphanNodes.length > 0 && props.nodes.length > 1) {
const orphanNodes = store.flowNodes.filter((n: any) => !allConnected.has(n.id))
if (orphanNodes.length > 0 && store.flowNodes.length > 1) {
message.warning(`存在未连接的节点: ${orphanNodes.map((n: any) => n.data?.label).join(', ')}`)
return
}

View File

@ -30,9 +30,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
// Vue Flow state
const flowNodes = ref<Node<WorkflowNodeData>[]>([])
const flowEdges = ref<Edge[]>([])
// Vue Flow state — use any[] to avoid @vue-flow/core deep type recursion with Vue 3.5+
const flowNodes = ref<any[]>([])
const flowEdges = ref<any[]>([])
const selectedNodeId = ref<string | null>(null)
// Undo/Redo state
@ -51,9 +51,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
const executionHistoryTotal = ref(0)
// --- Getters ---
const selectedNode = computed<Node<WorkflowNodeData> | null>(() => {
const selectedNode = computed<any | null>(() => {
if (!selectedNodeId.value) return null
return flowNodes.value.find((n) => n.id === selectedNodeId.value) || null
return flowNodes.value.find((n: any) => n.id === selectedNodeId.value) || null
})
const selectedNodeData = computed<WorkflowNodeData | null>(() => {
@ -65,12 +65,12 @@ export const useWorkflowStore = defineStore('workflow', () => {
// --- Internal mutation methods (no command tracking) ---
function _addNodeDirect(node: Node<WorkflowNodeData>): void {
flowNodes.value = [...flowNodes.value, node]
function _addNodeDirect(node: any): void {
flowNodes.value.push(node)
}
function _removeNodeDirect(nodeId: string): { node: Node<WorkflowNodeData>; edges: Edge[] } | null {
const node = flowNodes.value.find((n) => n.id === nodeId)
function _removeNodeDirect(nodeId: string): { node: any; edges: any[] } | null {
const node = flowNodes.value.find((n: any) => n.id === nodeId)
if (!node) return null
const removedEdges = flowEdges.value.filter(
(e) => e.source === nodeId || e.target === nodeId
@ -97,11 +97,12 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
function _updateNodeDataDirect(nodeId: string, data: Partial<WorkflowNodeData>): void {
const index = flowNodes.value.findIndex((n) => n.id === nodeId)
const index = flowNodes.value.findIndex((n: any) => n.id === nodeId)
if (index !== -1) {
const existing = flowNodes.value[index]
flowNodes.value[index] = {
...flowNodes.value[index],
data: { ...flowNodes.value[index].data, ...data },
...existing,
data: { ...existing.data, ...data },
}
}
}

View File

@ -5,4 +5,6 @@
*/
import './tokens.css'
import './transitions.css'
import './responsive.css'
export { themeConfig } from './theme'

View File

@ -0,0 +1,89 @@
/**
* Fischer AgentKit Responsive Breakpoints
*
* 1440px: Four quadrants fully visible
* 1280-1440px: Bottom-right quadrant auto-collapsed
* <1280px: Prompt to use larger screen
*/
/* ── Full four-quadrant layout ── */
@media (min-width: 1440px) {
.agent-layout__body {
display: flex;
}
}
/* ── Compact: bottom-right quadrant collapsed ── */
@media (min-width: 1280px) and (max-width: 1439px) {
.agent-layout__body {
display: flex;
}
/* Auto-collapse bottom-right quadrant via CSS */
.quadrant-panel--bottom-right-collapsed {
height: auto !important;
}
}
/* ── Too small: show prompt ── */
@media (max-width: 1279px) {
.agent-layout__body {
display: none;
}
.agent-layout__small-screen {
display: flex;
}
}
/* ── Default: hide small screen prompt ── */
.agent-layout__small-screen {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-4);
color: var(--text-tertiary);
text-align: center;
padding: var(--space-8);
}
.agent-layout__small-screen h2 {
font-size: var(--font-lg);
color: var(--text-primary);
}
.agent-layout__small-screen p {
font-size: var(--font-base);
max-width: 400px;
}
/* ── Quadrant min-size for readability ── */
.quadrant-panel {
min-width: var(--quadrant-min-size);
min-height: var(--quadrant-min-size);
}
/* ── TopNav responsive ── */
@media (max-width: 768px) {
.top-nav__center {
display: none;
}
}
/* ── Chat sidebar responsive ── */
@media (max-width: 1024px) {
.chat-sidebar:not(.chat-sidebar--collapsed) {
width: 200px;
}
}
/* ── Print: hide interactive elements ── */
@media print {
.top-nav,
.split-pane__handle,
.quadrant-panel__collapse-btn {
display: none !important;
}
}

View File

@ -0,0 +1,132 @@
/**
* Fischer AgentKit Transition Animations
*
* Unified transition classes for Vue <Transition> components.
* All durations reference Design Token variables for consistency.
*/
/* ── Fade ── */
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--transition-fast);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* ── Slide Up ── */
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform var(--transition-normal), opacity var(--transition-fast);
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(8px);
opacity: 0;
}
/* ── Slide Down ── */
.slide-down-enter-active,
.slide-down-leave-active {
transition: transform var(--transition-normal), opacity var(--transition-fast);
}
.slide-down-enter-from,
.slide-down-leave-to {
transform: translateY(-8px);
opacity: 0;
}
/* ── Slide Right ── */
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform var(--transition-normal), opacity var(--transition-fast);
}
.slide-right-enter-from,
.slide-right-leave-to {
transform: translateX(-8px);
opacity: 0;
}
/* ── Collapse (height) ── */
.collapse-enter-active,
.collapse-leave-active {
transition: max-height var(--transition-slow) ease, opacity var(--transition-fast);
overflow: hidden;
}
.collapse-enter-from,
.collapse-leave-to {
max-height: 0;
opacity: 0;
}
.collapse-enter-to,
.collapse-leave-from {
max-height: 500px;
opacity: 1;
}
/* ── Scale ── */
.scale-enter-active,
.scale-leave-active {
transition: transform var(--transition-fast), opacity var(--transition-fast);
}
.scale-enter-from,
.scale-leave-to {
transform: scale(0.95);
opacity: 0;
}
/* ── Stagger list items ── */
.stagger-list-enter-active {
transition: transform var(--transition-normal), opacity var(--transition-fast);
}
.stagger-list-leave-active {
transition: transform var(--transition-fast), opacity var(--transition-fast);
}
.stagger-list-enter-from,
.stagger-list-leave-to {
transform: translateY(8px);
opacity: 0;
}
.stagger-list-move {
transition: transform var(--transition-normal);
}
/* ── Skeleton pulse ── */
@keyframes skeleton-pulse {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
.skeleton-loading {
animation: skeleton-pulse 1.5s ease-in-out infinite;
background: linear-gradient(
90deg,
var(--bg-tertiary) 25%,
var(--border-color) 50%,
var(--bg-tertiary) 75%
);
background-size: 200% 100%;
border-radius: var(--radius-sm);
}
/* ── Pulse dot (running status) ── */
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.pulse-dot {
animation: pulse-dot 1.5s ease-in-out infinite;
}

View File

@ -1,909 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AgentKit</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>">
<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=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f8f7f4;
--surface:#ffffff;
--surface2:#f1f0ec;
--surface3:#e8e7e3;
--border:#e2e0db;
--border-light:#eceae6;
--text:#1a1a1a;
--text2:#737068;
--text3:#a09d95;
--primary:#3b5bdb;
--primary-hover:#2c4ac6;
--primary-light:#eef1fd;
--primary-subtle:#d4daf9;
--user-bg:#3b5bdb;
--user-text:#ffffff;
--agent-bg:#f1f0ec;
--agent-text:#1a1a1a;
--danger:#dc2626;
--danger-light:#fef2f2;
--success:#16a34a;
--success-light:#f0fdf4;
--warning:#d97706;
--radius-sm:8px;
--radius:12px;
--radius-lg:16px;
--radius-xl:20px;
--shadow-xs:0 1px 2px rgba(0,0,0,.04);
--shadow-sm:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
--shadow-md:0 4px 12px rgba(0,0,0,.07),0 1px 3px rgba(0,0,0,.05);
--shadow-lg:0 8px 24px rgba(0,0,0,.09),0 2px 6px rgba(0,0,0,.05);
--sidebar-w:280px;
--right-w:340px;
--font:'Plus Jakarta Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);overflow:hidden;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
.app{display:flex;height:100vh}
/* ── Left Sidebar ────────────────────────────────────────────── */
.sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border-light);display:flex;flex-direction:column;flex-shrink:0}
.sidebar-header{padding:20px 16px 16px;display:flex;align-items:center;justify-content:space-between}
.sidebar-brand{display:flex;align-items:center;gap:10px}
.sidebar-logo{width:32px;height:32px;background:var(--primary);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:#fff;font-size:16px;font-weight:700}
.sidebar-header h1{font-size:17px;font-weight:700;letter-spacing:-0.4px;color:var(--text)}
.btn-new{background:var(--primary-light);color:var(--primary);border:none;border-radius:var(--radius-sm);padding:7px 14px;font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;font-family:var(--font)}
.btn-new:hover{background:var(--primary-subtle);transform:translateY(-1px)}
.session-list{flex:1;overflow-y:auto;padding:8px 8px 16px}
.session-item{padding:10px 12px;border-radius:var(--radius-sm);cursor:pointer;transition:all .15s;margin-bottom:2px;display:flex;align-items:center;justify-content:space-between;border:1px solid transparent}
.session-item:hover{background:var(--surface2);border-color:var(--border-light)}
.session-item.active{background:var(--primary-light);border-color:var(--primary-subtle);color:var(--primary)}
.session-item.active .title{font-weight:600}
.session-item .title{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;font-weight:450}
.session-item .time{font-size:11px;color:var(--text3);margin-left:8px;flex-shrink:0}
.session-item .del{opacity:0;color:var(--danger);cursor:pointer;margin-left:6px;font-size:16px;flex-shrink:0;transition:opacity .15s;width:20px;height:20px;display:flex;align-items:center;justify-content:center;border-radius:4px}
.session-item:hover .del{opacity:.6}
.session-item .del:hover{opacity:1;background:var(--danger-light)}
.empty-state{color:var(--text3);font-size:13px;text-align:center;padding:40px 16px;line-height:1.7}
/* ── Main Chat Area ──────────────────────────────────────────── */
.chat-area{flex:1;display:flex;flex-direction:column;min-width:0;background:var(--bg)}
.chat-header{padding:12px 24px;border-bottom:1px solid var(--border-light);display:flex;align-items:center;gap:12px;background:var(--surface);box-shadow:var(--shadow-xs)}
.chat-header .agent-name{font-size:15px;font-weight:600;flex:1;letter-spacing:-0.2px}
.chat-header .status{font-size:12px;color:var(--text3);display:flex;align-items:center;gap:5px}
.chat-header .status::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--text3);flex-shrink:0}
.chat-header .status.connected{color:var(--success)}
.chat-header .status.connected::before{background:var(--success)}
.btn-icon{background:var(--surface);border:1px solid var(--border);color:var(--text2);border-radius:var(--radius-sm);width:36px;height:36px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .2s;font-size:16px}
.btn-icon:hover{background:var(--surface2);color:var(--text);border-color:var(--primary);box-shadow:var(--shadow-xs)}
/* ── Messages ────────────────────────────────────────────────── */
.messages{flex:1;overflow-y:auto;padding:24px 24px 16px;display:flex;flex-direction:column;gap:20px;scroll-behavior:smooth}
.msg{display:flex;flex-direction:column;max-width:72%;animation:msgIn .35s cubic-bezier(.16,1,.3,1)}
.msg.user{align-self:flex-end}
.msg.agent{align-self:flex-start}
.msg .bubble{padding:12px 18px;font-size:14px;line-height:1.7;white-space:pre-wrap;word-break:break-word;position:relative}
.msg.user .bubble{background:var(--user-bg);color:var(--user-text);border-radius:var(--radius-lg) var(--radius-lg) var(--radius-sm) var(--radius-lg);box-shadow:0 2px 8px rgba(59,91,219,.2)}
.msg.agent .bubble{background:var(--surface);color:var(--agent-text);border-radius:var(--radius-lg) var(--radius-lg) var(--radius-lg) var(--radius-sm);border:1px solid var(--border-light);box-shadow:var(--shadow-xs)}
/* ── Markdown in Agent Bubbles ──────────────────────────────── */
.msg.agent .bubble.md{white-space:normal}
.msg.agent .bubble.md p{margin:0 0 .6em}
.msg.agent .bubble.md p:last-child{margin-bottom:0}
.msg.agent .bubble.md h1,.msg.agent .bubble.md h2,.msg.agent .bubble.md h3,.msg.agent .bubble.md h4{margin:.8em 0 .4em;font-weight:700;letter-spacing:-.3px;line-height:1.3}
.msg.agent .bubble.md h1{font-size:1.4em}
.msg.agent .bubble.md h2{font-size:1.2em}
.msg.agent .bubble.md h3{font-size:1.05em}
.msg.agent .bubble.md h4{font-size:1em}
.msg.agent .bubble.md ul,.msg.agent .bubble.md ol{margin:.4em 0 .6em 1.5em;padding:0}
.msg.agent .bubble.md li{margin:.2em 0}
.msg.agent .bubble.md li>p{margin:.2em 0}
.msg.agent .bubble.md blockquote{margin:.5em 0;padding:.4em 1em;border-left:3px solid var(--primary);background:var(--primary-light);border-radius:0 var(--radius-sm) var(--radius-sm) 0;color:var(--text2)}
.msg.agent .bubble.md blockquote p{margin:.2em 0}
.msg.agent .bubble.md code{font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:.88em;background:var(--surface2);padding:.15em .4em;border-radius:4px;color:var(--text)}
.msg.agent .bubble.md pre{margin:.5em 0;padding:12px 14px;background:var(--surface2);border:1px solid var(--border-light);border-radius:var(--radius-sm);overflow-x:auto;line-height:1.5}
.msg.agent .bubble.md pre code{background:none;padding:0;font-size:.85em;color:var(--text)}
.msg.agent .bubble.md table{border-collapse:collapse;margin:.5em 0;width:100%;font-size:.9em}
.msg.agent .bubble.md th,.msg.agent .bubble.md td{border:1px solid var(--border);padding:6px 10px;text-align:left}
.msg.agent .bubble.md th{background:var(--surface2);font-weight:600}
.msg.agent .bubble.md hr{border:none;border-top:1px solid var(--border);margin:.8em 0}
.msg.agent .bubble.md a{color:var(--primary);text-decoration:none}
.msg.agent .bubble.md a:hover{text-decoration:underline}
.msg.agent .bubble.md img{max-width:100%;border-radius:var(--radius-sm)}
.msg.agent .bubble.md strong{font-weight:700}
.msg.agent .bubble.md em{font-style:italic}
.msg .meta{font-size:11px;color:var(--text3);margin-top:5px;padding:0 4px;font-weight:500}
.msg.user .meta{text-align:right}
.typing-indicator{display:inline-flex;gap:5px;padding:6px 0}
.typing-indicator span{width:7px;height:7px;background:var(--text3);border-radius:50%;animation:bounce 1.4s ease-in-out infinite}
.typing-indicator span:nth-child(2){animation-delay:.15s}
.typing-indicator span:nth-child(3){animation-delay:.3s}
/* ── Input Area ──────────────────────────────────────────────── */
.input-area{padding:16px 24px 20px;background:transparent}
.input-wrap{display:flex;gap:10px;align-items:flex-end;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:6px 6px 6px 16px;box-shadow:var(--shadow-sm);transition:all .2s}
.input-wrap:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px rgba(59,91,219,.1),var(--shadow-md)}
.input-wrap textarea{flex:1;background:transparent;border:none;padding:8px 0;font-size:14px;color:var(--text);resize:none;outline:none;min-height:40px;max-height:160px;font-family:var(--font);line-height:1.5}
.input-wrap textarea::placeholder{color:var(--text3)}
.btn-send{background:var(--primary);color:#fff;border:none;border-radius:var(--radius);padding:10px 20px;font-size:14px;font-weight:600;cursor:pointer;transition:all .2s;flex-shrink:0;font-family:var(--font)}
.btn-send:hover{background:var(--primary-hover);transform:translateY(-1px);box-shadow:0 2px 8px rgba(59,91,219,.3)}
.btn-send:active{transform:translateY(0)}
.btn-send:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
/* ── Welcome ─────────────────────────────────────────────────── */
.welcome{flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;color:var(--text2);padding:40px}
.welcome-icon{width:64px;height:64px;background:var(--primary-light);border-radius:var(--radius-xl);display:flex;align-items:center;justify-content:center;font-size:28px;margin-bottom:4px}
.welcome h2{color:var(--text);font-size:24px;font-weight:700;letter-spacing:-0.5px}
.welcome p{font-size:14px;max-width:380px;text-align:center;line-height:1.7;color:var(--text2)}
/* ── Right Sidebar ───────────────────────────────────────────── */
.right-sidebar{width:0;overflow:hidden;background:var(--surface);border-left:1px solid var(--border-light);display:flex;flex-direction:column;transition:width .3s cubic-bezier(.16,1,.3,1);flex-shrink:0}
.right-sidebar.open{width:var(--right-w)}
.right-sidebar-header{padding:16px 16px 12px;display:flex;align-items:center;justify-content:space-between}
.right-sidebar-header h2{font-size:15px;font-weight:700;letter-spacing:-0.2px}
.right-sidebar-content{flex:1;overflow-y:auto;padding:0}
/* ── Tabs ────────────────────────────────────────────────────── */
.tab-bar{display:flex;gap:2px;padding:0 12px;border-bottom:1px solid var(--border-light);background:var(--surface)}
.tab-btn{padding:10px 14px;font-size:12px;font-weight:600;color:var(--text3);background:none;border:none;border-bottom:2px solid transparent;cursor:pointer;transition:all .2s;text-align:center;letter-spacing:.2px;text-transform:uppercase}
.tab-btn:hover{color:var(--text2)}
.tab-btn.active{color:var(--primary);border-bottom-color:var(--primary)}
.tab-panel{display:none;padding:16px}
.tab-panel.active{display:block}
/* ── Skill Grid ──────────────────────────────────────────────── */
.skill-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.skill-card{background:var(--surface2);border:1px solid var(--border-light);border-radius:var(--radius);padding:14px;cursor:pointer;transition:all .2s cubic-bezier(.16,1,.3,1);position:relative}
.skill-card:hover{border-color:var(--primary-subtle);transform:translateY(-2px);box-shadow:var(--shadow-md);background:var(--surface)}
.skill-card .skill-name{font-size:13px;font-weight:600;margin-bottom:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-0.1px}
.skill-card .skill-desc{font-size:11px;color:var(--text2);line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.skill-card .skill-tools{font-size:10px;color:var(--primary);margin-top:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;letter-spacing:.2px}
.skill-card .skill-remove{position:absolute;top:8px;right:8px;width:22px;height:22px;border-radius:6px;background:var(--surface);border:1px solid var(--border);color:var(--text3);font-size:13px;cursor:pointer;display:none;align-items:center;justify-content:center;line-height:1;transition:all .15s}
.skill-card:hover .skill-remove{display:flex}
.skill-card .skill-remove:hover{background:var(--danger);color:#fff;border-color:var(--danger)}
/* ── Add Skill ───────────────────────────────────────────────── */
.add-skill-area{margin-top:16px;padding-top:16px;border-top:1px solid var(--border-light)}
.add-skill-label{font-size:12px;color:var(--text2);margin-bottom:8px;font-weight:500}
.add-skill-input{display:flex;gap:8px}
.add-skill-input input{flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:9px 12px;font-size:13px;color:var(--text);outline:none;transition:all .2s;font-family:var(--font)}
.add-skill-input input:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(59,91,219,.08)}
.add-skill-input input::placeholder{color:var(--text3)}
.btn-add-skill{background:var(--primary);color:#fff;border:none;border-radius:var(--radius-sm);padding:9px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;white-space:nowrap;font-family:var(--font)}
.btn-add-skill:hover{background:var(--primary-hover)}
.btn-add-skill:disabled{opacity:.4;cursor:not-allowed}
.install-status{font-size:11px;margin-top:8px;color:var(--text3);font-weight:500}
.install-status.success{color:var(--success)}
.install-status.error{color:var(--danger)}
/* ── Scrollbar ───────────────────────────────────────────────── */
::-webkit-scrollbar{width:5px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--text3)}
/* ── Animations ──────────────────────────────────────────────── */
@keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
@keyframes bounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-8px)}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes slideInRight{from{opacity:0;transform:translateX(12px)}to{opacity:1;transform:translateX(0)}}
/* ── Thinking ──────────────────────────────────────────────────── */
.thinking-msg{display:flex;flex-direction:column;max-width:72%;animation:msgIn .35s cubic-bezier(.16,1,.3,1);align-self:flex-start}
.thinking-msg .bubble{padding:10px 16px;font-size:13px;line-height:1.6;color:var(--text3);font-style:italic;background:var(--surface2);border-radius:var(--radius-lg) var(--radius-lg) var(--radius-lg) var(--radius-sm);border:1px dashed var(--border)}
/* ── Loading indicator ───────────────────────────────────────── */
.loading-msg{display:flex;flex-direction:column;max-width:72%;align-self:flex-start;animation:msgIn .35s cubic-bezier(.16,1,.3,1)}
.loading-msg .loading-bubble{padding:8px 20px;background:var(--surface);border:1px solid var(--border-light);border-radius:var(--radius-lg) var(--radius-lg) var(--radius-lg) var(--radius-sm);display:inline-flex;align-items:center;gap:6px;box-shadow:var(--shadow-xs)}
.loading-msg .loading-dot{width:8px;height:8px;border-radius:50%;background:var(--text3);animation:loadingDot 1.4s infinite ease-in-out}
.loading-msg .loading-dot:nth-child(2){animation-delay:.2s}
.loading-msg .loading-dot:nth-child(3){animation-delay:.4s}
@keyframes loadingDot{0%,80%,100%{transform:scale(.4);opacity:.3}40%{transform:scale(1);opacity:1}}
/* ── Confirmation Card ──────────────────────────────────────── */
.confirm-card{display:flex;flex-direction:column;max-width:72%;align-self:flex-start;animation:msgIn .35s cubic-bezier(.16,1,.3,1)}
.confirm-card .card-inner{background:var(--surface);border:1px solid var(--warning);border-radius:var(--radius-lg);box-shadow:var(--shadow-md);overflow:hidden}
.confirm-card .card-header{display:flex;align-items:center;gap:10px;padding:14px 18px 10px;border-bottom:1px solid var(--border-light)}
.confirm-card .card-icon{width:32px;height:32px;border-radius:var(--radius-sm);background:#fef3c7;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.confirm-card .card-title{font-size:14px;font-weight:600;color:var(--text);letter-spacing:-0.2px}
.confirm-card .card-body{padding:12px 18px 14px}
.confirm-card .card-command{background:var(--surface2);border:1px solid var(--border-light);border-radius:var(--radius-sm);padding:10px 14px;font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:13px;color:var(--text);word-break:break-all;line-height:1.6;margin-bottom:10px}
.confirm-card .card-reason{font-size:13px;color:var(--text2);line-height:1.5;margin-bottom:4px}
.confirm-card .card-actions{display:flex;gap:8px;padding:0 18px 14px}
.confirm-card .btn-confirm{flex:1;padding:9px 16px;border-radius:var(--radius-sm);font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;border:1px solid;font-family:var(--font)}
.confirm-card .btn-confirm.approve{background:var(--primary-light);color:var(--primary);border-color:var(--primary-subtle)}
.confirm-card .btn-confirm.approve:hover{background:var(--primary);color:#fff;border-color:var(--primary)}
.confirm-card .btn-confirm.deny{background:var(--danger-light);color:var(--danger);border-color:#fecaca}
.confirm-card .btn-confirm.deny:hover{background:var(--danger);color:#fff;border-color:var(--danger)}
.confirm-card .btn-confirm:disabled{opacity:.5;cursor:not-allowed;transform:none}
.confirm-card .card-status{padding:10px 18px 14px;font-size:13px;font-weight:500;display:flex;align-items:center;gap:6px}
.confirm-card .card-status.approved{color:var(--success);background:var(--success-light)}
.confirm-card .card-status.denied{color:var(--danger);background:var(--danger-light)}
/* ── Mobile ──────────────────────────────────────────────────── */
@media(max-width:768px){
.sidebar{position:fixed;left:-100%;z-index:10;transition:left .3s cubic-bezier(.16,1,.3,1);width:85vw;max-width:320px;box-shadow:var(--shadow-lg)}
.sidebar.open{left:0}
.sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:9;backdrop-filter:blur(2px)}
.sidebar-overlay.show{display:block}
.mobile-toggle{display:flex!important}
.right-sidebar.open{position:fixed;right:0;z-index:10;width:85vw;max-width:360px;box-shadow:var(--shadow-lg)}
.messages{padding:16px}
.input-area{padding:12px 16px 16px}
.msg{max-width:88%}
}
.mobile-toggle{display:none;align-items:center;justify-content:center;background:none;border:none;color:var(--text);font-size:20px;cursor:pointer;padding:4px}
</style>
</head>
<body>
<div class="app">
<!-- Left Sidebar -->
<div class="sidebar-overlay" id="overlay" onclick="toggleSidebar()"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-brand">
<div class="sidebar-logo">A</div>
<h1>AgentKit</h1>
</div>
<button class="btn-new" onclick="createSession()" title="新建对话">+ 新对话</button>
</div>
<div class="session-list" id="sessionList"></div>
</aside>
<!-- Chat -->
<main class="chat-area" id="chatArea">
<div class="chat-header">
<button class="mobile-toggle" onclick="toggleSidebar()">&#9776;</button>
<span class="agent-name" id="agentName">AgentKit</span>
<span class="status" id="connStatus">未连接</span>
<button class="btn-icon" onclick="toggleRightSidebar()" title="技能与工具" id="rightSidebarBtn">&#9881;</button>
</div>
<div class="messages" id="messages">
<div class="welcome" id="welcome">
<div class="welcome-icon">🤖</div>
<h2>欢迎使用 AgentKit</h2>
<p>开始一段新对话,或从侧边栏选择已有会话。</p>
</div>
</div>
<div class="input-area">
<div class="input-wrap">
<textarea id="input" rows="1" placeholder="输入消息..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="btn-send" id="sendBtn" onclick="sendMessage()">发送</button>
</div>
</div>
</main>
<!-- Right Sidebar -->
<aside class="right-sidebar" id="rightSidebar">
<div class="right-sidebar-header">
<h2>工具</h2>
<button class="btn-icon" onclick="toggleRightSidebar()" title="关闭" style="width:28px;height:28px;font-size:14px">&times;</button>
</div>
<div class="tab-bar">
<button class="tab-btn" onclick="switchTab('sources')" data-tab="sources">来源</button>
<button class="tab-btn active" onclick="switchTab('skills')" data-tab="skills">技能</button>
<button class="tab-btn" onclick="switchTab('templates')" data-tab="templates">模板</button>
</div>
<div class="right-sidebar-content">
<!-- Sources Tab -->
<div class="tab-panel" id="tab-sources">
<div class="empty-state" style="padding:20px">信息来源配置即将上线。</div>
</div>
<!-- Skills Tab -->
<div class="tab-panel active" id="tab-skills">
<div class="skill-grid" id="skillGrid"></div>
<div class="add-skill-area">
<div class="add-skill-label">安装新技能</div>
<div class="add-skill-input">
<input type="text" id="installSkillName" placeholder="技能名称..." onkeydown="if(event.key==='Enter')installSkill()" oninput="updateInstallBtn()">
<button class="btn-add-skill" id="installBtn" onclick="installSkill()" disabled>搜索</button>
</div>
<div class="install-status" id="installStatus"></div>
</div>
</div>
<!-- Templates Tab -->
<div class="tab-panel" id="tab-templates">
<div class="empty-state" style="padding:20px">输出模板配置即将上线。</div>
</div>
</div>
</aside>
</div>
<script>
// ── State ──────────────────────────────────────────────────────────────
let sessions = [];
let activeSessionId = null;
let ws = null;
let isStreaming = false;
let currentAgentBubble = null;
let currentAgentRawText = ''; // accumulate raw text during streaming
let currentThinkingBubble = null;
let loadingEl = null;
let skills = [];
const API = '/api/v1/chat';
const SKILLS_API = '/api/v1/skills';
// ── Markdown helpers ──────────────────────────────────────────
const _MD_RE = /(?:^#{1,4}\s|^\s*[-*+]\s|^\s*\d+\.\s|```|\*\*|__|\[.*?\]\(|^\s*>)/m;
function isMarkdown(text) {
if (!text) return false;
return _MD_RE.test(text);
}
function renderMarkdown(text) {
if (!text || !text.trim()) return '';
try {
return marked.parse(text, { breaks: true, gfm: true });
} catch {
return esc(text);
}
}
function renderBubbleContent(bubble, text, forceMd) {
const md = forceMd || isMarkdown(text);
if (md) {
bubble.classList.add('md');
bubble.innerHTML = renderMarkdown(text);
} else {
bubble.classList.remove('md');
bubble.textContent = text;
}
}
// ── API helpers ────────────────────────────────────────────────
async function api(base, path, opts = {}) {
const res = await fetch(base + path, {
...opts,
headers: { 'Content-Type': 'application/json', ...opts.headers },
});
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
return res.json();
}
// ── Sessions ───────────────────────────────────────────────────
async function loadSessions() {
try {
sessions = await api(API, '/sessions');
} catch {
sessions = [];
}
renderSessions();
const savedId = localStorage.getItem('agentkit_active_session');
if (savedId && sessions.some(s => s.session_id === savedId)) {
await selectSession(savedId);
}
}
function renderSessions() {
const el = document.getElementById('sessionList');
if (!sessions.length) {
el.innerHTML = '<div class="empty-state">暂无对话<br>点击 <b>+ 新对话</b> 开始</div>';
return;
}
el.innerHTML = sessions.map(s => {
const t = new Date(s.created_at);
const time = t.toLocaleDateString() === new Date().toLocaleDateString()
? t.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})
: t.toLocaleDateString([], {month:'short',day:'numeric'});
const title = s.metadata?.title || `对话 ${s.session_id.slice(0,6)}`;
const active = s.session_id === activeSessionId ? 'active' : '';
return `<div class="session-item ${active}" onclick="selectSession('${s.session_id}')">
<span class="title">${esc(title)}</span>
<span class="time">${time}</span>
<span class="del" onclick="event.stopPropagation();deleteSession('${s.session_id}')" title="删除">&times;</span>
</div>`;
}).join('');
}
async function createSession() {
try {
const s = await api(API, '/sessions', {
method: 'POST',
body: JSON.stringify({ agent_name: 'default', metadata: { title: '新对话' } }),
});
sessions.unshift(s);
selectSession(s.session_id);
} catch (e) {
console.error('Create session failed:', e);
}
}
async function deleteSession(id) {
try {
await api(API, `/sessions/${id}`, { method: 'DELETE' });
sessions = sessions.filter(s => s.session_id !== id);
if (activeSessionId === id) {
activeSessionId = null;
localStorage.removeItem('agentkit_active_session');
disconnectWs();
showWelcome();
}
renderSessions();
} catch (e) {
console.error('Delete session failed:', e);
}
}
async function selectSession(id) {
activeSessionId = id;
localStorage.setItem('agentkit_active_session', id);
renderSessions();
showChat();
try {
const msgs = await api(API, `/sessions/${id}/messages`);
renderHistory(msgs);
} catch {
renderHistory([]);
}
connectWs(id);
}
// ── WebSocket ──────────────────────────────────────────────────
function connectWs(sessionId) {
disconnectWs();
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}${API}/ws/${sessionId}`;
ws = new WebSocket(url);
ws.onopen = () => { setConnStatus('已连接', true); };
ws.onmessage = (e) => { handleWsMessage(JSON.parse(e.data)); };
ws.onclose = () => { setConnStatus('未连接', false); ws = null; };
ws.onerror = () => { setConnStatus('连接错误', false); };
}
function disconnectWs() {
if (ws) { ws.close(); ws = null; }
setConnStatus('未连接', false);
}
function handleWsMessage(msg) {
switch (msg.type) {
case 'connected':
setConnStatus('已连接', true);
break;
case 'routing':
removeLoading();
appendStep(`路由: ${msg.skill || 'default'} (${msg.method}, ${Math.round((msg.confidence || 0) * 100)}%)`);
break;
case 'thinking':
removeLoading();
if (!currentThinkingBubble) {
currentThinkingBubble = appendThinking();
}
const thinkingContent = msg.content || '';
if (currentThinkingBubble) {
currentThinkingBubble.textContent += thinkingContent;
}
scrollToBottom();
break;
case 'token':
removeLoading();
// Clear any thinking bubble when real content starts
if (currentThinkingBubble) {
currentThinkingBubble.remove();
currentThinkingBubble = null;
}
if (!currentAgentBubble) {
currentAgentBubble = appendMessage('agent', '');
currentAgentRawText = '';
isStreaming = true;
updateSendBtn();
}
currentAgentRawText += msg.content || '';
// During streaming, show raw text for performance
currentAgentBubble.textContent = currentAgentRawText;
scrollToBottom();
break;
case 'final_answer':
removeLoading();
// Clear thinking bubble
if (currentThinkingBubble) {
currentThinkingBubble.remove();
currentThinkingBubble = null;
}
if (currentAgentBubble) {
const final = msg.content || '';
// Use the longer/more complete text
const bestText = (!currentAgentRawText.trim() || final.length > currentAgentRawText.length) ? final : currentAgentRawText;
renderBubbleContent(currentAgentBubble, bestText);
currentAgentBubble = null;
currentAgentRawText = '';
} else {
appendMessage('agent', msg.content || '');
}
isStreaming = false;
updateSendBtn();
scrollToBottom();
// Auto-refresh skill list after agent finishes (may have installed new skills)
loadSkills();
break;
case 'step':
removeLoading();
if (msg.data?.event_type === 'tool_call') {
// Replace thinking with tool step
if (currentThinkingBubble) {
currentThinkingBubble.remove();
currentThinkingBubble = null;
}
appendStep(`使用工具: ${msg.data?.data?.tool_name || 'tool'}`);
}
break;
case 'skill_match':
removeLoading();
if (msg.data?.skill) {
appendStep(`技能: ${msg.data.skill} (${msg.data.method}, ${Math.round((msg.data.confidence || 0) * 100)}%)`);
}
break;
case 'confirmation_request':
removeLoading();
showConfirmationCard(msg.data);
break;
case 'confirmation_result':
updateConfirmationCard(msg.data);
break;
case 'error':
removeLoading();
// Clear thinking bubble on error
if (currentThinkingBubble) {
currentThinkingBubble.remove();
currentThinkingBubble = null;
}
appendMessage('agent', `[错误] ${msg.data?.message || '未知错误'}`);
currentAgentBubble = null;
isStreaming = false;
updateSendBtn();
break;
}
}
// ── Loading indicator ──────────────────────────────────────────
function showLoading() {
if (loadingEl) return;
hideWelcome();
const container = document.getElementById('messages');
const div = document.createElement('div');
div.className = 'loading-msg';
div.id = 'loadingIndicator';
div.innerHTML = '<div class="loading-bubble"><div class="loading-dot"></div><div class="loading-dot"></div><div class="loading-dot"></div></div>';
container.appendChild(div);
loadingEl = div;
scrollToBottom();
}
function removeLoading() {
if (loadingEl) {
loadingEl.remove();
loadingEl = null;
}
}
// ── Confirmation Card ──────────────────────────────────────────
function showConfirmationCard(data) {
hideWelcome();
const container = document.getElementById('messages');
const confirmationId = data.confirmation_id;
const command = data.command || '';
const reason = data.reason || '';
const div = document.createElement('div');
div.className = 'confirm-card';
div.id = `confirm-${confirmationId}`;
div.innerHTML = `
<div class="card-inner">
<div class="card-header">
<div class="card-icon">&#9888;</div>
<div class="card-title">操作确认</div>
</div>
<div class="card-body">
<div class="card-command">${esc(command)}</div>
${reason ? `<div class="card-reason">${esc(reason)}</div>` : ''}
</div>
<div class="card-actions" id="confirm-actions-${confirmationId}">
<button class="btn-confirm approve" onclick="replyConfirmation('${confirmationId}', true)">确认执行</button>
<button class="btn-confirm deny" onclick="replyConfirmation('${confirmationId}', false)">拒绝</button>
</div>
</div>
`;
container.appendChild(div);
scrollToBottom();
}
function replyConfirmation(confirmationId, approved) {
// Disable buttons immediately
const actionsEl = document.getElementById(`confirm-actions-${confirmationId}`);
if (actionsEl) {
const buttons = actionsEl.querySelectorAll('.btn-confirm');
buttons.forEach(btn => { btn.disabled = true; });
}
// Send reply to server
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'confirmation_reply',
confirmation_id: confirmationId,
approved: approved,
}));
}
}
function updateConfirmationCard(data) {
const confirmationId = data.confirmation_id;
const approved = data.approved;
const cardEl = document.getElementById(`confirm-${confirmationId}`);
if (!cardEl) return;
// Replace actions with status
const actionsEl = document.getElementById(`confirm-actions-${confirmationId}`);
if (actionsEl) {
const statusClass = approved ? 'approved' : 'denied';
const statusText = approved ? '&#10003; 已确认执行' : '&#10007; 已拒绝';
actionsEl.outerHTML = `<div class="card-status ${statusClass}">${statusText}</div>`;
}
scrollToBottom();
}
// ── Send message ───────────────────────────────────────────────
async function sendMessage() {
const input = document.getElementById('input');
const text = input.value.trim();
if (!text) return;
// Auto-create session if none is active
if (!activeSessionId) {
try {
const s = await api(API, '/sessions', {
method: 'POST',
body: JSON.stringify({ agent_name: 'default', metadata: { title: text.slice(0, 30) } }),
});
sessions.unshift(s);
activeSessionId = s.session_id;
localStorage.setItem('agentkit_active_session', s.session_id);
renderSessions();
showChat();
renderHistory([]);
connectWs(s.session_id);
// Wait for WebSocket to open before sending
await new Promise(resolve => {
const check = () => ws && ws.readyState === WebSocket.OPEN ? resolve() : setTimeout(check, 50);
check();
});
} catch (e) {
console.error('Auto-create session failed:', e);
return;
}
}
if (!ws || ws.readyState !== WebSocket.OPEN) return;
appendMessage('user', text);
input.value = '';
autoResize(input);
ws.send(JSON.stringify({ type: 'message', content: text }));
currentAgentBubble = null;
isStreaming = true;
updateSendBtn();
showLoading();
}
function handleKey(e) {
// e.isComposing is true during IME composition — don't submit then
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
sendMessage();
}
}
// ── UI helpers ─────────────────────────────────────────────────
function appendMessage(role, content) {
hideWelcome();
const container = document.getElementById('messages');
const div = document.createElement('div');
const cssRole = role === 'assistant' ? 'agent' : role;
div.className = `msg ${cssRole}`;
const bubble = document.createElement('div');
bubble.className = 'bubble';
// Render markdown for agent messages
if (cssRole === 'agent' && content) {
renderBubbleContent(bubble, content);
} else {
bubble.textContent = content;
}
div.appendChild(bubble);
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = cssRole === 'user' ? '你' : '智能体';
div.appendChild(meta);
container.appendChild(div);
scrollToBottom();
return bubble;
}
function appendStep(text) {
hideWelcome();
const container = document.getElementById('messages');
const div = document.createElement('div');
div.className = 'msg agent';
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.style.cssText = 'opacity:.5;font-size:12px;font-style:italic;border:none;background:transparent;padding:4px 8px;box-shadow:none';
bubble.textContent = text;
div.appendChild(bubble);
container.appendChild(div);
scrollToBottom();
}
function appendThinking() {
hideWelcome();
const container = document.getElementById('messages');
const div = document.createElement('div');
div.className = 'thinking-msg';
const bubble = document.createElement('div');
bubble.className = 'bubble';
div.appendChild(bubble);
container.appendChild(div);
return bubble;
}
function renderHistory(msgs) {
const container = document.getElementById('messages');
container.innerHTML = '';
if (!msgs.length) {
container.innerHTML = '<div class="welcome" id="welcome"><div class="welcome-icon">🤖</div><h2>欢迎使用 AgentKit</h2><p>开始一段新对话,或从侧边栏选择已有会话。</p></div>';
return;
}
for (const m of msgs) {
if (m.role === 'user' || m.role === 'assistant') {
appendMessage(m.role, m.content);
}
}
scrollToBottom();
}
function showWelcome() { const el = document.getElementById('welcome'); if (el) el.style.display = 'flex'; }
function hideWelcome() { const el = document.getElementById('welcome'); if (el) el.style.display = 'none'; }
function showChat() { hideWelcome(); }
function setConnStatus(text, connected) {
const el = document.getElementById('connStatus');
el.textContent = text;
el.className = 'status' + (connected ? ' connected' : '');
}
function updateSendBtn() { document.getElementById('sendBtn').disabled = isStreaming; }
function scrollToBottom() { const el = document.getElementById('messages'); el.scrollTop = el.scrollHeight; }
function autoResize(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 160) + 'px'; }
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('overlay').classList.toggle('show');
}
// ── Right Sidebar ──────────────────────────────────────────────
function toggleRightSidebar() {
document.getElementById('rightSidebar').classList.toggle('open');
if (document.getElementById('rightSidebar').classList.contains('open')) {
loadSkills();
}
}
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tabId));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === `tab-${tabId}`));
}
// ── Skills ─────────────────────────────────────────────────────
async function loadSkills() {
try {
skills = await api(SKILLS_API, '');
renderSkillGrid();
} catch (e) {
console.error('Load skills failed:', e);
skills = [];
renderSkillGrid();
}
}
function renderSkillGrid() {
const grid = document.getElementById('skillGrid');
if (!skills.length) {
grid.innerHTML = '<div class="empty-state" style="padding:20px;grid-column:1/-1">暂无已安装的技能。</div>';
return;
}
grid.innerHTML = skills.map(s => {
const desc = s.intent_description || s.description || '暂无描述';
const tools = s.bound_tools && s.bound_tools.length ? s.bound_tools.join(', ') : (s.tools && s.tools.length ? s.tools.join(', ') : '');
return `<div class="skill-card" onclick="useSkill('${esc(s.name)}')" title="点击使用此技能">
<button class="skill-remove" onclick="event.stopPropagation();removeSkill('${esc(s.name)}')" title="移除">&times;</button>
<div class="skill-name">${esc(s.name)}</div>
<div class="skill-desc">${esc(desc)}</div>
${tools ? `<div class="skill-tools">${esc(tools)}</div>` : ''}
</div>`;
}).join('');
}
function useSkill(name) {
const skill = skills.find(s => s.name === name);
if (!skill) return;
const input = document.getElementById('input');
const skillRef = `@skill:${name} `;
if (!input.value.includes(skillRef)) {
input.value = skillRef + input.value;
input.focus();
autoResize(input);
}
}
function updateInstallBtn() {
const nameInput = document.getElementById('installSkillName');
const btn = document.getElementById('installBtn');
btn.disabled = !nameInput.value.trim();
}
async function installSkill() {
const nameInput = document.getElementById('installSkillName');
const name = nameInput.value.trim();
if (!name) return;
// Clear input immediately to prevent re-triggering
nameInput.value = '';
updateInstallBtn();
const btn = document.getElementById('installBtn');
const status = document.getElementById('installStatus');
btn.disabled = true;
btn.textContent = '搜索中...';
status.className = 'install-status';
status.textContent = '正在搜索并安装...';
try {
const result = await api(SKILLS_API, '/install', {
method: 'POST',
body: JSON.stringify({ name }),
});
status.className = 'install-status success';
status.textContent = `技能 "${result.name}" 安装成功!`;
await loadSkills();
} catch (e) {
status.className = 'install-status error';
status.textContent = `自动安装失败,正在请求智能体协助...`;
if (ws && ws.readyState === WebSocket.OPEN) {
const installMsg = `请帮我安装一个名为"${name}"的技能。请使用 skill_install 工具安装source 参数格式为 owner/repo@skill。如果不知道完整 source先用 shell 执行 npx skills search ${name} 搜索。`;
appendMessage('user', installMsg);
ws.send(JSON.stringify({ type: 'message', content: installMsg }));
currentAgentBubble = null;
isStreaming = true;
updateSendBtn();
}
} finally {
btn.disabled = false;
btn.textContent = '搜索';
}
}
async function removeSkill(name) {
if (!confirm(`确定移除技能 "${name}" 吗?`)) return;
try {
await api(SKILLS_API, `/${encodeURIComponent(name)}`, { method: 'DELETE' });
await loadSkills();
} catch (e) {
console.error('Remove skill failed:', e);
}
}
// ── Init ───────────────────────────────────────────────────────
loadSessions();
</script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fischer AgentKit</title>
<script type="module" crossorigin src="/assets/index-C33ddnd0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-vR_mL1Yy.css">
</head>
<body>
<div id="app"></div>
</body>
</html>