From a234e621b7bc33ee26c464ba9f3cd991e1b98578 Mon Sep 17 00:00:00 2001 From: bridge Date: Fri, 21 Nov 2025 22:14:27 +0800 Subject: [PATCH] add long objective setting func --- src/classes/long_term_objective.py | 12 ++ src/server/main.py | 39 ++++++- static/templates/ai.txt | 1 + web/src/components/InfoPanel.vue | 170 ++++++++++++++++++++++++++++- web/src/services/apiClient.ts | 25 +++++ web/src/services/gameApi.ts | 16 ++- web/src/stores/game.ts | 42 +++++-- 7 files changed, 289 insertions(+), 16 deletions(-) diff --git a/src/classes/long_term_objective.py b/src/classes/long_term_objective.py index 511abe3..a3c44f4 100644 --- a/src/classes/long_term_objective.py +++ b/src/classes/long_term_objective.py @@ -177,3 +177,15 @@ def set_user_long_term_objective(avatar: "Avatar", objective_content: str) -> No ) logger.info(f"玩家为角色 {avatar.name} 设定长期目标:{objective_content}") + +def clear_user_long_term_objective(avatar: "Avatar") -> bool: + """ + 清空玩家设定的长期目标 + 如果当前目标是 system/llm 生成的,则不清除并返回 False + 如果是 user 生成的,清除并返回 True + """ + if avatar.long_term_objective and avatar.long_term_objective.origin == "user": + avatar.long_term_objective = None + logger.info(f"玩家清空了角色 {avatar.name} 的长期目标") + return True + return False diff --git a/src/server/main.py b/src/server/main.py index 59a0f57..2f89828 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -7,6 +7,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Quer from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles import uvicorn +from pydantic import BaseModel # 确保可以导入 src 模块 sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) @@ -20,6 +21,7 @@ from src.utils.config import CONFIG from src.classes.sect import sects_by_id from src.classes.color import serialize_hover_lines from src.classes.event import Event +from src.classes.long_term_objective import set_user_long_term_objective, clear_user_long_term_objective import random # 全局游戏实例 @@ -71,7 +73,6 @@ def serialize_events_for_client(events: List[Event]) -> List[dict]: year = None try: month_obj = month_stamp.get_month() - month = int(getattr(month_obj, "value", month_obj)) except Exception: month = None @@ -374,6 +375,42 @@ def get_hover_info( "lines": serialize_hover_lines([str(line) for line in lines]), } +class SetObjectiveRequest(BaseModel): + avatar_id: str + content: str + +class ClearObjectiveRequest(BaseModel): + avatar_id: str + +@app.post("/api/action/set_long_term_objective") +def set_long_term_objective(req: SetObjectiveRequest): + world = game_instance.get("world") + if not world: + raise HTTPException(status_code=503, detail="World not initialized") + + avatar = world.avatar_manager.avatars.get(req.avatar_id) + if not avatar: + raise HTTPException(status_code=404, detail="Avatar not found") + + set_user_long_term_objective(avatar, req.content) + return {"status": "ok", "message": "Objective set"} + +@app.post("/api/action/clear_long_term_objective") +def clear_long_term_objective(req: ClearObjectiveRequest): + world = game_instance.get("world") + if not world: + raise HTTPException(status_code=503, detail="World not initialized") + + avatar = world.avatar_manager.avatars.get(req.avatar_id) + if not avatar: + raise HTTPException(status_code=404, detail="Avatar not found") + + cleared = clear_user_long_term_objective(avatar) + return { + "status": "ok", + "message": "Objective cleared" if cleared else "No user objective to clear" + } + def start(): """启动服务的入口函数""" # 改为 8002 端口 diff --git a/static/templates/ai.txt b/static/templates/ai.txt index da0e6fc..0a38bcb 100644 --- a/static/templates/ai.txt +++ b/static/templates/ai.txt @@ -20,6 +20,7 @@ 要求与约束: - thought从侧面体现出角色特质、宗门信息等 +- 长期目标是非常重要的一个参数,其权重最高 - 执行动作只能从给定的全部动作中选,且需满足对应条件,见动作的requirements文本 - 一些动作需要先移动满足某些条件才可执行,可以适当规划。 - 和另一个角色交互的动作,必须在对应角色附近。执行前可以先MoveToAvatar \ No newline at end of file diff --git a/web/src/components/InfoPanel.vue b/web/src/components/InfoPanel.vue index 4096a25..7dca2a4 100644 --- a/web/src/components/InfoPanel.vue +++ b/web/src/components/InfoPanel.vue @@ -6,11 +6,16 @@ const store = useGameStore() const panelRef = ref(null) let lastOpenAt = 0 +const showObjectiveModal = ref(false) +const objectiveContent = ref('') + const title = computed(() => store.selectedTarget?.name ?? '') watch( () => store.selectedTarget, (target) => { + showObjectiveModal.value = false + objectiveContent.value = '' if (target) { lastOpenAt = performance.now() } @@ -29,6 +34,20 @@ function handleDocumentPointerDown(event: PointerEvent) { store.closeInfoPanel() } +async function handleSetObjective() { + if (!store.selectedTarget || !objectiveContent.value.trim()) return + await store.setLongTermObjective(store.selectedTarget.id, objectiveContent.value) + showObjectiveModal.value = false + objectiveContent.value = '' +} + +async function handleClearObjective() { + if (!store.selectedTarget) return + if (confirm('确定要清空该角色的长期目标吗?(系统将在之后自动重新生成)')) { + await store.clearLongTermObjective(store.selectedTarget.id) + } +} + onMounted(() => { document.addEventListener('pointerdown', handleDocumentPointerDown) }) @@ -44,6 +63,11 @@ onUnmounted(() => { class="info-panel" ref="panelRef" > +
+ + +
+
{{ title || '详情' }}
@@ -70,6 +94,20 @@ onUnmounted(() => {
暂无信息
+ + +
+ + + +
@@ -88,7 +126,51 @@ onUnmounted(() => { pointer-events: auto; display: flex; flex-direction: column; - overflow: hidden; + overflow: visible; /* Allow modal to show outside */ +} + +.panel-actions { + display: flex; + flex-direction: column; + padding: 12px 14px 4px; + gap: 6px; + background: rgba(38, 38, 38, 0.95); + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +.action-btn { + border: none; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + transition: all 0.2s; +} + +.action-btn.primary { + background: #177ddc; + color: white; + padding: 6px 12px; + font-size: 13px; + font-weight: 500; +} + +.action-btn.primary:hover { + background: #1890ff; +} + +.action-btn.secondary { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #aaa; + padding: 5px 12px; + font-size: 12px; +} + +.action-btn.secondary:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.25); + color: #ccc; } .info-header { @@ -123,6 +205,9 @@ onUnmounted(() => { flex: 1; overflow-y: auto; padding: 12px 16px; + /* 确保body内容滚动时圆角 */ + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; } .info-list { @@ -149,5 +234,86 @@ onUnmounted(() => { .placeholder.error { color: #ff7875; } - +/* Modal Styles */ +.objective-modal { + position: absolute; + top: 0; + right: 100%; /* Position to the left of the panel */ + margin-right: 12px; + width: 280px; + background: rgba(32, 32, 32, 0.98); + border: 1px solid #444; + border-radius: 8px; + padding: 16px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + gap: 12px; + z-index: 100; +} + +.modal-title { + font-size: 14px; + font-weight: bold; + color: #ddd; +} + +.objective-input { + width: 100%; + height: 120px; + background: #1f1f1f; + border: 1px solid #444; + border-radius: 4px; + color: #eee; + padding: 8px; + resize: none; + font-family: inherit; + font-size: 13px; + line-height: 1.5; + outline: none; +} + +.objective-input:focus { + border-color: #177ddc; +} + +.modal-actions { + display: flex; + gap: 10px; +} + +.modal-btn { + flex: 1; + padding: 6px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: opacity 0.2s; +} + +.modal-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.modal-btn.confirm { + background: #177ddc; + color: white; +} + +.modal-btn.confirm:hover:not(:disabled) { + background: #1890ff; +} + +.modal-btn.cancel { + background: #444; + color: #bbb; +} + +.modal-btn.cancel:hover { + background: #555; + color: white; +} + diff --git a/web/src/services/apiClient.ts b/web/src/services/apiClient.ts index a61f69c..c2b2c40 100644 --- a/web/src/services/apiClient.ts +++ b/web/src/services/apiClient.ts @@ -19,3 +19,28 @@ export async function apiGet(url: string, options: ApiRequestOptions = {}): P window.clearTimeout(timer) } } + +export async function apiPost(url: string, body: any, options: ApiRequestOptions = {}): Promise { + const { timeout = DEFAULT_TIMEOUT, headers, ...init } = options + const controller = new AbortController() + const timer = window.setTimeout(() => controller.abort(), timeout) + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(body), + signal: controller.signal, + ...init + }) + if (!response.ok) { + throw new Error(`请求失败:${response.status}`) + } + return (await response.json()) as T + } finally { + window.clearTimeout(timer) + } +} diff --git a/web/src/services/gameApi.ts b/web/src/services/gameApi.ts index bfc0956..fc327d1 100644 --- a/web/src/services/gameApi.ts +++ b/web/src/services/gameApi.ts @@ -4,7 +4,7 @@ import type { InitialStateResponse, MapResponse } from '../types/game' -import { apiGet } from './apiClient' +import { apiGet, apiPost } from './apiClient' function buildHoverQuery(target: HoverTarget) { const query = new URLSearchParams({ @@ -25,6 +25,18 @@ export const gameApi = { getMap() { return apiGet('/api/map') + }, + + setLongTermObjective(avatarId: string, content: string) { + return apiPost<{ status: string; message: string }>('/api/action/set_long_term_objective', { + avatar_id: avatarId, + content + }) + }, + + clearLongTermObjective(avatarId: string) { + return apiPost<{ status: string; message: string }>('/api/action/clear_long_term_objective', { + avatar_id: avatarId + }) } } - diff --git a/web/src/stores/game.ts b/web/src/stores/game.ts index e8de324..a0c6efa 100644 --- a/web/src/stores/game.ts +++ b/web/src/stores/game.ts @@ -111,21 +111,23 @@ export const useGameStore = defineStore('game', () => { } } - async function fetchHoverInfo(target: HoverTarget) { + async function fetchHoverInfo(target: HoverTarget, forceRefresh = false) { const key = cacheKey(target) - const cached = hoverCache.get(key) - if (cached) { - if (selectedTarget.value && cacheKey(selectedTarget.value) === key) { - hoverInfo.value = cached + if (!forceRefresh) { + const cached = hoverCache.get(key) + if (cached) { + if (selectedTarget.value && cacheKey(selectedTarget.value) === key) { + hoverInfo.value = cached + } + infoLoading.value = false + infoError.value = null + return } - infoLoading.value = false - infoError.value = null - return } infoLoading.value = true infoError.value = null - hoverInfo.value = [] + if (!forceRefresh) hoverInfo.value = [] try { const data = await gameApi.getHoverInfo(target) @@ -146,6 +148,22 @@ export const useGameStore = defineStore('game', () => { } } + async function setLongTermObjective(avatarId: string, content: string) { + await gameApi.setLongTermObjective(avatarId, content) + // 成功后刷新 info panel + if (selectedTarget.value && selectedTarget.value.id === avatarId && selectedTarget.value.type === 'avatar') { + await fetchHoverInfo(selectedTarget.value, true) + } + } + + async function clearLongTermObjective(avatarId: string) { + await gameApi.clearLongTermObjective(avatarId) + // 成功后刷新 info panel + if (selectedTarget.value && selectedTarget.value.id === avatarId && selectedTarget.value.type === 'avatar') { + await fetchHoverInfo(selectedTarget.value, true) + } + } + function connect() { gateway.connect() } @@ -181,6 +199,8 @@ export const useGameStore = defineStore('game', () => { disconnect, fetchInitialState, openInfoPanel, - closeInfoPanel + closeInfoPanel, + setLongTermObjective, + clearLongTermObjective } -}) \ No newline at end of file +})