Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,8 @@ jobs:
run: npm run check

- name: Export build
env:
# 只是导出/校验配置,不产出真机可用的原生包;占位值够让百度定位插件的
# apiKey 校验通过,不需要在 CI 里存真的百度 Key。
TIMEFLOW_BAIDU_LOCATION_API_KEY: ci-placeholder
run: npx expo export --platform android --output-dir dist
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.DS_Store
.idea/
.cursor/
.env
.venv/
node_modules/
Expand Down
8 changes: 8 additions & 0 deletions backend/src/timeflow/gateway/websocket/handlers/session.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""The session.hello handshake that opens an authenticated session."""

import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
Expand All @@ -25,6 +26,7 @@
DEFAULT_TIMEZONE = "Asia/Shanghai"
DEFAULT_VOICE_MODE = "push_to_talk"
_VOICE_MODES = frozenset({"push_to_talk", "continuous"})
logger = logging.getLogger(__name__)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -105,6 +107,12 @@ def perform(
timezone=_resolved_timezone(hello.payload.timezone),
voice_mode=_resolved_voice_mode(hello.payload.voice_mode),
)
logger.info(
"voice session authenticated: voice_mode=%s location_available=%s coordinate_system=%s",
session.voice_mode,
session.latitude is not None and session.longitude is not None,
session.coordinate_system,
)
reply = SessionReady(
request_id=hello.request_id,
payload=SessionReadyPayload(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ async def search(
candidate = _candidate(item)
if candidate is not None:
candidates.append(candidate)
logger.info("Tencent Maps search succeeded: candidate_count=%s", len(candidates))
return tuple(candidates)

async def _get(
Expand All @@ -106,20 +107,35 @@ async def _get(
response = await self._client.get(f"{self._base_url}{path}", params=params)
response.raise_for_status()
payload = response.json()
except (httpx.RequestError, httpx.HTTPStatusError):
logger.warning("Tencent Maps request failed", extra={"operation": operation})
except httpx.HTTPStatusError as error:
logger.warning(
"Tencent Maps request failed: operation=%s error_type=%s status_code=%s",
operation,
type(error).__name__,
error.response.status_code,
)
raise LocationConnectionError("Tencent Maps request failed") from None
except httpx.RequestError as error:
logger.warning(
"Tencent Maps request failed: operation=%s error_type=%s",
operation,
type(error).__name__,
)
raise LocationConnectionError("Tencent Maps request failed") from None
except ValueError:
logger.warning("Tencent Maps returned invalid JSON: operation=%s", operation)
raise LocationProtocolError("Tencent Maps returned invalid JSON") from None
if not isinstance(payload, dict):
logger.warning("Tencent Maps returned invalid response: operation=%s", operation)
raise LocationProtocolError("Tencent Maps returned an invalid response")
status = payload.get("status")
if isinstance(status, bool) or not isinstance(status, int) or status != 0:
# Tencent's status/message say why (quota, auth, bad params, ...); neither
# contains user data, so logging them is safe and is the only way to tell
# these apart later -- LocationProtocolError itself carries no detail on.
logger.warning(
"Tencent Maps rejected the request: status=%s message=%s",
"Tencent Maps rejected the request: operation=%s status=%s message=%s",
operation,
status,
payload.get("message"),
)
Expand Down
9 changes: 7 additions & 2 deletions backend/src/timeflow/intelligence/location/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
import logging
from collections.abc import Mapping
from dataclasses import dataclass

Expand All @@ -18,6 +19,7 @@
from timeflow.intelligence.location.service import LocationSearchService

LOCATION_SEARCH = "location_search"
logger = logging.getLogger(__name__)


@dataclass(frozen=True, slots=True)
Expand All @@ -33,10 +35,13 @@ async def execute(self, arguments: Mapping[str, object]) -> str:
try:
query = _query(arguments)
candidates = await self.service.search(self.context, query)
except LocationInputError:
except LocationInputError as error:
logger.warning("location search rejected input: error_type=%s", type(error).__name__)
return _json({"status": "invalid_input", "candidates": []})
except (LocationConfigurationError, LocationConnectionError, LocationProtocolError):
except (LocationConfigurationError, LocationConnectionError, LocationProtocolError) as error:
logger.warning("location search provider unavailable: error_type=%s", type(error).__name__)
return _json({"status": "provider_unavailable", "candidates": []})
logger.info("location search completed: candidate_count=%s", len(candidates))
return _json(
{
"status": "ok",
Expand Down
28 changes: 23 additions & 5 deletions backend/src/timeflow/intelligence/realtime/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,19 @@ async def _session_for(self, key: tuple[str, str], stream: StreamInfo) -> _Held
account_id, _ = key
timezone = stream.timezone
voice_mode = stream.voice_mode
client_location = _client_location_from_stream(stream)
logger.info(
"opening realtime session: voice_mode=%s location_fields_complete=%s "
"location_valid=%s coordinate_system=%s",
voice_mode,
stream.latitude is not None
and stream.longitude is not None
and stream.coordinate_system is not None,
client_location is not None,
stream.coordinate_system,
)
tools = (
await self._tools_factory(account_id, timezone, _client_location_from_stream(stream))
await self._tools_factory(account_id, timezone, client_location)
if self._tools_factory is not None
else None
)
Expand Down Expand Up @@ -327,6 +338,9 @@ def __init__(
self._question_id_factory = question_id_factory
# None between replies; assigned on first use so each reply gets a fresh id.
self._audio_id: str | None = None
# 保留最近一次已经交给客户端播放的 id。模型生成通常快于手机播放:等模型
# 侧交付完成后再发生的打断,仍必须准确指出需要停止的是哪条旧音频。
self._last_audio_id: str | None = None
self._reply_id: str | None = None
self._spoken = ""
self._purpose = REPLY_PURPOSE
Expand Down Expand Up @@ -373,6 +387,7 @@ async def audio(self, data: bytes) -> None:
"""Queue one chunk, starting the delivery on the first one."""
if self._speaking is None:
self._audio_id = self._audio_id_factory()
self._last_audio_id = self._audio_id
self._speaking = asyncio.create_task(self._speak())
await self._audio.put(data)

Expand All @@ -389,6 +404,7 @@ async def tool_requested(self, call_id: str, name: str, arguments: dict[str, Any
)
return

logger.info("realtime model requested tool: name=%s", name)
result = await self._tools.run(name, arguments)
if result.outcome is not None:
outcome = result.outcome
Expand Down Expand Up @@ -476,10 +492,12 @@ async def _finish_reply(self, *, canceled: bool) -> None:
# own before the barge-in arrived -- but the model generates audio faster
# than it plays back, so the phone can still be sounding it out. The session
# only calls interrupted() this late when its own playable-until estimate
# says that's still plausible, so the client is told to stop regardless of
# what this turn still has queued locally. audio_id is empty because there
# is no reply left here to name; the client's handler does not read it.
await self._result_sink.deliver_canceled(AudioCanceled(audio_id=""), self._stream)
# says that's still plausible. Preserve the original id so the client can
# distinguish this old cancellation from a newer reply that has just begun.
if self._last_audio_id is not None:
await self._result_sink.deliver_canceled(
AudioCanceled(audio_id=self._last_audio_id), self._stream
)
self._spoken = ""
self._reply_id = None
self._audio_id = None
Expand Down
2 changes: 1 addition & 1 deletion backend/src/timeflow/intelligence/realtime/instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
- 没提到地点,就当时间型日程处理,不要为了填 latitude/longitude 去调用 location_search
或编一个地点——地点型日程的地点仍然必须问清楚,这条只管时间型日程不要凭空加地点。
- 没说提醒方式,默认建一个 reminder_strength 为 medium 的提醒:非全天日程用
reminder_type=before_start、reminder_offset_minutes=200(开始前 200 分钟);全天日程用
reminder_type=before_start、reminder_offset_minutes=15(开始前 15 分钟);全天日程用
reminder_type=at_time、reminder_trigger_at 填当天上午 10:00(带时区偏移)。

地点怎么定
Expand Down
13 changes: 12 additions & 1 deletion backend/src/timeflow/intelligence/realtime/schedule_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,12 +344,23 @@ async def _location_search(self, arguments: dict[str, Any]) -> ToolResult:
provider recovers.
"""
if self._location_service is None or self._client_location is None:
logger.warning(
"location search unavailable: provider_configured=%s client_location_available=%s",
self._location_service is not None,
self._client_location is not None,
)
return ToolResult(output=PROVIDER_UNAVAILABLE_RESULT)
if self._location_context is None:
try:
logger.info("preparing location search context")
self._location_context = await self._location_service.prepare(self._client_location)
except LocationError:
except LocationError as error:
logger.warning(
"location search context preparation failed: error_type=%s",
type(error).__name__,
)
return ToolResult(output=PROVIDER_UNAVAILABLE_RESULT)
logger.info("location search context prepared")
tool = build_location_search_tool(self._location_service, self._location_context)
return ToolResult(output=await tool.execute(arguments))

Expand Down
10 changes: 10 additions & 0 deletions backend/src/timeflow/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ def create_app(
owned_http_client, settings.tencent_map_api_key, settings.tencent_map_base_url
)
)
logger.info(
"location search configured: provider=tencent_maps timeout_seconds=%s",
settings.tencent_map_timeout_seconds,
)
elif audio_sink is None:
logger.warning(
"location search unavailable at startup: voice_agent_mode=%s tencent_maps_configured=%s",
settings.voice_agent_mode,
settings.tencent_maps_is_configured(),
)

@asynccontextmanager
async def lifespan(_application: FastAPI) -> AsyncIterator[None]:
Expand Down
7 changes: 4 additions & 3 deletions backend/tests/intelligence/realtime/test_realtime_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,8 +495,8 @@ def test_a_barge_in_after_the_reply_already_finished_sending_still_tells_the_cli
back, so a reply can finish sending -- turn_completed() already settled it -- while
the phone is still sounding it out. The session only calls interrupted() this late
when its own playable-until estimate says that is still plausible, so the client
must still be told to stop even though this turn's own bookkeeping has nothing left
to name; there is no reply id left here to attach to it, hence the empty audio_id.
must still be told to stop even though this turn's current bookkeeping has already
been reset. The original audio id must be preserved so a newer reply is not stopped.
"""

async def scenario() -> None:
Expand All @@ -515,7 +515,8 @@ async def scenario() -> None:
)

(canceled,) = [payload for kind, payload in sink.calls if kind == "canceled"]
assert canceled.audio_id == ""
(audio_reply,) = [payload for kind, payload in sink.calls if kind == "audio_start"]
assert canceled.audio_id == audio_reply.audio_id

asyncio.run(scenario())

Expand Down
4 changes: 4 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1
EXPO_PUBLIC_WS_URL=ws://10.0.2.2:8000/ws
EXPO_PUBLIC_DEVICE_ID=device_001

# Baidu Location API key, consumed by app.config.js at build/prebuild time
# (plugins/withTimeflowBaiduLocation.js writes it into AndroidManifest.xml).
TIMEFLOW_BAIDU_LOCATION_API_KEY=
1 change: 1 addition & 0 deletions frontend/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist/
web-build/
package-lock.json
assets/
modules/**/android/build/**
81 changes: 81 additions & 0 deletions frontend/app.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
module.exports = {
expo: {
name: 'Timeflow',
slug: 'timeflow',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'light',
ios: {
supportsTablet: true,
infoPlist: {
NSMicrophoneUsageDescription: '需要麦克风权限来录制语音指令',
NSLocationWhenInUseUsageDescription: '需要定位权限来关联语音指令发生的地点',
UIBackgroundModes: ['location'],
},
bundleIdentifier: 'com.anonymous.timeflow',
},
android: {
package: 'com.anonymous.timeflow',
permissions: [
'RECORD_AUDIO',
'ACCESS_COARSE_LOCATION',
'ACCESS_FINE_LOCATION',
'ACCESS_BACKGROUND_LOCATION',
'FOREGROUND_SERVICE',
'FOREGROUND_SERVICE_LOCATION',
'SCHEDULE_EXACT_ALARM',
'POST_NOTIFICATIONS',
'USE_FULL_SCREEN_INTENT',
'SYSTEM_ALERT_WINDOW',
'REQUEST_IGNORE_BATTERY_OPTIMIZATIONS',
'FOREGROUND_SERVICE_MEDIA_PLAYBACK',
'VIBRATE',
],
adaptiveIcon: {
backgroundColor: '#E6F4FE',
foregroundImage: './assets/android-icon-foreground.png',
backgroundImage: './assets/android-icon-background.png',
monochromeImage: './assets/android-icon-monochrome.png',
},
predictiveBackGestureEnabled: false,
},
plugins: [
'expo-sqlite',
[
'expo-location',
{
locationAlwaysAndWhenInUsePermission:
'允许 Timeflow 使用你的位置,以便在到达或离开地点时提醒你。',
locationWhenInUsePermission: '允许 Timeflow 在使用期间访问位置,以便地点提醒生效。',
isIosBackgroundLocationEnabled: true,
isAndroidBackgroundLocationEnabled: true,
isAndroidForegroundServiceEnabled: true,
},
],
'@irvingouj/expo-audio-stream',
[
'expo-audio',
{
microphonePermission: '需要麦克风权限来录制语音指令',
enableBackgroundPlayback: true,
},
],
[
'expo-notifications',
{
icon: './assets/icon.png',
color: '#15352B',
defaultChannel: 'timeflow-reminders',
},
],
'./plugins/withTimeflowAlarm',
[
'./plugins/withTimeflowBaiduLocation',
{
apiKey: process.env.TIMEFLOW_BAIDU_LOCATION_API_KEY,
},
],
],
},
};
34 changes: 0 additions & 34 deletions frontend/app.json

This file was deleted.

Binary file added frontend/assets/sounds/alarm_prompt.mp3
Binary file not shown.
Loading