feat: v0.1.1 迭代——人设外键重构、Chat UI优化、意识节点防幻觉、日志双视图

1. 人设外键重构:persona_template 成为 system_prompt 唯一权威来源,
   agent/系统节点通过 persona_id FK 引用,含数据迁移脚本
2. Chat UI:去掉底部AI提示、加号改为弹出菜单、新建对话乐观跳转
3. 意识节点:无可用worker时禁止编造agent_id,只能自行完成或拒绝
4. 日志页面:双tab布局(系统日志 + 工作流日志列表选择)
5. 其他:SSE流式聊天、对话删除/重命名、standalone模式修复

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 06:18:47 +00:00
parent e3b8686d45
commit 6f1bc27101
39 changed files with 2904 additions and 524 deletions
@@ -0,0 +1,34 @@
"""simplify persona_template to name + system_prompt
Revision ID: 0006
Revises: 0005
Create Date: 2026-06-04
"""
from alembic import op
import sqlalchemy as sa
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("persona_template", "description")
op.drop_column("persona_template", "agent_type")
op.drop_column("persona_template", "provider_title")
op.drop_column("persona_template", "model_id")
op.drop_column("persona_template", "tools")
op.drop_column("persona_template", "tags")
op.drop_column("persona_template", "is_builtin")
def downgrade() -> None:
op.add_column("persona_template", sa.Column("description", sa.Text(), nullable=False, server_default=""))
op.add_column("persona_template", sa.Column("agent_type", sa.String(32), nullable=False, server_default="ordinary"))
op.add_column("persona_template", sa.Column("provider_title", sa.String(50), nullable=True))
op.add_column("persona_template", sa.Column("model_id", sa.String(100), nullable=True))
op.add_column("persona_template", sa.Column("tools", sa.JSON(), server_default="[]"))
op.add_column("persona_template", sa.Column("tags", sa.JSON(), server_default="[]"))
op.add_column("persona_template", sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default="false"))
@@ -0,0 +1,82 @@
"""persona_id FK refactor: agents and system nodes reference persona_template
Revision ID: 0007
Revises: 0006
Create Date: 2026-06-05
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
from ulid import ULID
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. Data migration: create persona records from existing system_prompts
conn = op.get_bind()
# Migrate base_individual.system_prompt → persona_template records
rows = conn.execute(
sa.text(
"SELECT agent_id, system_prompt, owner_id FROM base_individual "
"WHERE system_prompt IS NOT NULL AND system_prompt != ''"
)
).fetchall()
for row in rows:
template_id = str(ULID())
conn.execute(
sa.text(
"INSERT INTO persona_template (template_id, name, system_prompt, owner_id) "
"VALUES (:tid, :name, :prompt, :owner)"
),
{"tid": template_id, "name": f"auto_{row[0][:8]}", "prompt": row[1], "owner": row[2]},
)
conn.execute(
sa.text(
"UPDATE base_individual SET template_origin_id = :tid WHERE agent_id = :aid"
),
{"tid": template_id, "aid": row[0]},
)
# Migrate system_node_config.custom_system_prompt → persona_template
node_rows = conn.execute(
sa.text(
"SELECT node_name, custom_system_prompt FROM system_node_config "
"WHERE custom_system_prompt IS NOT NULL AND custom_system_prompt != ''"
)
).fetchall()
# Add persona_id column to system_node_config before populating
op.add_column(
"system_node_config",
sa.Column("persona_id", sa.String(64), sa.ForeignKey("persona_template.template_id", ondelete="SET NULL"), nullable=True),
)
for row in node_rows:
template_id = str(ULID())
conn.execute(
sa.text(
"INSERT INTO persona_template (template_id, name, system_prompt, owner_id) "
"VALUES (:tid, :name, :prompt, NULL)"
),
{"tid": template_id, "name": f"node_{row[0]}", "prompt": row[1]},
)
conn.execute(
sa.text(
"UPDATE system_node_config SET persona_id = :tid WHERE node_name = :nn"
),
{"tid": template_id, "nn": row[0]},
)
# 2. Rename template_origin_id → persona_id on base_individual
op.alter_column("base_individual", "template_origin_id", new_column_name="persona_id")
# 3. Drop old columns
op.drop_column("base_individual", "system_prompt")
op.drop_column("system_node_config", "custom_system_prompt")
+8 -5
View File
@@ -1,10 +1,10 @@
services: services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: kilostar_db container_name: kilostar_db_test
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgrespassword} POSTGRES_PASSWORD: testpass123
POSTGRES_DB: kilostar POSTGRES_DB: kilostar
ports: ports:
- "5432:5432" - "5432:5432"
@@ -16,7 +16,7 @@ services:
kilostar: kilostar:
build: . build: .
container_name: kilostar container_name: kilostar_test
ports: ports:
- "8000:8000" - "8000:8000"
- "8265:8265" - "8265:8265"
@@ -25,8 +25,11 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgrespassword} POSTGRES_PASSWORD: testpass123
POSTGRES_HOST: db POSTGRES_HOST: db
POSTGRES_PORT: 5432 POSTGRES_PORT: 5432
POSTGRES_DB: kilostar POSTGRES_DB: kilostar
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: test-secret-key-not-for-production
KILOSTAR_SECRET_KEY: test-secret-key-not-for-production
KILOSTAR_MODE: standalone
KILOSTAR_ENV: dev
+96
View File
@@ -0,0 +1,96 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
KiloStar is a distributed multi-agent system. Python/FastAPI backend with Ray actor orchestration, React 19 frontend, PostgreSQL storage. Supports two deployment modes: standalone (pure asyncio) and distributed (full Ray cluster).
## Commands
### Backend
```bash
uv sync # Install Python deps
uv run python main.py # Start distributed mode
KILOSTAR_MODE=standalone uv run python main.py # Start standalone mode
```
### Frontend (this directory)
```bash
npm install # Install deps
npm run dev # Dev server (Vite)
npm run build # Production build
npm run lint # ESLint
npx tsc --noEmit # Type-check without emitting
```
### Database Migrations (from project root)
```bash
make db-revision m="description" # Generate migration
make db-upgrade # Apply to HEAD
make db-downgrade # Rollback one
```
### Testing (from project root)
```bash
uv run pytest tests -q # Full suite
uv run pytest tests/unit -q # Unit only
uv run pytest tests/integration -q # Integration (needs DB)
```
### Docker
```bash
docker-compose up -d # Full stack (frontend build + backend)
```
## Architecture
### Dual-Mode Actor System
All core components are Ray actors in distributed mode, plain Python instances in standalone mode. Code uses a unified call pattern:
```python
from kilostar.utils.ray_hook import ray_actor_hook
# Returns a namespace object with actor handles
actors = ray_actor_hook("postgres_database")
await actors.postgres_database.some_method.remote(arg)
```
`StandaloneProxy` (in `kilostar/utils/standalone_proxy.py`) wraps instances to expose the same `.method.remote()` interface via asyncio. The `@actor_class` decorator marks classes that can be Ray actors or standalone instances depending on mode.
Mode is set via `KILOSTAR_MODE` env var. Entry point `main.py` branches into `start_standalone()` or `start_distributed()`.
### Backend Layout (`kilostar/`)
- `api/` — FastAPI routers (auth, chat, agent, workflow, system, resource, platform)
- `core/individual/` — 4 node types: RegulatoryNode (user-facing QA), ConsciousnessNode (planning), ControlNode (routing), GrowthNode (capability expansion)
- `core/global_state_machine/` — Provider registry, model config state
- `core/global_workflow_manager/` — Workflow queue & recovery
- `core/postgres_database/` — DAO layer: `model/` (SQLAlchemy models), `module/` (CRUD methods), `postgres.py` (facade)
- `worker_cluster/` — Task queue & worker dispatch
- `adapter/` — LLM model adapters (pydantic-ai AgentFactory)
- `plugin/tool_plugin/` — MCP-style tool plugins
- `utils/` — ray_hook, standalone_proxy, config_loader, access (JWT), i18n
### Frontend Layout (`frontend/src/`)
- `store/` — Zustand stores (useAppStore, useChatStore, etc.)
- `components/` — Chat, Agent, Plugin, Settings, Layout
- `api/client.ts` — Axios instance with auth interceptor
- `i18n/` — Chinese + English translations
### Key Patterns
- **Database facade**: `postgres.py` delegates to per-entity modules (`module/chat_history.py`, `module/persona_template.py`, etc.). Always add new DB methods to both the module AND the facade.
- **pydantic-ai agents**: Regulatory/Consciousness nodes use pydantic-ai with structured output (tool calls). Streaming chat uses direct httpx calls to OpenAI-compatible API instead.
- **SSE streaming**: Chat stream endpoint uses `StreamingResponse(media_type="text/event-stream")` with `data: {json}\n\n` format.
- **Config**: Multi-YAML in `config/` directory, loaded via `config_loader.py` at startup.
- **Alembic migrations**: `alembic/versions/` — naming convention: `YYYY_MM_DD_HHMM-NNNN_description.py`
### Environment Variables
- `KILOSTAR_MODE``standalone` or omit for distributed
- `KILOSTAR_SECRET_KEY` — JWT signing key
- `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` — DB connection
- `KILOSTAR_ENV``dev` or `prod`
+1755 -5
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"axios": "^1.15.1", "axios": "^1.15.1",
"i18next": "^26.3.0", "i18next": "^26.3.0",
@@ -20,6 +21,9 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-markdown": "^9.0.3",
"react-syntax-highlighter": "^15.6.1",
"remark-gfm": "^4.0.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
+14
View File
@@ -6,6 +6,8 @@ import { SetupGuideModal } from './components/Layout/SetupGuideModal';
import { SettingsLayout } from './components/Settings/SettingsLayout'; import { SettingsLayout } from './components/Settings/SettingsLayout';
import { AgentLayout } from './components/Agent/AgentLayout'; import { AgentLayout } from './components/Agent/AgentLayout';
import { PluginLayout } from './components/Plugin/PluginLayout'; import { PluginLayout } from './components/Plugin/PluginLayout';
import { WorkflowConfigSettings } from './components/Agent/WorkflowConfigSettings';
import { SystemLogsView } from './components/Agent/SystemLogsView';
import { LeftPanel } from './components/Chat/LeftPanel'; import { LeftPanel } from './components/Chat/LeftPanel';
import { ChatPanel } from './components/Chat/ChatPanel'; import { ChatPanel } from './components/Chat/ChatPanel';
import { RightPanel } from './components/Chat/RightPanel'; import { RightPanel } from './components/Chat/RightPanel';
@@ -96,6 +98,18 @@ function App() {
{mode === 'agent' && agentTab === 'agents' && <AgentLayout />} {mode === 'agent' && agentTab === 'agents' && <AgentLayout />}
{mode === 'agent' && agentTab === 'plugin' && <PluginLayout />} {mode === 'agent' && agentTab === 'plugin' && <PluginLayout />}
{mode === 'agent' && agentTab === 'config' && (
<div className="flex-1 overflow-y-auto p-8">
<WorkflowConfigSettings />
</div>
)}
{mode === 'agent' && agentTab === 'logs' && (
<div className="flex-1 overflow-y-auto p-8">
<SystemLogsView />
</div>
)}
</div> </div>
</> </>
)} )}
+1 -1
View File
@@ -4,7 +4,7 @@ import axios from 'axios';
// If missing, defaulting to '' means requests will be relative to the current browser origin. // If missing, defaulting to '' means requests will be relative to the current browser origin.
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '', baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 10000, timeout: 120000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -2,8 +2,6 @@ import { useTranslation } from 'react-i18next';
import { useAppStore } from '../../store/useAppStore'; import { useAppStore } from '../../store/useAppStore';
import { ProvidersSettings } from './ProvidersSettings'; import { ProvidersSettings } from './ProvidersSettings';
import { WorkerIndividualSettings } from './WorkerIndividualSettings'; import { WorkerIndividualSettings } from './WorkerIndividualSettings';
import { WorkflowConfigSettings } from './WorkflowConfigSettings';
import { SystemLogsView } from './SystemLogsView';
import { PersonaTemplateSettings } from './PersonaTemplateSettings'; import { PersonaTemplateSettings } from './PersonaTemplateSettings';
export function AgentLayout() { export function AgentLayout() {
@@ -14,8 +12,6 @@ export function AgentLayout() {
{ key: 'worker', label: t('agent.individual') }, { key: 'worker', label: t('agent.individual') },
{ key: 'persona', label: t('agent.personaManagement') }, { key: 'persona', label: t('agent.personaManagement') },
{ key: 'providers', label: t('agent.providerManagement') }, { key: 'providers', label: t('agent.providerManagement') },
{ key: 'config', label: t('agent.config') },
{ key: 'logs', label: t('agent.systemLogs') },
]; ];
return ( return (
@@ -39,8 +35,6 @@ export function AgentLayout() {
{innerAgentTab === 'worker' && <WorkerIndividualSettings />} {innerAgentTab === 'worker' && <WorkerIndividualSettings />}
{innerAgentTab === 'persona' && <PersonaTemplateSettings />} {innerAgentTab === 'persona' && <PersonaTemplateSettings />}
{innerAgentTab === 'providers' && <ProvidersSettings />} {innerAgentTab === 'providers' && <ProvidersSettings />}
{innerAgentTab === 'config' && <WorkflowConfigSettings />}
{innerAgentTab === 'logs' && <SystemLogsView />}
</div> </div>
</div> </div>
); );
@@ -1,28 +1,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import apiClient from '../../api/client'; import apiClient from '../../api/client';
import { Plus, Edit2, Trash2, X, Save, Loader2, FileText, Copy } from 'lucide-react'; import { Plus, Edit2, Trash2, X, Save, Loader2, FileText } from 'lucide-react';
import type { Provider } from '../../types';
interface PersonaTemplate { interface PersonaTemplate {
template_id: string; template_id: string;
name: string; name: string;
description: string;
system_prompt: string; system_prompt: string;
agent_type: string;
provider_title: string | null;
model_id: string | null;
tools: string[];
tags: string[];
is_builtin: boolean;
owner_id: string | null; owner_id: string | null;
} }
export function PersonaTemplateSettings() { export function PersonaTemplateSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const [templates, setTemplates] = useState<PersonaTemplate[]>([]); const [templates, setTemplates] = useState<PersonaTemplate[]>([]);
const [providers, setProviders] = useState<Provider[]>([]);
const [availableTools, setAvailableTools] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -34,14 +24,8 @@ export function PersonaTemplateSettings() {
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
const [tplRes, provRes, toolsRes] = await Promise.all([ const res = await apiClient.get('/api/v1/agent/template');
apiClient.get('/api/v1/agent/template'), setTemplates(res.data.templates || []);
apiClient.get('/api/v1/provider/list'),
apiClient.get('/api/v1/resource/tool')
]);
setTemplates(tplRes.data.templates || []);
setProviders(Object.values(provRes.data.provider_list || {}));
setAvailableTools(toolsRes.data.tools || []);
} catch { setError(t('agent.loadFailed')); } } catch { setError(t('agent.loadFailed')); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
@@ -49,9 +33,7 @@ export function PersonaTemplateSettings() {
useEffect(() => { fetchData(); }, []); useEffect(() => { fetchData(); }, []);
const handleAddNew = () => { const handleAddNew = () => {
setEditData({ name: '', description: '', system_prompt: '', agent_type: 'ordinary_individual', setEditData({ name: '', system_prompt: '' });
provider_title: providers.length > 0 ? providers[0].provider_title : '',
model_id: '', tools: [], tags: [] });
setIsNew(true); setIsNew(true);
setIsEditing(true); setIsEditing(true);
setModalMessage(''); setModalMessage('');
@@ -70,25 +52,12 @@ export function PersonaTemplateSettings() {
catch { alert(t('common.deleteFailed')); } catch { alert(t('common.deleteFailed')); }
}; };
const handleCreateWorker = async (id: string) => {
try {
await apiClient.post(`/api/v1/agent/worker/from-template/${id}`);
alert(t('agent.workerCreatedFromTemplate'));
} catch (err: any) {
alert(err.response?.data?.detail || t('common.saveFailed'));
}
};
const handleModalSave = async (e: React.FormEvent) => { const handleModalSave = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setModalMessage(''); setModalMessage('');
setSubmitLoading(true); setSubmitLoading(true);
try { try {
const payload = { name: editData.name, description: editData.description, const payload = { name: editData.name, system_prompt: editData.system_prompt };
system_prompt: editData.system_prompt, agent_type: editData.agent_type,
provider_title: editData.provider_title || null,
model_id: editData.model_id || null,
tools: editData.tools || [], tags: editData.tags || [] };
if (isNew) await apiClient.post('/api/v1/agent/template', payload); if (isNew) await apiClient.post('/api/v1/agent/template', payload);
else await apiClient.put(`/api/v1/agent/template/${editData.template_id}`, payload); else await apiClient.put(`/api/v1/agent/template/${editData.template_id}`, payload);
setIsEditing(false); setIsEditing(false);
@@ -98,15 +67,6 @@ export function PersonaTemplateSettings() {
} finally { setSubmitLoading(false); } } finally { setSubmitLoading(false); }
}; };
const getTypeBadge = (type: string) => {
const colors: Record<string, string> = {
ordinary_individual: 'bg-bg-secondary text-text-muted',
skill_individual: 'bg-success-bg text-success',
special_individual: 'bg-warning-bg text-warning',
};
return <span className={`px-2 py-0.5 rounded-md text-[10px] font-medium ${colors[type] || colors.ordinary_individual}`}>{t(`agent.type.${type}`, type)}</span>;
};
return ( return (
<div className="max-w-5xl space-y-6"> <div className="max-w-5xl space-y-6">
<div className="flex justify-between items-end"> <div className="flex justify-between items-end">
@@ -133,133 +93,46 @@ export function PersonaTemplateSettings() {
<span className="text-sm">{t('agent.noTemplates')}</span> <span className="text-sm">{t('agent.noTemplates')}</span>
</div> </div>
) : ( ) : (
<table className="w-full text-left text-sm"> <div className="divide-y divide-border-secondary">
<thead> {templates.map((tpl) => (
<tr className="bg-bg-secondary border-b border-border-primary text-text-muted text-xs uppercase tracking-wider"> <div key={tpl.template_id} className="flex items-center justify-between px-5 py-3.5 hover:bg-bg-hover transition-colors">
<th className="px-5 py-3 font-semibold">{t('agent.templateName')}</th> <div className="flex items-center gap-3 min-w-0 flex-1">
<th className="px-5 py-3 font-semibold">{t('agent.type')}</th> <div className="w-8 h-8 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center shrink-0">
<th className="px-5 py-3 font-semibold">{t('agent.tags')}</th> <FileText size={14} className="text-text-muted" />
<th className="px-5 py-3 font-semibold text-right">{t('common.actions')}</th> </div>
</tr> <div className="min-w-0 flex-1">
</thead> <div className="font-medium text-text-primary text-sm">{tpl.name}</div>
<tbody className="divide-y divide-border-secondary"> <div className="text-xs text-text-muted truncate mt-0.5">{tpl.system_prompt || t('agent.noPrompt')}</div>
{templates.map((tpl) => ( </div>
<tr key={tpl.template_id} className="hover:bg-bg-hover transition-colors"> </div>
<td className="px-5 py-3"> <div className="flex items-center gap-0.5 shrink-0 ml-3">
<div className="flex items-center gap-2.5"> <button onClick={() => handleEdit(tpl)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"><Edit2 size={14} /></button>
<div className="w-7 h-7 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center"> <button onClick={() => handleDelete(tpl.template_id)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all"><Trash2 size={14} /></button>
<FileText size={14} className="text-text-muted" /> </div>
</div> </div>
<div className="flex flex-col"> ))}
<span className="font-medium text-text-primary text-xs">{tpl.name}</span> </div>
{tpl.is_builtin && <span className="text-[10px] text-accent">{t('agent.builtin')}</span>}
</div>
</div>
</td>
<td className="px-5 py-3">{getTypeBadge(tpl.agent_type)}</td>
<td className="px-5 py-3">
<div className="flex flex-wrap gap-1">
{(tpl.tags || []).map(tag => (
<span key={tag} className="px-1.5 py-0.5 rounded text-[10px] bg-bg-secondary text-text-muted">{tag}</span>
))}
</div>
</td>
<td className="px-5 py-3 text-right">
<button onClick={() => handleCreateWorker(tpl.template_id)} title={t('agent.createFromTemplate')} className="p-1.5 text-text-muted hover:text-success hover:bg-success-bg rounded-lg transition-all mr-0.5"><Copy size={14} /></button>
{!tpl.is_builtin && <button onClick={() => handleEdit(tpl)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all mr-0.5"><Edit2 size={14} /></button>}
{!tpl.is_builtin && <button onClick={() => handleDelete(tpl.template_id)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all"><Trash2 size={14} /></button>}
</td>
</tr>
))}
</tbody>
</table>
)} )}
</div> </div>
{isEditing && ( {isEditing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto border border-border-primary animate-fade-in-scale"> <div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto border border-border-primary">
<div className="flex justify-between items-center p-5 border-b border-border-primary sticky top-0 bg-bg-card z-10"> <div className="flex justify-between items-center p-5 border-b border-border-primary">
<h2 className="text-base font-bold text-text-primary">{isNew ? t('agent.addTemplate') : t('agent.editTemplate')}</h2> <h2 className="text-base font-bold text-text-primary">{isNew ? t('agent.addTemplate') : t('agent.editTemplate')}</h2>
<button onClick={() => setIsEditing(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors"><X size={20} /></button> <button onClick={() => setIsEditing(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors"><X size={20} /></button>
</div> </div>
<form onSubmit={handleModalSave} className="p-5 space-y-4"> <form onSubmit={handleModalSave} className="p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.templateName')}</label>
<input type="text" required value={editData.name || ''} onChange={(e) => setEditData({...editData, name: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.type')}</label>
<select value={editData.agent_type || 'ordinary_individual'} onChange={(e) => setEditData({...editData, agent_type: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="ordinary_individual">{t('agent.type.ordinary_individual')}</option>
<option value="skill_individual">{t('agent.type.skill_individual')}</option>
<option value="special_individual">{t('agent.type.special_individual')}</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.provider')}</label>
<select value={editData.provider_title || ''} onChange={(e) => setEditData({...editData, provider_title: e.target.value, model_id: ''})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="">{t('common.none')}</option>
{providers.map((p) => (<option key={p.provider_title} value={p.provider_title}>{p.provider_title}</option>))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.model')}</label>
{(() => {
const sp = providers.find(p => p.provider_title === editData.provider_title);
const models = sp?.provider_models || [];
return (
<select value={editData.model_id || ''} onChange={(e) => setEditData({...editData, model_id: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="">{t('common.none')}</option>
{models.map(m => <option key={m} value={m}>{m}</option>)}
</select>
);
})()}
</div>
</div>
<div> <div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.description')}</label> <label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.templateName')}</label>
<textarea value={editData.description || ''} onChange={(e) => setEditData({...editData, description: e.target.value})} rows={2} <input type="text" required value={editData.name || ''} onChange={(e) => setEditData({...editData, name: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" /> className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.systemPrompt')}</label> <label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.systemPrompt')}</label>
<textarea value={editData.system_prompt || ''} onChange={(e) => setEditData({...editData, system_prompt: e.target.value})} rows={4} <textarea value={editData.system_prompt || ''} onChange={(e) => setEditData({...editData, system_prompt: e.target.value})} rows={6}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" /> className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div> </div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.tags')}</label>
<input type="text" value={(editData.tags || []).join(', ')}
onChange={(e) => setEditData({...editData, tags: e.target.value.split(',').map(s => s.trim()).filter(Boolean)})}
placeholder={t('agent.tagsPlaceholder')}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.tools')}</label>
<div className="flex flex-wrap gap-1.5 p-3 bg-bg-input border border-border-primary rounded-xl max-h-40 overflow-y-auto">
{availableTools.map(tool => {
const currentTools: string[] = editData.tools || [];
const isSelected = currentTools.includes(tool);
return (
<button key={tool} type="button" onClick={() => {
const updated = isSelected ? currentTools.filter(x => x !== tool) : [...currentTools, tool];
setEditData({...editData, tools: updated});
}}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${isSelected ? 'bg-accent-light text-accent border border-accent/20' : 'bg-bg-secondary text-text-muted border border-border-primary hover:border-text-muted'}`}>
{tool}
</button>
);
})}
{availableTools.length === 0 && <span className="text-xs text-text-muted">{t('agent.noTools')}</span>}
</div>
</div>
{modalMessage && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{modalMessage}</div>} {modalMessage && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{modalMessage}</div>}
<div className="pt-3 flex justify-end gap-2 border-t border-border-primary"> <div className="pt-3 flex justify-end gap-2 border-t border-border-primary">
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">{t('common.cancel')}</button> <button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">{t('common.cancel')}</button>
+240 -83
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RefreshCw, Search } from 'lucide-react'; import { RefreshCw, Search, Server, GitBranch } from 'lucide-react';
import apiClient from '../../api/client';
interface EventLog { interface EventLog {
id: number; id: number;
@@ -13,20 +14,52 @@ interface EventLog {
created_at: string | null; created_at: string | null;
} }
interface WorkflowSummary {
trace_id: string;
title: string;
status: string;
created_at: string;
}
interface WorkflowStep {
name: string;
step: number;
node: string;
action: string;
status: string;
agent_id: string | null;
}
const LEVEL_STYLES: Record<string, { bg: string; text: string; label: string }> = { const LEVEL_STYLES: Record<string, { bg: string; text: string; label: string }> = {
error: { bg: 'bg-[rgba(196,145,122,0.12)]', text: 'text-[#a0705a]', label: 'ERROR' }, error: { bg: 'bg-[rgba(196,145,122,0.12)]', text: 'text-[#a0705a]', label: 'ERROR' },
warn: { bg: 'bg-[rgba(196,168,130,0.15)]', text: 'text-[#9a7d5e]', label: 'WARN' }, warn: { bg: 'bg-[rgba(196,168,130,0.15)]', text: 'text-[#9a7d5e]', label: 'WARN' },
info: { bg: 'bg-[rgba(156,175,136,0.12)]', text: 'text-[#7a8e6a]', label: 'INFO' }, info: { bg: 'bg-[rgba(156,175,136,0.12)]', text: 'text-[#7a8e6a]', label: 'INFO' },
}; };
const STATUS_STYLES: Record<string, { bg: string; text: string }> = {
completed: { bg: 'bg-success-bg', text: 'text-success' },
failed: { bg: 'bg-[rgba(196,145,122,0.12)]', text: 'text-[#a0705a]' },
working: { bg: 'bg-[rgba(156,175,136,0.12)]', text: 'text-[#7a8e6a]' },
pending: { bg: 'bg-bg-secondary', text: 'text-text-muted' },
};
export function SystemLogsView() { export function SystemLogsView() {
const { t } = useTranslation(); const { t } = useTranslation();
const [tab, setTab] = useState<'system' | 'workflow'>('system');
// System logs state
const [logs, setLogs] = useState<EventLog[]>([]); const [logs, setLogs] = useState<EventLog[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [traceFilter, setTraceFilter] = useState(''); const [traceFilter, setTraceFilter] = useState('');
const [typeFilter, setTypeFilter] = useState(''); const [typeFilter, setTypeFilter] = useState('');
const [levelFilter, setLevelFilter] = useState(''); const [levelFilter, setLevelFilter] = useState('');
// Workflow logs state
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [selectedTrace, setSelectedTrace] = useState<string | null>(null);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
const [wfLoading, setWfLoading] = useState(false);
const fetchLogs = async () => { const fetchLogs = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -35,13 +68,8 @@ export function SystemLogsView() {
if (typeFilter) params.set('event_type', typeFilter); if (typeFilter) params.set('event_type', typeFilter);
if (levelFilter) params.set('level', levelFilter); if (levelFilter) params.set('level', levelFilter);
params.set('limit', '200'); params.set('limit', '200');
const resp = await apiClient.get(`/api/v1/system/logs?${params.toString()}`);
const resp = await fetch(`/api/v1/system/logs?${params.toString()}`, { setLogs(resp.data.logs || []);
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
});
if (!resp.ok) throw new Error('Failed to fetch logs');
const data = await resp.json();
setLogs(data.logs || []);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
@@ -49,7 +77,36 @@ export function SystemLogsView() {
} }
}; };
useEffect(() => { fetchLogs(); }, []); const fetchWorkflows = async () => {
try {
const resp = await apiClient.get('/api/v1/workflow/list');
setWorkflows(resp.data.workflows || []);
} catch (err) {
console.error(err);
}
};
const fetchWorkflowDetail = async (traceId: string) => {
setWfLoading(true);
try {
const resp = await apiClient.get(`/api/v1/workflow/${traceId}`);
setWorkflowSteps(resp.data.steps || []);
} catch (err) {
console.error(err);
setWorkflowSteps([]);
} finally {
setWfLoading(false);
}
};
useEffect(() => {
if (tab === 'system') fetchLogs();
else fetchWorkflows();
}, [tab]);
useEffect(() => {
if (selectedTrace) fetchWorkflowDetail(selectedTrace);
}, [selectedTrace]);
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -58,87 +115,187 @@ export function SystemLogsView() {
return ( return (
<div className="max-w-6xl"> <div className="max-w-6xl">
<div className="flex items-center justify-between mb-6"> {/* Tab Switcher */}
<h2 className="text-xl font-bold text-text-primary">{t('agent.systemLogs')}</h2> <div className="flex items-center gap-6 mb-6">
<button onClick={fetchLogs} disabled={loading} className="p-2 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"> <button
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} /> onClick={() => setTab('system')}
className={`flex items-center gap-2 pb-2 text-sm font-medium border-b-2 transition-colors ${tab === 'system' ? 'border-accent text-accent' : 'border-transparent text-text-muted hover:text-text-primary'}`}
>
<Server size={14} />
{t('agent.systemLogs')}
</button>
<button
onClick={() => setTab('workflow')}
className={`flex items-center gap-2 pb-2 text-sm font-medium border-b-2 transition-colors ${tab === 'workflow' ? 'border-accent text-accent' : 'border-transparent text-text-muted hover:text-text-primary'}`}
>
<GitBranch size={14} />
{t('agent.workflowLogs')}
</button>
<div className="flex-1" />
<button
onClick={() => tab === 'system' ? fetchLogs() : fetchWorkflows()}
disabled={loading || wfLoading}
className="p-2 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"
>
<RefreshCw size={16} className={(loading || wfLoading) ? 'animate-spin' : ''} />
</button> </button>
</div> </div>
<form onSubmit={handleSearch} className="grid grid-cols-4 gap-3 mb-5"> {tab === 'system' ? (
<input <>
type="text" <form onSubmit={handleSearch} className="grid grid-cols-4 gap-3 mb-5">
value={traceFilter} <input
onChange={(e) => setTraceFilter(e.target.value)} type="text"
placeholder={t('agent.logFilterTraceId')} value={traceFilter}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15" onChange={(e) => setTraceFilter(e.target.value)}
/> placeholder={t('agent.logFilterTraceId')}
<select className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15"
value={typeFilter} />
onChange={(e) => setTypeFilter(e.target.value)} <select
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15" value={typeFilter}
> onChange={(e) => setTypeFilter(e.target.value)}
<option value="">{t('agent.logFilterAllTypes')}</option> className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
<option value="workflow_start">workflow_start</option> >
<option value="step_enter">step_enter</option> <option value="">{t('agent.logFilterAllTypes')}</option>
<option value="step_complete">step_complete</option> <option value="workflow_start">workflow_start</option>
<option value="step_error">step_error</option> <option value="step_enter">step_enter</option>
<option value="workflow_complete">workflow_complete</option> <option value="step_complete">step_complete</option>
<option value="workflow_fail">workflow_fail</option> <option value="step_error">step_error</option>
<option value="system">system</option> <option value="workflow_complete">workflow_complete</option>
</select> <option value="workflow_fail">workflow_fail</option>
<select <option value="system">system</option>
value={levelFilter} </select>
onChange={(e) => setLevelFilter(e.target.value)} <select
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15" value={levelFilter}
> onChange={(e) => setLevelFilter(e.target.value)}
<option value="">{t('agent.logFilterAllLevels')}</option> className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
<option value="info">INFO</option> >
<option value="warn">WARN</option> <option value="">{t('agent.logFilterAllLevels')}</option>
<option value="error">ERROR</option> <option value="info">INFO</option>
</select> <option value="warn">WARN</option>
<button type="submit" className="flex items-center justify-center gap-2 px-4 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors"> <option value="error">ERROR</option>
<Search size={14} /> {t('agent.logSearch')} </select>
</button> <button type="submit" className="flex items-center justify-center gap-2 px-4 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">
</form> <Search size={14} /> {t('agent.logSearch')}
</button>
</form>
<div className="bg-bg-card border border-border-primary rounded-xl overflow-hidden"> <div className="bg-bg-card border border-border-primary rounded-xl overflow-hidden">
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto"> <div className="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="bg-bg-secondary sticky top-0"> <thead className="bg-bg-secondary sticky top-0">
<tr> <tr>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logLevel')}</th> <th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logLevel')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logType')}</th> <th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logType')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">Trace ID</th> <th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">Trace ID</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logNode')}</th> <th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logNode')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logMessage')}</th> <th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logMessage')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logTime')}</th> <th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logTime')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-primary"> <tbody className="divide-y divide-border-primary">
{logs.length === 0 ? ( {logs.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-text-muted">{t('agent.noLogs')}</td></tr> <tr><td colSpan={6} className="px-4 py-12 text-center text-text-muted">{t('agent.noLogs')}</td></tr>
) : (
logs.map((log) => {
const style = LEVEL_STYLES[log.level] || LEVEL_STYLES.info;
return (
<tr key={log.id} className="hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-2.5">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${style.bg} ${style.text}`}>{style.label}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary font-mono">{log.event_type}</td>
<td className="px-4 py-2.5 text-text-muted font-mono">{log.trace_id.slice(-8)}</td>
<td className="px-4 py-2.5 text-text-secondary">{log.node_name || '-'}</td>
<td className="px-4 py-2.5 text-text-primary max-w-xs truncate" title={log.message}>{log.message}</td>
<td className="px-4 py-2.5 text-text-muted whitespace-nowrap">{log.created_at ? new Date(log.created_at).toLocaleString() : '-'}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</>
) : (
<div className="flex gap-4 h-[calc(100vh-220px)]">
{/* Workflow List */}
<div className="w-72 shrink-0 bg-bg-card border border-border-primary rounded-xl overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-border-primary bg-bg-secondary">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">{t('agent.workflowLogList')}</span>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border-secondary">
{workflows.length === 0 ? (
<div className="px-4 py-8 text-center text-text-muted text-xs">{t('workflow.noWorkflows')}</div>
) : ( ) : (
logs.map((log) => { workflows.map((wf) => (
const style = LEVEL_STYLES[log.level] || LEVEL_STYLES.info; <button
return ( key={wf.trace_id}
<tr key={log.id} className="hover:bg-bg-secondary/50 transition-colors"> onClick={() => setSelectedTrace(wf.trace_id)}
<td className="px-4 py-2.5"> className={`w-full text-left px-4 py-3 hover:bg-bg-hover transition-colors ${selectedTrace === wf.trace_id ? 'bg-accent-light border-l-2 border-accent' : ''}`}
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${style.bg} ${style.text}`}>{style.label}</span> >
</td> <div className="text-xs font-medium text-text-primary truncate">{wf.title}</div>
<td className="px-4 py-2.5 text-text-secondary font-mono">{log.event_type}</td> <div className="flex items-center gap-2 mt-1">
<td className="px-4 py-2.5 text-text-muted font-mono">{log.trace_id.slice(-8)}</td> <span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${(STATUS_STYLES[wf.status] || STATUS_STYLES.pending).bg} ${(STATUS_STYLES[wf.status] || STATUS_STYLES.pending).text}`}>
<td className="px-4 py-2.5 text-text-secondary">{log.node_name || '-'}</td> {wf.status}
<td className="px-4 py-2.5 text-text-primary max-w-xs truncate" title={log.message}>{log.message}</td> </span>
<td className="px-4 py-2.5 text-text-muted whitespace-nowrap">{log.created_at ? new Date(log.created_at).toLocaleString() : '-'}</td> <span className="text-[10px] text-text-muted">{new Date(wf.created_at).toLocaleDateString()}</span>
</tr> </div>
); </button>
}) ))
)} )}
</tbody> </div>
</table> </div>
{/* Workflow Steps Detail */}
<div className="flex-1 bg-bg-card border border-border-primary rounded-xl overflow-hidden flex flex-col">
{!selectedTrace ? (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
{t('agent.selectWorkflowToView')}
</div>
) : wfLoading ? (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
{t('common.loading')}
</div>
) : (
<div className="flex-1 overflow-y-auto">
<table className="w-full text-xs">
<thead className="bg-bg-secondary sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">#</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.name')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logNode')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('common.status')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.workflowAction')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{workflowSteps.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-text-muted">{t('workflow.noStepsYet')}</td></tr>
) : (
workflowSteps.map((step, idx) => {
const ss = STATUS_STYLES[step.status] || STATUS_STYLES.pending;
return (
<tr key={idx} className="hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-2.5 text-text-muted">{step.step || idx + 1}</td>
<td className="px-4 py-2.5 text-text-primary font-medium">{step.name}</td>
<td className="px-4 py-2.5 text-text-secondary">{step.node}</td>
<td className="px-4 py-2.5">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${ss.bg} ${ss.text}`}>{step.status}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary max-w-sm truncate" title={step.action}>{step.action}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
)}
</div>
</div> </div>
</div> )}
</div> </div>
); );
} }
@@ -11,18 +11,25 @@ interface WorkerIndividual {
description?: string; description?: string;
provider_title: string; provider_title: string;
model_id: string; model_id: string;
system_prompt?: string; persona_id?: string;
output_template?: string; output_template?: string;
bound_skill?: string; bound_skill?: string;
workspace?: string; workspace?: string;
tools?: string; tools?: string;
} }
interface PersonaTemplate {
template_id: string;
name: string;
system_prompt: string;
}
export function WorkerIndividualSettings() { export function WorkerIndividualSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const [providers, setProviders] = useState<Provider[]>([]); const [providers, setProviders] = useState<Provider[]>([]);
const [workers, setWorkers] = useState<WorkerIndividual[]>([]); const [workers, setWorkers] = useState<WorkerIndividual[]>([]);
const [systemNodes, setSystemNodes] = useState<any[]>([]); const [systemNodes, setSystemNodes] = useState<any[]>([]);
const [personaTemplates, setPersonaTemplates] = useState<PersonaTemplate[]>([]);
const [availableSkills, setAvailableSkills] = useState<string[]>([]); const [availableSkills, setAvailableSkills] = useState<string[]>([]);
const [availableTools, setAvailableTools] = useState<string[]>([]); const [availableTools, setAvailableTools] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -36,17 +43,19 @@ export function WorkerIndividualSettings() {
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
const [provRes, workRes, sysRes, toolsRes, skillsRes] = await Promise.all([ const [provRes, workRes, sysRes, toolsRes, skillsRes, tplRes] = await Promise.all([
apiClient.get('/api/v1/provider/list'), apiClient.get('/api/v1/provider/list'),
apiClient.get('/api/v1/agent/worker'), apiClient.get('/api/v1/agent/worker'),
apiClient.get('/api/v1/agent'), apiClient.get('/api/v1/agent'),
apiClient.get('/api/v1/resource/tool'), apiClient.get('/api/v1/resource/tool'),
apiClient.get('/api/v1/resource/skill') apiClient.get('/api/v1/resource/skill'),
apiClient.get('/api/v1/agent/template')
]); ]);
setProviders(Object.values(provRes.data.provider_list || {})); setProviders(Object.values(provRes.data.provider_list || {}));
setWorkers(workRes.data.workers || []); setWorkers(workRes.data.workers || []);
setAvailableTools(toolsRes.data.tools || []); setAvailableTools(toolsRes.data.tools || []);
setAvailableSkills(Object.keys(skillsRes.data.skills || {})); setAvailableSkills(Object.keys(skillsRes.data.skills || {}));
setPersonaTemplates(tplRes.data.templates || []);
const providersList = Object.values(provRes.data.provider_list || {}) as Provider[]; const providersList = Object.values(provRes.data.provider_list || {}) as Provider[];
const defaultProvider = providersList.length > 0 ? providersList[0].provider_title : ''; const defaultProvider = providersList.length > 0 ? providersList[0].provider_title : '';
@@ -61,7 +70,7 @@ export function WorkerIndividualSettings() {
provider_title: found?.provider_title || defaultProvider, provider_title: found?.provider_title || defaultProvider,
model_id: found?.model_id || '', model_id: found?.model_id || '',
tools: found?.tools ? JSON.stringify(found.tools) : '[]', tools: found?.tools ? JSON.stringify(found.tools) : '[]',
custom_system_prompt: found?.custom_system_prompt || '', persona_id: found?.persona_id || '',
is_system: true is_system: true
}; };
})); }));
@@ -90,7 +99,7 @@ export function WorkerIndividualSettings() {
const handleAddNew = () => { const handleAddNew = () => {
setEditData({ agent_name: '', agent_type: 'ordinary_individual', description: '', setEditData({ agent_name: '', agent_type: 'ordinary_individual', description: '',
provider_title: providers.length > 0 ? providers[0].provider_title : '', model_id: '', provider_title: providers.length > 0 ? providers[0].provider_title : '', model_id: '',
system_prompt: '', output_template: '{}', bound_skill: '{}', workspace: '[]', tools: '[]' }); persona_id: '', output_template: '{}', bound_skill: '{}', workspace: '[]', tools: '[]' });
setIsNew(true); setIsNew(true);
setIsEditing(true); setIsEditing(true);
setModalMessage(''); setModalMessage('');
@@ -112,7 +121,7 @@ export function WorkerIndividualSettings() {
provider_title: editData.provider_title, provider_title: editData.provider_title,
model_id: editData.model_id, model_id: editData.model_id,
tools: JSON.parse(editData.tools || '[]'), tools: JSON.parse(editData.tools || '[]'),
custom_system_prompt: (editData as any).custom_system_prompt || null, persona_id: (editData as any).persona_id || null,
display_name: (editData as any).display_name || null display_name: (editData as any).display_name || null
}); });
} else { } else {
@@ -280,10 +289,12 @@ export function WorkerIndividualSettings() {
</div> </div>
{(editData as any).is_system && ( {(editData as any).is_system && (
<div> <div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.customPrompt')}</label> <label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.persona')}</label>
<textarea value={(editData as any).custom_system_prompt || ''} onChange={(e) => setEditData({...editData, custom_system_prompt: e.target.value} as any)} rows={3} <select value={(editData as any).persona_id || ''} onChange={(e) => setEditData({...editData, persona_id: e.target.value || null} as any)}
placeholder={t('agent.customPromptPlaceholder')} className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" /> <option value="">{t('common.none')}</option>
{personaTemplates.map(p => <option key={p.template_id} value={p.template_id}>{p.name}</option>)}
</select>
</div> </div>
)} )}
{!(editData as any).is_system && ( {!(editData as any).is_system && (
@@ -294,9 +305,12 @@ export function WorkerIndividualSettings() {
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" /> className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.systemPrompt')}</label> <label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.persona')}</label>
<textarea value={editData.system_prompt || ''} onChange={(e) => setEditData({...editData, system_prompt: e.target.value})} rows={3} <select value={editData.persona_id || ''} onChange={(e) => setEditData({...editData, persona_id: e.target.value})} required
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" /> className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="" disabled>{t('agent.selectPersona')}</option>
{personaTemplates.map(p => <option key={p.template_id} value={p.template_id}>{p.name}</option>)}
</select>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
+123 -87
View File
@@ -1,6 +1,10 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MessageSquare, Activity, ArrowUp, Plus, Sparkles, Code, FileText, Search, User } from 'lucide-react'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Activity, ArrowUp, Plus, Sparkles, Code, FileText, Search, Paperclip } from 'lucide-react';
import { useChatStore } from '../../store/useChatStore'; import { useChatStore } from '../../store/useChatStore';
export function ChatPanel() { export function ChatPanel() {
@@ -13,7 +17,9 @@ export function ChatPanel() {
{ icon: Search, label: t('chat.quickActions.search'), prompt: '帮我搜索相关资料' }, { icon: Search, label: t('chat.quickActions.search'), prompt: '帮我搜索相关资料' },
]; ];
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [showPlusMenu, setShowPlusMenu] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const plusMenuRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const { const {
@@ -41,22 +47,31 @@ export function ChatPanel() {
} }
}, [activeSessionId]); }, [activeSessionId]);
const handleNewChat = async () => { useEffect(() => {
await createChat(t('chat.newChat'), '你好'); const handleClickOutside = (e: MouseEvent) => {
}; if (plusMenuRef.current && !plusMenuRef.current.contains(e.target as Node)) {
setShowPlusMenu(false);
}
};
if (showPlusMenu) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPlusMenu]);
const handleSendMessage = async () => { const handleSendMessage = async () => {
if (!input.trim() || !activeSessionId) return; if (!input.trim()) return;
const text = input.trim(); const text = input.trim();
setInput(''); setInput('');
await sendMessage(activeSessionId, text); if (!activeSessionId) {
const title = text.slice(0, 20) + (text.length > 20 ? '...' : '');
await createChat(title, text);
} else {
await sendMessage(activeSessionId, text);
}
}; };
const handleQuickAction = (prompt: string) => { const handleQuickAction = (prompt: string) => {
if (!activeSessionId) { if (!activeSessionId) {
createChat(prompt.slice(0, 20), prompt).then((id) => { createChat(prompt.slice(0, 20), prompt);
if (id) sendMessage(id, prompt);
});
} else { } else {
sendMessage(activeSessionId, prompt); sendMessage(activeSessionId, prompt);
} }
@@ -87,13 +102,32 @@ export function ChatPanel() {
))} ))}
</div> </div>
<button <div className="w-full max-w-[480px]">
onClick={handleNewChat} <div className="flex items-end gap-2">
className="px-5 py-2.5 bg-accent text-white rounded-xl font-medium hover:bg-accent-hover transition-all text-sm flex items-center gap-2" <div className="flex-1 relative">
> <textarea
<Plus size={16} /> value={input}
{t('chat.newChat')} onChange={(e) => setInput(e.target.value)}
</button> onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
placeholder={t('chat.placeholder')}
rows={1}
className="w-full bg-bg-card border border-border-primary rounded-xl pl-4 pr-4 py-3 focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_var(--accent-light),0_1px_3px_rgba(0,0,0,0.02)] transition-all text-text-primary placeholder:text-[#bbb5ae] text-[13px] resize-none min-h-[44px] max-h-[100px]"
/>
</div>
<button
onClick={handleSendMessage}
disabled={!input.trim()}
className="w-10 h-10 rounded-[10px] bg-accent text-white hover:bg-accent-hover hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(156,175,136,0.25)] transition-all disabled:opacity-30 flex items-center justify-center flex-shrink-0"
>
<ArrowUp size={16} />
</button>
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -101,85 +135,90 @@ export function ChatPanel() {
return ( return (
<div className="flex-1 flex flex-col bg-bg-primary overflow-hidden relative"> <div className="flex-1 flex flex-col bg-bg-primary overflow-hidden relative">
{/* Header */}
<div className="h-12 border-b border-border-primary flex items-center px-6 gap-2.5 bg-bg-primary/60 backdrop-blur-[8px] shrink-0">
<div className="w-[26px] h-[26px] rounded-[7px] flex items-center justify-center bg-accent-light">
<MessageSquare size={12} className="text-accent" />
</div>
<span className="text-[13px] font-semibold text-text-primary">{activeSession?.title || t('chat.defaultTitle')}</span>
</div>
{/* Messages */} {/* Messages */}
<div className="flex-1 overflow-y-auto px-8 py-6 flex flex-col gap-4.5"> <div className="flex-1 overflow-y-auto px-6 py-6">
{loadingMessages ? ( <div className="max-w-[720px] mx-auto flex flex-col gap-6">
<div className="flex justify-center items-center h-full"> {loadingMessages ? (
<div className="flex items-center gap-1.5"> <div className="flex justify-center items-center py-12">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" /> <div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
</div>
</div> </div>
</div> ) : (
) : ( messages.map((msg) => (
messages.map((msg, idx) => ( <div key={msg.id} className={msg.role === 'user' ? 'flex justify-end' : ''}>
<div {msg.role === 'user' ? (
key={msg.id} <div className="max-w-[80%] px-4 py-2.5 bg-bg-card border border-border-primary rounded-2xl rounded-br-sm text-[13px] text-text-primary leading-relaxed whitespace-pre-wrap">
className={`flex gap-2.5 max-w-[78%] ${msg.role === 'user' ? 'self-end flex-row-reverse' : ''} animate-fade-in`} {msg.content}
style={{ animationDelay: `${idx * 30}ms` }} </div>
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0"
style={{
background: msg.role === 'assistant'
? 'var(--accent)'
: 'linear-gradient(135deg, #6b6860, #8a8578)',
}}
>
{msg.role === 'assistant' ? (
<Activity size={12} className="text-white" />
) : ( ) : (
<User size={12} className="text-white" /> <div className="prose-chat text-[13.5px] text-text-primary leading-[1.75]">
{msg.content ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const inline = !match && !String(children).includes('\n');
if (inline) {
return <code className="px-1.5 py-0.5 bg-bg-secondary rounded text-[12px] font-mono" {...props}>{children}</code>;
}
return (
<SyntaxHighlighter
style={oneDark}
language={match?.[1] || 'text'}
PreTag="div"
customStyle={{ borderRadius: '10px', fontSize: '12px', margin: '12px 0' }}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
);
},
}}
>
{msg.content}
</ReactMarkdown>
) : (
<div className="flex items-center gap-1.5 py-1">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
</div>
)}
</div>
)} )}
</div> </div>
<div className={`px-4 py-3 text-[13px] leading-[1.7] whitespace-pre-wrap ${ ))
msg.role === 'user' )}
? 'bg-text-primary text-white rounded-[14px] rounded-br-[3px]' <div ref={messagesEndRef} />
: 'bg-bg-card border border-border-primary rounded-[14px] rounded-bl-[3px] shadow-[0_1px_3px_rgba(0,0,0,0.02)]' </div>
}`}>
{msg.content}
</div>
</div>
))
)}
{/* Typing indicator */}
{activeSession && activeSession.messages.length > 0 && activeSession.messages[activeSession.messages.length - 1].role === 'user' && (
<div className="flex gap-2.5 max-w-[78%] animate-fade-in">
<div className="w-7 h-7 rounded-full bg-accent flex items-center justify-center flex-shrink-0">
<Activity size={12} className="text-white animate-pulse" />
</div>
<div className="bg-bg-card border border-border-primary rounded-[14px] rounded-bl-[3px] px-4 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div> </div>
{/* Input */} {/* Input */}
<div className="px-6 pt-3.5 pb-4.5 border-t border-border-primary bg-bg-primary/60 backdrop-blur-[8px] shrink-0"> <div className="px-6 pt-3.5 pb-4.5 border-t border-border-primary bg-bg-primary/60 backdrop-blur-[8px] shrink-0">
<div className="flex items-end gap-2 max-w-[720px] mx-auto"> <div className="flex items-end gap-2 max-w-[720px] mx-auto">
<input type="file" ref={fileInputRef} className="hidden" /> <input type="file" ref={fileInputRef} className="hidden" />
<button <div className="relative" ref={plusMenuRef}>
onClick={() => fileInputRef.current?.click()} <button
className="w-10 h-10 rounded-xl text-text-muted hover:text-accent hover:bg-bg-hover transition-all flex items-center justify-center flex-shrink-0" onClick={() => setShowPlusMenu(!showPlusMenu)}
title={t('chat.addAttachment')} className="w-10 h-10 rounded-xl text-text-muted hover:text-accent hover:bg-bg-hover transition-all flex items-center justify-center flex-shrink-0"
> >
<Plus size={18} /> <Plus size={18} />
</button> </button>
{showPlusMenu && (
<div className="absolute bottom-12 left-0 bg-bg-card border border-border-primary rounded-xl shadow-lg py-1.5 min-w-[160px] z-50">
<button
onClick={() => { fileInputRef.current?.click(); setShowPlusMenu(false); }}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-xs text-text-secondary hover:bg-bg-hover hover:text-text-primary transition-colors"
>
<Paperclip size={14} />
{t('chat.addAttachment')}
</button>
</div>
)}
</div>
<div className="flex-1 relative"> <div className="flex-1 relative">
<textarea <textarea
value={input} value={input}
@@ -204,9 +243,6 @@ export function ChatPanel() {
<ArrowUp size={16} /> <ArrowUp size={16} />
</button> </button>
</div> </div>
<p className="text-center text-[10px] text-text-muted mt-2">
{t('chat.mistakeWarning')}
</p>
</div> </div>
</div> </div>
); );
+49 -13
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Plus, Trash2, MessageSquare, Workflow as WorkflowIcon } from 'lucide-react'; import { Plus, Trash2, MessageSquare, Workflow as WorkflowIcon, Pencil, Check } from 'lucide-react';
import apiClient from '../../api/client'; import apiClient from '../../api/client';
import type { Workflow } from '../../types'; import type { Workflow } from '../../types';
import { useChatStore } from '../../store/useChatStore'; import { useChatStore } from '../../store/useChatStore';
@@ -13,15 +13,17 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [workflows, setWorkflows] = useState<Workflow[]>([]); const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loadingWorkflows, setLoadingWorkflows] = useState(false); const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const { const {
sessions, sessions,
activeSessionId, activeSessionId,
setActiveSessionId, setActiveSessionId,
removeSession, removeSession,
createChat,
selectedWorkflow, selectedWorkflow,
setSelectedWorkflow, setSelectedWorkflow,
updateSessionTitle,
} = useChatStore(); } = useChatStore();
useEffect(() => { useEffect(() => {
@@ -57,8 +59,8 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
}; };
}, [activeTab]); }, [activeTab]);
const handleNewChat = async () => { const handleNewChat = () => {
await createChat(t('chat.newChat'), '你好'); setActiveSessionId(null);
}; };
const handleDeleteChat = (e: React.MouseEvent, id: string) => { const handleDeleteChat = (e: React.MouseEvent, id: string) => {
@@ -66,6 +68,20 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
removeSession(id); removeSession(id);
}; };
const handleStartRename = (e: React.MouseEvent, id: string, title: string) => {
e.stopPropagation();
setRenamingId(id);
setRenameValue(title);
};
const handleConfirmRename = (e: React.MouseEvent) => {
e.stopPropagation();
if (renamingId && renameValue.trim()) {
updateSessionTitle(renamingId, renameValue.trim());
}
setRenamingId(null);
};
const isChats = activeTab === 'chats'; const isChats = activeTab === 'chats';
return ( return (
@@ -95,6 +111,7 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
) : ( ) : (
sessions.map((session) => { sessions.map((session) => {
const isActive = activeSessionId === session.id; const isActive = activeSessionId === session.id;
const isRenaming = renamingId === session.id;
return ( return (
<div <div
key={session.id} key={session.id}
@@ -109,16 +126,35 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
<MessageSquare size={12} className={isActive ? 'text-accent' : 'text-text-muted'} /> <MessageSquare size={12} className={isActive ? 'text-accent' : 'text-text-muted'} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}> {isRenaming ? (
{session.title} <input
</h3> autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => { if (e.key === 'Enter') handleConfirmRename(e as any); if (e.key === 'Escape') setRenamingId(null); }}
className="w-full text-xs bg-bg-input border border-border-primary rounded px-1.5 py-0.5 text-text-primary focus:outline-none focus:border-accent"
/>
) : (
<h3 className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
{session.title}
</h3>
)}
</div> </div>
<button {isRenaming ? (
onClick={(e) => handleDeleteChat(e, session.id)} <button onClick={handleConfirmRename} className="p-1 rounded text-accent hover:bg-accent-light transition-all">
className="opacity-0 group-hover:opacity-100 p-1 rounded text-text-muted hover:text-danger transition-all" <Check size={11} />
> </button>
<Trash2 size={11} /> ) : (
</button> <div className="flex items-center opacity-0 group-hover:opacity-100 transition-all">
<button onClick={(e) => handleStartRename(e, session.id, session.title)} className="p-1 rounded text-text-muted hover:text-accent transition-all">
<Pencil size={11} />
</button>
<button onClick={(e) => handleDeleteChat(e, session.id)} className="p-1 rounded text-text-muted hover:text-danger transition-all">
<Trash2 size={11} />
</button>
</div>
)}
</div> </div>
); );
}) })
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MessageSquare, Workflow, Box, Bot, ChevronLeft, ChevronRight } from 'lucide-react'; import { MessageSquare, Workflow, Box, Bot, ChevronLeft, ChevronRight, Settings, ScrollText } from 'lucide-react';
import { useAppStore } from '../../store/useAppStore'; import { useAppStore } from '../../store/useAppStore';
export function CollapsibleSidebar() { export function CollapsibleSidebar() {
@@ -22,6 +22,8 @@ export function CollapsibleSidebar() {
: [ : [
{ key: 'plugin', label: t('nav.plugin'), icon: Box }, { key: 'plugin', label: t('nav.plugin'), icon: Box },
{ key: 'agents', label: t('nav.agents'), icon: Bot }, { key: 'agents', label: t('nav.agents'), icon: Bot },
{ key: 'config', label: t('nav.config'), icon: Settings },
{ key: 'logs', label: t('nav.logs'), icon: ScrollText },
]; ];
const activeTab = mode === 'work' ? workTab : agentTab; const activeTab = mode === 'work' ? workTab : agentTab;
+11 -1
View File
@@ -10,6 +10,8 @@
"workflow": "Workflow", "workflow": "Workflow",
"plugin": "Plugin", "plugin": "Plugin",
"agents": "Agents", "agents": "Agents",
"config": "Config",
"logs": "Logs",
"settings": "Settings" "settings": "Settings"
}, },
"auth": { "auth": {
@@ -187,6 +189,10 @@
"logMessage": "Message", "logMessage": "Message",
"logTime": "Time", "logTime": "Time",
"noLogs": "No log entries yet", "noLogs": "No log entries yet",
"workflowLogs": "Workflow Logs",
"workflowLogList": "Workflows",
"selectWorkflowToView": "Select a workflow to view execution logs",
"workflowAction": "Action",
"description": "Description", "description": "Description",
"systemPrompt": "System Prompt", "systemPrompt": "System Prompt",
"outputTemplate": "Output Template (JSON)", "outputTemplate": "Output Template (JSON)",
@@ -205,6 +211,8 @@
"deleteTemplateConfirm": "Delete this template?", "deleteTemplateConfirm": "Delete this template?",
"noTemplates": "No persona templates yet", "noTemplates": "No persona templates yet",
"templateName": "Template Name", "templateName": "Template Name",
"selectPersona": "Select persona...",
"noPrompt": "(no prompt)",
"builtin": "Built-in", "builtin": "Built-in",
"createFromTemplate": "Create worker from template", "createFromTemplate": "Create worker from template",
"workerCreatedFromTemplate": "Worker created from template successfully", "workerCreatedFromTemplate": "Worker created from template successfully",
@@ -212,7 +220,8 @@
"tagsPlaceholder": "Comma-separated, e.g.: assistant, translator, coder", "tagsPlaceholder": "Comma-separated, e.g.: assistant, translator, coder",
"customPrompt": "Custom Prompt", "customPrompt": "Custom Prompt",
"customPromptPlaceholder": "Additional content appended after the default system prompt...", "customPromptPlaceholder": "Additional content appended after the default system prompt...",
"displayName": "Display Name" "displayName": "Display Name",
"persona": "Persona"
}, },
"plugin": { "plugin": {
"toolManagement": "Tool Management", "toolManagement": "Tool Management",
@@ -265,6 +274,7 @@
"actions": "Actions", "actions": "Actions",
"cancel": "Cancel", "cancel": "Cancel",
"skip": "Skip for now", "skip": "Skip for now",
"status": "Status",
"reset": "Reset", "reset": "Reset",
"save": "Save" "save": "Save"
}, },
+11 -1
View File
@@ -10,6 +10,8 @@
"workflow": "工作流", "workflow": "工作流",
"plugin": "插件", "plugin": "插件",
"agents": "智能体", "agents": "智能体",
"config": "配置",
"logs": "日志",
"settings": "设置" "settings": "设置"
}, },
"auth": { "auth": {
@@ -187,6 +189,10 @@
"logMessage": "消息", "logMessage": "消息",
"logTime": "时间", "logTime": "时间",
"noLogs": "暂无日志记录", "noLogs": "暂无日志记录",
"workflowLogs": "工作流日志",
"workflowLogList": "工作流列表",
"selectWorkflowToView": "选择左侧工作流查看执行日志",
"workflowAction": "动作",
"description": "描述", "description": "描述",
"systemPrompt": "系统提示词", "systemPrompt": "系统提示词",
"outputTemplate": "输出模板 (JSON)", "outputTemplate": "输出模板 (JSON)",
@@ -205,6 +211,8 @@
"deleteTemplateConfirm": "确定要删除此模板吗?", "deleteTemplateConfirm": "确定要删除此模板吗?",
"noTemplates": "暂无人设模板", "noTemplates": "暂无人设模板",
"templateName": "模板名称", "templateName": "模板名称",
"selectPersona": "请选择人设",
"noPrompt": "(无提示词)",
"builtin": "内置", "builtin": "内置",
"createFromTemplate": "从模板创建工作者", "createFromTemplate": "从模板创建工作者",
"workerCreatedFromTemplate": "已从模板成功创建工作者", "workerCreatedFromTemplate": "已从模板成功创建工作者",
@@ -212,7 +220,8 @@
"tagsPlaceholder": "用逗号分隔,如:助手, 翻译, 代码", "tagsPlaceholder": "用逗号分隔,如:助手, 翻译, 代码",
"customPrompt": "附加人设", "customPrompt": "附加人设",
"customPromptPlaceholder": "在默认系统提示词之后追加的自定义内容...", "customPromptPlaceholder": "在默认系统提示词之后追加的自定义内容...",
"displayName": "显示名称" "displayName": "显示名称",
"persona": "人设"
}, },
"plugin": { "plugin": {
"toolManagement": "工具管理", "toolManagement": "工具管理",
@@ -265,6 +274,7 @@
"actions": "操作", "actions": "操作",
"cancel": "取消", "cancel": "取消",
"skip": "稍后再说", "skip": "稍后再说",
"status": "状态",
"reset": "重置", "reset": "重置",
"save": "保存" "save": "保存"
}, },
+35
View File
@@ -263,3 +263,38 @@ body::before {
opacity: 0.4; opacity: 0.4;
animation: pulse-glow 2s ease-in-out infinite; animation: pulse-glow 2s ease-in-out infinite;
} }
/* Chat markdown prose */
.prose-chat p { margin: 0.5em 0; }
.prose-chat p:first-child { margin-top: 0; }
.prose-chat p:last-child { margin-bottom: 0; }
.prose-chat ul, .prose-chat ol { margin: 0.5em 0; padding-left: 1.5em; }
.prose-chat li { margin: 0.25em 0; }
.prose-chat h1, .prose-chat h2, .prose-chat h3 {
margin: 0.75em 0 0.35em;
font-weight: 600;
line-height: 1.3;
}
.prose-chat h1 { font-size: 1.25em; }
.prose-chat h2 { font-size: 1.1em; }
.prose-chat h3 { font-size: 1em; }
.prose-chat blockquote {
margin: 0.5em 0;
padding-left: 0.75em;
border-left: 3px solid var(--border-primary);
color: var(--text-secondary);
}
.prose-chat table {
border-collapse: collapse;
margin: 0.5em 0;
font-size: 0.9em;
}
.prose-chat th, .prose-chat td {
border: 1px solid var(--border-primary);
padding: 0.35em 0.6em;
}
.prose-chat th { background: var(--bg-secondary); font-weight: 600; }
.prose-chat a { color: var(--accent); text-decoration: underline; }
.prose-chat hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.75em 0; }
.prose-chat strong { font-weight: 600; }
.prose-chat em { font-style: italic; }
+1 -1
View File
@@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware';
type AppMode = 'work' | 'agent'; type AppMode = 'work' | 'agent';
type WorkTab = 'chat' | 'workflow'; type WorkTab = 'chat' | 'workflow';
type AgentTab = 'plugin' | 'agents'; type AgentTab = 'plugin' | 'agents' | 'config' | 'logs';
type ThemeMode = 'light' | 'dark' | 'system'; type ThemeMode = 'light' | 'dark' | 'system';
interface AppState { interface AppState {
+117 -38
View File
@@ -29,7 +29,7 @@ interface ChatState {
addSession: (session: ChatSession) => void; addSession: (session: ChatSession) => void;
updateSessionMessages: (sessionId: string, messages: Message[]) => void; updateSessionMessages: (sessionId: string, messages: Message[]) => void;
updateSessionTitle: (sessionId: string, title: string) => void; updateSessionTitle: (sessionId: string, title: string) => void;
removeSession: (sessionId: string) => void; removeSession: (sessionId: string) => Promise<void>;
loadSessions: () => Promise<void>; loadSessions: () => Promise<void>;
loadMessages: (chatId: string) => Promise<void>; loadMessages: (chatId: string) => Promise<void>;
@@ -72,7 +72,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
sessions: state.sessions.map((s) => (s.id === sessionId ? { ...s, title } : s)), sessions: state.sessions.map((s) => (s.id === sessionId ? { ...s, title } : s)),
})), })),
removeSession: (sessionId) => removeSession: async (sessionId) => {
try {
await apiClient.delete(`/api/v1/chat/${sessionId}`);
} catch (error) {
console.error('Failed to delete chat session', error);
}
set((state) => { set((state) => {
const filtered = state.sessions.filter((s) => s.id !== sessionId); const filtered = state.sessions.filter((s) => s.id !== sessionId);
return { return {
@@ -84,7 +89,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
: null : null
: state.activeSessionId, : state.activeSessionId,
}; };
}), });
},
loadSessions: async () => { loadSessions: async () => {
set({ loadingSessions: true }); set({ loadingSessions: true });
@@ -123,6 +129,28 @@ export const useChatStore = create<ChatState>((set, get) => ({
}, },
createChat: async (title = '新对话', initialMessage = '你好') => { createChat: async (title = '新对话', initialMessage = '你好') => {
const tempId = `temp_${Date.now()}`;
const userMsg: Message = {
id: tempId + '_user',
role: 'user',
content: initialMessage,
timestamp: Date.now(),
};
const aiMsg: Message = {
id: tempId + '_ai',
role: 'assistant',
content: '',
timestamp: Date.now(),
};
const optimisticSession: ChatSession = {
id: tempId,
title,
messages: [userMsg, aiMsg],
updatedAt: Date.now(),
};
get().addSession(optimisticSession);
try { try {
const response = await apiClient.post('/api/v1/chat', { const response = await apiClient.post('/api/v1/chat', {
title, title,
@@ -132,19 +160,28 @@ export const useChatStore = create<ChatState>((set, get) => ({
const reply: string = const reply: string =
response.data.reply || '你好!我是 kilostar 助手,有什么可以帮你的吗?'; response.data.reply || '你好!我是 kilostar 助手,有什么可以帮你的吗?';
const newSession: ChatSession = { set((state) => ({
id: chatId, sessions: state.sessions.map((s) =>
title, s.id === tempId
messages: [ ? {
{ id: chatId + '_user', role: 'user', content: initialMessage, timestamp: Date.now() }, ...s,
{ id: chatId + '_ai', role: 'assistant', content: reply, timestamp: Date.now() }, id: chatId,
], messages: [
updatedAt: Date.now(), { ...userMsg, id: chatId + '_user' },
}; { ...aiMsg, id: chatId + '_ai', content: reply },
get().addSession(newSession); ],
}
: s
),
activeSessionId: state.activeSessionId === tempId ? chatId : state.activeSessionId,
}));
return chatId; return chatId;
} catch (error) { } catch (error) {
console.error('Failed to create chat session', error); console.error('Failed to create chat session', error);
set((state) => ({
sessions: state.sessions.filter((s) => s.id !== tempId),
activeSessionId: state.activeSessionId === tempId ? null : state.activeSessionId,
}));
return null; return null;
} }
}, },
@@ -161,38 +198,80 @@ export const useChatStore = create<ChatState>((set, get) => ({
timestamp: Date.now(), timestamp: Date.now(),
}; };
const currentMessages = [...session.messages, userMessage]; const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '',
timestamp: Date.now(),
};
const currentMessages = [...session.messages, userMessage, aiMessage];
get().updateSessionMessages(chatId, currentMessages); get().updateSessionMessages(chatId, currentMessages);
try { if (session.messages.length <= 1 && text.length > 0) {
const response = await apiClient.post(`/api/v1/chat/${chatId}/reply`, { const newTitle = text.slice(0, 20) + (text.length > 20 ? '...' : '');
message: text, get().updateSessionTitle(chatId, newTitle);
}); }
const replyContent: string = response.data?.reply || '收到你的消息。';
// Auto-update title on first user message try {
if (session.messages.length <= 1 && text.length > 0) { const token = localStorage.getItem('token');
const newTitle = text.slice(0, 20) + (text.length > 20 ? '...' : ''); const baseURL = apiClient.defaults.baseURL || '';
get().updateSessionTitle(chatId, newTitle); const response = await fetch(`${baseURL}/api/v1/chat/${chatId}/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ message: text }),
});
if (!response.ok || !response.body) {
throw new Error('Stream request failed');
} }
const aiMessage: Message = { const reader = response.body.getReader();
id: (Date.now() + 1).toString(), const decoder = new TextDecoder();
role: 'assistant', let buffer = '';
content: replyContent,
timestamp: Date.now(),
};
get().updateSessionMessages(chatId, [...currentMessages, aiMessage]); while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const dataStr = line.slice(6).trim();
if (!dataStr) continue;
try {
const data = JSON.parse(dataStr);
if (data.done) break;
if (data.token) {
aiMessage.content += data.token;
const latestSession = get().sessions.find((s) => s.id === chatId);
if (latestSession) {
const msgs = latestSession.messages.map((m) =>
m.id === aiMessage.id ? { ...m, content: aiMessage.content } : m
);
get().updateSessionMessages(chatId, msgs);
}
}
} catch { /* skip malformed JSON */ }
}
}
} catch (error) { } catch (error) {
console.error('Error sending message', error); console.error('Error in streaming message', error);
const errorMessage: Message = { const latestSession = get().sessions.find((s) => s.id === chatId);
id: (Date.now() + 1).toString(), if (latestSession) {
role: 'assistant', const msgs = latestSession.messages.map((m) =>
content: '抱歉,与服务器通信时出错。', m.id === aiMessage.id
timestamp: Date.now(), ? { ...m, content: m.content || '抱歉,与服务器通信时出错。' }
}; : m
get().updateSessionMessages(chatId, [...currentMessages, errorMessage]); );
get().updateSessionMessages(chatId, msgs);
}
} }
}, },
})); }));
+3 -2
View File
@@ -28,7 +28,7 @@ if not _STANDALONE:
from .agent import agent_router from .agent import agent_router
from .auth import auth_router from .auth import auth_router
from .system import system_router from .system import system_router, system_api_router
from .platform.frontend import client_router from .platform.frontend import client_router
from .platform.onebot import onebot_router from .platform.onebot import onebot_router
from .provider import provider_router from .provider import provider_router
@@ -93,7 +93,8 @@ async def request_id_middleware(request: Request, call_next):
response.headers["X-Request-Id"] = request_id response.headers["X-Request-Id"] = request_id
return response return response
app.include_router(system_router) # 健康探针 + 系统信息 app.include_router(system_router) # 健康探针
app.include_router(system_api_router) # 系统信息(/api/v1/system
app.include_router(client_router) # 客户端路径 app.include_router(client_router) # 客户端路径
app.include_router(onebot_router) # OneBot v11 路径 app.include_router(onebot_router) # OneBot v11 路径
app.include_router(auth_router) # 用户路径 app.include_router(auth_router) # 用户路径
+19 -48
View File
@@ -36,7 +36,7 @@ class AgentRegister(BaseModel):
model_id: str model_id: str
individual_name: str individual_name: str
tools: Optional[List[str]] = None tools: Optional[List[str]] = None
custom_system_prompt: Optional[str] = None persona_id: Optional[str] = None
display_name: Optional[str] = None display_name: Optional[str] = None
@@ -79,13 +79,19 @@ async def load_agent(
agent_register.provider_title, agent_register.provider_title,
agent_register.model_id, agent_register.model_id,
agent_register.tools, agent_register.tools,
agent_register.custom_system_prompt, agent_register.persona_id,
agent_register.display_name, agent_register.display_name,
) )
scope = agent_register.individual_name scope = agent_register.individual_name
toolsets = await get_all_toolsets_for_scope(scope) toolsets = await get_all_toolsets_for_scope(scope)
custom_prompt = agent_register.custom_system_prompt
# Resolve persona system_prompt from DB
persona_prompt = None
if agent_register.persona_id:
tpl = await postgres_database.get_template.remote(agent_register.persona_id)
if tpl:
persona_prompt = tpl.system_prompt
match scope: match scope:
case "regulatory_node": case "regulatory_node":
@@ -97,7 +103,7 @@ async def load_agent(
agent_register.tools, agent_register.tools,
toolsets, toolsets,
accept_lang, accept_lang,
custom_prompt, persona_prompt,
) )
case "consciousness_node": case "consciousness_node":
node = ray_actor_hook("consciousness_node").consciousness_node node = ray_actor_hook("consciousness_node").consciousness_node
@@ -108,7 +114,7 @@ async def load_agent(
agent_register.tools, agent_register.tools,
toolsets, toolsets,
accept_lang, accept_lang,
custom_prompt, persona_prompt,
) )
case _: case _:
pass pass
@@ -130,7 +136,7 @@ class WorkerIndividualCreate(BaseModel):
description: str description: str
provider_title: str provider_title: str
model_id: str model_id: str
system_prompt: str persona_id: str
output_template: dict output_template: dict
bound_skill: Dict[str, List[str]] bound_skill: Dict[str, List[str]]
workspace: List[str] workspace: List[str]
@@ -153,7 +159,7 @@ class WorkerIndividualUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
provider_title: Optional[str] = None provider_title: Optional[str] = None
model_id: Optional[str] = None model_id: Optional[str] = None
system_prompt: Optional[str] = None persona_id: Optional[str] = None
output_template: Optional[dict] = None output_template: Optional[dict] = None
bound_skill: Optional[Dict[str, List[str]]] = None bound_skill: Optional[Dict[str, List[str]]] = None
workspace: Optional[List[str]] = None workspace: Optional[List[str]] = None
@@ -280,34 +286,21 @@ async def delete_worker_individual(
class PersonaTemplateCreate(BaseModel): class PersonaTemplateCreate(BaseModel):
name: str name: str
description: str = ""
system_prompt: str = "" system_prompt: str = ""
agent_type: AgentType = "ordinary"
provider_title: Optional[str] = None
model_id: Optional[str] = None
tools: Optional[List[str]] = None
tags: Optional[List[str]] = None
class PersonaTemplateUpdate(BaseModel): class PersonaTemplateUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None
system_prompt: Optional[str] = None system_prompt: Optional[str] = None
agent_type: Optional[AgentType] = None
provider_title: Optional[str] = None
model_id: Optional[str] = None
tools: Optional[List[str]] = None
tags: Optional[List[str]] = None
@agent_router.get("/template") @agent_router.get("/template")
async def list_templates( async def list_templates(
include_builtin: bool = True,
token_data: TokenData = Depends(Accessor.get_current_user), token_data: TokenData = Depends(Accessor.get_current_user),
): ):
postgres_database = ray_actor_hook("postgres_database").postgres_database postgres_database = ray_actor_hook("postgres_database").postgres_database
templates = await postgres_database.list_templates.remote( templates = await postgres_database.list_templates.remote(
owner_id=token_data.user_id, include_builtin=include_builtin owner_id=token_data.user_id
) )
return {"templates": templates} return {"templates": templates}
@@ -319,7 +312,9 @@ async def create_template(
): ):
postgres_database = ray_actor_hook("postgres_database").postgres_database postgres_database = ray_actor_hook("postgres_database").postgres_database
tpl = await postgres_database.add_template.remote( tpl = await postgres_database.add_template.remote(
**data.model_dump(), owner_id=token_data.user_id, is_builtin=False name=data.name,
system_prompt=data.system_prompt,
owner_id=token_data.user_id,
) )
return {"message": "success", "template_id": tpl.template_id} return {"message": "success", "template_id": tpl.template_id}
@@ -334,7 +329,7 @@ async def update_template(
tpl = await postgres_database.get_template.remote(template_id) tpl = await postgres_database.get_template.remote(template_id)
if not tpl: if not tpl:
raise HTTPException(status_code=404, detail="Template not found") raise HTTPException(status_code=404, detail="Template not found")
if not tpl.is_builtin and tpl.owner_id != token_data.user_id: if tpl.owner_id != token_data.user_id:
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
updated = await postgres_database.update_template.remote( updated = await postgres_database.update_template.remote(
template_id, **data.model_dump(exclude_unset=True) template_id, **data.model_dump(exclude_unset=True)
@@ -351,31 +346,7 @@ async def delete_template(
tpl = await postgres_database.get_template.remote(template_id) tpl = await postgres_database.get_template.remote(template_id)
if not tpl: if not tpl:
raise HTTPException(status_code=404, detail="Template not found") raise HTTPException(status_code=404, detail="Template not found")
if tpl.is_builtin or tpl.owner_id != token_data.user_id: if tpl.owner_id != token_data.user_id:
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
await postgres_database.delete_template.remote(template_id) await postgres_database.delete_template.remote(template_id)
return {"message": "success"} return {"message": "success"}
@agent_router.post("/worker/from-template/{template_id}")
async def create_worker_from_template(
template_id: str,
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
"""从人设模板快速创建一个 Worker Agent,字段直接从模板复制。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
tpl = await postgres_database.get_template.remote(template_id)
if not tpl:
raise HTTPException(status_code=404, detail="Template not found")
worker = await postgres_database.add_worker_individual.remote(
agent_name=tpl.name,
agent_type=tpl.agent_type,
description=tpl.description,
system_prompt=tpl.system_prompt,
provider_title=tpl.provider_title or "",
model_id=tpl.model_id or "",
tools=tpl.tools or [],
owner_id=token_data.user_id,
template_origin_id=template_id,
)
return {"message": "success", "agent_id": worker.agent_id}
+10 -3
View File
@@ -38,9 +38,16 @@ async def create_user(user_register: UserRegister, request: Request):
"""注册新用户:异步线程池里做 argon2 哈希,再交由 PostgresDatabase Actor 落库。""" """注册新用户:异步线程池里做 argon2 哈希,再交由 PostgresDatabase Actor 落库。"""
register_limiter.check(request) register_limiter.check(request)
postgres_database = ray_actor_hook("postgres_database").postgres_database postgres_database = ray_actor_hook("postgres_database").postgres_database
hashed_password = await run_in_threadpool( try:
Accessor.hash_password, user_register.password hashed_password = await run_in_threadpool(
) Accessor.hash_password, user_register.password
)
except ValueError as e:
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=400,
content={"code": "password_invalid", "message": str(e)},
)
user = await postgres_database.add_user.remote( user = await postgres_database.add_user.remote(
user_register.user_name, hashed_password user_register.user_name, hashed_password
) )
+119 -1
View File
@@ -12,7 +12,11 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from fastapi import APIRouter, Depends import json
import asyncio
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from kilostar.utils.ray_hook import ray_actor_hook from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.access import Accessor, TokenData from kilostar.utils.access import Accessor, TokenData
@@ -136,3 +140,117 @@ async def send_chat_message(
) )
return {"reply": response_msg} return {"reply": response_msg}
@chat_router.delete("/{chat_id}")
async def delete_chat_session(
chat_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
session = await postgres_database.get_chat_session.remote(chat_id=chat_id)
if not session:
raise HTTPException(status_code=404, detail="Chat session not found")
if session.user_id != token_data.user_id:
raise HTTPException(status_code=403, detail="Forbidden")
await postgres_database.delete_chat_session.remote(chat_id=chat_id)
return {"message": "success"}
@chat_router.post("/{chat_id}/stream")
async def stream_chat_message(
chat_id: str,
request_body: SendMessageRequest,
request: Request,
token_data: TokenData = Depends(Accessor.get_current_user),
):
"""SSE 流式聊天端点:逐 token 推送 AI 回复。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
# 存用户消息
await postgres_database.add_chat_message.remote(
chat_id=chat_id, message=request_body.message, message_owner="user"
)
# 获取 regulatory_node 的 provider 配置
node_config = await postgres_database.get_system_node_config.remote("regulatory_node")
if not node_config:
raise HTTPException(status_code=500, detail="Regulatory node not configured")
# 获取 provider 详情
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
snapshot = await fetch_snapshot(gsm_actor=global_state_machine)
provider = snapshot.providers.get(node_config.provider_title)
if not provider:
raise HTTPException(status_code=500, detail="Provider not available")
# 加载历史消息作为上下文
history_msgs = await postgres_database.list_chat_messages.remote(chat_id=chat_id)
messages = []
system_prompt = "你是 KiloStar 助手,友善、简洁地回答用户的问题。"
if node_config.persona_id:
tpl = await postgres_database.get_template.remote(node_config.persona_id)
if tpl and tpl.system_prompt:
system_prompt += "\n" + tpl.system_prompt
messages.append({"role": "system", "content": system_prompt})
for msg in history_msgs:
role = "user" if msg.message_owner == "user" else "assistant"
messages.append({"role": role, "content": msg.message})
async def event_generator():
full_response = ""
try:
async with httpx.AsyncClient(timeout=120.0) as client:
url = provider.provider_url.rstrip("/") + "/chat/completions"
payload = {
"model": node_config.model_id,
"messages": messages,
"stream": True,
}
async with client.stream(
"POST",
url,
json=payload,
headers={
"Authorization": f"Bearer {provider.provider_apikey}",
"Content-Type": "application/json",
},
) as resp:
async for line in resp.aiter_lines():
if await request.is_disconnected():
break
if not line.startswith("data: "):
continue
data_str = line[6:]
if data_str.strip() == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
token = delta.get("content", "")
if token:
full_response += token
yield f"data: {json.dumps({'token': token})}\n\n"
except (json.JSONDecodeError, IndexError, KeyError):
continue
except Exception as e:
from kilostar.utils.logger import get_logger
get_logger("chat_stream").exception(f"Stream error: {e}")
if not full_response:
full_response = "抱歉,生成回复时出错。"
yield f"data: {json.dumps({'token': full_response})}\n\n"
# 流结束,存入数据库
if full_response:
await postgres_database.add_chat_message.remote(
chat_id=chat_id,
message=full_response,
message_owner="regulatory_node",
)
yield f"data: {json.dumps({'done': True, 'full_message': full_response})}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
+7 -8
View File
@@ -35,6 +35,7 @@ from kilostar.utils.config_loader import (
) )
system_router = APIRouter(tags=["system"]) system_router = APIRouter(tags=["system"])
system_api_router = APIRouter(prefix="/api/v1/system", tags=["system"])
@system_router.get("/health/live", include_in_schema=True) @system_router.get("/health/live", include_in_schema=True)
@@ -69,15 +70,15 @@ async def readiness():
) )
@system_router.get("/config/workflow") @system_api_router.get("/config/workflow")
async def get_workflow_config_endpoint( async def get_workflow_config_endpoint(
_: TokenData = Depends(Accessor.get_current_user), _: TokenData = Depends(Accessor.get_current_user),
): ):
config = get_workflow_config() config = get_workflow_config()
return {"config": config.model_dump()} return config.model_dump()
@system_router.put("/config/workflow") @system_api_router.put("/config/workflow")
async def update_workflow_config_endpoint( async def update_workflow_config_endpoint(
update: WorkflowConfig, update: WorkflowConfig,
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)), _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
@@ -86,7 +87,7 @@ async def update_workflow_config_endpoint(
return {"status": "ok", "config": update.model_dump()} return {"status": "ok", "config": update.model_dump()}
@system_router.get("/logs") @system_api_router.get("/logs")
async def query_system_logs( async def query_system_logs(
trace_id: str | None = None, trace_id: str | None = None,
event_type: str | None = None, event_type: str | None = None,
@@ -95,9 +96,7 @@ async def query_system_logs(
offset: int = 0, offset: int = 0,
_: TokenData = Depends(Accessor.get_current_user), _: TokenData = Depends(Accessor.get_current_user),
): ):
from kilostar.utils.ray_hook import ray_actor_hook pg = ray_actor_hook("postgres_database").postgres_database
pg = await ray_actor_hook.get_actor("postgres_database")
logs = await pg.query_event_logs.remote( logs = await pg.query_event_logs.remote(
trace_id=trace_id, trace_id=trace_id,
event_type=event_type, event_type=event_type,
@@ -108,7 +107,7 @@ async def query_system_logs(
return {"logs": logs, "count": len(logs)} return {"logs": logs, "count": len(logs)}
@system_router.get("/api/v1/system/node-labels") @system_api_router.get("/node-labels")
async def get_node_labels( async def get_node_labels(
_: TokenData = Depends(Accessor.get_current_user), _: TokenData = Depends(Accessor.get_current_user),
): ):
@@ -122,8 +122,7 @@ class ProviderManager:
async def delete_provider(self, provider_title: str, postgres_database) -> None: async def delete_provider(self, provider_title: str, postgres_database) -> None:
"""从内存注册表 + Postgres 中一并删除指定 Provider;不存在时静默返回。""" """从内存注册表 + Postgres 中一并删除指定 Provider;不存在时静默返回。"""
if provider_title in self.provider_register: if provider_title in self.provider_register:
provider = self.provider_register[provider_title] await postgres_database.delete_provider_by_title.remote(
await postgres_database.delete_provider_db.remote( provider_title=provider_title
provider_id=provider.provider_id
) )
del self.provider_register[provider_title] del self.provider_register[provider_title]
@@ -87,6 +87,12 @@ class ConsciousnessNode:
prompt += "你可以直接将以下 Skill Individual 安排进工作流的步骤中(设置 node 为 skill_individual,并将 agent_id 设置为对应 Skill Individual 的真实 agent_id,不要用名称!),作为可调用的工具。\n" prompt += "你可以直接将以下 Skill Individual 安排进工作流的步骤中(设置 node 为 skill_individual,并将 agent_id 设置为对应 Skill Individual 的真实 agent_id,不要用名称!),作为可调用的工具。\n"
for skill in ctx.deps.available_skills: for skill in ctx.deps.available_skills:
prompt += f"- 真实 agent_id: {skill.get('agent_id')}\n 名称: {skill['name']}\n 描述: {skill['description']}\n" prompt += f"- 真实 agent_id: {skill.get('agent_id')}\n 名称: {skill['name']}\n 描述: {skill['description']}\n"
else:
prompt += "\n=== 重要:当前无可用 Worker Individual ===\n"
prompt += "系统中当前没有注册任何 Worker Individual。在生成工作流时,你有且仅有以下两种选择:\n"
prompt += "1. 将步骤分配给 consciousness_node 自己完成(设置 node 为 consciousness_nodeagent_id 为 null)。\n"
prompt += "2. 如果任务确实需要专用工具或技能才能完成,则拒绝执行并在输出中说明需要先创建对应的 Worker。\n"
prompt += "绝对禁止编造不存在的 agent_id!\n"
return prompt return prompt
@@ -37,14 +37,13 @@ class BaseIndividualModel(BaseDataModel):
agent_id: Mapped[str] = mapped_column(String(64), primary_key=True) agent_id: Mapped[str] = mapped_column(String(64), primary_key=True)
agent_name: Mapped[str] = mapped_column(String(100), index=True, nullable=False) agent_name: Mapped[str] = mapped_column(String(100), index=True, nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False)
system_prompt: Mapped[Optional[str]] = mapped_column(Text)
provider_title: Mapped[str] = mapped_column(String(50)) provider_title: Mapped[str] = mapped_column(String(50))
model_id: Mapped[str] = mapped_column(String(100)) model_id: Mapped[str] = mapped_column(String(100))
owner_id: Mapped[str] = mapped_column(String(64), index=True) owner_id: Mapped[str] = mapped_column(String(64), index=True)
agent_type: Mapped[str] = mapped_column(String(32)) agent_type: Mapped[str] = mapped_column(String(32))
node_affinity: Mapped[str] = mapped_column(String(32), nullable=False, default="cpu") node_affinity: Mapped[str] = mapped_column(String(32), nullable=False, default="cpu")
template_origin_id: Mapped[Optional[str]] = mapped_column( persona_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("persona_template.template_id", ondelete="SET NULL"), ForeignKey("persona_template.template_id", ondelete="SET NULL"),
nullable=True, nullable=True,
index=True, index=True,
@@ -1,6 +1,5 @@
from typing import List, Optional from typing import Optional
from sqlalchemy import String, Text, Boolean, text from sqlalchemy import String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import BaseDataModel from .base import BaseDataModel
@@ -11,16 +10,5 @@ class PersonaTemplate(BaseDataModel):
template_id: Mapped[str] = mapped_column(String(64), primary_key=True) template_id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="") system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="")
agent_type: Mapped[str] = mapped_column(String(32), nullable=False, default="ordinary")
provider_title: Mapped[Optional[str]] = mapped_column(String(50))
model_id: Mapped[Optional[str]] = mapped_column(String(100))
tools: Mapped[Optional[List[str]]] = mapped_column(
JSONB, default=list, server_default=text("'[]'::jsonb")
)
tags: Mapped[Optional[List[str]]] = mapped_column(
JSONB, default=list, server_default=text("'[]'::jsonb")
)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
owner_id: Mapped[Optional[str]] = mapped_column(String(64), index=True) owner_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
@@ -13,7 +13,7 @@
# limitations under the License. # limitations under the License.
from typing import List, Optional from typing import List, Optional
from sqlalchemy import String, Text from sqlalchemy import String, ForeignKey
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import BaseDataModel from .base import BaseDataModel
@@ -35,6 +35,8 @@ class SystemNodeConfigModel(BaseDataModel):
tools: Mapped[Optional[List[str]]] = mapped_column( tools: Mapped[Optional[List[str]]] = mapped_column(
JSONB, default=list, comment="节点可调用的工具标识列表" JSONB, default=list, comment="节点可调用的工具标识列表"
) )
custom_system_prompt: Mapped[Optional[str]] = mapped_column( persona_id: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, comment="管理员自定义追加的提示词,拼接在默认 system prompt 之后" ForeignKey("persona_template.template_id", ondelete="SET NULL"),
nullable=True,
index=True,
) )
@@ -85,3 +85,29 @@ class ChatHistoryDatabase:
) )
results = await session.execute(statement) results = await session.execute(statement)
return results.scalars().all() return results.scalars().all()
@database_exception
async def get_chat_session(self, chat_id: str) -> ChatHistoryRegister | None:
async with self.async_session_maker() as session:
statement = select(ChatHistoryRegister).where(
ChatHistoryRegister.chat_id == chat_id
)
results = await session.execute(statement)
return results.scalar_one_or_none()
@database_exception
async def delete_chat_session(self, chat_id: str) -> None:
from sqlalchemy import delete as sa_delete
async with self.async_session_maker() as session:
await session.execute(
sa_delete(ChatHistoryMessage).where(
ChatHistoryMessage.chat_id == chat_id
)
)
await session.execute(
sa_delete(ChatHistoryRegister).where(
ChatHistoryRegister.chat_id == chat_id
)
)
await session.commit()
@@ -27,18 +27,11 @@ class PersonaTemplateDatabase:
return result.scalar_one_or_none() return result.scalar_one_or_none()
@database_exception @database_exception
async def list_templates(self, owner_id: str = None, include_builtin: bool = True): async def list_templates(self, owner_id: str = None, **kwargs):
async with self.async_session_maker() as session: async with self.async_session_maker() as session:
stmt = select(PersonaTemplate) stmt = select(PersonaTemplate)
if owner_id and include_builtin: if owner_id:
from sqlalchemy import or_
stmt = stmt.where(
or_(PersonaTemplate.owner_id == owner_id, PersonaTemplate.is_builtin == True)
)
elif owner_id:
stmt = stmt.where(PersonaTemplate.owner_id == owner_id) stmt = stmt.where(PersonaTemplate.owner_id == owner_id)
elif include_builtin:
stmt = stmt.where(PersonaTemplate.is_builtin == True)
result = await session.execute(stmt) result = await session.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
@@ -93,6 +93,19 @@ class ProviderDatabase:
session.delete(provider) session.delete(provider)
await session.commit() await session.commit()
@database_exception
async def delete_provider_by_title(self, provider_title: str) -> None:
"""按 provider_title 删除 Provider;不存在时静默返回。"""
async with self.async_session_maker() as session:
statement = select(ProviderModel).where(
ProviderModel.provider_title == provider_title
)
result = await session.execute(statement)
provider = result.scalar_one_or_none()
if provider is not None:
await session.delete(provider)
await session.commit()
@database_exception @database_exception
async def update_provider(self, provider_id: str, **kwargs) -> None: async def update_provider(self, provider_id: str, **kwargs) -> None:
"""部分更新指定 Provider 的字段;``provider_apikey`` 写入前自动加密。""" """部分更新指定 Provider 的字段;``provider_apikey`` 写入前自动加密。"""
@@ -31,7 +31,7 @@ class SystemNodeDatabase:
provider_title: str, provider_title: str,
model_id: str, model_id: str,
tools: Optional[List[str]] = None, tools: Optional[List[str]] = None,
custom_system_prompt: Optional[str] = None, persona_id: Optional[str] = None,
display_name: Optional[str] = None, display_name: Optional[str] = None,
) -> SystemNodeConfigModel: ) -> SystemNodeConfigModel:
"""按 node_name 插入或更新一个系统节点的模型配置。""" """按 node_name 插入或更新一个系统节点的模型配置。"""
@@ -46,8 +46,8 @@ class SystemNodeDatabase:
config.model_id = model_id config.model_id = model_id
if tools is not None: if tools is not None:
config.tools = tools config.tools = tools
if custom_system_prompt is not None: if persona_id is not None:
config.custom_system_prompt = custom_system_prompt config.persona_id = persona_id
if display_name is not None: if display_name is not None:
config.display_name = display_name config.display_name = display_name
else: else:
@@ -56,7 +56,7 @@ class SystemNodeDatabase:
provider_title=provider_title, provider_title=provider_title,
model_id=model_id, model_id=model_id,
tools=tools, tools=tools,
custom_system_prompt=custom_system_prompt, persona_id=persona_id,
display_name=display_name, display_name=display_name,
) )
session.add(config) session.add(config)
+24 -4
View File
@@ -171,6 +171,11 @@ class PostgresDatabase:
await self.ready_event.wait() await self.ready_event.wait()
return await self._provider_database.delete_provider(provider_id) return await self._provider_database.delete_provider(provider_id)
async def delete_provider_by_title(self, provider_title: str):
"""按 provider_title 删除模型 Provider 记录。"""
await self.ready_event.wait()
return await self._provider_database.delete_provider_by_title(provider_title)
async def update_provider_db(self, provider_id: str, **kwargs): async def update_provider_db(self, provider_id: str, **kwargs):
"""部分更新指定 Provider 的字段。""" """部分更新指定 Provider 的字段。"""
await self.ready_event.wait() await self.ready_event.wait()
@@ -183,13 +188,13 @@ class PostgresDatabase:
provider_title: str, provider_title: str,
model_id: str, model_id: str,
tools: list[str] = None, tools: list[str] = None,
custom_system_prompt: str = None, persona_id: str = None,
display_name: str = None, display_name: str = None,
): ):
"""插入或更新某个系统节点(如 consciousness/regulatory)的模型配置。""" """插入或更新某个系统节点(如 consciousness/regulatory)的模型配置。"""
await self.ready_event.wait() await self.ready_event.wait()
return await self._system_node_database.upsert_system_node_config( return await self._system_node_database.upsert_system_node_config(
node_name, provider_title, model_id, tools, custom_system_prompt, display_name node_name, provider_title, model_id, tools, persona_id, display_name
) )
async def get_all_system_node_configs(self): async def get_all_system_node_configs(self):
@@ -197,6 +202,11 @@ class PostgresDatabase:
await self.ready_event.wait() await self.ready_event.wait()
return await self._system_node_database.get_all_system_node_configs() return await self._system_node_database.get_all_system_node_configs()
async def get_system_node_config(self, node_name: str):
"""按 node_name 取出单个系统节点的模型配置。"""
await self.ready_event.wait()
return await self._system_node_database.get_system_node_config(node_name)
# Individual Database Methods # Individual Database Methods
async def add_worker_individual(self, **kwargs): async def add_worker_individual(self, **kwargs):
"""登记一个新的 Worker Individual 配置。""" """登记一个新的 Worker Individual 配置。"""
@@ -306,6 +316,16 @@ class PostgresDatabase:
await self.ready_event.wait() await self.ready_event.wait()
return await self._chat_history_database.list_chat_messages(chat_id) return await self._chat_history_database.list_chat_messages(chat_id)
async def get_chat_session(self, chat_id: str):
"""按 chat_id 取出单个聊天会话。"""
await self.ready_event.wait()
return await self._chat_history_database.get_chat_session(chat_id)
async def delete_chat_session(self, chat_id: str):
"""删除聊天会话及其所有消息。"""
await self.ready_event.wait()
return await self._chat_history_database.delete_chat_session(chat_id)
# MCP Server Database Methods # MCP Server Database Methods
async def upsert_mcp_server(self, server_id: str, config: dict): async def upsert_mcp_server(self, server_id: str, config: dict):
"""插入或更新一条 MCP 服务器配置;env 中敏感字段自动加密。""" """插入或更新一条 MCP 服务器配置;env 中敏感字段自动加密。"""
@@ -423,9 +443,9 @@ class PostgresDatabase:
await self.ready_event.wait() await self.ready_event.wait()
return await self._persona_template_database.get_template(template_id) return await self._persona_template_database.get_template(template_id)
async def list_templates(self, owner_id: str = None, include_builtin: bool = True): async def list_templates(self, owner_id: str = None, **kwargs):
await self.ready_event.wait() await self.ready_event.wait()
return await self._persona_template_database.list_templates(owner_id, include_builtin) return await self._persona_template_database.list_templates(owner_id=owner_id)
async def update_template(self, template_id: str, **kwargs): async def update_template(self, template_id: str, **kwargs):
await self.ready_event.wait() await self.ready_event.wait()
+5 -6
View File
@@ -167,11 +167,10 @@ class Accessor:
"""对明文口令做强哈希;空值或不满足复杂度要求会抛 ValueError。""" """对明文口令做强哈希;空值或不满足复杂度要求会抛 ValueError。"""
if not password: if not password:
raise ValueError("密码不能为空") raise ValueError("密码不能为空")
if len(password) < 8: if len(password) < 6:
raise ValueError("密码长度不能小于 8") raise ValueError("密码长度不能小于 6")
has_upper = any(c.isupper() for c in password) has_alpha = any(c.isalpha() for c in password)
has_lower = any(c.islower() for c in password)
has_digit = any(c.isdigit() for c in password) has_digit = any(c.isdigit() for c in password)
if not (has_upper and has_lower and has_digit): if not (has_alpha and has_digit):
raise ValueError("密码必须包含大写字母、小写字母和数字") raise ValueError("密码必须同时包含字母和数字")
return password_hasher.hash(password) return password_hasher.hash(password)
+6 -3
View File
@@ -104,7 +104,7 @@ class WorkerCluster:
if self.task_queue is None: if self.task_queue is None:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
continue continue
task = await self.task_queue.get_async() task = await self.task_queue.get() if _STANDALONE else await self.task_queue.get_async()
task_id = task.get("task_id") task_id = task.get("task_id")
agent_id = task.get("agent_id") agent_id = task.get("agent_id")
task_event = task.get("task_event") task_event = task.get("task_event")
@@ -150,7 +150,10 @@ class WorkerCluster:
self.results_futures[task_id] = future self.results_futures[task_id] = future
task = {"task_id": task_id, "agent_id": agent_id, "task_event": task_event} task = {"task_id": task_id, "agent_id": agent_id, "task_event": task_event}
await self.task_queue.put_async(task) if _STANDALONE:
await self.task_queue.put(task)
else:
await self.task_queue.put_async(task)
self.logger.debug(f"[WorkerCluster] 任务 {task_id} 已加入队列。") self.logger.debug(f"[WorkerCluster] 任务 {task_id} 已加入队列。")
try: try:
@@ -165,5 +168,5 @@ class WorkerCluster:
"active_worker_count": len(self._active_workers), "active_worker_count": len(self._active_workers),
"max_capacity": self.max_capacity, "max_capacity": self.max_capacity,
"cached_agent_ids": list(self._active_workers.keys()), "cached_agent_ids": list(self._active_workers.keys()),
"queue_size": self.task_queue.size(), "queue_size": self.task_queue.qsize() if _STANDALONE else self.task_queue.size(),
} }
+9 -1
View File
@@ -58,7 +58,10 @@ async def start_standalone():
await postgres_database.init_db() await postgres_database.init_db()
register_standalone("postgres_database", postgres_database) register_standalone("postgres_database", postgres_database)
global_state_machine = GlobalStateMachine(postgres_database) from kilostar.utils.standalone_proxy import StandaloneProxy
postgres_proxy = StandaloneProxy(postgres_database)
global_state_machine = GlobalStateMachine(postgres_proxy)
await global_state_machine.init_state_machine() await global_state_machine.init_state_machine()
register_standalone("global_state_machine", global_state_machine) register_standalone("global_state_machine", global_state_machine)
@@ -104,6 +107,11 @@ async def start_distributed():
dashboard_host="0.0.0.0", dashboard_host="0.0.0.0",
dashboard_port=8265, dashboard_port=8265,
runtime_env={"env_vars": env_vars}, runtime_env={"env_vars": env_vars},
resources={
"kilostar_node_cpu": 1,
"kilostar_node_core": 1,
"kilostar_node_gpu": 1,
},
) )
postgres_database = PostgresDatabase.options( postgres_database = PostgresDatabase.options(
-1
View File
@@ -28,7 +28,6 @@ dependencies = [
"ray[default,serve]>=2.54.0", "ray[default,serve]>=2.54.0",
"rich>=14.3.3", "rich>=14.3.3",
"sqlalchemy>=2.0.49", "sqlalchemy>=2.0.49",
"stardomain>=0.1.0",
"tavily-python>=0.7.0", "tavily-python>=0.7.0",
] ]