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