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:
db:
image: postgres:16-alpine
container_name: kilostar_db
container_name: kilostar_db_test
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgrespassword}
POSTGRES_PASSWORD: testpass123
POSTGRES_DB: kilostar
ports:
- "5432:5432"
@@ -16,7 +16,7 @@ services:
kilostar:
build: .
container_name: kilostar
container_name: kilostar_test
ports:
- "8000:8000"
- "8265:8265"
@@ -25,8 +25,11 @@ services:
condition: service_healthy
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgrespassword}
POSTGRES_PASSWORD: testpass123
POSTGRES_HOST: db
POSTGRES_PORT: 5432
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": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.10.2",
"axios": "^1.15.1",
"i18next": "^26.3.0",
@@ -20,6 +21,9 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"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"
},
"devDependencies": {
+14
View File
@@ -6,6 +6,8 @@ import { SetupGuideModal } from './components/Layout/SetupGuideModal';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { AgentLayout } from './components/Agent/AgentLayout';
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 { ChatPanel } from './components/Chat/ChatPanel';
import { RightPanel } from './components/Chat/RightPanel';
@@ -96,6 +98,18 @@ function App() {
{mode === 'agent' && agentTab === 'agents' && <AgentLayout />}
{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>
</>
)}
+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.
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 10000,
timeout: 120000,
headers: {
'Content-Type': 'application/json',
},
@@ -2,8 +2,6 @@ import { useTranslation } from 'react-i18next';
import { useAppStore } from '../../store/useAppStore';
import { ProvidersSettings } from './ProvidersSettings';
import { WorkerIndividualSettings } from './WorkerIndividualSettings';
import { WorkflowConfigSettings } from './WorkflowConfigSettings';
import { SystemLogsView } from './SystemLogsView';
import { PersonaTemplateSettings } from './PersonaTemplateSettings';
export function AgentLayout() {
@@ -14,8 +12,6 @@ export function AgentLayout() {
{ key: 'worker', label: t('agent.individual') },
{ key: 'persona', label: t('agent.personaManagement') },
{ key: 'providers', label: t('agent.providerManagement') },
{ key: 'config', label: t('agent.config') },
{ key: 'logs', label: t('agent.systemLogs') },
];
return (
@@ -39,8 +35,6 @@ export function AgentLayout() {
{innerAgentTab === 'worker' && <WorkerIndividualSettings />}
{innerAgentTab === 'persona' && <PersonaTemplateSettings />}
{innerAgentTab === 'providers' && <ProvidersSettings />}
{innerAgentTab === 'config' && <WorkflowConfigSettings />}
{innerAgentTab === 'logs' && <SystemLogsView />}
</div>
</div>
);
@@ -1,28 +1,18 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '../../api/client';
import { Plus, Edit2, Trash2, X, Save, Loader2, FileText, Copy } from 'lucide-react';
import type { Provider } from '../../types';
import { Plus, Edit2, Trash2, X, Save, Loader2, FileText } from 'lucide-react';
interface PersonaTemplate {
template_id: string;
name: string;
description: 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;
}
export function PersonaTemplateSettings() {
const { t } = useTranslation();
const [templates, setTemplates] = useState<PersonaTemplate[]>([]);
const [providers, setProviders] = useState<Provider[]>([]);
const [availableTools, setAvailableTools] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isEditing, setIsEditing] = useState(false);
@@ -34,14 +24,8 @@ export function PersonaTemplateSettings() {
const fetchData = async () => {
setLoading(true);
try {
const [tplRes, provRes, toolsRes] = await Promise.all([
apiClient.get('/api/v1/agent/template'),
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 || []);
const res = await apiClient.get('/api/v1/agent/template');
setTemplates(res.data.templates || []);
} catch { setError(t('agent.loadFailed')); }
finally { setLoading(false); }
};
@@ -49,9 +33,7 @@ export function PersonaTemplateSettings() {
useEffect(() => { fetchData(); }, []);
const handleAddNew = () => {
setEditData({ name: '', description: '', system_prompt: '', agent_type: 'ordinary_individual',
provider_title: providers.length > 0 ? providers[0].provider_title : '',
model_id: '', tools: [], tags: [] });
setEditData({ name: '', system_prompt: '' });
setIsNew(true);
setIsEditing(true);
setModalMessage('');
@@ -70,25 +52,12 @@ export function PersonaTemplateSettings() {
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) => {
e.preventDefault();
setModalMessage('');
setSubmitLoading(true);
try {
const payload = { name: editData.name, description: editData.description,
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 || [] };
const payload = { name: editData.name, system_prompt: editData.system_prompt };
if (isNew) await apiClient.post('/api/v1/agent/template', payload);
else await apiClient.put(`/api/v1/agent/template/${editData.template_id}`, payload);
setIsEditing(false);
@@ -98,15 +67,6 @@ export function PersonaTemplateSettings() {
} 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 (
<div className="max-w-5xl space-y-6">
<div className="flex justify-between items-end">
@@ -133,133 +93,46 @@ export function PersonaTemplateSettings() {
<span className="text-sm">{t('agent.noTemplates')}</span>
</div>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="bg-bg-secondary border-b border-border-primary text-text-muted text-xs uppercase tracking-wider">
<th className="px-5 py-3 font-semibold">{t('agent.templateName')}</th>
<th className="px-5 py-3 font-semibold">{t('agent.type')}</th>
<th className="px-5 py-3 font-semibold">{t('agent.tags')}</th>
<th className="px-5 py-3 font-semibold text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-secondary">
{templates.map((tpl) => (
<tr key={tpl.template_id} className="hover:bg-bg-hover transition-colors">
<td className="px-5 py-3">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center">
<FileText size={14} className="text-text-muted" />
</div>
<div className="flex flex-col">
<span className="font-medium text-text-primary text-xs">{tpl.name}</span>
{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 className="divide-y divide-border-secondary">
{templates.map((tpl) => (
<div key={tpl.template_id} className="flex items-center justify-between px-5 py-3.5 hover:bg-bg-hover transition-colors">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center shrink-0">
<FileText size={14} className="text-text-muted" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-text-primary text-sm">{tpl.name}</div>
<div className="text-xs text-text-muted truncate mt-0.5">{tpl.system_prompt || t('agent.noPrompt')}</div>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0 ml-3">
<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>
<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>
</div>
</div>
))}
</div>
)}
</div>
{isEditing && (
<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="flex justify-between items-center p-5 border-b border-border-primary sticky top-0 bg-bg-card z-10">
<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">
<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>
</div>
<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>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.description')}</label>
<textarea value={editData.description || ''} onChange={(e) => setEditData({...editData, description: e.target.value})} rows={2}
<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.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" />
</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>}
<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>
+241 -84
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
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 {
id: number;
@@ -13,20 +14,52 @@ interface EventLog {
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 }> = {
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' },
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() {
const { t } = useTranslation();
const [tab, setTab] = useState<'system' | 'workflow'>('system');
// System logs state
const [logs, setLogs] = useState<EventLog[]>([]);
const [loading, setLoading] = useState(false);
const [traceFilter, setTraceFilter] = useState('');
const [typeFilter, setTypeFilter] = 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 () => {
setLoading(true);
try {
@@ -35,13 +68,8 @@ export function SystemLogsView() {
if (typeFilter) params.set('event_type', typeFilter);
if (levelFilter) params.set('level', levelFilter);
params.set('limit', '200');
const resp = await fetch(`/api/v1/system/logs?${params.toString()}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
});
if (!resp.ok) throw new Error('Failed to fetch logs');
const data = await resp.json();
setLogs(data.logs || []);
const resp = await apiClient.get(`/api/v1/system/logs?${params.toString()}`);
setLogs(resp.data.logs || []);
} catch (err) {
console.error(err);
} 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) => {
e.preventDefault();
@@ -58,87 +115,187 @@ export function SystemLogsView() {
return (
<div className="max-w-6xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-text-primary">{t('agent.systemLogs')}</h2>
<button onClick={fetchLogs} disabled={loading} className="p-2 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{/* Tab Switcher */}
<div className="flex items-center gap-6 mb-6">
<button
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>
</div>
<form onSubmit={handleSearch} className="grid grid-cols-4 gap-3 mb-5">
<input
type="text"
value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)}
placeholder={t('agent.logFilterTraceId')}
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"
/>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
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="">{t('agent.logFilterAllTypes')}</option>
<option value="workflow_start">workflow_start</option>
<option value="step_enter">step_enter</option>
<option value="step_complete">step_complete</option>
<option value="step_error">step_error</option>
<option value="workflow_complete">workflow_complete</option>
<option value="workflow_fail">workflow_fail</option>
<option value="system">system</option>
</select>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
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="">{t('agent.logFilterAllLevels')}</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
<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">
<Search size={14} /> {t('agent.logSearch')}
</button>
</form>
{tab === 'system' ? (
<>
<form onSubmit={handleSearch} className="grid grid-cols-4 gap-3 mb-5">
<input
type="text"
value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)}
placeholder={t('agent.logFilterTraceId')}
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"
/>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
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="">{t('agent.logFilterAllTypes')}</option>
<option value="workflow_start">workflow_start</option>
<option value="step_enter">step_enter</option>
<option value="step_complete">step_complete</option>
<option value="step_error">step_error</option>
<option value="workflow_complete">workflow_complete</option>
<option value="workflow_fail">workflow_fail</option>
<option value="system">system</option>
</select>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
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="">{t('agent.logFilterAllLevels')}</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
<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">
<Search size={14} /> {t('agent.logSearch')}
</button>
</form>
<div className="bg-bg-card border border-border-primary rounded-xl overflow-hidden">
<div className="overflow-x-auto max-h-[60vh] 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">{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">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.logMessage')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logTime')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{logs.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-text-muted">{t('agent.noLogs')}</td></tr>
<div className="bg-bg-card border border-border-primary rounded-xl overflow-hidden">
<div className="overflow-x-auto max-h-[60vh] 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">{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">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.logMessage')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logTime')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{logs.length === 0 ? (
<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) => {
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>
);
})
workflows.map((wf) => (
<button
key={wf.trace_id}
onClick={() => setSelectedTrace(wf.trace_id)}
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' : ''}`}
>
<div className="text-xs font-medium text-text-primary truncate">{wf.title}</div>
<div className="flex items-center gap-2 mt-1">
<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}`}>
{wf.status}
</span>
<span className="text-[10px] text-text-muted">{new Date(wf.created_at).toLocaleDateString()}</span>
</div>
</button>
))
)}
</tbody>
</table>
</div>
</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>
);
}
}
@@ -11,18 +11,25 @@ interface WorkerIndividual {
description?: string;
provider_title: string;
model_id: string;
system_prompt?: string;
persona_id?: string;
output_template?: string;
bound_skill?: string;
workspace?: string;
tools?: string;
}
interface PersonaTemplate {
template_id: string;
name: string;
system_prompt: string;
}
export function WorkerIndividualSettings() {
const { t } = useTranslation();
const [providers, setProviders] = useState<Provider[]>([]);
const [workers, setWorkers] = useState<WorkerIndividual[]>([]);
const [systemNodes, setSystemNodes] = useState<any[]>([]);
const [personaTemplates, setPersonaTemplates] = useState<PersonaTemplate[]>([]);
const [availableSkills, setAvailableSkills] = useState<string[]>([]);
const [availableTools, setAvailableTools] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
@@ -36,17 +43,19 @@ export function WorkerIndividualSettings() {
const fetchData = async () => {
setLoading(true);
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/agent/worker'),
apiClient.get('/api/v1/agent'),
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 || {}));
setWorkers(workRes.data.workers || []);
setAvailableTools(toolsRes.data.tools || []);
setAvailableSkills(Object.keys(skillsRes.data.skills || {}));
setPersonaTemplates(tplRes.data.templates || []);
const providersList = Object.values(provRes.data.provider_list || {}) as Provider[];
const defaultProvider = providersList.length > 0 ? providersList[0].provider_title : '';
@@ -61,7 +70,7 @@ export function WorkerIndividualSettings() {
provider_title: found?.provider_title || defaultProvider,
model_id: found?.model_id || '',
tools: found?.tools ? JSON.stringify(found.tools) : '[]',
custom_system_prompt: found?.custom_system_prompt || '',
persona_id: found?.persona_id || '',
is_system: true
};
}));
@@ -90,7 +99,7 @@ export function WorkerIndividualSettings() {
const handleAddNew = () => {
setEditData({ agent_name: '', agent_type: 'ordinary_individual', description: '',
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);
setIsEditing(true);
setModalMessage('');
@@ -112,7 +121,7 @@ export function WorkerIndividualSettings() {
provider_title: editData.provider_title,
model_id: editData.model_id,
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
});
} else {
@@ -280,10 +289,12 @@ export function WorkerIndividualSettings() {
</div>
{(editData as any).is_system && (
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.customPrompt')}</label>
<textarea value={(editData as any).custom_system_prompt || ''} onChange={(e) => setEditData({...editData, custom_system_prompt: e.target.value} as any)} rows={3}
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 font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.persona')}</label>
<select value={(editData as any).persona_id || ''} onChange={(e) => setEditData({...editData, persona_id: e.target.value || null} as any)}
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>
{personaTemplates.map(p => <option key={p.template_id} value={p.template_id}>{p.name}</option>)}
</select>
</div>
)}
{!(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" />
</div>
<div>
<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={3}
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" />
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.persona')}</label>
<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 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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
+123 -87
View File
@@ -1,6 +1,10 @@
import { useState, useEffect, useRef } from 'react';
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';
export function ChatPanel() {
@@ -13,7 +17,9 @@ export function ChatPanel() {
{ icon: Search, label: t('chat.quickActions.search'), prompt: '帮我搜索相关资料' },
];
const [input, setInput] = useState('');
const [showPlusMenu, setShowPlusMenu] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const plusMenuRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const {
@@ -41,22 +47,31 @@ export function ChatPanel() {
}
}, [activeSessionId]);
const handleNewChat = async () => {
await createChat(t('chat.newChat'), '你好');
};
useEffect(() => {
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 () => {
if (!input.trim() || !activeSessionId) return;
if (!input.trim()) return;
const text = input.trim();
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) => {
if (!activeSessionId) {
createChat(prompt.slice(0, 20), prompt).then((id) => {
if (id) sendMessage(id, prompt);
});
createChat(prompt.slice(0, 20), prompt);
} else {
sendMessage(activeSessionId, prompt);
}
@@ -87,13 +102,32 @@ export function ChatPanel() {
))}
</div>
<button
onClick={handleNewChat}
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"
>
<Plus size={16} />
{t('chat.newChat')}
</button>
<div className="w-full max-w-[480px]">
<div className="flex items-end gap-2">
<div className="flex-1 relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
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>
);
@@ -101,85 +135,90 @@ export function ChatPanel() {
return (
<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 */}
<div className="flex-1 overflow-y-auto px-8 py-6 flex flex-col gap-4.5">
{loadingMessages ? (
<div className="flex justify-center items-center h-full">
<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" />
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="max-w-[720px] mx-auto flex flex-col gap-6">
{loadingMessages ? (
<div className="flex justify-center items-center py-12">
<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" />
</div>
</div>
</div>
) : (
messages.map((msg, idx) => (
<div
key={msg.id}
className={`flex gap-2.5 max-w-[78%] ${msg.role === 'user' ? 'self-end flex-row-reverse' : ''} animate-fade-in`}
style={{ animationDelay: `${idx * 30}ms` }}
>
<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" />
) : (
messages.map((msg) => (
<div key={msg.id} className={msg.role === 'user' ? 'flex justify-end' : ''}>
{msg.role === 'user' ? (
<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">
{msg.content}
</div>
) : (
<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 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]'
: 'bg-bg-card border border-border-primary rounded-[14px] rounded-bl-[3px] shadow-[0_1px_3px_rgba(0,0,0,0.02)]'
}`}>
{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 ref={messagesEndRef} />
</div>
</div>
{/* 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="flex items-end gap-2 max-w-[720px] mx-auto">
<input type="file" ref={fileInputRef} className="hidden" />
<button
onClick={() => fileInputRef.current?.click()}
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"
title={t('chat.addAttachment')}
>
<Plus size={18} />
</button>
<div className="relative" ref={plusMenuRef}>
<button
onClick={() => setShowPlusMenu(!showPlusMenu)}
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} />
</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">
<textarea
value={input}
@@ -204,9 +243,6 @@ export function ChatPanel() {
<ArrowUp size={16} />
</button>
</div>
<p className="text-center text-[10px] text-text-muted mt-2">
{t('chat.mistakeWarning')}
</p>
</div>
</div>
);
+49 -13
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
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 type { Workflow } from '../../types';
import { useChatStore } from '../../store/useChatStore';
@@ -13,15 +13,17 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
const { t } = useTranslation();
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const {
sessions,
activeSessionId,
setActiveSessionId,
removeSession,
createChat,
selectedWorkflow,
setSelectedWorkflow,
updateSessionTitle,
} = useChatStore();
useEffect(() => {
@@ -57,8 +59,8 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
};
}, [activeTab]);
const handleNewChat = async () => {
await createChat(t('chat.newChat'), '你好');
const handleNewChat = () => {
setActiveSessionId(null);
};
const handleDeleteChat = (e: React.MouseEvent, id: string) => {
@@ -66,6 +68,20 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
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';
return (
@@ -95,6 +111,7 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
) : (
sessions.map((session) => {
const isActive = activeSessionId === session.id;
const isRenaming = renamingId === session.id;
return (
<div
key={session.id}
@@ -109,16 +126,35 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
<MessageSquare size={12} className={isActive ? 'text-accent' : 'text-text-muted'} />
</div>
<div className="flex-1 min-w-0">
<h3 className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
{session.title}
</h3>
{isRenaming ? (
<input
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>
<button
onClick={(e) => handleDeleteChat(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-1 rounded text-text-muted hover:text-danger transition-all"
>
<Trash2 size={11} />
</button>
{isRenaming ? (
<button onClick={handleConfirmRename} className="p-1 rounded text-accent hover:bg-accent-light transition-all">
<Check 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>
);
})
@@ -1,5 +1,5 @@
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';
export function CollapsibleSidebar() {
@@ -22,6 +22,8 @@ export function CollapsibleSidebar() {
: [
{ key: 'plugin', label: t('nav.plugin'), icon: Box },
{ 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;
+11 -1
View File
@@ -10,6 +10,8 @@
"workflow": "Workflow",
"plugin": "Plugin",
"agents": "Agents",
"config": "Config",
"logs": "Logs",
"settings": "Settings"
},
"auth": {
@@ -187,6 +189,10 @@
"logMessage": "Message",
"logTime": "Time",
"noLogs": "No log entries yet",
"workflowLogs": "Workflow Logs",
"workflowLogList": "Workflows",
"selectWorkflowToView": "Select a workflow to view execution logs",
"workflowAction": "Action",
"description": "Description",
"systemPrompt": "System Prompt",
"outputTemplate": "Output Template (JSON)",
@@ -205,6 +211,8 @@
"deleteTemplateConfirm": "Delete this template?",
"noTemplates": "No persona templates yet",
"templateName": "Template Name",
"selectPersona": "Select persona...",
"noPrompt": "(no prompt)",
"builtin": "Built-in",
"createFromTemplate": "Create worker from template",
"workerCreatedFromTemplate": "Worker created from template successfully",
@@ -212,7 +220,8 @@
"tagsPlaceholder": "Comma-separated, e.g.: assistant, translator, coder",
"customPrompt": "Custom Prompt",
"customPromptPlaceholder": "Additional content appended after the default system prompt...",
"displayName": "Display Name"
"displayName": "Display Name",
"persona": "Persona"
},
"plugin": {
"toolManagement": "Tool Management",
@@ -265,6 +274,7 @@
"actions": "Actions",
"cancel": "Cancel",
"skip": "Skip for now",
"status": "Status",
"reset": "Reset",
"save": "Save"
},
+11 -1
View File
@@ -10,6 +10,8 @@
"workflow": "工作流",
"plugin": "插件",
"agents": "智能体",
"config": "配置",
"logs": "日志",
"settings": "设置"
},
"auth": {
@@ -187,6 +189,10 @@
"logMessage": "消息",
"logTime": "时间",
"noLogs": "暂无日志记录",
"workflowLogs": "工作流日志",
"workflowLogList": "工作流列表",
"selectWorkflowToView": "选择左侧工作流查看执行日志",
"workflowAction": "动作",
"description": "描述",
"systemPrompt": "系统提示词",
"outputTemplate": "输出模板 (JSON)",
@@ -205,6 +211,8 @@
"deleteTemplateConfirm": "确定要删除此模板吗?",
"noTemplates": "暂无人设模板",
"templateName": "模板名称",
"selectPersona": "请选择人设",
"noPrompt": "(无提示词)",
"builtin": "内置",
"createFromTemplate": "从模板创建工作者",
"workerCreatedFromTemplate": "已从模板成功创建工作者",
@@ -212,7 +220,8 @@
"tagsPlaceholder": "用逗号分隔,如:助手, 翻译, 代码",
"customPrompt": "附加人设",
"customPromptPlaceholder": "在默认系统提示词之后追加的自定义内容...",
"displayName": "显示名称"
"displayName": "显示名称",
"persona": "人设"
},
"plugin": {
"toolManagement": "工具管理",
@@ -265,6 +274,7 @@
"actions": "操作",
"cancel": "取消",
"skip": "稍后再说",
"status": "状态",
"reset": "重置",
"save": "保存"
},
+35
View File
@@ -263,3 +263,38 @@ body::before {
opacity: 0.4;
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 WorkTab = 'chat' | 'workflow';
type AgentTab = 'plugin' | 'agents';
type AgentTab = 'plugin' | 'agents' | 'config' | 'logs';
type ThemeMode = 'light' | 'dark' | 'system';
interface AppState {
+117 -38
View File
@@ -29,7 +29,7 @@ interface ChatState {
addSession: (session: ChatSession) => void;
updateSessionMessages: (sessionId: string, messages: Message[]) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
removeSession: (sessionId: string) => void;
removeSession: (sessionId: string) => Promise<void>;
loadSessions: () => 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)),
})),
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) => {
const filtered = state.sessions.filter((s) => s.id !== sessionId);
return {
@@ -84,7 +89,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
: null
: state.activeSessionId,
};
}),
});
},
loadSessions: async () => {
set({ loadingSessions: true });
@@ -123,6 +129,28 @@ export const useChatStore = create<ChatState>((set, get) => ({
},
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 {
const response = await apiClient.post('/api/v1/chat', {
title,
@@ -132,19 +160,28 @@ export const useChatStore = create<ChatState>((set, get) => ({
const reply: string =
response.data.reply || '你好!我是 kilostar 助手,有什么可以帮你的吗?';
const newSession: ChatSession = {
id: chatId,
title,
messages: [
{ id: chatId + '_user', role: 'user', content: initialMessage, timestamp: Date.now() },
{ id: chatId + '_ai', role: 'assistant', content: reply, timestamp: Date.now() },
],
updatedAt: Date.now(),
};
get().addSession(newSession);
set((state) => ({
sessions: state.sessions.map((s) =>
s.id === tempId
? {
...s,
id: chatId,
messages: [
{ ...userMsg, id: chatId + '_user' },
{ ...aiMsg, id: chatId + '_ai', content: reply },
],
}
: s
),
activeSessionId: state.activeSessionId === tempId ? chatId : state.activeSessionId,
}));
return chatId;
} catch (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;
}
},
@@ -161,38 +198,80 @@ export const useChatStore = create<ChatState>((set, get) => ({
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);
try {
const response = await apiClient.post(`/api/v1/chat/${chatId}/reply`, {
message: text,
});
const replyContent: string = response.data?.reply || '收到你的消息。';
if (session.messages.length <= 1 && text.length > 0) {
const newTitle = text.slice(0, 20) + (text.length > 20 ? '...' : '');
get().updateSessionTitle(chatId, newTitle);
}
// Auto-update title on first user message
if (session.messages.length <= 1 && text.length > 0) {
const newTitle = text.slice(0, 20) + (text.length > 20 ? '...' : '');
get().updateSessionTitle(chatId, newTitle);
try {
const token = localStorage.getItem('token');
const baseURL = apiClient.defaults.baseURL || '';
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 = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: replyContent,
timestamp: Date.now(),
};
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
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) {
console.error('Error sending message', error);
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '抱歉,与服务器通信时出错。',
timestamp: Date.now(),
};
get().updateSessionMessages(chatId, [...currentMessages, errorMessage]);
console.error('Error in streaming message', error);
const latestSession = get().sessions.find((s) => s.id === chatId);
if (latestSession) {
const msgs = latestSession.messages.map((m) =>
m.id === aiMessage.id
? { ...m, content: m.content || '抱歉,与服务器通信时出错。' }
: m
);
get().updateSessionMessages(chatId, msgs);
}
}
},
}));
+3 -2
View File
@@ -28,7 +28,7 @@ if not _STANDALONE:
from .agent import agent_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.onebot import onebot_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
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(onebot_router) # OneBot v11 路径
app.include_router(auth_router) # 用户路径
+19 -48
View File
@@ -36,7 +36,7 @@ class AgentRegister(BaseModel):
model_id: str
individual_name: str
tools: Optional[List[str]] = None
custom_system_prompt: Optional[str] = None
persona_id: Optional[str] = None
display_name: Optional[str] = None
@@ -79,13 +79,19 @@ async def load_agent(
agent_register.provider_title,
agent_register.model_id,
agent_register.tools,
agent_register.custom_system_prompt,
agent_register.persona_id,
agent_register.display_name,
)
scope = agent_register.individual_name
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:
case "regulatory_node":
@@ -97,7 +103,7 @@ async def load_agent(
agent_register.tools,
toolsets,
accept_lang,
custom_prompt,
persona_prompt,
)
case "consciousness_node":
node = ray_actor_hook("consciousness_node").consciousness_node
@@ -108,7 +114,7 @@ async def load_agent(
agent_register.tools,
toolsets,
accept_lang,
custom_prompt,
persona_prompt,
)
case _:
pass
@@ -130,7 +136,7 @@ class WorkerIndividualCreate(BaseModel):
description: str
provider_title: str
model_id: str
system_prompt: str
persona_id: str
output_template: dict
bound_skill: Dict[str, List[str]]
workspace: List[str]
@@ -153,7 +159,7 @@ class WorkerIndividualUpdate(BaseModel):
description: Optional[str] = None
provider_title: Optional[str] = None
model_id: Optional[str] = None
system_prompt: Optional[str] = None
persona_id: Optional[str] = None
output_template: Optional[dict] = None
bound_skill: Optional[Dict[str, List[str]]] = None
workspace: Optional[List[str]] = None
@@ -280,34 +286,21 @@ async def delete_worker_individual(
class PersonaTemplateCreate(BaseModel):
name: str
description: 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):
name: Optional[str] = None
description: 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")
async def list_templates(
include_builtin: bool = True,
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
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}
@@ -319,7 +312,9 @@ async def create_template(
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
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}
@@ -334,7 +329,7 @@ async def update_template(
tpl = await postgres_database.get_template.remote(template_id)
if not tpl:
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")
updated = await postgres_database.update_template.remote(
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)
if not tpl:
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")
await postgres_database.delete_template.remote(template_id)
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 落库。"""
register_limiter.check(request)
postgres_database = ray_actor_hook("postgres_database").postgres_database
hashed_password = await run_in_threadpool(
Accessor.hash_password, user_register.password
)
try:
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_register.user_name, hashed_password
)
+119 -1
View File
@@ -12,7 +12,11 @@
# See the License for the specific language governing permissions and
# 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 kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.access import Accessor, TokenData
@@ -136,3 +140,117 @@ async def send_chat_message(
)
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_api_router = APIRouter(prefix="/api/v1/system", tags=["system"])
@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(
_: TokenData = Depends(Accessor.get_current_user),
):
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(
update: WorkflowConfig,
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
@@ -86,7 +87,7 @@ async def update_workflow_config_endpoint(
return {"status": "ok", "config": update.model_dump()}
@system_router.get("/logs")
@system_api_router.get("/logs")
async def query_system_logs(
trace_id: str | None = None,
event_type: str | None = None,
@@ -95,9 +96,7 @@ async def query_system_logs(
offset: int = 0,
_: TokenData = Depends(Accessor.get_current_user),
):
from kilostar.utils.ray_hook import ray_actor_hook
pg = await ray_actor_hook.get_actor("postgres_database")
pg = ray_actor_hook("postgres_database").postgres_database
logs = await pg.query_event_logs.remote(
trace_id=trace_id,
event_type=event_type,
@@ -108,7 +107,7 @@ async def query_system_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(
_: TokenData = Depends(Accessor.get_current_user),
):
@@ -122,8 +122,7 @@ class ProviderManager:
async def delete_provider(self, provider_title: str, postgres_database) -> None:
"""从内存注册表 + Postgres 中一并删除指定 Provider;不存在时静默返回。"""
if provider_title in self.provider_register:
provider = self.provider_register[provider_title]
await postgres_database.delete_provider_db.remote(
provider_id=provider.provider_id
await postgres_database.delete_provider_by_title.remote(
provider_title=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"
for skill in ctx.deps.available_skills:
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
@@ -37,14 +37,13 @@ class BaseIndividualModel(BaseDataModel):
agent_id: Mapped[str] = mapped_column(String(64), primary_key=True)
agent_name: Mapped[str] = mapped_column(String(100), index=True, 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))
model_id: Mapped[str] = mapped_column(String(100))
owner_id: Mapped[str] = mapped_column(String(64), index=True)
agent_type: Mapped[str] = mapped_column(String(32))
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"),
nullable=True,
index=True,
@@ -1,6 +1,5 @@
from typing import List, Optional
from sqlalchemy import String, Text, Boolean, text
from sqlalchemy.dialects.postgresql import JSONB
from typing import Optional
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import BaseDataModel
@@ -11,16 +10,5 @@ class PersonaTemplate(BaseDataModel):
template_id: Mapped[str] = mapped_column(String(64), primary_key=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="")
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)
@@ -13,7 +13,7 @@
# limitations under the License.
from typing import List, Optional
from sqlalchemy import String, Text
from sqlalchemy import String, ForeignKey
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from .base import BaseDataModel
@@ -35,6 +35,8 @@ class SystemNodeConfigModel(BaseDataModel):
tools: Mapped[Optional[List[str]]] = mapped_column(
JSONB, default=list, comment="节点可调用的工具标识列表"
)
custom_system_prompt: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, comment="管理员自定义追加的提示词,拼接在默认 system prompt 之后"
persona_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("persona_template.template_id", ondelete="SET NULL"),
nullable=True,
index=True,
)
@@ -85,3 +85,29 @@ class ChatHistoryDatabase:
)
results = await session.execute(statement)
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()
@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:
stmt = select(PersonaTemplate)
if owner_id and include_builtin:
from sqlalchemy import or_
stmt = stmt.where(
or_(PersonaTemplate.owner_id == owner_id, PersonaTemplate.is_builtin == True)
)
elif owner_id:
if 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)
return list(result.scalars().all())
@@ -93,6 +93,19 @@ class ProviderDatabase:
session.delete(provider)
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
async def update_provider(self, provider_id: str, **kwargs) -> None:
"""部分更新指定 Provider 的字段;``provider_apikey`` 写入前自动加密。"""
@@ -31,7 +31,7 @@ class SystemNodeDatabase:
provider_title: str,
model_id: str,
tools: Optional[List[str]] = None,
custom_system_prompt: Optional[str] = None,
persona_id: Optional[str] = None,
display_name: Optional[str] = None,
) -> SystemNodeConfigModel:
"""按 node_name 插入或更新一个系统节点的模型配置。"""
@@ -46,8 +46,8 @@ class SystemNodeDatabase:
config.model_id = model_id
if tools is not None:
config.tools = tools
if custom_system_prompt is not None:
config.custom_system_prompt = custom_system_prompt
if persona_id is not None:
config.persona_id = persona_id
if display_name is not None:
config.display_name = display_name
else:
@@ -56,7 +56,7 @@ class SystemNodeDatabase:
provider_title=provider_title,
model_id=model_id,
tools=tools,
custom_system_prompt=custom_system_prompt,
persona_id=persona_id,
display_name=display_name,
)
session.add(config)
+24 -4
View File
@@ -171,6 +171,11 @@ class PostgresDatabase:
await self.ready_event.wait()
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):
"""部分更新指定 Provider 的字段。"""
await self.ready_event.wait()
@@ -183,13 +188,13 @@ class PostgresDatabase:
provider_title: str,
model_id: str,
tools: list[str] = None,
custom_system_prompt: str = None,
persona_id: str = None,
display_name: str = None,
):
"""插入或更新某个系统节点(如 consciousness/regulatory)的模型配置。"""
await self.ready_event.wait()
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):
@@ -197,6 +202,11 @@ class PostgresDatabase:
await self.ready_event.wait()
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
async def add_worker_individual(self, **kwargs):
"""登记一个新的 Worker Individual 配置。"""
@@ -306,6 +316,16 @@ class PostgresDatabase:
await self.ready_event.wait()
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
async def upsert_mcp_server(self, server_id: str, config: dict):
"""插入或更新一条 MCP 服务器配置;env 中敏感字段自动加密。"""
@@ -423,9 +443,9 @@ class PostgresDatabase:
await self.ready_event.wait()
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()
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):
await self.ready_event.wait()
+5 -6
View File
@@ -167,11 +167,10 @@ class Accessor:
"""对明文口令做强哈希;空值或不满足复杂度要求会抛 ValueError。"""
if not password:
raise ValueError("密码不能为空")
if len(password) < 8:
raise ValueError("密码长度不能小于 8")
has_upper = any(c.isupper() for c in password)
has_lower = any(c.islower() for c in password)
if len(password) < 6:
raise ValueError("密码长度不能小于 6")
has_alpha = any(c.isalpha() for c in password)
has_digit = any(c.isdigit() for c in password)
if not (has_upper and has_lower and has_digit):
raise ValueError("密码必须包含大写字母、小写字母和数字")
if not (has_alpha and has_digit):
raise ValueError("密码必须同时包含字母和数字")
return password_hasher.hash(password)
+6 -3
View File
@@ -104,7 +104,7 @@ class WorkerCluster:
if self.task_queue is None:
await asyncio.sleep(0.1)
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")
agent_id = task.get("agent_id")
task_event = task.get("task_event")
@@ -150,7 +150,10 @@ class WorkerCluster:
self.results_futures[task_id] = future
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} 已加入队列。")
try:
@@ -165,5 +168,5 @@ class WorkerCluster:
"active_worker_count": len(self._active_workers),
"max_capacity": self.max_capacity,
"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()
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()
register_standalone("global_state_machine", global_state_machine)
@@ -104,6 +107,11 @@ async def start_distributed():
dashboard_host="0.0.0.0",
dashboard_port=8265,
runtime_env={"env_vars": env_vars},
resources={
"kilostar_node_cpu": 1,
"kilostar_node_core": 1,
"kilostar_node_gpu": 1,
},
)
postgres_database = PostgresDatabase.options(
-1
View File
@@ -28,7 +28,6 @@ dependencies = [
"ray[default,serve]>=2.54.0",
"rich>=14.3.3",
"sqlalchemy>=2.0.49",
"stardomain>=0.1.0",
"tavily-python>=0.7.0",
]